はじめに
普段の開発でなかなかお目にかかることも使うことも滅多にない queueMicrotask
について興味を持ったついでに、 process.nextTick
や Promise
, setTimeout
などとの比較をしてみました。
TL;DR
- 下記の優先度順でキューからタスクが取り出され処理される。
nextTick
-
queueMicrotask
,Promise.resolve
- → Microtask
-
setTimeout
- → Task (a.k.a. Macrotask)
- より具体的には、MicrotaskキューとTaskキューの2つのキューがあり、Microtaskキューが優先的に処理されるようになっている。
- Microtaskキューが空でない限りはTaskキューの中身を実行しない構造となっており、
queueMicrotask
,Promise.resolve
では Microtaskキューにコールバックを追加し、setTimeout
等ではTaskキューに追加している。
- Microtaskキューが空でない限りはTaskキューの中身を実行しない構造となっており、
サンプルコード
const cb = (msg) => () => console.log(msg);
cb('Top level 1')();
process.nextTick(cb('process.nextTick 1'));
queueMicrotask(cb('queueMicrotask 1'));
Promise.resolve().then(cb('Promise 1'));
setTimeout(cb('setTimeout 1'), 0);
setTimeout(cb('setTimeout 2'), 0);
Promise.resolve().then(cb('Promise 2'));
queueMicrotask(cb('queueMicrotask 2'));
process.nextTick(cb('process.nextTick 2'));
cb('Top level 2')();
上記のコードを実行した際の標準出力はどうなるでしょう?
結果は以下のようになります。
Top level 1
Top level 2
process.nextTick 1
process.nextTick 2
queueMicrotask 1
Promise 1
Promise 2
queueMicrotask 2
setTimeout 1
setTimeout 2
このことから以下の挙動が考察されます。
- トップレベルで呼び出されたものは最も早く処理される。
-
process.nextTick
はqueueMicrotask
や Promise よりも優先的に処理される。 -
queueMicrotask
とPromise.resolve
は同じキューを使用しており、登録順に処理されている。- Microtaskキューに追加されている。
Q1. Microtask内で新たにMicrotaskを追加した場合はどうなるのか?
- Microtaskキューに追加されている。
-
setTimeout
はqueueMicrotask
やPromise.resolve
のコールバックをすべて実行したあとに呼び出されている。- → Taskキューに追加されている。
- → Taskキューの中身の実行は、Microtaskキューの中身が空になってから行われる。
Q2. Task内でMicrotaskを追加した場合はどうなるのか?
Q1. Microtask内で新たにMicrotaskを追加した場合はどうなるのか?
結論: Microtask実行後、Taskの実行へと移行せず、新たに追加したMicrotaskを実行する。
const cb = (msg) => () => {
console.log(msg)
};
// Taskキューにコールバックを追加
setTimeout(cb("Task"), 0);
// Microtaskキューにコールバックを追加
queueMicrotask(() => {
console.log("queueMicrotask 1");
// Microtask内で更にMicrotaskキューにコールバックを追加
queueMicrotask(() => {
console.log("queueMicrotask 2");
})
})
標準出力
queueMicrotask 1
queueMicrotask 2
Task
上記の結果からも分かる通り、無限ループの可能性がある。
Taskキューの実行にはMicrotaskキューが空になる必要があることから、setTimeout
などの後続の処理の実行を止めてしまう可能性があるため、使用する際には注意する必要がある。
Q2. Task内でMicrotaskを追加した場合はどうなるのか?
結論: Taskが完了してからMicrotaskが実行される。
// Taskキューにコールバックを追加
setTimeout(() => {
console.log('setTimeout');
// Task内でMicrotaskを追加
queueMicrotask(() => {
console.log("queueMicrotask 1");
})
}, 0);
標準出力
setTimeout
queueMicrotask 1
Q3. で、queueMicrotask
はどんな用途があるの?
結論: 非同期処理を模したい場合や、バッチ処理の用途がありそう。でも普段の開発ではめったに使わないかも。
非同期処理を模したいケースとして、MDNに乗っていたサンプルをそのまま利用し説明します。
下記のコードでは、 getData
というプロパティに、キャッシュがあれば同期的にイベントをディスパッチし、無ければ非同期的に情報を取得するというハンドラーをセットしています。
customElement.prototype.getData = (url) => {
if (this.cache[url]) {
this.data = this.cache[url];
this.dispatchEvent(new Event("load"));
} else {
fetch(url)
.then((result) => result.arrayBuffer())
.then((data) => {
this.cache[url] = data;
this.data = data;
this.dispatchEvent(new Event("load"));
});
}
};
この場合、以下のコードを実行した際の結果は、キャッシュがある場合と無い場合で変わってきます。
element.addEventListener("load", () => console.log("Loaded data"));
console.log("Fetching data…");
element.getData();
console.log("Data fetched");
キャッシュが無い場合、else句に入り非同期処理を行うため内部ではmicrotaskへの追加はを行っています。よって標準出力は以下の通りになります。
Fetching data
Data fetched
Loaded data
Data fetched
の後に、 Loaded data
が表示されています。
一方でキャッシュがある場合、if文の中に入り同期的にイベントのディスパッチを行うため、よって以下の標準出力になります。
Fetching data
Loaded data
Data fetched
Loaded data
が Data fetched
よりも先に表示されていることが分かります。
これは同期的に処理を行うため順序が異なってしまっており、Fetchedの前にLoadedと表示される不思議な結果となってしまっています。
そこで非同期的に処理を行いつつ、かつなるべく早くイベントをディスパッチするために、 queueMicrotask
が役に立ちます。つまり、以下のように変更を加えることで想定する動作を達成することが出来ます。
customElement.prototype.getData = (url) => {
if (this.cache[url]) {
queueMicrotask(() => {
this.data = this.cache[url];
this.dispatchEvent(new Event("load"));
});
} else {
fetch(url)
.then((result) => result.arrayBuffer())
.then((data) => {
this.cache[url] = data;
this.data = data;
this.dispatchEvent(new Event("load"));
});
}
};
参考資料
queueMicrotask() - Web APIs | MDN
Using microtasks in JavaScript with queueMicrotask() - Web APIs | MDN