Reactライクなライブラリを作ってみた話
Build your own Reactなどの記事もありますが、そういうものを参考にせず、自力でJSXを用いたフロントエンドライブラリを作ってみた、という話です。
もう、4年も前の話なので、詳細には覚えていませんが、自分の学びを残しておくために記事にします。
きっかけ
きっかけはJSXがどのようにトランスパイルされるのかの仕組みを解説した記事を読んだことでした。
これを読んで、トランスパイルの仕組みを知ったことで、自分でもJSXを用いたフロントエンドライブラリが作れそうだなと感じました。
真面目にライブラリを作るというよりかは、車輪の再発明をすることで仕組みを詳細に理解したいという欲求がありました。
ざっくりと仕組みを知り、CSSライブラリやルーティングライブラリなどのエコシステムを作っていくことで、フロントエンドについてのより詳細な知識が得られたように思います。
どうやって作ったか
小さなステップを踏んで、Reactのように動くものが作れるまで試行錯誤しながら頑張る。という流れで作りました。型定義を除くと、200行くらいになりました。最初の形は8時間くらいで作れたように思います。
JSX Factoryの仕組みを知る
まずは、JSXをトランスパイルした結果がどのようになるか、JSX Factoryを自分で作ってみて、出力を確かめました。
最初は、TSXがどのようにJSに変換されるかを調べました。(JSXもTSXも本質的には同じなので、TSXからはじめることにしました)
以下のようなコードを書いて、トランスパイルされたJSを調べました。
const React = {
createElement(tag: string, props: any, ...children: any[]) {
return { tag, props, children };
}
};
declare global {
namespace JSX {
interface IntrinsicElements {
div: any;
h1: any;
p: any;
}
}
}
console.log(
<div>
<h1>My App</h1>
<p>It's working!</p>
</div>
);
トランスパイルされたJSを見ることで、Reactというライブラリが、JSXから巨大なオブジェクト(いわゆる仮想DOMのこと)を出力するだけの簡単なライブラリであることが理解できました(実際はそれほど簡単ではないのだと思いますが)。
むしろ、出力された巨大なオブジェクトをDOMに適用したり、HTMLのテキストを出力したりするReactDOMの方が本体で、より重要なのだと理解しました。
また、この作業を経て、TSXをJSにトランスパイルするために必要な設定や、JSX Factoryを自作する方法も知ることができました。
その辺の話は、別の機会に記事にしようと思います。
仮想DOMをHTMLに変換する
決まった形のオブジェクトがあれば、そのオブジェクトをもとにHTMLを出力することは簡単だと思いました。つまり、ReactDOMのサーバーAPIにある renderToString
関数を実装するということです。
renderToString関数を実装することで、JSX Factoryの実装の勘所も掴めそうだったのと、仮想DOMを構築するために必要なデータについてもより理解が深まるだろうと考えました。
以下はその関数の抜粋です。興味があればより中まで読んでみてください。
ざっくり説明すると、仮想DOMのオブジェクトにはHTMLタグ・テキスト・配列の3種類あり、それぞれの形式に応じて小要素を再帰的にHTMLに変換することで、機能を実現しています。
以下のような変換が行われています。
// こういう仮想DOMがあるとき・・・
{
type: "html",
tag: "div",
attributes: {
class: "flex items-center"
},
children: [
{
type: "text",
name: "Hello world"
}
]
};
// こう出力される
`<div class="flex items-center">Hello world</div>`;
仮想DOMを元に、本当のDOMを構築する
仮想DOMを元にHTMLのテキストを出力することができれば、仮想DOMからDOMを構築することもできます。単に、文字列の組み立てからDOM APIを駆使してDOMを構築するように変わるだけです。再帰的にツリーを構築していくところも変わりません。
これをレンダリングと言います。
DOM APIを使って、DOMを構築する例は以下のようなコードになります。
<div class="flex items-center">Hello world</div>
// 上記のHTMLを構築するスクリプト
const div = document.createElement("div"); // 空のdivを作る
div.setAttribute("class", "flex items-center"); // class属性を設定する
const text = new Text("Hello world"); // TextNodeを作る
div.appendChild(text); // divの子要素とする
このようにしてDOMを構築できるので、仮想DOMをもとにDOMを構築するのも、それほど難しくありません。
ここまで作れば、JSXを書いてブラウザーでHTMLを見ることができるようになります。
コンポーネントを仮想DOMにする
ここまで、コンポーネントの概念は存在せず、ただひとつのJSXをもとにDOMを構築する、ということに集中してきましたが、アプリを作るためにはコンポーネント分けができないとお話になりません。
そこで、コンポーネントを仮想DOMにすることに着手しました。
コンポーネントは本質的には仮想DOMを生成する、ただの関数です。
JSXを書くユーザーからはプリミティブなタグとコンポーネントは同じように扱えますが、ライブラリ内では別物として扱う必要があります。が、それほど難しくはなく、コンポーネント関数を実行して仮想DOMを生成し、ツリー構造に加えてあげるだけです。
ここまでのまとめ
詳細な説明は省いているため、読むだけだとさっぱりわからないと思いますが、手を動かすことで省いた部分が見えてくると思います。ここまでで以下のようなことができるようになりました。
- JSXをHTMLとして描画できるようになった(静的なページのみ)
- コンポーネント分けができるようになった
ステート管理を導入する
ステート管理できるようにすることで、カウンターアプリなどが作れるようになります。
私はもともと、アプリ内でひとつしかステートを扱えない代わりに、ステートをリアクティブオブジェクトにしたら、めちゃくちゃ簡単にアプリが作れるようになるんじゃないか?と考えていたので、その考えをもとに、ステート管理を作っていくことにしました。
リアクティブオブジェクトをステートとして作り、ステート変更をトリガーとして仮想DOMを作り直し、再レンダリングするようにプログラムを書き換えました。
これによって、状態をもつアプリケーション、カウンターアプリが作れるようになりました。
ただしこれには問題があり、ステート変更を元にすべてのDOMを捨てて、改めて構築しなおすという非効率極まりない実装になっているため、巨大なHTMLではおそらくまともに動かないと思います。
また、致命的な欠点として、テキストボックスなども文字を入力する度に作り直してしまうために、カーソルが外れてしまう(一度テキストボックスを削除して、新たなテキストボックスを作成しているので、カーソルが外れたように見える)、というものがありました。Reactのようなライブラリを目指す上で、この問題だけは最低限解決する必要があると思いました。
再レンダリング時に、既存のDOMを再利用する
これまでは、再レンダリングの際に、既存のDOMを吹っ飛ばして新しいDOMを構築していたため、実装はシンプルな状態を保っていましたが、既存のDOMを再利用するように変えていこうとすると、とたんにスパゲティコードになりました。
というのも、再レンダリングの際に、仮想DOMとDOMがリンクしているか、などをいちいち判定しては分岐しているために、めちゃくちゃ複雑なコードになってしまったからです。今では、diff, patchという概念を知っているので、次に作るときはもう少しよくできる気がしますが、当時はただひたすらに複雑な条件分岐が増えていく方法になりました。
まあ、とにかく、既存のDOMを再利用することによって、テキストボックスに文字を入力してもカーソルが外れなくなりました。
まとめ
色々詳細を省きましたが、ここまで実装して満足したため、開発は止まっています。
作った物は以下のリポジトリにあります。
また、実際に動くサンプルとしてドキュメントも作ってあります。
ライブラリを車輪の再発明することで、フロントエンドライブラリがどのように作られているか、想像できるようになりました。また、仮想DOMという概念について「言葉」でなく「心」で理解できました。こういう実装すると遅くなりそうだなーという勘所もわかるようになったような気がします。
そういう意味で、車輪の再発明をやってよかったなと思っています。
以上です。よろしくお願いします。
Discussion