JavaScriptがブラウザでどのように動くのか

はじめに

こんにちは、メルペイフロントエンドチームの @tokuda109 です。
この記事は、メルペイフロントエンドチームで取り組んでいるパフォーマンス改善について紹介するシリーズ記事の第2弾の記事で、JavaScript がブラウザ実行環境上で、どのように処理されるのかを紹介します。

調査するに至った経緯

現在、メルペイフロントエンドチームで管理しているマイクロサービスの中から1つに絞って、調査と改善を行っています。思いつく限りの改善方法を試してみましたが、十分な結果を得られませんでした。

p 要素が1つあるだけのシンプルな Nuxt.js アプリケーションとパフォーマンス改善中のサービスの両方に対して、Chrome DevTools を使ってパフォーマンス計測をした時に、表示されたアクティビティの様子が似ていることに気づきました。(図1、図2)

パフォーマンス改善中のサービスの処理
図1: パフォーマンス改善中のサービスの処理 (赤の斜線部分はブロッキング時間)

シンプルな Nuxt.js アプリケーションの処理
図2: シンプルな Nuxt.js アプリケーションの処理

処理時間は全く異なりますが、Evaluate Script で JavaScript の評価が行われ、Run Microtasks という処理が行われていることが分かります。

Run Microtasks は JavaScript の処理フローの一部分であることから、JavaScript がブラウザの実行環境でどのように処理されるのかを改めて調べてみることにしました。

実行環境

実行環境は、ソースコードを解釈し処理を実行するためのもので、ランタイムと呼ばれることもあります。全てのブラウザは JavaScript の実行環境を1つ持っています。

JavaScript の実行環境には、次の4つの主要なコンポーネントがあります。

  • JavaScript エンジン
  • イベントループ
  • Web APIs
  • コールバックキュー

これら以外にもコンポーネントがあります。

  • ブラウザエンジン: ブラウザの UI を管理する
  • レンダリングエンジン: HTML と CSS を解析し、画面に描画する
  • ネットワーキング: 通信周りの機能を提供する
  • データストレージ: Cookie 等の各種ストレージ機能を提供する

ブラウザベンダ毎のコンポーネント比較

ブラウザを構成する各コンポーネントは、ブラウザベンダ毎に独自で実装したコンポーネントを使っていて、次のようになります。

ブラウザ JavaScript エンジン レンダリングエンジン
Chrome, Opera V8 Blink
Microsoft Edge V8 (Chakaraから移行) Blink (EdgeHTMLから移行)
Firefox SpiderMonkey Gecko
Safari JavaScriptCore WebKit

Node.js の実行環境

Node.js は、JavaScript エンジンに V8 を使って作られた JavaScript 実行環境で、イベントループに libuv を使い、コアモジュールとして Web APIs の一部機能(例: Console API、Promise等)を提供することで、ブラウザのものと似た実行環境を提供しています。

処理の全体像

各コンポーネントの説明をする前に、実行環境上の処理の全体像から紹介します。(図3)
ブラウザの実行環境で JavaScript が処理される時に、次の1から7の順番で実行されます。

  1. ブラウザ上で読み込まれた JavaScript が、JavaScript エンジンのコールスタック上で実行される
  2. コールスタック上で実行中に非同期関数が呼び出されると、非同期関数の引数として渡されたコールバック関数は Web APIs に送られる
  3. Web APIs に送られたコールバック関数は、条件を満たすまでは Web APIs で待機する
  4. Web APIs で待機しているコールバック関数は、条件を満たすとコールバックキューに追加される
  5. コールバックキューに追加されたコールバック関数は、コールスタックで実行中の関数が空になるまで待機する
  6. コールスタックが空になると、コールバックキューで待機しているコールバック関数は、イベントループによってコールスタックに追加される
  7. コールスタックに追加されたコールバック関数が実行される

JavaScript の処理の全体像
図3: JavaScript の処理の全体像

この図は JavaScript が処理される様子を簡略化して示したもので、次から各コンポーネントの詳しい処理内容を紹介します。

JavaScript Engine (JavaScript エンジン)

JavaScript エンジンは JavaScript コードを解析し、マシンコードにコンパイルし、実行するプログラムのことで、スタック領域とヒープ領域の2つのメモリ領域を持っています。
JavaScript エンジンは、実行時に扱うデータの種類によって2つのメモリ領域を使い分け、メモリ領域の割り当てを行います。

スタック領域

JavaScript エンジンは、コンパイル時に必要なメモリ領域のサイズが決まる値に対しては、スタック領域にメモリ割り当てを行います。静的メモリ割り当てとも言います。
コンパイル時にサイズが決まるデータ型として、プリミティブ型(String, Number, Boolean, Undefined, Null)、オブジェクトや関数の参照があります。
スタック領域のデータ構造はスタックであるため、LIFO (Last In First Out: 後入れ先出し)方式で処理されます。(LIFOについては後述します)

ヒープ領域

JavaScript エンジンは、実行時に必要なメモリ領域のサイズが決まる値(動的なデータ)に対して、ヒープ領域にメモリ割り当てを行います。動的メモリ割り当てとも言います。
実行時にサイズが決まるデータ型として、オブジェクト、関数、配列等があります。
ヒープ領域のデータ構造は、スタック領域のデータ構造と異なり、構造化されていません。

メモリ割り当て

スタック領域にあるデータのメモリ領域のサイズは変わらないため、JavaScript エンジンは各データに対して固定のメモリサイズを割り当てます。一方、ヒープ領域にあるデータのメモリ領域のサイズは可変のため、固定のメモリサイズを割り当てることはせず、必要に応じた多めのメモリ領域を割り当てます。

オブジェクトや関数は、実体がヒープ領域に割り当てられ、実体の参照がスタック領域に割り当てられます。

スタック領域とヒープ領域の違いを簡単にまとめると、次のようになります。

スタック領域 ヒープ領域
確保するメモリサイズ 固定サイズのメモリ領域 オブジェクト毎にサイズを決めない
メモリサイズが決まるタイミング コンパイル時 実行時
メモリ割り当てを行うデータ型 プリミティブ型と参照 オブジェクトと関数
データの順序 ある なし

実際にコードを用いてスタック領域とヒープ領域の概念を説明します。
person オブジェクトを宣言した時、JavaScript エンジンはオブジェクトの実体をヒープ領域にメモリ割り当てを行い、ヒープ領域にある実体への参照をスタック領域にメモリ割り当てを行います。

const person = {
  name: 'Taro',
  age: 24
};

次のように新しい変数(newPerson)に再代入をすると参照がコピーされ、newPersonperson もヒープ領域に割り当てられた同じ実体に対する参照を持ちます。

const newPerson = person;

Object.assign を使って新しいオブジェクトを生成するのは、参照コピーをしないための方法の一つで、よく使われる手法の1つです。

function getName(person) {
  return person.name ? person.name : null;
}
const hobbies = ['hiking', 'reading'];

関数や配列も同様にオブジェクトで、実体がヒープ領域に割り当てられ、実態の参照がスタック領域に割り当てられます。
一方、文字列や数値等のプリミティブ型を宣言した時、JavaScript エンジンはスタック領域に対してメモリ割り当てを行います。

const name = 'John';
const age = 24;

ここまで説明したことをまとめると、図4のようになります。

スタック領域とヒープ領域のメモリ割り当て
図4: スタック領域とヒープ領域のメモリ割り当て

一番左が JavaScript コードで、コードは1行目から解析され、スタック領域に順番を持った状態でメモリの割り当てが行なわれます。
オブジェクト、関数、配列の実体はヒープ領域にメモリの割り当てが行なわれ、それらの参照はスタック領域にメモリの割り当てが行なわれていることが概念的に示されています。
(agename はスタック領域のみ割り当てられています)

コールスタック

コールスタックは、JavaScript ランタイム上で実行されている処理の履歴を格納する仕組みで、どの関数が現在実行されていて、その関数の中でどの関数が呼び出されたかをスタックのデータ構造で記録しています。

コールスタックに追加される関数は、LIFO 方式で処理され、コールスタックに追加することをプッシュ(Push)、処理が終わってコールスタックから取り出すことをポップ(Pop)と言います。

function b() {
}

function a() {
  b();
}

a();

LIFO 方式の処理では、上のコードを実行した時に次の1から6の順番でコールスタックに処理がプッシュされ、処理が終わるとポップされていきます。(図5)

  1. グローバルコンテキストの実行が開始する (関数 b と a の実体はメモリ領域上のヒープ領域に割り当てられ、その参照がスタック領域に割り当てられる)
  2. 最後の行で関数 a が呼び出される → 関数 a がコールスタックにプッシュされる
  3. 関数 a の内部で関数 b が呼び出される → 関数 b がコールスタックにプッシュされる
  4. 関数 b の処理が終了する → 関数 b がコールスタックからポップされる
  5. 関数 a の処理が終了する → 関数 a がコールスタックからポップされる
  6. グローバルコンテキストの実行が終了する

スタック構造の処理
図5: スタック構造の処理

Web APIs

Web APIs は、ブラウザによって提供されているウェブの API 群と、その実行環境を指します。
Web APIs で提供されている非同期関数を呼び出すと、コールバック関数が Web API Container に追加され、条件を満たすまで待機します。
コールバック関数は、待機している時点で実行されてなくて、条件を満たした時にコールバックキューに追加され、JavaScript エンジンが実行可能になったタイミングで JavaScript エンジンに渡され、実行されます。

Web APIs には次のものがあります。

  • console
  • setInterval, setTimeout
  • Promise
  • Fetch
  • XMLHttpRequest
  • DOM
  • Mouse Event

非同期関数の実行

setTimout 関数を例にして、コールバック関数がどのように処理されるのか説明します。

setTimeout(function callback() {
  console.log('fire after 1000ms.');
}, 1000);

setTimeout 関数を定義したコードを実行すると、コールスタックに setTimout 関数が追加され、実行されます。
この時点で、第1引数として渡された callback 関数の呼び出しは行なわれていなくて、関数リテラルとして Web API Container に追加され、待機します。
第2引数に1000を渡しているので、1000ミリ秒経過すると関数リテラルはコールバックキューに追加され、最終的に JavaScript エンジンに追加された後、実行されます。

コールバックキューに追加された順に随時コールスタックに移され実行されるため、callback 関数は先に追加された関数の実行完了を待ちます。
そのため、setTimeout 関数を用いた遅延処理は、1000ミリ秒後に確実に実行されることを保証しないので注意が必要です。

このことから、setTimeout 関数は第2引数で指定したミリ秒後に第1引数で渡したコールバック関数をコールバックキューに追加する関数と言い換えることができます。

コールバックキュー

JavaScript エンジンは同時に1つの処理しか実行できません。JavaScript エンジンが処理をしている間、次に処理されるものが待機する場所がコールバックキューと呼ばれます。
コールバックキューのデータ構造は FIFO (First In, First Out)方式で、コールバックキューに格納した順に取り出しが行なわれます。

ブラウザの実行環境では、コールバックキューにタスクキューマイクロタスクキューの2種類のキューがあります。(Node.js ではこのコンポーネントをイベントキューと言い、仕組みとしては似ているが、コールバックキューと完全に同じではありません)

図3のように簡略するとコールバックキューのみで処理しているように見えますが、より詳しい図にすると次のようになります。(図6)

タスクキューとマイクロタスクキュー
図6: タスクキューとマイクロタスクキュー

タスク

タスクは、次の種類のコールバック関数を指し、タスクキューに格納されます。

  • script タグで読み込んだ JavaScript ファイル
  • setTimeout / setInterval のコールバック関数
  • UI イベント(クリック、マウス移動等)のコールバック関数
  • requestAnimationFrame のコールバック関数

タスクは、マクロタスク(Macrotask)と呼ばれることもあります。

マイクロタスク

マイクロタスクは、次の種類のコールバック関数を指し、マイクロタスクキューに格納されます。

  • Promise の then / catch / finally のコールバック関数
  • queueMicrotask のコールバック関数
  • MutationObserver のコールバック関数

マイクロタスクはジョブ(Job)、マイクロタスクキューはジョブキュー(Job queue)と呼ばれることもあります。

タスクキューとマイクロタスクキューの違い

タスクキューとマイクロタスクキューの違いは、JavaScript の1つの処理サイクル内で優先して実行されるかどうかです。具体的には1つの処理サイクル内で、1つのタスクキューが実行された後、マイクロタスクキューにあるマイクロタスクは、空になるまで全て実行されます。タスクキューとマイクロタスクキューで実行された結果を受けて、最後にレンダーキューの描画タスクが実行されます。

マイクロタスクキューは格納されている全てのコールバック関数が終わるまで処理が行われるため、Promise.then 等の連結された大量の処理があると、それらのタスクの処理が終わるまで他のタスクの実行が行われません。

Event loop (イベントループ)

イベントループは、コールスタックが空になる度にコールバックキューからタスク(コールバック関数)を取り出し、JavaScript エンジンのコールスタックに追加します。

  • コールバックキューに実行待ちの関数があるかどうか
  • コールスタックに実行中の関数がないかどうか

イベントループはこの2つの条件が満たされるのを待ち、満たされた時にコールバックキューの実行待ちの関数をコールスタックに追加するという処理を無限に繰り返します。

JavaScript の処理のまとめ

4つのコンポーネントの処理の詳細を説明してきましたが、まとめると、JavaScript エンジンは図3では簡略的に書きましたが、コールスタックとメモリ領域という2つの概念の理解が必要になります。JavaScript が評価され、メモリ上に展開され、コールスタックで実行されます。
Web APIs から提供されている API を呼び出すと、Web APIs の実行環境で処理が実行されます。その時に非同期関数の呼び出しの場合、Web APIs の実行環境内で、条件を満たすまで待機します。
そして、条件を満たすと、コールバックキュー内のタスクキュー、もしくはマイクロタスクキューのいずれかのキューに格納されます。
コールバックキューに格納されたタスクはコールスタックで処理可能になるまで待機します。
コールスタックが処理中かどうかはイベントループによって監視され、コールスタックが空になるとキューで待機している先頭のタスクから取り出され、コールスタックで実行されます。

より詳細な全体処理
図7: より詳細な全体処理

JavaScript Visualizer 9000

ここまでたくさんの説明をしてきましたが、コードを実行しながら可視化された処理の流れを追うことができるオンラインツールがあります。

こちらのリンクから JavaScript の1つの処理サイクル内で、1つのタスク実行と2つのマイクロタスクが実行されることが確認できると思います。
Run ボタンを押して処理を開始し、STEP ボタンで1つずつ処理をステップ実行していくことで、どのように処理されるのか確認することができます。

最後に

Chrome DevTools のパフォーマンス計測結果をもう一度見てみると、Run Microtasks という1つの大きな処理ブロックを次のように説明することができます。

JavaScript の1つの処理サイクルで、マイクロタスクキューに格納された数多くのマイクロタスクが実行され、多くの時間を費やしている。

この処理は Nuxt.js のアプリケーションの初期化やハイドレーションであることが分かっているのですが、続きは次の記事でしたいと思います。
長大な記事になってしまいましたが、ここまで読んでいただきありがとうございます。何か間違いや認識違いがあれば @tokuda109 までDMいただければ助かります。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加