💨

仮想DOMのupdateが何度も実行される問題

2022/04/15に公開

Beako.jsが抱えている、3つの大きな問題の三つ目です。

一つ目と二つ目はこちら

https://zenn.dev/itte/articles/a80bd5a358ccbf

https://zenn.dev/itte/articles/b9f5d4616caa3d

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++
}

100回

図にすると次のように何度もupdate処理が実行されています。

何度も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++
}

1回

実行すると「Updated」は1回しか表示されません。

ここでのミソはsetTimeoutです。setTimeoutを遅延0で利用すると、実行を待っているタスクキューに即時追加されます。この場合、while文の直後に実行されることとなります。
JavaScriptはシングルスレッドですので、while文にどれだけ時間がかかろうとも、while文が終わるまで次に控えているsetTimeoutの中身が実行されることはありません。

setTimeout生成の際にisRequireUpdateプロパティをtrueにしていますので、残り99回のループではsetTimeoutは生成されません。

1回しか実行されない

問題は解決しました。

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とbodyのupdate

header更新処理を実行した後で、linkタグに書いたCSSファイルが読み込みが完了する(loadイベント)のを待ってからbody更新処理が実行されなければなりません。

「・・・」のところはブラウザの処理を待っているところですが、ここに新たなupdate処理が入ると、次のようにDOMが最新では無くなる恐れがあります。

2つのupdate処理

問題を再現

少しコードが長くなりますが、問題を再現すると次のようになります。先のsetTimeoutの方法を使っています。

js
// 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)

Bootstrapにならない

プログラムでは、まず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
}

実装してみる

さっそく実装して上手く動くか検証してみます。

SafeUpdaterクラス
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
    }
  }
}
SafeUpdaterクラスを使って改修
// 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)

Bootstrapが即時表示

Semantic UIのボタンが表示されることは無く、最初からBootstrapのボタンになりました。

このコードでは、Semantic UIのlink要素とBootstrapのlink要素が両方appendされていましたが、もしreplaceChildによる置き換えでも成功します。

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)

なんとか、問題解決まで漕ぎ着けることができました。

Discussion