仮想DOMのupdateが何度も実行される問題
Beako.jsが抱えている、3つの大きな問題の三つ目です。
一つ目と二つ目はこちら
update処理が何度も実行される問題
変更を監視されているプロパティcount
があるとします。一連の処理の中でcount
が複数回変更されると、その回数だけ変更が監視者に検知され、update処理が実行されます。
これは、期待された動作ですが、仮想DOMの場合、update処理はDOMに差分パッチを当てる処理となるため、最後の1回だけ実行されれば良いはずです。
問題を抽出
問題を分かりやすくするため、仮想DOMから問題個所を抽出してみました。
実行するとコンソールに100回「Updated」と出力されます。
document.body.innerHTML = ...
がupdate処理で、ここが何度も実行されるのは無意味です。
// countプロパティの変更を監視
const state = {
_count: 1,
get count() { return this._count },
set count(value) {
if (this._count !== value) {
this._count = value
// 更新処理
document.body.innerHTML = `Counter: ${this._count}`
console.log('Updated')
}
}
}
// countプロパティを101まで増やす
while (state.count <= 100) {
state.count++
}
図にすると次のように何度もupdate処理が実行されています。
このコードで、while
文の3行に手を加えずに「Updated」を1回にできれば問題解決です。
対処方法
この問題はJavaScriptがシングルスレッドであるということを活用すれば簡単に解決できます。
// countプロパティの変更を監視
const state = {
_count: 1,
// 更新が必要かどうか
isRequireUpdate: false,
get count() { return this._count },
set count(value) {
if (this._count !== value) {
this._count = value
// 既に更新が必要と分かっているときは何もしない
if (!this.isRequireUpdate) {
// 更新が発生した
this.isRequireUpdate = true
// setTimeoutで実行を後回しにする
setTimeout(() => {
// 更新処理
document.body.innerHTML = `Counter: ${this._count}`
console.log('Updated')
// 更新が完了した
this.isRequireUpdate = false
})
}
}
}
}
// countプロパティを101まで増やす
while (state.count <= 100) {
state.count++
}
実行すると「Updated」は1回しか表示されません。
ここでのミソはsetTimeout
です。setTimeout
を遅延0で利用すると、実行を待っているタスクキューに即時追加されます。この場合、while
文の直後に実行されることとなります。
JavaScriptはシングルスレッドですので、while
文にどれだけ時間がかかろうとも、while
文が終わるまで次に控えているsetTimeout
の中身が実行されることはありません。
setTimeout
生成の際にisRequireUpdate
プロパティをtrue
にしていますので、残り99回のループではsetTimeout
は生成されません。
問題は解決しました。
Promiseでやってみる
同じことをPromise
でやってみます。
// countプロパティの変更を監視
const state = {
_count: 1,
// 更新が必要かどうか
isRequireUpdate: false,
get count() { return this._count },
set count(value) {
if (this._count !== value) {
this._count = value
if (!this.isRequireUpdate) {
// 更新が発生した
this.isRequireUpdate = true
// Promiseで実行を後回しにする
new Promise(resolve => resolve()).then(() => {
document.body.innerHTML = `Counter: ${this._count}`
console.log('Updated')
// 更新が完了した
this.isRequireUpdate = false
})
}
}
}
}
// countプロパティを101まで増やす
while (state.count <= 100) {
state.count++
}
Promise
のコンストラクタにresolve => resolve()
という、ただresolve
関数を実行するだけの関数を渡すと、即時解決されるPromise
が生成されますが、即時解決してもthen()
に書いた関数はあくまで別タスクですので、setTimeout
と同じようにwhile
文が終わるまで実行されません。
update処理に時間がかかるとき
上手く解決できたように見えますが先の方法は、update処理に時間がかからないことを前提としています。
仮想DOMの差分パッチを当てる処理はふつう時間がかからないので、解決かと思いきや、前回のCSSファイルをJSで読み込むとレンダリングを妨げない問題によって、レンダリングに時間がかかる場合があるという事実が分かりました。
具体的には次の図のように、update処理がheaderとbodyの2つに分かれます。
header更新処理を実行した後で、link
タグに書いたCSSファイルが読み込みが完了する(load
イベント)のを待ってからbody更新処理が実行されなければなりません。
「・・・」のところはブラウザの処理を待っているところですが、ここに新たなupdate処理が入ると、次のようにDOMが最新では無くなる恐れがあります。
問題を再現
少しコードが長くなりますが、問題を再現すると次のようになります。先のsetTimeout
の方法を使っています。
// classプロパティの変更を監視
const state = {
_class: 1,
button: null,
// 更新が必要かどうか
isRequireUpdate: false,
get design() { return this._class },
set design(value) {
if (this._class !== value) {
this._class = value
// 既に更新が必要と分かっているときは何もしない
if (!this.isRequireUpdate) {
// 更新が発生した
this.isRequireUpdate = true
// setTimeoutで実行を後回しにする
setTimeout(() => {
// header update
// link要素を追加
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = value === 'Semantic UI' ?
'https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css' :
'https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.6.1/css/bootstrap.min.css'
document.body.append(link)
// loadイベント
link.addEventListener('load', () => {
// body update
if (!this.button) {
// ボタンが無ければボタンを追加
this.button = document.createElement('button')
this.button.innerText = 'Click me'
document.body.append(this.button)
}
// button要素のクラスを変更
this.button.classList = value === 'Semantic UI' ? 'ui button' : 'btn btn-primary'
console.log('Updated:', value)
})
// 更新が完了した
this.isRequireUpdate = false
})
}
}
}
}
// 10ms後Semantic UIに変更
setTimeout(() => {
state.design = 'Semantic UI'
}, 10)
// 20ms後Bootstrapに変更
setTimeout(() => {
state.design = 'Bootstrap'
}, 20)
プログラムでは、まずSemantic UIに変更してから次にBootstrapに変更しているので、最終的にBootstrapの青いボタンになっていてほしいのですが、逆にBootstrapの青いボタンになってからSemantic UIのボタンに変わりました。
コンソールを見ても先にBootstrapが実行されています。
これは、Bootstrapのほうがファイルサイズが小さいため、10ms程度の差ではBootstrapのload
イベントが先に実行されてしまうからです。
ちなみに、もし、isRequireUpdate = false
の位置をloadイベントの中に入れてしまうと、Bootstrapに変更したときにupdate処理が動かずに、結局Semantic UIの見た目のままになります。
キューだと別の問題が生まれる
時間がかかるupdate処理だろうと、キューに入れて順番に実行すれば、不整合にはなりません。
Promiseをキューで順番に実行する方法は調べれば色々と出てきます。
ところが、この方法だと不整合は起きないのですが、別の問題が生まれます。state更新②の後で一度画面が更新されるのですが、それはbody更新処理①とheader更新処理②の組み合わせであって、body更新処理②ではありません。そのため画面が2段階切り替わることになり少しの間チラつきます。10msというと1フレーム以下ですので、そんな短時間だけ変更したSemantic UIはできれば表示されずにBootstrapだけが表示されて欲しいものです。
問題解決に必要な条件
こういった問題が起きないようにするために達成しなければならない必要条件を一つずつ説明します。
条件1 header更新処理は複数発生したらbody更新処理の前に実行されなければならない。
まず、header更新処理①が発生した時点でheader更新処理②が発生するかどうかは分かりません。そのため、即時実行されるのですが、header更新処理②が発生したとき、body更新処理①を待つことなく実行されなければなりません。でなければ、body更新処理②が実行されるまでDOMが最新ではなくなるため、画面がチラつくことになります。
また、header更新処理①が開始されるのが遅れるため、link
タグの並列読み込みが活用できず、DOMが最新になるまでに時間がかかります。
条件2 body更新処理は複数発生したら最新の1つだけ実行されなければならない。
body更新処理が複数発生したとき、最新の1つ以外はDOMを最新の状態ではない状態に更新してしまいますので、実行されてはいけません。
最悪、順番通りに実行されてもいいのですが、画面がチラつくことになります。
条件3 load
イベントが無くてもbody更新処理は実行しなければならない。
DOMにlink
要素が追加されても、後のupdate処理でlink
要素が消えてしまうと、load
イベントは発生しません。もし最終的にload
イベントがすべて消えてしまってもbody更新処理は実行されなくてはなりません。
理想の処理順
必要条件をもとに、理想の処理順を考えると次のようになります。
①も②もstate更新を観測したとき即時header更新処理が実行されます。そしてload
イベントがすべて発生するか、load
イベントが無くなったときに最新のbody更新処理が実行されます。
言ってみればこれは、state更新②が発生した時点でstate更新①のupdate処理については個別のload
イベント以外無視していることになります。
導き出したインターフェース
さて、load
イベントに着目してみます。
「load
イベントがすべて発生するか、load
イベントが無くなったとき」
これは2つの異なる事象を言っていますが、視点を変えてみると1つのことで表現できます。
「発生を待っているload
イベントが無くなったとき」
複数のload
イベントの発生を待っていたとして、load
イベントが発生すると、発生を待っているload
イベントが1つ無くなります。
また、link
要素が消えてload
イベントが発生しなくなったら、同じく発生を待っているload
イベントが1つ無くなります。
そしてすべての発生を待っているload
イベントが無くなったとき、最新のbody更新処理を実行することになります。
最新以外のbody更新処理は残しておく必要がありません。
ということで、次のようなインターフェースがあれば、問題を解決できそうです。
interface ISafeUpdater {
// body更新処理をセットする
// 発生を待っている`load`イベントの要素が0個のときbody更新処理は即時実行されて消える
setUpdateBody(callback: () => void): void
// 発生を待っている`load`イベントの要素を追加する
addWaitLink(link: HTMLLinkElement): void
// 発生を待っている`load`イベントの要素を削除する
// 0個になるとbody更新処理が実行されて消える
removeWaitLink(link: HTMLLinkElement): void
}
実装してみる
さっそく実装して上手く動くか検証してみます。
class SafeUpdater {
constructor() {
this._updateBody = null
this._waitLinkSet = new Set()
}
// body更新処理をセットする
set updateBody(callback) {
this._updateBody = callback
this._executeUpdateBody()
}
// 発生を待っている`load`イベントの要素を追加する
addWaitLink(link) {
this._waitLinkSet.add(link)
}
// 発生を待っている`load`イベントの要素を削除する
removeWaitLink(link) {
this._waitLinkSet.delete(link)
this._executeUpdateBody()
}
// 発生を待っている`load`イベントの要素が0個のとき
// body更新処理は実行されて消える
_executeUpdateBody() {
if (!this._waitLinkSet.size && this._updateBody) {
this._updateBody()
this._updateBody = null
}
}
}
// classプロパティの変更を監視
const state = {
_class: 1,
button: null,
safeUpdater: new SafeUpdater(),
// 更新が必要かどうか
isRequireUpdate: false,
get design() { return this._class },
set design(value) {
if (this._class !== value) {
this._class = value
// 既に更新が必要と分かっているときは何もしない
if (!this.isRequireUpdate) {
// 更新が発生した
this.isRequireUpdate = true
// setTimeoutで実行を後回しにする
setTimeout(() => {
// header update
// link要素を追加
// 即時実行される
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = value === 'Semantic UI' ?
'https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css' :
'https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.6.1/css/bootstrap.min.css'
document.body.append(link)
// loadイベントを待機する
this.safeUpdater.addWaitLink(link)
link.addEventListener('load', () => {
// loadイベントが発生したら削除
this.safeUpdater.removeWaitLink(link)
})
// body更新を追加
this.safeUpdater.updateBody = () => {
// body update
if (!this.button) {
// ボタンが無ければボタンを追加
this.button = document.createElement('button')
this.button.innerText = 'Click me'
document.body.append(this.button)
}
// button要素のクラスを変更
this.button.classList = value === 'Semantic UI' ? 'ui button' : 'btn btn-primary'
console.log('Updated:', value)
}
// 更新が完了した
this.isRequireUpdate = false
})
}
}
}
}
// 10ms後Semantic UIに変更
setTimeout(() => {
state.design = 'Semantic UI'
}, 10)
// 20ms後Bootstrapに変更
setTimeout(() => {
state.design = 'Bootstrap'
}, 20)
Semantic UIのボタンが表示されることは無く、最初からBootstrapのボタンになりました。
このコードでは、Semantic UIのlink
要素とBootstrapのlink
要素が両方append
されていましたが、もしreplaceChild
による置き換えでも成功します。
// classプロパティの変更を監視
const state = {
_class: 1,
button: null,
safeUpdater: new SafeUpdater(),
// 更新が必要かどうか
isRequireUpdate: false,
get design() { return this._class },
set design(value) {
if (this._class !== value) {
this._class = value
// 既に更新が必要と分かっているときは何もしない
if (!this.isRequireUpdate) {
// 更新が発生した
this.isRequireUpdate = true
// setTimeoutで実行を後回しにする
setTimeout(() => {
// header update
// 即時実行される
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = value === 'Semantic UI' ?
'https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css' :
'https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.6.1/css/bootstrap.min.css'
// link要素を追加もしくは置き換え
const oldLink = document.querySelector('link')
if (oldLink) {
document.body.replaceChild(link, oldLink)
} else {
document.body.append(link)
}
// loadイベントを待機する
this.safeUpdater.addWaitLink(link)
link.addEventListener('load', () => {
// loadイベントが発生したら削除
this.safeUpdater.removeWaitLink(link)
})
// body更新を追加
this.safeUpdater.updateBody = () => {
// body update
if (!this.button) {
// ボタンが無ければボタンを追加
this.button = document.createElement('button')
this.button.innerText = 'Click me'
document.body.append(this.button)
}
// button要素のクラスを変更
this.button.classList = value === 'Semantic UI' ? 'ui button' : 'btn btn-primary'
console.log('Updated:', value)
}
// 旧link要素を削除
// addWaitLinkよりも先にremoveWaitLinkを実行すると
// body更新が即時発生してしまうので注意
if (oldLink) {
this.safeUpdater.removeWaitLink(oldLink)
}
// 更新が完了した
this.isRequireUpdate = false
})
}
}
}
}
// 10ms後Semantic UIに変更
setTimeout(() => {
state.design = 'Semantic UI'
}, 10)
// 20ms後Bootstrapに変更
setTimeout(() => {
state.design = 'Bootstrap'
}, 20)
なんとか、問題解決まで漕ぎ着けることができました。
Author And Source
この問題について(仮想DOMのupdateが何度も実行される問題), 我々は、より多くの情報をここで見つけました https://zenn.dev/itte/articles/71ba4005f5fb40著者帰属:元の著者の情報は、元のURLに含まれています。著作権は原作者に属する。
Collection and Share based on the CC protocol