Vue.jsにおけるnextTickの理解

Vue.jsのnextTickは、DOMの非同期更新に関連する機能であり、非常に興味深い概念です。この記事では、nextTickの基本的な使い方と内部動作について解説します。

一、実装例

まず、Vue.jsにおけるDOM更新とnextTickの挙動を理解するための例を見てみましょう。

テンプレート

<div class="app">
  <div ref="messageDiv">{{message}}</div>
  <div v-if="message1">$nextTick外で取得したメッセージ: {{message1}}</div>
  <div v-if="message2">$nextTick内で取得したメッセージ: {{message2}}</div>
  <div v-if="message3">$nextTick外で取得したメッセージ: {{message3}}</div>
  <button @click="updateMessage">
    メッセージを変更
  </button>
</div>

Vueインスタンス

new Vue({
  el: '.app',
  data: {
    message: 'こんにちは、Vue。',
    message1: '',
    message2: '',
    message3: ''
  },
  methods: {
    updateMessage() {
      this.message = "こんにちは、世界。"
      this.message1 = this.$refs.messageDiv.innerHTML
      this.$nextTick(() => {
        this.message2 = this.$refs.messageDiv.innerHTML
      })
      this.message3 = this.$refs.messageDiv.innerHTML
    }
  }
})

クリック前

クリック後

上記の例から、message1とmessage3は更新前の内容が表示されているのに対し、message2は更新後の内容が表示されていることがわかります。これは、Vue.jsにおけるDOM更新が非同期で行われるためです(詳細な説明は後述)。

二、使用シナリオ

次に、nextTickの主な使用シナリオとその理由について説明します。

  • Vueライフサイクルのcreated()フック関数内で行うDOM操作は、必ずVue.nextTick()のコールバック関数内に記述する必要があります

created()フック関数が実行される際、DOMはまだレンダリングされておらず、この時点でDOM操作を行っても効果がありません。そのため、DOM操作を行うJavaScriptコードは必ずVue.nextTick()のコールバック関数内に配置する必要があります。対照的なのがmounted()フック関数で、この関数が実行される時点ではすべてのDOMのマウントとレンダリングが完了しているため、このフック関数内でDOM操作を行っても問題ありません。

  • データ変更後に実行する必要があり、その操作がデータ変更に伴って変更されるDOM構造を使用する必要がある場合、その操作はVue.nextTick()のコールバック関数内に配置する必要があります。

Vueの公式ドキュメントには以下のように説明されています:

VueはDOM更新を非同期で実行します。データの変更が検出されると、Vueはキューを開始し、同じイベントループで発生するすべてのデータ変更をバッファリングします。同じウォッチャーが複数回トリガーされた場合、キューには一度だけ追加されます。この重複データの排除は、不要な計算とDOM操作を避ける上で非常に重要です。次に、次のイベントループ"tick"で、Vueはキューをフラッシュし、実際の(重複排除済みの)作業を実行します。Vueは内部で、ネイティブのPromise.thenMessageChannelを使用して非同期キューを処理しようとします。実行環境がサポートしない場合は、setTimeout(fn, 0)を使用します。

例えば、vm.someData = 'new value'を設定した場合、コンポーネントはすぐには再レンダリングされません。キューがフラッシュされると、コンポーネントはイベントループキューが空になった次の"tick"で更新されます。ほとんどの場合、このプロセスを気にする必要はありませんが、DOMの状態が更新された後に何かを行いたい場合、問題が発生することがあります。Vue.jsは通常、「データ駆動」の思考方式を開発者に奨励し、直接DOMに触れることを避けますが、時々直接DOMを操作する必要があります。データ変更後にVueがDOMの更新を完了するのを待ちたい場合は、データ変更直後にVue.nextTick(callback)を使用できます。これにより、コールバック関数はDOM更新完了後に呼び出されます。

三、nextTickのソースコード解析

機能

Vue.nextTickはコードの実行を遅らせるために使用され、2つのパラメータ(コールバック関数とコールバック関数の実行コンテキスト)を受け取ります。コールバック関数が提供されない場合、promiseオブジェクトが返されます。

ソースコード

/**
 * タスクを非同期で実行するために遅延させる
 */
export const nextTick = (function () {
  const callbackList = []
  let isPending = false
  let timerFunction

  function nextTickHandler () {
    isPending = false
    const copiedCallbacks = callbackList.slice(0)
    callbackList.length = 0
    for (let i = 0; i < copiedCallbacks.length; i++) {
      copiedCallbacks[i]()
    }
  }

  // nextTickの動作はマイクロタスクキューを利用しており、ネイティブのPromise.thenまたはMutationObserver経由でアクセスできます
  // MutationObserverはより広いサポートがありますが、iOS >= 9.3.3のUIWebViewではタッチイベントハンドラでトリガーされた場合、深刻なバグがあります
  // 数回トリガーすると完全に停止してしまうため、ネイティブのPromiseが利用可能な場合はそれを使用します:
  /* istanbul ignore if */
  if (typeof Promise !== 'undefined' && isNative(Promise)) {
    var promise = Promise.resolve()
    var logError = err => { console.error(err) }
    timerFunction = () => {
      promise.then(nextTickHandler).catch(logError)
      // 問題のあるUIWebViewでは、Promise.thenは完全に壊れるわけではなく、コールバックがマイクロタスクキューにプッシュされるもののキューがフラッシュされない奇妙な状態になることがあります
      // ブラウザがタイマーなどの他の作業を行う必要があるまでです。そのため、空のタイマーを追加することでマイクロタスクキューを"強制的に"フラッシュできます。
      if (isIOS) setTimeout(noop)
    }
  } else if (!isIE && typeof MutationObserver !== 'undefined' && (
    isNative(MutationObserver) ||
    // PhantomJSとiOS 7.x
    MutationObserver.toString() === '[object MutationObserverConstructor]'
  )) {
    // ネイティブのPromiseが利用できない場合(例: PhantomJS、iOS7、Android 4.4)にMutationObserverを使用します
    var counter = 1
    var observer = new MutationObserver(nextTickHandler)
    var textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
      characterData: true
    })
    timerFunction = () => {
      counter = (counter + 1) % 2
      textNode.data = String(counter)
    }
  } else {
    // setTimeoutにフォールバック
    /* istanbul ignore next */
    timerFunction = () => {
      setTimeout(nextTickHandler, 0)
    }
  }

  return function queueNextTick (callback?: Function, context?: Object) {
    let _resolve
    callbackList.push(() => {
      if (callback) {
        try {
          callback.call(context)
        } catch (e) {
          handleError(e, context, 'nextTick')
        }
      } else if (_resolve) {
        _resolve(context)
      }
    })
    if (!isPending) {
      isPending = true
      timerFunction()
    }
    if (!callback && typeof Promise !== 'undefined') {
      return new Promise((resolve, reject) => {
        _resolve = resolve
      })
    }
  }
})()

まず、nextTickで定義されている3つの重要な変数を理解しましょう。

  • callbackList

実行が必要なすべてのコールバック関数を格納するために使用されます

  • isPending

コールバック関数が実行中かどうかを示すフラグ

  • timerFunction

コールバック関数の実行をトリガーするために使用されます

次に、nextTickHandler()関数を見てみましょう。

function nextTickHandler () {
    isPending = false
    const copiedCallbacks = callbackList.slice(0)
    callbackList.length = 0
    for (let i = 0; i < copiedCallbacks.length; i++) {
      copiedCallbacks[i]()
    }
  }

この関数は、callbackListに格納されているすべてのコールバック関数を実行するために使用されます。

次に、トリガーメソッドをtimerFunctionに割り当てます。

  • まず、ネイティブでPromiseがサポートされているかどうかを判断し、サポートされている場合はPromiseを使用してコールバック関数の実行をトリガーします。
  • それ以外の場合は、MutationObserverがサポートされているかどうかを判断し、MutationObserverのインスタンスを作成してテキストノードの変更を監視し、変更が検出されたときにすべてのコールバック関数の実行をトリガーします。
  • どちらもサポートされていない場合は、タイムアウトを0に設定したsetTimeoutを使用します。

最後に、queueNextTick関数です。nextTickは即時関数であるため、queueNextTick関数が返される関数であり、ユーザーが渡したパラメータを受け取り、コールバックをcallbackListに格納するために使用されます。

上記は全体の実行フローであり、重要な点はtimerFunction()です。この関数は遅延実行の役割を果たします。上記の紹介から、timerFunction()には合計3つの実装方式があることがわかります。

  • Promise
  • MutationObserver
  • setTimeout

その中でPromisesetTimeoutは理解しやすく、同期タスクとDOM更新の非同期タスクの後に具体的な関数をコールバックします。

次に、MutationObserverについて詳しく説明します。

MutationObserverはHTML5の新しいAPIで、DOMの変更を監視するためのインターフェースです。子ノードの削除、属性の変更、テキスト内容の変更など、DOMオブジェクトで発生する変更を監視できます。 呼び出しプロセスは非常に簡単ですが、少し通常とは異なります:まずコールバックをバインドする必要があります:

var mutationObserver = new MutationObserver(callback)

MutationObserverのコンストラクタにコールバックを渡すことで、MutationObserverのインスタンスを取得でき、このコールバックはMutationObserverのインスタンスが変更を検出したときにトリガーされます。

この時点では、MutationObserverのインスタンスにコールバックをバインドしただけです。具体的にどのDOMを監視し、ノードの削除を監視するのか、属性の変更を監視するのかなどはまだ設定していません。そのインスタンスのobserveメソッドを呼び出すことでこれを設定できます:

var domTarget = 監視したいDOMノード
mutationObserver.observe(domTarget, {
      characterData: true // テキスト内容の変更を監視することを示します
})

nextTickにおけるMutationObserverの役割は上記の通りです。DOMの更新が検出されたら、コールバック関数を呼び出します。実際にMutationObserverを使用する理由は、nextTickが現在の同期コードの実行が完了した後に、実行したい非同期コールバックを実行するための非同期APIを必要としているからです。その中にはPromisesetTimeoutもこの理由に基づいています。さらに深く掘り下げるとmicrotaskなどの概念が関係してきますが、ここでは詳しく説明しません。

タグ: vue.js nexttick DOM更新 非同期処理 javascript

8月19日 02:26 投稿