MIKAN-919
Menu
irisoutを公開した

irisoutを公開した

2026/07/22

JSXを書いて、ブラウザに届くのは手書き相当のvanilla JS——そんなビルド時コンパイラを作って公開した話。

Summary

No summary available.

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で書き直して以降それなりに実装が完了。個人的には「使ってもらえる閾値」まで来たと思ってる。リポジトリは公開済み。

github.com/mikan-919/irisout