nextTickを正しく理解する〜Vueとマイクロタスク〜
突然ですがこのVueコンポーネントでボタンをクリックした時、どの順番でログが出力されると思いますか?分かる人はこの記事を読む必要はありません!解答は最後の方に書いてあります。
<script setup>
import { ref, nextTick } from 'vue';
const count = ref(0);
function increment() {
count.value++;
nextTick(() => {
console.log('nextTick');
});
setTimeout(() => {
console.log('setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('then');
});
}
</script>
<template>
<button @click="increment">count is {{ count }}</button>
</template>
nextTick
を使っている
俺たちは雰囲気でnextTick
、たまに使いますが使うときは雰囲気で使いがちですよね。少なくとも私はそうです。
今日は雰囲気でnextTick
を使うのを卒業しましょう!
ひとまず、公式ドキュメントを見てみます。
次の DOM 更新処理を待つためのユーティリティーです。
Vue でリアクティブな状態を変更したとき、その結果の DOM 更新は同期的に適用されません。その代わり、Vue は「次の tick」まで更新をバッファリングし、どれだけ状態を変更しても各コンポーネントの更新が一度だけであることを保証します。
状態を変更した直後に
nextTick()
を使用すると、DOM 更新が完了するのを待つことができます。引数としてコールバックを渡すか、戻り値の Promise を使用できます。
nextTick
は次のDOM更新処理を待つためのユーティリティです。では、nextTick
はどうやってDOM更新の完了を待っているのでしょうか?
nextTick
の実装を見てみる
これだけ見てもわからないですね😇
nextTick
を正しく理解するための前提知識
正しく理解するうえで、前提となる知識がいくつかあります。
- Promiseと
then
- タスク・マイクロタスクとイベントループ
- Vueとマイクロタスクの関係
それぞれ見ていきましょう!
then
PromiseとPromiseはJavaScriptにおける非同期な振る舞いの基本的な概念です。すべて説明しようとするととてつもない量になるので、ここではthen
の一部の振る舞いだけピックアップします。
then
はPromiseオブジェクトのプロトタイプメソッドで、新しいPromiseを返します。このPromiseはthen
の引数の関数の実行完了とともに解決されます。
例えば、以下のコードの1つ目のthen
の引数の関数は実行完了に約1秒かかるので、1つ目のthen
が返すPromiseが解決されるのは1秒後です。その直後に2つ目のthen
の引数の関数が実行されます。
// p1は解決済みのPromise
const p1 = Promise.resolve()
// p2は約1秒後に解決されるPromise
const p2 = p1.then(async () => {
await new Promise(resolve => setTimeout(resolve, 1000)); // 約1秒間待機する
return 'done!'
})
// p2が解決されたらすぐに引数の関数が実行される
p2.then((value) => {
console.log(value) // -> done!
})
then
の振る舞いは、いくつかパターンがあるので詳しくはMDNをご参照ください。
タスク・マイクロタスクとイベントループ
次にタスク・マイクロタスクとイベントループです。あまり聞き慣れない人もいるかもしれませんが、これもJavaScriptにおける重要な仕組みです。
- タスク:
setTimeout()
、scriptタグの評価、イベントリスナーなど - マイクロタスク:
Promise.prototype.then()
,queueMicrotask()
など - イベントループ:タスクとマイクロタスクをキューに入れて順番に処理するJavaScriptの反復処理
タスクとマイクロタスクを簡単なサンプルコードで理解していきましょう。
console.log('a')
setTimeout(() => {
console.log('b')
}, 0)
Promise.resolve().then(() => {
console.log('c')
})
console.log('d')
この出力順は a → d → c → b です。
JavaScriptのイベントループは タスク → マイクロタスク → タスク → マイクロタスク → ... の無限ループで実行されている、と思ってください(厳密にはもうちょっと複雑です)。
setTimeout
は第二引数のミリ秒後に第一引数の関数をタスクとして追加します。
then
はPromiseが解決された時に引数の関数をマイクロタスクとして追加します。
先程のサンプルコードをタスクとマイクロタスクに分けると以下のようになります。
// <-- Task 1
console.log('a')
setTimeout(() => {
// <-- Task 2
console.log('b')
// Task 2 -->
}, 0)
Promise.resolve().then(() => {
// <-- Microtask 1
console.log('c')
// Microtask 1 -->
})
console.log('d')
// Task 1 -->
つまり、Task 1 → Microtask 1 → Task 2 の順番で実行されます。
マイクロタスクで新たに追加されたマイクロタスクはすぐに実行される
では次のコードはどうでしょうか?
console.log('a')
setTimeout(() => {
console.log('b')
}, 0)
Promise.resolve().then(() => {
console.log('c')
}).then(() => {
console.log('d')
})
console.log('e')
この出力順は a → e → c → d → b です。
マイクロタスクは、実行されているマイクロタスクが新たにマイクロタスクを追加した場合、次のタスクの実行前に追加されたマイクロタスクを実行します。これがsetTimeout
の引数の関数のよりも2つ目のthen
の引数の関数が先に実行される理由です。
分解すると以下のようになります。
// <-- Task 1
console.log('a')
setTimeout(() => {
// <-- Task 2
console.log('b')
// Task 2 -->
}, 0)
Promise.resolve().then(() => {
// <-- Microtask 1
console.log('c')
// Microtask 1 -->
}).then(() => {
// <-- Microtask 2
console.log('d')
// Microtask 2 -->
})
console.log('e')
// Task 1 -->
つまり、Task 1 → Microtask 1 → Microtask 2 → Task 2 の順番で実行されます。
もし視覚的に理解したい場合は、JS Visualizer 9000 がおすすめです。
もっと詳しく理解したい方は以下の本がおすすめです。Chapter5と6あたりがここまでの話に近い内容です。
ここまでで、タスク・マイクロタスクとイベントループの仕組みをざっくりと理解できました。
Vueとマイクロタスク
次はVueとマイクロタスクの関係を見ていきましょう。公式ドキュメントにこんな記述があります。
リアクティブな状態を変化させると、DOM は自動的に更新されます。しかし、DOM の更新は同期的に適用されないことに注意する必要があります。
「同期的に適用されない=非同期」ということです。この非同期とは、「マイクロタスクで実行される」と置き換えられるので、VueはマイクロタスクでDOMを更新していると言えます。
つまり、マイクロタスクを理解すれば、どのタイミングでDOMが更新されるかわかりますね。
例えば、ref
が更新されるとマイクロタスクが一つ追加され、そのマイクロタスク内でDOMを更新します。
<script setup>
import { ref } from 'vue';
const count = ref(0);
</script>
<template>
<!-- count.value++ でVueはマイクロタスクを1つ追加する -->
<button @click="count.value++">{{ count }}</button>
</template>
count.value++
をすると、ref
の内部でvalue
を更新する際に、同時にDOM更新のマイクロタスクが1つ追加されます。
ref
のイメージは以下のコードのような感じです。
// refの擬似コード https://ja.vuejs.org/guide/extras/reactivity-in-depth.html#how-reactivity-works-in-vue
function ref(value) {
const refObject = {
get value() {
// getterでも実は色々してる
track(refObject, 'value')
return value
},
set value(newValue) {
value = newValue
// ↓triggerの実行から紆余曲折があって、最終的にDOMを更新するマイクロタスクが1つ追加される
trigger(refObject, 'value')
}
}
return refObject
}
この例はref
ですが、reactive
などの他のリアクティビティAPIでも同様です。
ここまでで、Vueとマイクロタスクの関係を掴めました。
nextTick
を理解する
前提となる知識を覚えた状態で、改めてnextTick
の実装とその周辺コードを見てみます。
const resolvedPromise = Promise.resolve()
let currentFlushPromise = null
// ...
export function nextTick(fn) {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(this ? fn.bind(this) : fn) : p
}
// ...
function queueFlush() {
if (!currentFlushPromise) {
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
https://github.com/vuejs/core/blob/v3.5.13/packages/runtime-core/src/scheduler.ts
currentFlushPromise
には、ref
の更新時になんやかんやで追加されたDOM更新処理に関わるPromiseが入っています。このPromiseはDOMの更新処理が完了したら解決されます。
// refの更新時に実行される関数
function queueFlush() {
if (!currentFlushPromise) {
// flushJobs ≒ DOM更新の関数
// thenはPromiseを返す。このPromiseはflushJobsが実行完了したら解決される。
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
https://github.com/vuejs/core/blob/v3.5.13/packages/runtime-core/src/scheduler.ts#L114-L118
そしてnextTick
はcurrentFlushPromise
の解決後(つまりDOM更新完了後)に、引数の関数を実行するマイクロタスクを追加します。これがnextTick
がDOM更新を待つことができる仕組みです。
export function nextTick(fn) {
// currentFlushPromiseはDOM更新処理のPromiseが入っている
const p = currentFlushPromise || resolvedPromise
// p.thenでflushJobsの完了後に、引数の関数を実行するマイクロタスクを追加する
return fn ? p.then(this ? fn.bind(this) : fn) : p
}
また、nextTick
は引数の関数があってもなくてもPromiseを返すため、await
やthen
も使えますね(むしろこっちの使い方のほうが多いかも)。
await nextTick()
console.log('after DOM update')
// あまり見ないがこうも書ける
nextTick().then(() => {
console.log('after DOM update')
})
これでnextTick
を理解できました!🥳
では最後に、冒頭のコードを振り返ってみましょう。
<script setup>
import { ref, nextTick } from 'vue';
const count = ref(0);
function increment() {
count.value++;
nextTick(() => {
console.log('nextTick');
});
setTimeout(() => {
console.log('setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('then');
});
}
</script>
<template>
<button @click="increment">count is {{ count }}</button>
</template>
これはタスクとマイクロタスクに分解すると以下のようになります。
function increment() {
// <-- Task 1
count.value++; // この裏でVueがMicrotask 1を追加する
nextTick(() => {
// <-- Microtask 3 (Microtask 1の完了後に追加される)
console.log('nextTick');
// Microtask 3 -->
});
setTimeout(() => {
// <-- Task 2
console.log('setTimeout');
// Task 2 -->
}, 0);
Promise.resolve().then(() => {
// <-- Microtask 2
console.log('then');
// Microtask 2 -->
});
// Task1 -->
}
ということは、Task 1 → Microtask 1 → Microtask 2 → Microtask 3 → Task 2 の順に実行されますね。
つまり、最初の質問の解答の出力順は then → nextTick → setTimeout です。
おわりに
今回は雰囲気で使いがちなnextTick
を細かく深掘ってみました。Vueの奥深さが感じられて楽しいですね。
この記事を書きながら、複数のリアクティブな状態を変化させても更新が一度だけなのか(=バッチ処理されるのか)、が気になったので、また調べて記事にしようと思っています。
Discussion