先日の新春 LT 大会で, PixiJS と Vite を使って VJ するという話をしました.

実装や資料は以下のリポジトリにまとまっています. が操作説明などは一切用意していないのでご了承ください (大体キーボード操作で, keydown イベントのハンドラを見るとなんらかわかると思います).
以下は発表中で触れた Hot Module Replacement の実装についての, もう少し詳しい解説です.
Hot Reload と Hot Module Replacement
ご存知の通り Vite には元々 Hot Reload (あるいは Live Reload) の仕組みがあって, ファイルが変更されたときに自動でページを再読み込みしてくれます. 便利ですね.
一方でページの再読み込みとなると, ページ内で保持していた状態は全てリセットされてしまいます. ごくシンプルなページならこれでも十分なのですが, ページが状態を持っているような場合は, 変更の結果を確認するために毎回ページを操作して望んだ状態に戻したりする必要が出てきます. それもアプリケーションの開発中であればまだ面倒というだけで済まなくもないですが, VJ の実演中にライブコーディング的にコードを変更したくなったときに毎回映像がリセットされてしまっては困ります.
そこで登場するのが Hot Module Replacement (HMR) です. Hot Reload ではページ単位の再読み込みでしたが, HMR ではより粒度の細かいモジュール単位での再読み込みが行えます. これを使うと, 例えば状態を保持するモジュールと画面を描画するモジュールを分けることで, 状態をリセットすることなく画面の描画を差し替えるということが可能になります.
React など一般的なライブラリやフレームワークを使っている場合は, vite-plugin-react などのプラグインを使うと HMR がついてくると思います. PixiJS で何かを作る場合も pixi-react を使ったりするとこういった既存の資産を活かせそうではあるんですが, 状態まわりの挙動を完全に把握したかった1のと, 別に HMR 以外の部分で React が欲しいわけでもなかったので, 折角なので自作してみることにしました.
Vite の HMR API
Vite には HMR を実現するための API が用意されているので, これを使います.
最もシンプルには, モジュールに以下のようなコードを埋め込みます.
if (import.meta.hot) { import.meta.hot.accept((newModule) => { // noop }); }
vite dev でサーバーを起動し, このモジュールを読み込んだページを表示した上で, モジュールのファイルに変更を加えると, なんとページの再読み込みが起こりません.
通常はモジュールが変更されると, その変更は上位のモジュール (そのモジュールを参照しているモジュール) にも伝わり, 最上位のページまで伝わったときに再読み込みが行われます.
ところが上のように import.meta.hot.accept( が含まれるモジュールはこの変更の伝播の境界 (HMR 境界) となり, デフォルトでは上位のモジュールに変更が伝播しなくなるため, ページの再読み込みがされなくなるというわけです.
もし HMR 境界から上位のモジュールに変更を伝播させる必要がある場合は, 以下のように明示的に import.meta.hot.invalidate を呼び出す必要があります.
if (import.meta.hot) { import.meta.hot.accept((newModule) => { import.meta.hot.invalidate(); }); }
import.meta.hot.accept のコールバックの引数 newModule は, 変更後のモジュールの module namespace object (モジュールから export される全ての値を持ったオブジェクト) です.
例えば以下のようにしてからモジュールのファイルを変更すると, 変更後の foo の値がログに出力されることが確認できます.
export const foo = 0; if (import.meta.hot) { import.meta.hot.accept((newModule) => { if (!newModule) { // ファイルにエラーがあったとき (newModule === undefined) は無視 return; } console.log(newModule.foo); }); }
ひとまず HMR の実装に最低限必要な API はこれだけ.
HMR に適したアプリケーションの構成
API を理解したので次は具体的に HMR を実装していきたいわけですが, その前にまずはアプリケーション全体の構成について考える必要があります.
例えば PixiJS で図形をアニメーションさせるようなコードを書くと, 最も素朴には以下のようになります2.
export function Circle(): void { // 状態 let r = 0; // 図形 (円) を画面に配置 const g = new Graphics().circle(0, 0, 100).fill("#ffffff"); app.stage.addChild(g); // フレームごとのアニメーション app.ticker.add((ticker) => { r += ((2 * Math.PI) / 2000) * ticker.deltaMS; g.x = 200 * Math.cos(r); g.y = 200 * Math.sin(r); }); }
さて上で紹介した HMR API を使うと, このファイルが変更されたときに新しい Circle 関数が得られます.
画面を更新するにはこれを再実行すれば良いでしょうか?
当然このままではダメですよね. HMR API は魔法ではないので, 関数内の状態は勝手に維持されたりしませんし, 前回実行時の副作用のクリーンアップも行ってくれないので画面上に図形が増えていきます.
これらを解決するために, アプリケーション全体を,
- 各要素を描画するための, 原則として純粋な関数. ユーザーが記述し, 実行中に変更され得る
- 関数の呼び出し, 状態や副作用の管理を行うランタイム. あらかじめ記述され, 実行中に変更されない
の 2 つの部分に分けて構成することにします. 要するに React の真似事ですね.
最終的に, 各要素を描画するための関数 (Layer と呼んでいます. React でいうところのコンポーネント) は以下のように記述することになりました.
export const Circle: Layer<{ r: number }> = ({ app, state, container, effects }) => { // 状態 // state はランタイムが管理, 関数が更新されても毎回同じオブジェクトが渡される state.r ??= 0; // 図形 (円) を画面に配置 // container の実際の画面への配置やクリーンアップはランタイムが管理 const g = new Graphics().circle(0, 0, 100).fill("#ffffff"); container.addChild(g); // フレームごとのアニメーション // effects に追加した副作用の実行やクリーンアップはランタイムが管理 effects.add(() => { const callback: TickerCallback<unknown> = (ticker) => { state.r += ((2 * Math.PI) / 2000) * ticker.deltaMS; g.x = 200 * Math.cos(state.r); g.y = 200 * Math.sin(state.r); }; app.ticker.add(callback); return () => { app.ticker.remove(callback); }; }); // 子 (今回は無し) return []; };
画面全体をこういった関数の組み合わせで表現し, ランタイム (Renderer と呼んでいます) には最上位の関数を渡すことで, 状態や副作用がよしなに管理されつつ画面が描画されるという仕組みになっています.
ランタイムについては詳しくは実際のソースコードを見てください.
const renderer = new Renderer({ app }); // 最上位の関数 Root を描画 renderer.render(Root); app.stage.addChild(renderer.container);
このようなアプリケーションの構成を前提とすることで, とうとう HMR が実装できる状態になりました.
具体的な HMR の実装
HMR の実装でも React と vite-plugin-react を大いに参考にしました. とはいえコード変換や再描画の実行のあたりはかなり手を抜いています.
まずは関数やランタイムを登録するグローバルなレジストリを用意しておきます. これらはそれぞれランタイムが最新の関数を取得したり, 再描画処理を呼び出す際にランタイムを取得したりするのに使います.
const layerRegistry = window.layerRegistry ?? new Map(); window.layerRegistry = layerRegistry; const rendererRegistry = window.rendererRegistry ?? new Set(); window.rendererRegistry = rendererRegistry;
各モジュールには Vite プラグインを使って以下のようなコードを埋め込んでいまsす.
liv:refresh の実装については実際のソースコードを見てください.
import { importModule, registerLayers, performRefresh } from "liv:refresh"; if (import.meta.hot) { // 現在の module namespace object も欲しいので dynamic import で取得 importModule(import.meta.url).then((module) => { // 関数を登録 registerLayers("/path/to/file", module); import.meta.hot.accept((newModule) => { if (!newModule) { return; } // 新しい関数を登録 = 更新 registerLayers("/path/to/file", newModule); // 再描画を実行, または上位モジュールに伝播が必要ならそうする if (!performRefresh(module, newModule)) { import.meta.hot.invalidate(); } }); }); }
ランタイムでは状態を関数ごとに管理しているのですが, HMR の前後では関数自体が置き換わるため, 素朴には関数の同一性を判定できず, ランタイムがどの状態をどの関数に渡したら良いのかわからなくなってしまいます3. そのため, 関数にはファイル名と関数名を元にした ID を付与した上でグローバルなレジストリに登録し, ID を使って関数の同一性を判定できるようにしています.
モジュールから export している値のうち, 描画に関する関数のみが変更された場合は, 上位モジュールには変更を伝播させず, その場で再描画を実行します.
それ以外の値が変更されたり, export の数に増減があった場合は, 上位のモジュールも更新の必要があるため, import.meta.hot.invalidate を呼び出して変更を伝播させています.
なお再描画は更新された関数に対してのみ行えると効率が良さそうなのですが, 実装が面倒になったので毎回全体を再描画しています.
まとめ
車輪の再発明楽しい.