ブラウザレンダリングの仕組み

18 min read読了の目安(約16900字 12

以前書いた記事「Webページがブラウザに表示されるまでに何が起こるのか?」で
ブラウザレンダリングについて詳細に知りたいという意見をいただいたので、調べてまとめてみました。

全体図

レンダリングの大まかな流れです。

HTMLのダウンロード

サーバから送られてきたHTMLをダウンロードします。

HTMLの解析

サーバから送られてきたHTMLファイルは、「0」と「1」でできたデータになっています。

ブラウザは、サーバから受け取ったデータをそのままHTMLとして解釈することはできないので、自分で扱うことができる形、つまりDOMに変換する必要があります。この作業を 解析 ( Parse ) と言います。

HTMLをダウンロードしたら、すぐにこの解析作業に入ります。作業は以下のようなステップになります。

BytesからCharactersに変換

まず、未加工のバイトから人間が読める文字 ( Characters ) に変換します。

変換は、指定された文字コードに基づいて行われます。
どこで指定されているのかというと、レスポンスの Content-Type ヘッダーです。

Content-Type ヘッダーに文字コードが指定されていない場合は、HTMLの <meta> タグを見に行きます。

文字コードの指定
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">  <!-- ここで文字コードをUTF-8に指定 -->
    <title>My test page</title>
  </head>
  <body>
    <p>This is my page</p>
  </body>
</html>

Content-Type ヘッダーと <meta> タグの両方に文字コードが指定されている場合は、Content-Type ヘッダーの方を優先的に参照します。

もしどちらにもなかった場合は、HTMLの内容から推測して文字コードを自動判別する仕組みになっています。

Tokensに変換

Characters に変換したら、今度はトークン ( Tokens ) と呼ばれる形にします。
トークンとは、それ以上細かく分割できないような最小単位に分けた文字列のことを指します。

例えば以下のようなHTMLがあったとして...

トークン化するHTML
<html>
  <body>
    Hi!
  </body>
</html>

これをトークンに変換するときは、文字を1文字ずつ読み込んで細かく小さな塊に分割します。

上記のHTMLの一番最初の文字は < です。


< を見つけたとき、その次の文字からは Start Tag Token であると判断されます。次の h、t、m、l を順に読み込んでいき、> を見つけたときに、<> に挟まれた文字をトークンに変換します。この場合は html という名前の Start Tag Token に変換されます。

次の <body> タグも同様の手順でトークンに変換されます。

Hi!という文字列は、1文字ごとに Character Token に変換されます。< を見つけるまで Character Token に変換し続けます。

/ を見つけると、次の文字からは End Tag Token であると判断されます。アルファベットを順に読み込んで > を見つけたときに、body という文字が End Tag Token に変換されます。

</html> タグも同様に End Tag Token に変換されます。

以上で、すべての文字をトークンに変換できました。

上記の例では、Start Tag、End Tag、Character の3種類のトークンが登場しましたが、実際には以下の6種類が存在します。

  • Start Tag
  • End Tag
  • Character
  • DOCTYPE
  • Comment
  • EOF ( End-Of-File / ファイルの終わり )

また、<div id="container"> というように要素に属性が付いていた場合、その情報もトークンの中に保存されます。

Nodesに変換

今度はトークンからノード ( Nodes ) に変換します。

つぎの「DOMの構築」のステップで詳しく話しますが、最終的にHTML文書はツリー構造にしていくので、このステップでは木を構成するためのノードというのを作成していきます。

では、ノードとは何でしょうか?ここでのポイントは2つあります。

  1. ノードとはオブジェクトである。
  2. HTMLの要素やその属性、要素に含まれたテキストもノードである。

この2つのポイントについて、これから書いていきたいと思います。

ノードとはオブジェクトである

Google Developers の「オブジェクトモデルの構築」を見に行くと、トークンからノードへの変換について以下のような説明が書かれていました。

発行されたトークンは、プロパティとルールを定義する「オブジェクト」に変換されます。

この説明から、ノードというのはオブジェクトであることが分かります。

このオブジェクトがどのようなプロパティやメソッドを持っているのかは、Nodeというインターフェイスで定められています。

インターフェイスとは、いわば設計図 ( blueprint ) です。オブジェクト指向プログラミングにおいて、クラスが実装しなければならない決まりごとを定めます。

以下は、「DOM Living Standard」に載っている Nodeインターフェイスの定義の一部です。

interface Node : EventTarget {
   //...省略
  [CEReactions] Node appendChild(Node node);
  [CEReactions] Node replaceChild(Node node, Node child);
  [CEReactions] Node removeChild(Node child);
};

ここでは、appendChildreplaceChildremoveChild というメソッドが定義されていることが分かります。

このことからノードはオブジェクトであり、オブジェクトを作るのに必要なプロパティやメソッドはNodeというインターフェイスで定義されているということが分かります。

HTMLの要素やその属性、要素に含まれたテキストもノードである

HTMLの要素は Elementというインターフェイスに基づいて作成されます。MDN を見に行くと、以下のような説明が書かれています。

親インターフェスである Node、およびその親インターフェイスである EventTarget からプロパティを継承します。

つまりElementインターフェイスは、Nodeインターフェイスが持つ全てのプロパティとメソッドを継承しているということです。

これはHTMLの要素はノードであるということを意味しています。

HTMLの要素と同様に、要素の属性や要素に含まれたテキストなどもNodeインターフェイスを継承しているので、ノードになります。

ノードの種類は全部で12種類あります。

上記の他には、コメントを表すCommentノード、ドキュメントの種類を表すDocumentTypeノード、ドキュメントツリーの一部分を表すDocumentFragmentノードなどがあります。もし他にも知りたければこちらに載っています。

上記のポイントをまとめると、トークンからノードに変換されるときは、オブジェクトになります。このオブジェクトに必要なプロパティやメソッドは、Nodeインターフェイスでの定義をもとに実装されます。要素や要素の属性、要素内のテキスト等もノードになります。

DOMの構築

Nodesに変換された後は、これらをDOMと呼ばれるツリー状の形にしていきます。

DOM ( Document Object Model ) とは、HTML文書をツリー状のデータ構造として扱い、それをプログラムから参照したり操作したりするためのデータ構造やインタフェース ( API ) を定義したものです。

そもそもDOMはなぜ生まれたのでしょうか?

1990年代前半、Webが最初に開始されたとき、世界には静的なWebページしかありませんでした。しかし、それでは表現できることが非常に限られていたので、次第に動的なコンテンツを配信したいという要求が高まっていきます。

そんな中で開発されたのがJavaScriptです。( 当時は「LiveScript」という名前でした。 )

Webの世界で動的なコンテンツは爆発的に普及していき、DHTMLと呼ばれる技術まで到達しました。このDHTMLにおける中心的な技術がDOMというAPIなわけです。

では実際、どのようにDOMを使えばWebページを動的に変更できるのでしょうか?

exapmple.comというWebサイトを使って少し遊んでみます。
ChromeのDevToolに以下のコードを入力します。

コード
const p = document.querySelector("p")
p.textContent = "updated Text!"

textContent というメソッドを使って、Webサイトの「Exaple Domain」という文字の下のテキストを「updated Text!」 という文字に変更してみました。

このようにしてDOMを用いることで、ノードを変更や追加、削除等ができるようになります。

ちなみに上記で使った textContent というメソッドは、「ノードとはオブジェクトである」で登場した Nodeインターフェイスで定義されているメソッドになります。トークンからノードに変換するときに、こういったノードを操作するのに便利なメソッドやプロパティが一式装備されるイメージです。

このDOM構築のステップでは、元のHTMLソースでネストして書かれていた親子関係をノードにリンクして、DOMツリーに反映させます。ノードの数が増えるほど、ツリーの構築にかかる時間は長くなります。

DOMツリーが出来上がると、HTMLの解析は終了です。

CSSのダウンロード

HTMLの解析の途中で以下のような <link> タグを見つけたら、CSSのダウンロードを開始します。

<!DOCTYPE html>
<html>
  <head>
    <link rel="stylesheet" type="text/css" media="screen" href="main.css" />
  </head>
  <body></body>
</html>

CSSの解析

HTMLのときと同じようなステップで、CSS の解析も行われます。

CSSOMの構築

CSSの場合は、CSSOM ( CSS Object Model ) と呼ばれるオブジェクトのツリーが構築されます。

CSSには「カスケード」という概念があります。
カスケードは直訳すると「何段も連なった小さな滝」のことです。上流で適用されたスタイルがあれば引き継いで、競合する場合は上書きしながら、段階的に設定していくことを表します。

例えば上記のように、 <body> に設定された親のスタイル情報の font-size: 16px; というのは、子要素の <p><span><img> にも引き継がれます。

また上記の図では省略していますが、「user agent stylesheet」と呼ばれるものがあります。ほとんどのブラウザで用意されている、ブラウザごとに定義されたデフォルトのスタイルシートです。独自のスタイルが定義されていれば、基本的にこちらは上書きされます。

CSSOMの構築は、セレクターで指定されたDOM要素に対して、このようにスタイルを調整しながら行われます。ただし、<meta><script><title> などのページに表示されないDOM要素はCSSOMには含まれません。

JavaScriptのダウンロード

HTMLの解析の途中で <script> タグを見つけたとき、JavaScriptファイルのダウンロードが始まります。

JavaScriptの実行

プログラマが書いたプログラム ( ソースコード ) は、作成段階ではただのテキストファイルです。
コンピュータの頭脳であるCPUがソースコードを理解するためには、機械語に変換する必要があります。

このソースコードを解析して機械語まで落とし込むのがJavaScriptエンジンです。
JavaScriptエンジンは、「字句解析」 → 「構文解析」 → 「コンパイル」のステップでソースコードを機械語に変換して、最終的にCPUに渡すことでJavaScriptが実行されます。

字句解析

まずJavaScriptエンジンは、ソースコードの文字列を一文字ずつ読んでいって、トークンと呼ばれるプログラム的に意味のある文字列の最小単位に切り分けます。

構文解析

次に、字句解析で得たトークンの配列から抽象構文木 ( Abstract Syntax Tree ) を作成します。

名前から難しそうに聞こえますが、実はもう既にこの記事でASTには出会っています。DOMです。

<> などのタグを表す文字がDOMで保持されなかったように、ASTでは言語の意味に関係ない ( などの情報は取り除かれます。そして、意味に関係ある情報のみを取り出した ( 抽象した ) ツリー構造のデータになります。

JavaScriptの場合、ASTはJavaScriptオブジェクト ( JSON ) として表現されます。

コンパイル

ASTを作成したら、これをもとにCPUが直接解釈できる機械語に変換していきます。

一般的に、機械語に翻訳する方法は2つあります。

一つは「インタープリタ」と呼ばれる方式です。
インタープリタでは、実行時にソースコードの内容を一行ずつ解釈して処理していきます。ソースコードを即座に実行開始できるため、開発や修正をテンポよく進めることができます。
しかし、同じコードを何度も実行するようなループ ( 繰り返し ) の箇所などでは、一度構文解釈した部分でも毎回最初から解釈と実行を行うので、実行速度が遅くなるという欠点があります。

対照的に、もう一つの「コンパイル」と呼ばれる方式は実行速度が速いのが長所です。
実行前にソースコード全体を解釈して処理するので、ループのときに毎回翻訳を繰り返すということはありません。またコンパイラでは、「最適化」と呼ばれるコードの中身を確認して、より速く動作するよう編集を行うことも可能です。( インタープリタは実行時に作業を行っているため、時間がなくてこの最適化ができない。)
コンパイラの欠点としては、ソースコードの修正から実行までにコンパイルの時間を要するため、開発時などでは作業性が悪いという点があります。

初期のJavaScriptエンジンのほとんどは、インタプリタで実装されていました。
しかし近年では「JITコンパイラ」というコンパイラで実装していることが多いです。Google Chrome のV8や、SafariのNitroでこのJITコンパイラを利用しています。

JITコンパイラは、簡単にいうと上記2つのいいとこ取りをしたようなコンパイラです。
機械語に一気に変換するのでなく、中間言語に変換したあとに一行ずつコンパイルして実行します。中間言語のファイルを作ることでOSやCPUに依存しない環境で実行でき、インタプリタ形式よりも実行速度が早くなります。JITとは Just In Time の略で、ちょうど間に合う ( その都度 ) というような意味を持ちます。

JITコンパイラについてもっと知りたい方は、こちらの記事が分かりやすくておすすめです。

また、こちらはJavaScriptエンジンの仕組みを解説した記事です。GIFアニメでとても分かりやすいです。

実行

最終的に機械語に変換されたJavaScriptコードは、CPUで実行されます🎉

レンダーツリーの構築

ブラウザの手元には、各HTMLの要素の関係を表すDOMと、HTMLの要素にどんなスタイルを当てるかを表すCSSOMの両方が用意できました。

このステップでは、この2つをがっちゃんこして、レンダーツリーというのを作っていきます。

ここでのポイントは、レンダーツリーはページのレンダリングに必要なノードのみで構築していくということです。

画像引用元: https://developers.google.com/web/fundamentals/performance/critical-rendering-path/render-tree-construction

例えば、上記のCSSOMでは body > p > span の要素に display: none というスタイルを適応しています。このスタイルは「画面上で非表示にする」というものなので、レンダーツリーの構築の際には除外されます。

また、DOMに含まれている <html><head> も画面上では視覚化されないので、こちらもレンダーツリーからは除外されます。

Layout

レンダーツリーが構築された後、ブラウザはviewport内でのノードの正確な位置やサイズなどを計算します。このステップをレイアウトと呼びます。

viewportとは、「現在表示されている領域」のことです。
viewportのサイズは、head内の <meta name="viewport" ...> での指定によって決まります。

<!DOCTYPE html>
<html>
  <head>
    <!-- ここでサイズを指定 -->
    <meta name="viewport" content="width=device-width" />
  </head>
  <body style="background-color: orange">
    <div style="width: 50%; background-color: blue">
      <div style="width: 50%; background-color: yellow">Hello!</div>
    </div>
  </body>
</html>

上記の場合は、content に "width=device-width" という値を指定しているので、viewportの幅は 端末の画面幅と等しく なります。

ブラウザはviewportのサイズを見て、各要素のサイズや配置場所などを決めていきます。

上記のHTMLの場合 <body> の中に2つの <div> があって、サイズを決めるとき、親の方(青)の <div> は viewport 幅の50%に設定します。子(黄)の <div> は親の50%、つまりviewport幅の25%になります。

ちなみにデフォルトのviewportの幅は980pxです。
もしviewportのサイズが指定されていない場合は、以下のようにスマホサイズのときに見づらくなります。

Painting

レイアウトの計算が終わったので、ようやくレンダリング結果の描画 ( Painting ) に移ります。
Paintingには、以下の2つのステップがあります。

Paint

まず、描画する要素の順番を決めます。

順番を決めずに描画してしまうと、要素に z-index が設定されていた場合など、要素の重なりが考慮されない画面になってしまいます。

そのため「Paint Records」と呼ばれるものを作成します。Paint Recordsは、「最初に背景、次に長方形、次にテキスト...」のように、指示を書いたメモ書きのようなものです。だいたい奥から手前の順番で指定されます。

このPaint Recordsの順番は、Stacking Contextに要素がスタックされる順番と同じになります。具体的には以下のような順番です。

  1. background color
  2. background image
  3. border
  4. children

Composite

ペイントする順番が決まったので、これまでの情報をもとに画面上のピクセルに変換します。これを「Rasterize」と呼びます。

初期のChromeでは、viewport 内で Rasterize が行われていました。


動画引用元: https://developers.google.com/web/updates/2018/09/inside-browser-part3#how_would_you_draw_a_page

どういうことかというと、上記のようにユーザーが画面をスクロールしたときにフレームを移動させて、さらに Rasterize をして不足している部分を埋めていくというようなシンプルな方法です。

しかし、現在では「Compositing」と呼ばれる高度なプロセスを使って実行されます。


動画引用元: https://developers.google.com/web/updates/2018/09/inside-browser-part3#what_is_compositing

Compositingとは、ページをいくつかの層 ( レイヤー ) に分けて、ピクセルを塗りつぶす作業を別々に行って、最終的に1つのページとして組み合わせる手法です。

ユーザーが画面をスクロールしたときには、レイヤーはすでにピクセルとして描かれているため、新しいフレームを合成するだけで済みます。

また、再レンダリングの際にも、変更があったレイヤーのみ再計算すれば済むので、計算量を大幅に削減することができます。

このように、ブラウザはレイヤーごとに Rasterize を行っていきます。
どの要素がどのレイヤーにあるべきか知るために、ブラウザは以前構築したレンダーツリーを見て「 レイヤーツリー (Layer Tree) 」と呼ばれるものを新たに作成します。ここまでの処理はブラウザの「Main Thread (メインスレッド) 」上で行われます。

さて、描画する順序が決まり、レイヤーツリーの作成も終わったので、Main Thread はこれらの情報を「Compositor Thread (コンポジタースレッド) 」に託します。Compositor Thread は、レイヤーツリーの各レイヤーごとにピクセル単位で色を当て込みます。

レイヤーの大きさはそれぞれですが、ページ全長のように大きなサイズになる可能性があるため、Compositor Thread はレイヤーをタイルのように小さく分割して、各タイルを「Raster Threads ( ラスタースレッド ) 」に送信します。

Raster Threads は合計4つ用意されており、それぞれ各タイルをピクセルに落とし込みます。viewport 内のものを最初に描画できるように、Compositor Thread は Raster Threads に優先順位を付けることができます。

完了したら、Compositor Thread はそれらを集約し、「Composite Layers」を作成します。
そして、この Composite Layers をGPUに送り、最終的に画面に描画することができます。

参考文献

全体の流れ

Tokenについて

Nodes・DOM・CSSOMについて

JavaScript実行について

Layoutについて

Paintについて

この記事に贈られたバッジ