◆ scheduler.postTask でタスクを登録できる
◆ Promise で扱える setTimeout みたいなもの
◆ 優先度を指定できる

Chrome 113 の新機能を見ていたら Developer trial ですが scheduler.yield() というのがありました
そもそも scheduler ってなんだっけ?と思って調べると Chrome 94 で追加された機能でした
https://developer.mozilla.org/en-US/docs/Web/API/Scheduler

postTask

scheduler.postTask(() => {})

でタスクを実行できるというものです
タスクは関数で渡します

返り値として Promise を受け取れます

new Promise(() => {})

と似たようなものです

実行タイミング

scheduler という名前的にも あとから実行するものになってます

new Promise() は同期的に実行されます

console.log(1)
new Promise(() => {
console.log(2)
})
console.log(3)
1
2
3

Promise.resolve().then() は非同期で マイクロタスクとして登録されます

console.log(1)
queueMicrotask(() => {
console.log(3)
})
setTimeout(() => {
console.log(6)
})
Promise.resolve().then(() => {
console.log(4)
})
queueMicrotask(() => {
console.log(5)
})
setTimeout(() => {
console.log(7)
})
console.log(2)
1
2
3
4
5
6
7

scheduler.postTask はマイクロタスクではなく setTimeout に 0 を指定するのと同じタイミングです

console.log(1)
queueMicrotask(() => {
console.log(3)
})
setTimeout(() => {
console.log(5)
})
scheduler.postTask(() => {
console.log(6)
})
queueMicrotask(() => {
console.log(4)
})
setTimeout(() => {
console.log(7)
})
console.log(2)
1
2
3
4
5
6
7

scheduler.postTask より前に登録した setTimeout と後に登録した setTimeout の間で実行されています

優先度

scheduler では優先度を設定することもできて 優先度を上げると setTimeout より先に実行され 優先度を下げると setTimeout より後に実行されるようです
優先度を上げてもマイクロタスクにはならないです
優先度を上げる場合は priority プロパティに user-blocking を指定します

setTimeout(() => {
console.log(3)
})
scheduler.postTask(() => {
console.log(2)
}, { priority: "user-blocking" })
setTimeout(() => {
console.log(4)
})
queueMicrotask(() => {
console.log(1)
})
1
2
3
4

コンソールで実行していると 1 と 2 の間で実行結果の undefined が表示されます

優先度を下げるには priority プロパティに background を指定します

setTimeout(() => {
console.log(2)
})
scheduler.postTask(() => {
console.log(4)
}, { priority: "background" })
setTimeout(() => {
console.log(3)
})
queueMicrotask(() => {
console.log(1)
})
1
2
3
4

現状優先度は 3 段階で 指定しない場合のデフォルトは user-visible です
これだと上に書いたように setTimeout で即実行させるのと同じタイミングになっています

user-blocking という名前から タスクの処理中はユーザーの処理をブロックすることができて fetch でも昔の XHR みたいに結果を受け取るまでユーザーが操作できなくできるのかなと思いましたが そういうものではなかったです
優先度を上げることでブロックするのではなく 画面の表示などブロックしてしまう処理を行うタスクなので優先度を上げて実行してほしい ということを伝えるもののようです
setTimeout で 0 設定された処理より先に行うことでユーザーの操作をブロックする時間を短くできるということなのでしょう

並列

優先度があると順番に実行していくのかなと思ったりもしましたが setTimeout などで登録する処理と同じで並列に動作します

const now = Date.now()

scheduler.postTask(async () => {
await new Promise(r => setTimeout(r, 1000))
}).then(() => {
console.log(1, Date.now() - now)
})

scheduler.postTask(async () => {
await new Promise(r => setTimeout(r, 1000))
}).then(() => {
console.log(2, Date.now() - now)
})

scheduler.postTask(async () => {
await new Promise(r => setTimeout(r, 1000))
}).then(() => {
console.log(3, Date.now() - now)
})
1 1013
2 1013
3 1013

1 秒かかるタスクを 3 つ登録しても 1 秒後に全部終わってます
順番に実行したい場合は Promise が返ってくるので then の中で処理を行うか then の中で再度次の処理をタスクとして登録するか になります

ディレイ

delay で実行を遅延させることもできます
setTimeout を使うようなものです
優先度を上げていてもディレイがあればその分あとにはなります
delay と setTimeout が同じだった場合は ここでも「優先度を上げているもの」→「setTimeout と通常優先度」→「優先度を下げているもの」の順になるようです

setTimeout(() => {
console.log(2)
})
setTimeout(() => {
console.log(5)
}, 1000)
scheduler.postTask(() => {
console.log(8)
}, { delay: 1000, priority: "background" })
scheduler.postTask(() => {
console.log(6)
}, { delay: 1000, priority: "user-visible" })
scheduler.postTask(() => {
console.log(4)
}, { delay: 1000, priority: "user-blocking" })
setTimeout(() => {
console.log(3)
})
setTimeout(() => {
console.log(7)
}, 1000)
queueMicrotask(() => {
console.log(1)
})
1
2
3
4
5
6
7
8

返り値

return した値が Promise に入ってます
Promise.resolve().then() と同じ感じです

await scheduler.postTask(async () => {
await new Promise(r => setTimeout(r, 1000))
return 10
})
// 10

await Promise.resolve().then(() => {
await new Promise(r => setTimeout(r, 1000))
return 10
})
// 10

タスクの処理中のエラーの場合は then メソッドの第二引数のコールバック関数や catch メソッドのコールバック関数で受け取れます
ここも普通の Promise と同じです

中止

fetch などのように AbortController を使ってタスクを中止できます
ただ中止と言っても実行が始まってから途中で停止はできません

const abort_controller = new AbortController()

scheduler.postTask(
async () => {
await new Promise(r => setTimeout(r, 1000))
console.log(1)
await new Promise(r => setTimeout(r, 1000))
console.log(2)
await new Promise(r => setTimeout(r, 1000))
console.log(3)
return "OK"
},
{ signal: abort_controller.signal }
).then(console.log, console.error)

setTimeout(() => {
abort_controller.abort()
}, 1500)
1
2
3
OK

1 だけ表示されて終了してほしいのに 2 と 3 も表示されて then では OK を受け取れています
自分から中断状態かをチェックしない限り await の部分でエラーを起こして止めるのは JavaScript の仕組み上 難しいので 2 と 3 が出るのは仕方ないにしても then で エラー扱いにもなりません
タスクの処理を最後まで実行している以上 成功と出てほしいのかもですが これだとあまり使い所がありません
clearTimeout で setTimeout の登録を削除するようなものです

中止できるのはこういうケースです

const abort_controller = new AbortController()

scheduler.postTask(
async () => {
await new Promise(r => setTimeout(r, 1000))
console.log(1)
await new Promise(r => setTimeout(r, 1000))
console.log(2)
await new Promise(r => setTimeout(r, 1000))
console.log(3)
return "OK"
},
{ signal: abort_controller.signal, delay: 2000 }
).then(console.log, console.error)

setTimeout(() => {
abort_controller.abort()
}, 1500)
signal is aborted without reason

delay を設定しておき タスクが実行されるより前に abort します

途中で中止したいなら自分で await のたびにチェックしてこういうふうにする必要があります

const abort_controller = new AbortController()
const check = () => {
if (abort_controller.signal.aborted) throw new Error("ABORTED")
}

scheduler.postTask(
async () => {
await new Promise(r => setTimeout(r, 1000))
check()
console.log(1)
await new Promise(r => setTimeout(r, 1000))
check()
console.log(2)
await new Promise(r => setTimeout(r, 1000))
check()
console.log(3)
return "OK"
},
{ signal: abort_controller.signal }
).then(console.log, console.error)

setTimeout(() => {
abort_controller.abort()
}, 1500)
1
Error: ABORTED

TaskController

シグナルには AbortController 以外に TaskController のシグナル (TaskSignal) を渡すこともできます
TaskSignal では優先度の変更を通知することができ 途中で優先度を変更できます
途中で優先度を変更したいなんてことはあまりないですし 中止と同じで実行前じゃないと効果ないのだと使い所が思いつかないです

また postTask のオプションで優先度が指定されるとそっちが優先されるようで TaskSignal の優先度を使う場合は postTask の priority オプションは省略しないといけないようです

使い所

機能が色々ある割にはあまり使い所がないように思います
別に setTimeout でいいんだけど という印象です

マイクロタスクじゃなくて setTimeout と同じような扱いで Promise が使えて返り値を受け取れるというメリットはあるので 少し遅延させて処理して結果を受け取りたいけどマイクロタスクではなくて setTimeout のタイミングがいいというときに使うくらいでしょうか
ライブラリがまとめて処理するために処理を setTimeout で遅延させてるようなケースはありますし そういうときに Promise.resolve().then() を使って遅延させてライブラリの処理後のデータを取得しようとしてもまだ実行されていなくて取得できず setTimeout にするケースがあります
setTimeout は Promise じゃないので一工夫が必要で書きづらさや読みづらさがあるので そういうときの代替にはいいのかもです

// この処理でライブラリの更新が行われるけど同期的には行われない
something()

// ここで更新後の値を取得したい
// しかし同期的に取得してもライブラリの処理がまだ行われてないので更新されてない
getValue()

// マイクロタスクで非同期処理化してもライブラリがマイクロタスク中の処理をまとめるのが目的だと
// マイクロタスクの遅延ではまだ更新されてない
await Promise.resolve().then(() => {
return getValue()
})

// ライブラリと同じ遅延時間で setTimeout していれば後で登録されたこの処理のほうが
// 後で実行されるので目的の値を取得できる が見づらいし長いし書きづらい
await new Promise((r => setTimeout(r))).then(() => {
return getValue()
})

yield

Chrome 113 で Developer trial が始まった scheduler.yield() ですが 重たいタスクの実行中に 一旦中断して処理をイベントループに戻すもののようです
https://chromestatus.com/feature/6266249336586240

これまででも重たい処理をメインスレッドで実行する場合 同期的に長時間処理が行われると画面が固まったりするので間で setTimeout を挟むようなことはありました

for (const item of many_items) {
something()

await new Promise(r => setTimeout(r))
}

1 件の処理ごとに await でイベントループに戻って他の処理ができるので 長時間ユーザーの操作をブロックしません
これと同じようなものみたいです

ぱっと見ではなにしてるか分かりづらい書き方なので専用の書き方ができるのはよさそうですね

scheduler.postTask(() => {
for (const item of many_items) {
something()

await scheduler.yield()
}
})

こういう仕組みがあるなら yield のタイミングで中止もできそうに思います
リンク先にあった提案内容から簡単に探すと abort に関する記載もあったので サポートしてくれそうです
ただ このタイミングでのタスクの中止を望まない開発者もいるのでオプションで signal を継承するかどうかで設定するみたいです
タスクを開始してる以上 途中で止められたくなくて 止めるなら適切な終了処理をしたいというのはわかります
でも yield で得られる Promise が resolve も reject のされないままならともかく reject されて停止するなら try-catch で囲んで catch すればいいだけのようにも思います
catch で何もせず 中止を無視して続行させることもできますし

まだデフォルトで有効化されるスケジュールも決まってないようですし 正式なリリースが近づいてきたら実際に使って試してみようと思います