🐥

イベントループを踏まえて気をつけるべきことの抜粋

2023/01/24に公開

前回の記事(下記)は仔細に入りすぎ感があったので、自分用に要点を抽出してみた。

(イベントループの仕様は実行系に左右されるけど、今回の話はすべてブラウザに限定されます)

イベントループの復習

ブラウザのいろいろな仕事の分類のされ方

タスク

  • イベントへの反応
  • HTMLパーサによるパース
  • コールバックの実行
  • リソースの使用(??)
  • DOM 操作への反応

タスクの何たるかはwhatwgがここで定義している
https://html.spec.whatwg.org/multipage/webappapis.html#definitions-3

マイクロタスク

  • MutationObserverで登録しておいたコールバック関数
  • Promiseの解決時に呼び出されるコールバック関数
  • queueMicrotask()で登録されたコールバック関数

アニメーションコールバック

  • rAF()で登録したコールバック関数

各種仕事の行われ方

最強の動画

この動画をこのタイミングで再生すると、各仕事のキューの処理のされ方が分かる。
タスクは1つずつ、マイクロタスクはある限り消費し続ける、アニメーションコールバックは今ある分を消費しきったら一旦終わる。

https://www.youtube.com/watch?v=cCOL7MC4Pl0&t=1657

コード

ブラウザ環境のイベントループの擬似コード(下記リンク先より引用)
while (true) {
    queue = getNextQueue();
    task = queue.pop();
    execute(task);

    while (micortaskQueue.hasTasks()) {
        doMicrotask();
    }

    if(isReapintTime()) {
        animationTasks = animationQueue.copyTasks();
        for(task in animationTasks) {
            doAnimationTask(task);
            while (micortaskQueue.hasTasks()) {
                doMicrotask();
            }
        }
        repaint();
    }
}

体感してみる

おもしろい示唆

Promiseの順序保証

PromiseがMicrotaskであるおかげで、非同期処理まわりの処理順序を確定できる。下記のような関数を実行した時、処理Bがどれだけ時間を必要としても/どれだけ一瞬で終わろうとも、Aは処理Bのあとに行われることが約束されている(もちろんマシンスペックにもよらない)

const res = fetch('aaaaa')
res.then(処理A)

//処理B

return

※本当にどうでもいいけど、ES自体はPromiseについて「あとから実行される」としか定義しておらず、そのタイミングは指定していない。処理系に任せている。Microtaskとかいう概念もESには現れない

タスク、マイクロタスク、アニメーションコールバックが頻発・連鎖したときのブラウザの振る舞い、と注意

タスクとマイクロタスクとアニメーションコールバックは処理の優先順位(?)タイミング(?)が違うので、無限ループさせたときにブラウザの挙動が異なってくる

結論

タスクもアニメーションタスクもどれだけ頻発・連鎖させてもハングしない

マイクロタスクは最悪、ハングする

見て確かめる

https://ja.javascript.info/event-loop

示唆

  • 重い処理をタスクに分割することでハングを防げる
    • ただしその処理は非同期的になるので注意
  • マイクロタスクについては下記のことがいえる
    • マイクロタスクがマイクロタスクを作り続ければ、ブラウザは完全にハングする
    • マイクロタスクが頻発すれば(=ブラウザのアニメーションコールバック実行の速度や、タスクの実行の速度を上回るくらいで頻発すれば)、ブラウザは段々と重くなり、いずれはハングする

await使用上の注意

awaitのイメージ

awaitは、『そのasync関数の残り全ての部分を、「awaitされるPromise」がresolvedになった時点でMicrotaskとしてキューに入れる仕組み』である

function resolveAfter2Seconds() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('resolved');
    }, 2000);
  });
}

async function asyncCall() {
  console.log('calling');
  const result = await resolveAfter2Seconds();
	// ここから下がresolveAfter2Seconds()にthenでつながれたものとみなされる
  console.log(result);
  console.log("hi")
}

asyncCall();
// calling
// resolved
// hi

// これと同じ
async function asyncCallrewrited() {
  console.log('calling');
  resolveAfter2Seconds().then((result) => {
    console.log(result);
		console.log("hi")
  })
}

// これは違う
async function asyncCall2() {
  console.log('calling');
  resolveAfter2Seconds().then((result) => {
    console.log(result);
  })
  console.log("hi")
}

asyncCall2()
// calling
// hi
// resolved

注意

このように考えると、あるPromiseに対してawaitするときは、そのPromiseの結果とは無関係に実行できる処理を巻き込まないように注意しないといけない!!

You'll probably use async functions a lot where you might otherwise use promise chains, and they make working with promises much more intuitive.
Keep in mind that just like a promise chain, await forces asynchronous operations to be completed in series. This is necessary if the result of the next operation depends on the result of the last one, but if that's not the case then something like Promise.all() will be more performant.
https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous/Promises

例えばこういうこと

// 例えば同時に実行できるfetchを待たせてしまう
async function badFetch() {
  const fetch1 = await fetch('1')
  const fetch2 = await fetch('2')
  const fetch3 = await fetch('3')
  console.log(fetch1, fetch2, fetch3)
}

// こんなふうに同時実行したほうが良い
function goodFetch() {
  const fetch1 = fetch('1')
  const fetch2 = fetch('2')
  const fetch3 = fetch('3')
  Promise.all(fetch1, fetch2, fetch3).then(console.log(fetch1, fetch2, fetch3))
}
// より一般的に、全然関係ない処理を待たせてしまうという問題を考える
async function badAwait1() {
	const fetch1 = await fetch('1')
	const notRelatedResult = notRelatedCalc()
	// ↑notRelatedCalc()はfetch1の結果に左右されない関数
	return calc(fetch1, notRelatedResult)
}

// 下記も、notRelatedCalcが終わってからじゃないとfetchが走らないのでもったいない
async function badAwait2() {
	const notRelatedResult = notRelatedCalc()
	const fetch1 = await fetch('1')
	return calc(fetch1, notRelatedResult)
}

// async使うなら、こういうのがよさそう
async function goodAwait1() {
	const fetch1 = fetch('1')
	const notRelatedResult = notRelatedCalc()
	const result = await fetch1
	reutrn calc(fetch1, notRelatedResult)
}

// asyncなしだとこう書ける
function goodAwait2() {
	const fetch1 = fetch('1')
	const notRelatedResult = notRelatedCalc()
	const result = fetch1.then((resolve)=> calc(fetch1, notRelatedResult))
	return result
}

fetchを先に済ませておこうという発想はprefetchという言葉で色々なフレームワーク、ライブラリに実際に取り入れられていると思う。

めちゃ細かいルール

MutationObserverの挙動

  • MutationObserverによりセットされたコールバックは、1つのJSスクリプトコンテクストにつき1回しか呼ばれない(=特定のコールバックが紐付けられた特定のDOM操作を、1つのJSスクリプトコンテクスト内でどれだけ繰り返し行っても、紐付けられたコールバックは一度しかキューに登録されない)
    • whatwgさんの仕様にもある

      To queue a mutation observer microtask, run these steps:

      1. If the surrounding agent’s mutation observer microtask queued is true, then return.
      2. Set the surrounding agent’s mutation observer microtask queued to true.
      3. Queue a microtask to notify mutation observers.

https://dom.spec.whatwg.org/#mutation-observers

  • この独特の仕様は、mutationObserverを90年代に導入するときに課題になっていた「(ルートドキュメントに対する)1スクリプト内での大量のイベント発生」を念頭においていそう(だし、それは実際そうすべきだろう)

https://www.youtube.com/watch?v=cCOL7MC4Pl0&t=1447s

  • 下記リンク先の3つ目の例をみると実際に体感できる

https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/

  • でもmutationObserverへのレコード登録は毎回される。下記リンク先のjsbinをためすといい

http://disq.us/p/1cj9tmh

  • なので、MutationObserverでループさせるとハングする。呼び出しもされずに静かに死んでいく。これはMicrotaskの連鎖で死ぬのとはまた毛色が違うはず

https://codesandbox.io/s/wu-xian-mutationobserver-5905b5?file=/index.html

イベントコールバックの同期性

  • スクリプト内でイベントを発生させる(e.g. click())と、イベントを発生させたJSスクリプトコンテクストが継続して、そのコンテクストの中でイベントによって呼ばれたコールバックが処理される=同期的に行われる
    • 下記リンク先の3つ目の例をみるといい

https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/

  • addEventListener()で登録して発動したコールバックはMicrotaskではない!タスク!
    • 補足: addEventListenerの登録は同期的に行われる

https://memowomome.hatenablog.com/entry/js_async_viz#addEventListenerに渡したコールバックの同期的実行

バブリングの同期性

  • 「イベントに反応してコールバックをタスクに入れる」という1つのタスクの中で、バブリングも一気に処理される(=バブリングは他タスクによって中断されず、ひとまとまりで行われる)
    • このルールはあくまでも下記リンクの実験結果から推察されることなので、仕様を見つけたわけではないです

https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/

GitHubで編集を提案

Discussion