irisoutを公開した
2026/07/22
JSXを書いて、ブラウザに届くのは手書き相当のvanilla JS——そんなビルド時コンパイラを作って公開した話。
irisoutというビルド時コンパイラを作って、公開した。
やっていることは単純で、JSXを書くと、Reactみたいな仮想DOMやリアクティブランタイムを一切持たない、ただのvanilla JSにコンパイルされる。ブラウザに届くのは焼き込み済みのHTMLと、それを更新するための専用関数だけ。
export function Counter() {
const count = signal(0)
const doubled = derived(() => count() * 2)
render(
<div>
<p>count: {count()} / doubled: {doubled()}</p>
<button type="button" onClick={increment}>increment</button>
</div>,
)
function increment() {
count(count() + 1)
}
}
これがビルド時に実行されて、依存グラフを見ながら「経験豊富なエンジニアがこのUIを素のJSで書くならこうする」というコードに変換される。VDOMのdiffもない、汎用のsignal購読の仕組みも出力には残らない。
なんでこれを作ったか
Reactの対抗馬を作りたかったわけじゃない。むしろ逆で、「もしReactを使わずに、人間がこのUIを手でDOM操作するならどう書くか」を、JSXの書き心地のまま自動でやらせたかった。
宣言的に書きたい。でも実行時に余計なものは持ちたくない。この2つを両立させる方法として、「ビルド時に一度実行して、実行でしか分からない情報(コンポーネント構造、signal、props、ハンドラ)を先に確定させて、実行では分からない情報(JSXの依存関係)だけを静的解析する」というハイブリッドな仕組みにした。全部を静的解析で頑張ろうとしないし、全部を実行に頼ろうともしない。
設計としてこだわったのは3つ。
- 穴のない宣言、 「あとで埋まる箱」を作らない。
render()より前には完成した値だけがあり、後には中身を差し込む関数宣言だけがある。 - プレースホルダーの向きは一方向ということ
{count()}もonClick={handle}も、UI側が名前を宣言して、後段が差し込む向きは常に同じ。逆流はない。 - リアクティビティは「どこに書いたか」で決まるということ 「どう読むか」による購読という実行時の概念は存在しない。JSXの穴・derivedの中・actionの返り値、書く場所そのものがリアクティブかどうかを決める。
で、実際どうなの
TodoMVC相当のシナリオ(mount / filter / add / remove、N=100〜100,000)を実ブラウザで計測したら、全シナリオ・全Nで素朴なReact(useStateのみ、最適化なし)より速かった。だいたい1.1〜4倍、中心は1.4〜1.8倍くらい。
ただしまだ実験的な段階で、制約もそれなりにある。ルートコンポーネントは1つだけ、複数マウント不可、構造ネストは2階層まで、みたいな話。「本番投入できるか」でいうとまだ早いけど、「動くものとして触れる」段階には来た。
状態
TypeScriptで書き直して以降それなりに実装が完了。個人的には「使ってもらえる閾値」まで来たと思ってる。リポジトリは公開済み。