🎨

Deno で簡易レンダリングエンジンを作ってみた

2022/08/23に公開

作ったもの

Web ブラウザの仕組みを基礎から勉強したいと思い、「Let's build a browser engine!」の記事を参考に Deno で簡易的な HTML レンダリングエンジンを作ってみました。

簡易的という言葉の通り、実用性はないです。
HTML と CSS を入力として受け取り、Canvas にボックスを描画するだけです。

https://deno-toy-rendering-engine.deno.dev/

また、描画に対応しているものは、ブロック要素のレイアウトのみで、使える CSS もごくわずか。サイズ・位置指定(width、 height、padding、margin、border-width)と装飾(background-color、border-color)のみ。テキストの描画もできません。

ただ、ひとつひとつの過程を自分で実装していくので、レンダリングエンジンの仕組みを勉強するにはとても良いものでした。


本記事で実装するレンダリングエンジンの流れ

参考記事では、Rust で作られていたのですが、Rust のコンパイラが怖いので TypeScript で実装してみました。また、ランタイムは Node.js ではなく Deno を選択しています。 実装コードはすべてこちらのリポジトリで公開しています。

https://github.com/kawamataryo/deno-toy-browser-engine

章ごとの所感

参考記事「Let's build a browser engine!」の章ごとの所感をまとめます。

Part 1: Getting started, Part 2: HTML


https://limpet.net/mbrubeck/2014/08/08/toy-layout-engine-1.html
https://limpet.net/mbrubeck/2014/08/11/toy-layout-engine-2.html

この記事で作るレンダリングエンジンの全体の流れを説明した上で、HTML の文字列を受け取り、DOM ノードツリーを構築するのパーサーを作ります。

この章で作る HTML パーサーが対応している HTML 構文は以下のみです。

  • <p></p>``などのオープンとクローズが対になったタグ
  • タグに付与する id と class の属性
  • hello worldなどのテキストノード

<!-- -->でのコメントや<br />の自己完結タグなどはすべて未対応です。

consumeChar等のメソッドをつくり、再帰やループを駆使して 1 文字ずつ解析していく過程で、パーサーの実装が徐々に理解できました。

実装コード
https://github.com/kawamataryo/deno-toy-rendering-engine/blob/main/src/lib/html_parser.ts#L1-L141

テストコード
https://github.com/kawamataryo/deno-toy-rendering-engine/blob/main/src/lib/__tests__/html_parser.test.ts#L1-L116

Part 3: CSS

https://limpet.net/mbrubeck/2014/08/13/toy-layout-engine-3-css.html

CSS も HTML と同じようにパーサーを作りパースします。CSS の場合は階層構造とはなっていないので、パース結果は、ツリーではなくオブジェクトの配列になります。

ここで作るパーサーでも、すべてのプロパティと値をサポートすると莫大なコード量が必要なので、最低限のものしかサポートしていません。

プロパティは

  • ボックスのサイズ・レイアウト指定(width、 height、 padding、margin、border-width)
  • ボックスのカラーリング(background-color, border-color)

値は

  • サイズは数値 + px
  • カラーは HEX(#00ff00など)

のみです。

実装コード
https://github.com/kawamataryo/deno-toy-rendering-engine/blob/main/src/lib/css_parser.ts#L1-L189

テストコード
https://github.com/kawamataryo/deno-toy-rendering-engine/blob/main/src/lib/__tests__/css_parser.test.ts

Part 4: Style

https://limpet.net/mbrubeck/2014/08/23/toy-layout-engine-4-style.html

DOM ノードツリーと CSS のパース結果を入力として受けとり、各ノードに CSS のスタイル定義が割り当てられた Style ツリーというものを作ります。

DOM ノードツリーに新しいフィールドを追加することでも同じような出力結果を得ることはできますが、Style の指定によっては、後続の処理に渡さない(たとえばdisplay: noneなど)こともあるので、新しいノードを作るほうが効率的とのことです。これは今後の処理でも同じで、都度新しい構造体を作っていきます。

idclasstag
などセレクターでの詳細度を考慮して、ノードのスタイルを決める(同名のプロパティの場合は、詳細度の高いものを優先する)処理など勉強になりました。

実装コード
https://github.com/kawamataryo/deno-toy-rendering-engine/blob/main/src/lib/styled_node.ts#L1-L130

テストコード
https://github.com/kawamataryo/deno-toy-rendering-engine/blob/main/src/lib/__tests__/styled_node.test.ts#L1-L197

Part 5: Boxes, Part 6: Block layout

https://limpet.net/mbrubeck/2014/09/08/toy-layout-engine-5-boxes.html
https://limpet.net/mbrubeck/2014/09/17/toy-layout-engine-6-block.html

Style ツリーを入力として受けとり、CSS のボックスモデルに対応したレイアウト情報を持つボックスの集合(レイアウトツリー)を作ります。

Style ツリーの情報から、各ノードに対応するボックスの幅、高さ、ポジションをそれぞれ計算します。

height が指定されていないときのボックスの高さは、そのボックスが内包する子要素の高さによって決まるので子要素から再帰的に高さを計算し、最後に親要素の高さを決める。インライン要素を配置するときは匿名ブロックで覆うなどのロジックが強になりました。

この 5 章、6 章がなかなか複雑で個人的に 1 番の難所だった気がします(今も理解できているか怪しい 😅 )。

実装コード
https://github.com/kawamataryo/deno-toy-rendering-engine/blob/main/src/lib/layout_box.ts#L1-L295

テストコード
https://github.com/kawamataryo/deno-toy-rendering-engine/blob/main/src/lib/__tests__/layout_box.test.ts

Part 7: Painting 101


https://limpet.net/mbrubeck/2014/11/05/toy-layout-engine-7-painting.html

レイアウトツリーを受け取り描画処理に使うピクセルの配列を出力するモジュールを使っています、実際に画像に出力するまでを行います。

流れとしては、レイアウトツリーを最初にディスプレイコマンドという描画処理を内包するクラスの配列に変換し、そのコマンドを順に実行することでピクセル情報を取得、そのピクセル情報から画像へ出力というものでした。
画像出力部分は、deno-canvas というライブラリを使って実装しています。

https://github.com/DjDeveloperr/deno-canvas

レイアウトツリーをそのままピクセル化するのではなく、一度ディスプレイコマンドに変換するこで、ムダな描画を排除したいり、処理の再利用ができるそうです。

最終的に、この Tweet のように HTML と CSS から画像を出力することができました。
結果としては画像を出すだけですが、パーサーからひとつひとつ作ってきたので達成感はすごかったです。

https://twitter.com/KawamataRyo/status/1557148537745702912

実装コード
https://github.com/kawamataryo/deno-toy-rendering-engine/blob/main/src/lib/painting.ts#L1-L164

テストコード
https://github.com/kawamataryo/deno-toy-rendering-engine/blob/main/src/lib/__tests__/painting.test.ts#L1-L151

Appendix

記事のすべての実装をやり終えた後、「せっかく作ったので画像出力だけでなく、これを Web 上で動かせたらおもしろいかも」と思いました。そこで、最近メジャーバージョンがリリースされた Deno の Web フレームワークであるFreshで実装しみました。

https://deno-toy-rendering-engine.deno.dev/

実装時は、Fresh の設計思想のislands architecture
に最初戸惑ったのですが、なれてみれば責務が明確で良かったです。

レンダリングエンジンの組み込みも、Painting の章で行った画像出力部分を、HTML の Canvas に置き換えるだけで動いたので、やりやすかったです。

Deploy 先も Deno Deploy を試してみました。サクッと公開できて良いですね。総じて開発体験が良かったです。
fresh でのデモサイトの実装は以下にあります。

https://github.com/kawamataryo/deno-toy-rendering-engine/tree/main/web

全体を通して

多言語で再実装するという学習方法

今回はじめてサンプルコードとは別の言語で再実装するという学習方法を試してみたのですが、これが思いのほか良かったです。

そのまま写経する場合は内容を理解してなくとも動いてしまうのですが、言語構造の違う言語で再実装しようとすると、ある程度内容を理解していないと動かすことが難しいです。時間はかかったのですが、再実装する過程で大分理解が深まりました。

その他、この学習方法を行う上で大切なのは、テストを書くことだと思いました。やはり実行しないとわからない部分がとても多く、そのときに手軽に実行結果を試せるテストを書いておくことはテストコードを書く時間を上回る、効率化に繋がったと思います。

Deno の使いやすさ

ちゃんと Deno をランタイムとして使ったのは今回がはじめてなのですが、とても開発体験が良かったです。

素で TS が使える点、formatter と linter が同梱されている点など、機能としてはどれも Node.js でもできることなのですが、外部ライブラリに依存せず設定不要で使えることは、開発体験に大きく寄与すると感じました。

また TDD で開発していたので、テストを何度も実行していたのですが、そのテストの実行速度の速さにも驚きました。最近速いと話題の Vitestと使い比べて見たのですが、Vitest よりもさらに数段早かったです。

Rust の文法の表現の豊かさ

単純に Rust 良いなーと思いました w
参考記事では、Rust の Enum のデータ添付やパターンマッチでの処理の分岐がよく使われていたのですが、これを TS で再現すると、Rust と比べかなり冗長なコードになってしまいました。

とくにパターンマッチは強力だと感じたので、是非 ECMAScript にも入って欲しいなと思っています。

とくに再現に苦労したコード (Part 6: Block layout より)

match (width == auto, margin_left == auto, margin_right == auto) {
    // If the values are overconstrained, calculate margin_right.
    (false, false, false) => {
        margin_right = Length(margin_right.to_px() + underflow, Px);
    }

    // If exactly one size is auto, its used value follows from the equality.
    (false, false, true) => { margin_right = Length(underflow, Px); }
    (false, true, false) => { margin_left  = Length(underflow, Px); }

    // If width is set to auto, any other auto values become 0.
    (true, _, _) => {
        if margin_left == auto { margin_left = Length(0.0, Px); }
        if margin_right == auto { margin_right = Length(0.0, Px); }

        if underflow >= 0.0 {
            // Expand width to fill the underflow.
            width = Length(underflow, Px);
        } else {
            // Width can't be negative. Adjust the right margin instead.
            width = Length(0.0, Px);
            margin_right = Length(margin_right.to_px() + underflow, Px);
        }
    }

    // If margin-left and margin-right are both auto, their used values are equal.
    (false, true, true) => {
        margin_left = Length(underflow / 2.0, Px);
        margin_right = Length(underflow / 2.0, Px);
    }
}

参考

https://developer.chrome.com/blog/inside-browser-part1/

https://dackdive.hateblo.jp/entry/2021/02/23/113522

Discussion