Vue.nextTickとは?

callbackを延期し、DOMの更新サイクル後に実行します。DOM更新を待ち受けるために、いくつかのデータを変更した直後に使用してください。

VueはDOMを非同期に更新するため、「DOMを更新した後にその更新済みのDOMに対して何らかの処理をする」といったような場面でnextTickが役立ちます。

// single file component

<template>
  <div>{{ message }}</div>
</template>

<script>
  export default {

    data() {
      return { message: 'default' }
    },

    mounted() {
      this.message = 'hello';
      console.log(this.$el.textContent);    // default この時点ではまだDOMは更新されていない
      this.nextTick(() => {
        console.log(this.$el.textContent);  // hello   DOM更新後にこのコードに到達する 
      });  
    }
  }

</script>

dataの値を更新し、それをDOMに反映するコードです。

このときVueは、dataの更新処理をある程度ため込み、同じDOMを更新する処理が複数件たまった場合はまとめて1回だけ更新します。つまり非同期的に(コードの記述順とは違った順序で)DOMが更新されるということです。

もう少し正確な言葉で言うと、あるイベントループにおいて同じwatcherが複数回更新される場合は、タスクキューには重複除外した1つのタスクが詰められます。そして次のイベントループでタスクを順次捌いていきます。

そしてこの後に、つまりタスクキューに入った処理が全て完了した後に何か別の処理を行う方法がnextTickというわけです。

コードリーディングしてみる

src/core/util/next-tick.js に実装されています。コード量が少なく他モジュールへの依存も薄いので、読みやすそうです。ちなみに執筆時点で最新のv2.5.17を参考にしています。

nextTick関数本体
const callbacks = []
let pending = false

/* 中略 */

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  /* 以下省略 */

主要な部分だけピックアップしています。nextTick関数は引数で受け取ったcbcallbacksに詰め、macroTimerFuncもしくはmicroTimerFuncを呼び出しています。これらがどんな関数か見てみます。

macroTimerFunc と microTimerFunc の実装
// 補足: ①callbacksに詰め込んだ関数を実行する本体
function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

// 補足: ②macroTimerFuncの定義
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
  /* 中略 */
} else {
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

// 補足: ③microTimerFuncの定義
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
    /* 省略 */
  }
} 
  1. flushCallbacksは、callbacksの中身を取り出して実行し、callbacksを初期化しています
  2. macroTimerFuncは、setImmediateもしくはsetTimeoutを使ってflushCallbacksを呼び出しています
  3. microTimerFuncは、Promise.resolve()を使ってflushCallbacksを呼び出しています

ここで登場する macro tasksmicro tasks という考え方はPromises/A+にも記載があり、どちらも現在のイベントループ内の全てのタスクの実行完了後に、タスクを追加で実行する手法を指しています。
nextTickはこの手法を利用して「DOMの更新後に処理をする」ことを実現しているわけですね。

改めて macro tasks と micro tasks の実装を確認してみます。

macro tasks
setImmediate(() => {});

// or

setTimeout(() => {}, 0);

micro tasks
Promise.resolve().then(() => {});

いずれも「イベントループの後に処理を実行する」のでどちらを使ってもnextTickの目的は果たせそうですが、Vueはこれらを使い分けていて、その理由についてコード内にコメントされています。

In < 2.4 we used microtasks everywhere, but there are some scenarios where microtasks have too high a priority and fire in between supposedly sequential events (e.g. #4521, #6690) or even between bubbling of the same event (#6566).
However, using (macro) tasks everywhere also has subtle problems when state is changed right before repaint (e.g. #6813, out-in transitions).
Here we use microtask by default, but expose a way to force (macro) task when needed (e.g. in event handlers attached by v-on).

Vue2.4以前までは micro tasks のみを利用していていましたが、それだと実行タイミングが早すぎてしまうケースがあり、場面に応じて macro tasks と使い分けているということですね。
言い換えると、これらは実行タイミングが違うということです。

実行タイミングの検証

macro tasks と micro tasks の実行タイミングはどのように違うのでしょうか? 簡単なコードで検証してみます。

// execute nextTick(macroTask)
macroTask(() => log('call from macroTask (use setTimeout)'));

// execute nextTick(microTask)
microTask(() => log('call from microTask (use Promise)'));


function macroTask(fn) {
  setTimeout(fn, 0);
}

function microTask(fn) {
  Promise.resolve().then(fn);
}

function log(_message) {
  const message = `${logIndex++}: ${_message} (${performance.now()})`;
  console.log(message);
}
// console

1: call from microTask (use Promise) (469.1000000020722)
2: call from macroTask (use setTimeout) (488.5999999969499)

macroTask と microTask をそれぞれ実行します。
何度やってもPromiseを利用した microTask が先に実行されます。

同期処理をはさんでみます。

// execute nextTick(macroTask)
macroTask(() => log('call from macroTask (use setTimeout)'));

// execute nextTick(microTask)
microTask(() => log('call from microTask (use Promise)'));

// execute sync function
syncSomething();


function syncSomething() {
  const startTime = Date.now();
  while (Date.now() - startTime < 1000) {}
  log('call from sync function');
}
//console

1: call from sync function (1525.500000003376)
2: call from microTask (use Promise) (1525.8000000030734)
3: call from macroTask (use setTimeout) (1529.900000008638)

同期処理(タスクキューにあるタスク)を実行した後に microTask » macroTask の順で実行されます。この挙動については、MDNのRun-to-completionメッセージの追加に詳細が記載されています。

最後に、macro tasks と micro tasks がDOM更新処理と絡んできた場合にどのように動作するのかを検証したコードを残しておきます。

nextTickとは直接関係ない余談ですが、DOMの更新ロジックが完了してもすぐに画面に反映されずイベントループの後で反映される、というのも見どころです。