SPIDERPLUS Tech Blog

建設SaaS「スパイダープラス」のエンジニアとデザイナーのブログ

自作クラスにHMR APIを活用して、既存インスタンスごと差し替える方法

はじめに

こんにちは。プラットフォーム開発部のわにわに🐊です。

HMR対応シリーズ第3弾(最終回) をお送りします。

 

これまで以下の2記事

を書きましたが、今回は、自作モジュールをViteでHMR対応する前回とは違う方法について書きます。

前回の記事では、自作モジュールをReactコンポーネントに“擬態”させてHMRするという荒技で、HMR対応を実現しました。しかし、今回はもっとまっとうな(?)方法で、自作モジュールをViteのHMR APIを使って更新できるようにします。

HMR対応のメリット

HMR(Hot Module Replacement)を活用することで、開発中のコード変更が即時に反映され、以下のようなメリットがあります。

  • 開発速度の向上: ページリロードなしでコード変更を反映できるため、開発のスピードが向上します。
  • 状態の保持: アプリの状態を保持したまま、ロジックやUIの変更を適用可能です。
  • 開発体験の向上: 特に状態管理が必要なアプリ開発(SPAなど)では、ストレスなく作業できます。

HMR APIについて

ViteにはHot Module Replacement (HMR) を制御するための APIが用意されています。

ja.vite.dev


その中心となるのが import.meta.hot です。
そしてimport.meta.hot.acceptで差し替え対象のファイルを指定し、モジュール更新されたときの処理を記述します。
「HMR=モジュール差し替え」というイメージが強すぎるとHMR APIの挙動やこの記事のコードが理解しにくいかもしれません。 実際には、差し替えと言うよりもHMR APIがやっているのは「変更のあったモジュールを自動で取得する」ことです。 ですので、acceptメソッドのコールバックで、差し替え作業を自分で行う必要があります。
Reactなどのフレームワークコンポーネントが差し替わっているように見えるのは、フレームワークが裏で差し替え処理を頑張っているからのはずなのです。(しらんけど...)

// 例: あるファイル内で
import.meta.hot.accept(['pathOfFileA', 'pathOfFileB'], ([moduleOfFileA, moduleOfFileB]) => {
  // 対象ファイルに変更が合ったときに呼ばれるコールバック関数
  // moduleOfFileA, moduleOfFileB には新しいモジュールが入ってくるので、差し替え処理を書く
});

import.meta.hot.acceptの第一引数に配列でHMR更新対象となるファイルを列挙し、コールバック関数の引数で新モジュールを受け取って、差し替え時の処理を記述します。
あらかじめここにファイルを列挙するのが面倒な場合、各モジュール側から自ファイルをどこかの配列に登録しておいて、hot.accept でそれを参照する形にするとファイルが増えたときには楽だと思います。

小ネタ1: IDEでの型補完やヒント表示

import.meta.hot などHMR固有のメソッドは、標準的なJavaScript/TypeScriptのAPIではないため、エディタが補完してくれないことがあります。

そんなときは、下記のように jsconfig.json または tsconfig.json に設定を追加すると、VS Codeなどで補完、型処理やヒント表示が効くようになります。

{
    "compilerOptions": {
        "types": ["vite/client"]
    }
}

小ネタ2: REMOTE - SSH & REMOTE - TUNNEL でHMRを通す

もし、リモートサーバーにSSH接続して開発している場合、HMRを動作させるためにポート転送をしてあげる必要があります。
私のお勧めは、Viteのconfigファイルの中でhmrオプションで下記のようにlocalhostを参照するように設定し、

hmr: {
        protocol: 'ws',
        host: 'localhost',
        port: 5174,
        clientPort: 5174,
    },

この設定のhmr.hostとは、ブラウザからみたときのHMRサーバーのことです。
ですので、VS Codeを使っている場合は拡張機能「REMOTE - TUNNEL」などを使って、ポート5174をローカルにトンネルで持ってきてあげると、リモート上でもHMRを活用できるようになります。
ステータスバーの電波塔のようなアイコンからトンネル設定を行い、ローカルポート5174をリモートサーバーの5174に転送することでHMRが動作します。

VS Codeを使っていなければ、sshコマンドでポートフォーワード設定をしても動作します。

参考:

www.karakaram.com

SSH経由で開発していてうまく動かず「おや?」と思った際には、ポート転送設定を確認してみてください。

自作モジュールをHMRで差し替えるアプローチ

ポイント

  • import.meta.hot?.accept(...) を呼び、HMR対象となるファイルを指定し、更新されたときの再読み込み処理を記述する。
  • 新しいモジュールをロードしたからといって、古いモジュールと差し替わるわけではない
    • 関数、クラス、オブジェクトの参照を保持していたら、その参照が持つのは古いモジュールのまま
    • acceptのコールバック関数で古いモジュールを差し替える処理を書く必要がある
  • HMR対象モジュールを利用する側は、直接モジュールを参照せず、代わりに参照を保持するオブジェクトを介してアクセスする
    • これによりモジュール差し替えが可能になる

前回の記事では、Reactコンポーネントを装った「なんちゃってロジック」を作り、ReactHMRの仕組みにどさくさ紛れに乗っかる手法を紹介しましたが、今回の方法ではそれを卒業し、きちんと HMR API を使います。

techblog.spiderplus.co.jp

サンプルコード(1) - 関数差し替え

まずは簡単な例として、「関数群」の差し替えケースから見てみましょう。次のLogic.tsというファイルで

  • モジュール利用側がHMR対象のモジュールにアクセスする際に利用する中継オブジェクトを作成(Logicという名前でエクスポートしている)
  • acceptでHMR対象ファイルと、ファイル変更時の差し替え処理を記述

を行っています。

// Logic.ts
import JikkenClass from './JikkenClass';
import JikkenFuncs from './JikkenFuncs';

const Logic = {
    JikkenFuncs: JikkenFuncs,
    JikkenClass: JikkenClass,
};

// ここで「accept」して、JikkenFuncs, JikkenClass の更新を受け取る
import.meta.hot?.accept(['./JikkenFuncs.js', './JikkenClass.js'], ([JikkenFuncs, JikkenClass]) => {
    console.log('Logic.accept');
    // ??の右側に変更前モジュールを置いているのは、変更がなかったファイルのモジュールはundefinedになるため
    Logic.JikkenFuncs = JikkenFuncs?.default ?? Logic.JikkenFuncs;
    Logic.JikkenClass = JikkenClass?.default ?? Logic.JikkenClass;
});

export default Logic;

上記は、

  1. Logic オブジェクトに JikkenFuncs と JikkenClass をまとめる
  2. import.meta.hot?.accept(['./JikkenFuncs.js', './JikkenClass.js'], cb)で 2つのファイルが更新されたときに受け取るコールバックを定義
  3. コールバック内で Logic.JikkenFuncs や Logic.JikkenClass を新しいモジュールに切り替える

という仕組みです。
Logic.jsそのものをHMR 対象にしてしまうとページリロードされてしまうため、Logic.jsはあくまでも固定コードで更新管理として使い、差し替えの対象をJikkenFuncs.jsやJikkenClass.jsに限定している、というイメージですね。

差し替え対象の関数 (JikkenFuncs.ts)

// JikkenFuncs.ts
const JikkenFuncs = {
    /** あいさつ */
    hello: () => 'hello from JikkenFuncs 1',
    konnichiwa: () => 'こんにちは',
};

export default JikkenFuncs;

たとえば hello の戻り値を'hello from JikkenFuncs 2'にして保存すると、HMRが動いてLogic.JikkenFuncsが自動的に置き換わります。
ページリロードなしで即時反映されるのはとても快適です。

サンプルコード(2) - クラスの差し替え

次はクラスの入れ替えです。クラスの場合も同様に差し替えるだけならそこまで難しくありません。

しかし、既にnewしたインスタンスのクラスを差し替えるのは一筋縄ではいきません。

古いインスタンスを「最新クラス」に変身させる仕掛けが必要だからです。

HMR対象クラスの定義

サンプルで簡単なクラスを作成しました。

// JikkenClass.ts
import HmrClass from './HmrClass';

export default class JikkenClass extends HmrClass {
    value = 1;

    /** 足す */
    inc(i: number) {
        this.value += i;
    }

    getModifiedValue() {
        return this.value + 200;
    }
}

JikkenClass.handleHmrUpdate(import.meta.hot);

後述する、親クラスHmrClassの実装がなければ、HMR動作後に新しく生成されたインスタンスは最新のコードベースで動くようになりますが、差し替え前に生成されたインスタンスは旧バージョンのままです。

HMR化するクラスの親クラスの定義 - 既存のインスタンスも新しいクラスに!

ここが最終回の本丸です。「コードの書き換え前に生成されていたインスタンス」も含めて、新しいクラスのメソッドに置き換える、という仕掛けの解説です。

新しいクラスをロードしたら、すでに生成済みだったインスタンスのメソッドだけ上書きする(データはいじらない)ようにします。これを実現するために用いるのが、ここで紹介する HmrClass.js です。

ちょっと長いですが

// HmrClass.ts
import { ViteHotContext } from "../../../../node_modules/vite/types/hot";

export default class HmrClass {
    static #weakRefsMap = new Map<string, WeakRef[]>();

    /**
     * HmrClassのコンストラクタ
     * インスタンス化したオブジェクトの弱参照をHMR Update時のメソッド差し替え用に保持する
     */
    constructor() {
        if (import.meta.hot) {
            const className = this.constructor.name;
            if (!HmrClass.#weakRefsMap.has(className)) {
                throw new Error(`実装エラー: ${className}.handleHmrUpdate(import.meta.hot); を呼んでください`);
            }
            HmrClass.#weakRefsMap.get(className).push(new WeakRef(this));
        }
    }

    /**
     * HMR Update時の処理
     *
     * 既にインスタンス化されたオブジェクトのメソッドを差し替える
     * @param hot
     */
    static handleHmrUpdate(hot?: ViteHotContext) {
        // HMRモードでないときはundefinedが来る
        if (!hot) {
            return;
        }

        // biome-ignore lint/complexity/noThisInStatic: 子クラスを取得するのに必要
        // biome-ignore lint/complexity/noUselessThisAlias: わかりやすくするためにchildClassに代入
        const childClass = this;
        const className = childClass.name;

        if (!hot.data.weakRefs) {
            hot.data.weakRefs = [];
        }

        // 切れた弱参照を切り捨てる
        hot.data.weakRefs = hot.data.weakRefs?.filter((ref: WeakRef) => ref.deref()) ?? [];
        HmrClass.#weakRefsMap.set(className, hot.data.weakRefs);

        // 継承先のクラスからメソッド一覧を取得
        const prototypeMethods = Object.getOwnPropertyNames(childClass.prototype).filter(
            (key) => typeof childClass.prototype[key] === 'function',
        );

        // 既存インスタンスのメソッドを丸ごと上書き
        for (const ref of hot.data.weakRefs) {
            const obj = ref.deref();
            // objの全関数をloopして上書き
            for (const method of prototypeMethods) {
                obj[method] = childClass.prototype[method].bind(obj);
            }
        }

        // dispose内のコールバックは、次のバージョンのモジュールがロードされたときに呼ばれる後始末用の関数
        // ここでは、弱参照リストをhotのdataに保存し、HMR Update後のモジュールへ受け渡す
        hot.dispose((data) => {
            data.weakRefs = HmrClass.#weakRefsMap.get(className);
            // handleHmrUpdateが呼ばれなくなる悪い変更を検知するために一旦削除
            HmrClass.#weakRefsMap.delete(className);
        });
    }
}

ポイントは次のとおりです。

  1. コンストラクタで弱参照(WeakRef)を収集 → 生成されたインスタンスの弱参照をHmrClassの#weakRefsMapに溜め込みます。
  2. 新しいクラスがロードされたら古い弱参照リストを引き継ぎ、既存インスタンスのメソッドを上書き → hot.disposeでViteHotContextのdata(import.meta.hot.data)にweakRefsを保存しておき、差し替え後のモジュール側でhot.data.weakRefsから復元し、生きているインスタンスを全てアップデートします。

最終的に、「既存のインスタンスもまるっと最新版クラスに変わる」仕組みが完成しました。

単一ページアプリケーション開発時にこれは非常に強力で、ページリロードなしでオブジェクトの状態を保持したまま、メソッドが新しい実装へ切り替わるのは気持ちがいいですよ!

終わりに

3回にわたりお送りしてきた「HMR対応シリーズ」、いかがでしたでしょうか。

第1回: 既存プロジェクトをHMR対応した話(Vite利用)

techblog.spiderplus.co.jp

第2回: Reactコンポーネント以外のロジックコードのHMR化

techblog.spiderplus.co.jp

第3回(今回): 自作クラスにHMR APIを活用して、既存インスタンスごと差し替える方法
ついに、「既存インスタンスも最新クラスに変化する」

というHMRを手にし、実装の生産性はさらに高まりました。
古い世代の開発環境を知っているわたしから見ると、まるで魔法のようです。
ぜひプロダクト開発の効率アップに役立ててみてください。

私たちスパイダープラスでは、開発を楽しくするさまざまな工夫を日々探求しています。一緒に開発を盛り上げてくれる仲間を募集中ですので、興味があればお気軽にご連絡ください。

ここまで読んでいただき、ありがとうございました。

3回にわたるHMR記事を無事完走でき、私自身とても嬉しいです。これからも日々の発見や実験で開発を楽しくしていきたいです!