12k
All articles

Octaneを始める — Infernoの後継フレームワーク

Infernoの後継Octaneは、React風コンポーネントを直接DOMコードにコンパイルし、hooks、依存配列、導入手順、beta版の状況を解説します。

OpenReplay Team
OpenReplay Team
Octaneを始める — Infernoの後継フレームワーク

Octaneは、Dominic Gannawayによって開発されたJavaScript製UIフレームワークです。ReactのAPI(useState、useEffect、memo、context、portals、Suspense)で書かれたコンポーネントを、事前(ahead of time)にコンパイルして直接DOMを操作するコードへと変換します。そのため、ブラウザに配信されるコードには仮想DOMが含まれません。

Reactをある程度使ってきた方であれば、そのいくつかのルールがモデルそのものの一部であるかのように感じられるようになっているかもしれません。hooksは毎回のレンダーで同じ順序で実行される必要があり、依存配列は手作業で保守しなければならず、条件付きのエフェクトは子コンポーネントとして切り出すか、エフェクト本体の中にガード節を書くことになります。これらの大半は、ランタイムのreconcilerを正しく動作させるために存在しているものであり、そのreconcilerこそがOctaneが取り除いた部分なのです。

本記事では、Octaneが何を変えるのか、その変更のうち最初に気づくのはどれか、どうやって動かすのか、そしてプロジェクトがどこまで進んでいるのかを取り上げます。

要点

  • Octaneは、Reactスタイルのコンポーネントを直接的なDOM操作へとコンパイルし、配信されるランタイムから仮想DOMを取り除きます。
  • hookのアイデンティティは、hooksが実行される順序ではなく、ソースコード上のどこに書かれているかによって決まります。そのため、hookをif分岐の中や早期リターンの後ろに置くことができます。コンパイラが拒否する唯一の配置は、素のJavaScriptループの中です。
  • 依存配列は省略して、コンパイラにクロージャを読み取らせることができます。自分で書いた場合はReactと同じ意味を持ちます。毎回のレンダーで実行したい場合はnullを渡します。
  • 公開されているパッケージにはNode.js 22.22.2以降が必要で、npm create octane my-appでプロジェクトの雛形を作成できます。
  • Octane自身がベータソフトウェアであると表明しています。ランタイム、コンパイラ、SSR/hydrationの各経路は動作しますが、APIはまだ変動中です。

Octaneとは何か、そして誰が作ったのか

Octaneは自らをInfernoの後継と位置づけています。すでに馴染みのあるReact APIはそのままに、今日Reactのランタイムが担っている3つの仕事、すなわち仮想DOM、hookの順序管理、依存配列をコンパイラが引き受けます。GannawayはInfernoの作者であり、その他の業績にはReact、Lexical、Ripple、Svelteへの関与があります。

この系譜こそが、本記事がブックマークして終わりではなく10分を費やす価値がある理由です。Infernoは、仮想DOMの実装を現実的に可能な限り高速にすることで速度を追求しました。これは以前のInferno.jsに関する記事で検証したテーゼです。Octaneはパフォーマンス最優先という目標を引き継ぎつつ、そのメカニズムを反転させました。より速い差分計算ではなく、差分計算をしないのです。「後継」という位置づけはOctane側の資料によるもので、Inferno側に対応する発表があるわけではありません。

コンパイラという発想: ランタイムに仮想DOMを持たない

Reactがレンダーごとに要素記述のツリーを構築し、前回のツリーと突き合わせて調整(reconcile)するのに対し、Octaneは各テンプレートをDOMノードへとコンパイルし、それをランタイムでクローンして直接パッチを当てます。プロジェクトのドキュメントサイト自体がOctaneで構築されており、これはコンパイラが決して単純ではないアプリケーションを扱えることの妥当な裏付けになっています。

実務上の帰結として、Reactがランタイムで行っている作業(ツリーの走査、propsの比較、何が変わったかの判定)が、代わりにビルド時に決定されるということになります。プロジェクトはホームページにベンチマークのグリッドを掲載しており、複数のスイートにわたってOctaneを基準に正規化されています。ただしこれらはプロジェクト自身が測定した自前の数値であり、ページにはハードウェア構成も実行日も記載されていません。したがって、独立した検証結果ではなく、検証すべき主張として扱うのが適切です。

hooksは呼び出し順ではなく呼び出し箇所で追跡される

Octaneは、各hookのアイデンティティを、hooksが実行される順序ではなくソースコード上に現れる位置から与え、省略されたエフェクトやmemoの依存リストをクロージャから導出します。だからこそ、条件分岐の後ろにhookを置いても問題ないのです。これは日々の開発に最も大きな影響をもたらす変更点です。

以下は、hookを無条件に実行しなければならないReactでの書き方です:

function Panel({ isEditing }: Props) {
  const [draft, setDraft] = useState('');
  useEffect(() => {
    if (!isEditing) return;
    syncDraft(draft);
  }, [isEditing, draft]);

  if (!isEditing) return <Readonly />;
  return <Editor value={draft} onChange={e => setDraft(e.target.value)} />;
}

stateとエフェクトは、それらを必要とする分岐の上へと巻き上げられ、分岐のロジックはエフェクトの内部で繰り返されています。Octaneでは、hookを本来あるべき場所に置けます:

function Panel({ isEditing }: Props) {
  if (!isEditing) return <Readonly />;

  const [draft, setDraft] = useState('');
  useEffect(() => syncDraft(draft));
  return <Editor value={draft} onInput={e => setDraft(e.currentTarget.value)} />;
}

これによって実際に不要になるもの: hookを条件付きにするためだけに切り出していた子コンポーネント、「hookは常に実行するが条件次第で何もしない」というパターン、そして呼び出し回数を一定に保つためだけに存在する三項演算子です。なお、Octaneのイベントは素のDOMイベントをそのまま使うため、キー入力ごとに更新したい場合はonInputを使い、onChangeはブラウザが編集を確定したときに発火します。

プロジェクトが明示している唯一の制約は、素のJavaScriptループです。hooksはコンパイラが割り当てた呼び出し箇所をキーとするため、forループの中にあるスロットキー付きhookは安定したアイデンティティを持てず、コンパイラがこれを拒否します。テンプレート内のキー付きリストを使うか、要素ごとに子コンポーネントを設けるのが解決策です。

なぜOctaneでは依存配列がオプションなのか

リストを省略すると、コンパイラがクロージャからそれを導出します。自分で配列を書けば、Reactとまったく同じように振る舞います。毎回のレンダーで処理を実行したい場合はnullを渡します。これはuseEffect、useMemo、useCallback、およびリストを受け取るその他のhooksに適用されます。

// React: リストは自分で保守する
useEffect(() => {
  socket.subscribe(roomId, onMessage);
}, [socket, roomId, onMessage]);

// Octane: クロージャが何をキャプチャしたかをコンパイラが読み取る
useEffect(() => {
  socket.subscribe(roomId, onMessage);
});

この推論と同じくらい重要なのが、エスケープハッチの存在です。明示的に書かれた配列が書き換えられることは決してないので、正確な制御が必要な箇所ではそれを書けばよいのです。組み込みhooksへの直接呼び出しであれば、コンパイラが処理するどのモジュールでもこの推論が有効で、素の.tsや.jsにあるカスタムhooksも対象に含まれます。自作のラッパーへの呼び出しはより限定的なケースで、そのラッパーは完全にコンパイルされる.tsrxまたは.tsxモジュール内でローカルに宣言されている必要があり、さらにコールバックと最後の依存パラメータをサポート対象のhookへそのまま渡さなければなりません。

octanejsを動かすには

公開されているパッケージにはNode.js 22.22.2以降が必要です。octane createコマンドは、クライアントのみのアプリ用に--template spaを、ルーティング・ストリーミングSSR・hydration・本番ビルドを含む構成には--template fullstackを受け付けます。フラグを省略すると対話的に質問されます。

npm create octane my-app
cd my-app
npm run dev

このコマンドをどのパッケージマネージャで実行したかによって、依存関係をインストールするパッケージマネージャが決まります。新規ディレクトリには読み取るべきlockfileが存在しないためで、これがドキュメントとリポジトリで同じ手順に異なるパッケージマネージャが示されている理由です。既存のプロジェクトについては、クイックスタートガイドがViteでの手順を扱っています。octaneと@octanejs/vite-pluginをインストールし、プラグインを追加するだけです。このプラグインがコンパイラを一緒に持ち込みます。Rspackでは@octanejs/rspack-plugin、Rsbuildでは@octanejs/rsbuild-pluginを使います。

TSRXについて簡潔に

TSRXはOctaneコンポーネントを記述するための構文で、.tsrxファイルに書かれ、テンプレートディレクティブ(@if、@for、@switch、@try)と、適用先のマークアップの隣に置けるスコープ付き<style>ブロックを追加します。これはOctaneの機能というよりそれ自体が独立した言語プロジェクトであり、Octaneはコンパイルターゲットの一つとして、React、Preact、Solid、Vue、Rippleと並んでいます。さらに@{ ... }という記法も追加されており、これは単一のJSX要素またはフラグメントを返す関数本体の短縮形で、先頭にセットアップを書き、最後のノードが出力になります。これを採用する義務はありません。TSRX vs TSX/JSXガイドは、2つの方言がhooks、context、portals、Suspense、transitions、ネイティブイベント、スコープ付きスタイル、サーバーレンダリング、hydrationを共有していると指摘し、動作しているTSXはそのままにしておき、拡張子を変えることそれ自体を目的にしないよう助言しています。

Octaneは実際のところどの段階にあるのか

プロジェクトはOctaneをベータソフトウェアと表明しています。ランタイム、コンパイラ、SSR/hydrationの各経路はすべて動作しますが、1.0までにAPIが変わる可能性があります。Octaneのchangelogによれば現在のリリースは0.3系にあり、実運用では必ずバージョンを固定せよというクイックスタートの助言もそこに由来します。プロジェクト自身の集計では、コアスイートは3,900を超える個別の振る舞いテストを実行し、conformance、differential、hydration、runtime、compiler、SSRの各チェックにまたがっています。それがReact自身のカバレッジのどれだけに相当するかは、スイートの合計数から読み取るのではなく、生成されるparityレポートでケースごとに追跡されます。

相互運用は双方向です。ReactCompatとOctaneCompatはいずれもoctane/reactのエントリポイントから提供されます。前者は本物のReactコンポーネントをOctaneの中で動かし続け、後者はコンパイル済みのOctaneコンポーネントをReactアプリに埋め込みます。React互換ガイドでは、両方のコンパイラの設定、Reactツリー内へのOctaneアイランドのレンダリング、境界をまたいだReact contextの共有、そしてhydrationを伴うサーバーレンダリングまでを一通り解説しています。

エコシステムについては冷静に見ておきましょう。Octaneは広く使われているReactライブラリのファーストパーティ版@octanejs/*を提供していますが、それぞれの完成度にはばらつきがあります。上流の挙動と一致するものもあれば、partialやalphaと表示されているものもあります。生成されるdocs/bindings-status.mdの表が、各パッケージが何をカバーし、どの上流バージョンを追跡し、どこが乖離していて、SSRとhydrationに対応しているかを確認する場所です。厳選されたファーストパーティのバインディング群は、Reactアプリが意識せずに頼っているパッケージエコシステムとは別物です。

Octaneは、「ランタイムのreconcilerを取り除いたときReactのプログラミングモデルはどう見えるのか」という問いに対する、これまでで最も興味深い答えです。そして2つのエルゴノミクス上の改善は、午後のひとときで実感できる程度には本物です。依存しているものについてはバインディングのステータス表を確認し、その上で使い捨てのSPAの雛形を作り、hookを分岐の中に置いてみてください。

FAQ

既存のReactアプリに、書き直しなしでOctaneを導入できますか?

はい。octane/reactエントリポイントはOctaneCompatをエクスポートしており、これによりコンパイル済みのOctaneサブツリーを本物のReact 19ツリーの中に配置できます。そのため、画面・ウィジェット・コンポーネントを1つずつ移行していけます。アイランド内では、use()やuseContextが周囲のReact contextを読み取り、イベントはネイティブのまま、サーバーレンダリングもoctane/react/serverからホストをインポートすることで動作します。

OctaneでもContext.Providerは使えますか?

いいえ。0.3.0リリースで、client、server、nativeの各contextからレガシーなContext.Providerエイリアスが削除されました。コンパイラは静的に認識できるProviderへのアクセスを拒否し、代わりに何を書くべきかを提示します。context自体をproviderコンポーネントとして使い、value propを渡すか、createElement(Theme, { value }, children)を呼び出してください。render-prop形式のConsumerも廃止されており、OctaneのDifferences from Reactページでは今後も追加しないと述べられています。スロットキー付きhooksにより、use()やuseContextを条件分岐の後ろで実行できるようになっており、それこそがConsumerが解決していた問題だからです。

Octaneの「ループ内でhooksを使わない」というルールから除外されるhooksはありますか?

はい。use()とuseContextはhookスロットを消費しないため、素のJavaScriptループの中でも安全に使えます。一方、スロットキー付きhooksはすべての反復で1つの呼び出し箇所を共有することになってしまい、コンパイラはこれをエラーとして報告します。ドキュメントで示されている回避策は、各要素に独自のhook stateを与えるキー付き@forディレクティブを使うか、hookを子コンポーネントへ下ろすことです。

なぜOctaneのonChangeはReactと挙動が異なるのですか?

Octaneは合成イベント層を持たない、本物の委譲されたDOMイベントを使います。そのためonChangeはブラウザ本来のchangeイベントであり、キー入力ごとではなく、編集が確定したとき(通常はblur時)に発火します。編集ごとの更新にはonInputを使ってください。制御されたインプットはvalueとcheckedについて引き続きReactのルールに従い、refsはラッパーオブジェクト経由で渡されるものではなく、通常のpropsとして扱われます。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.