《游戏引擎实现原理》7.3 模块替换与状态迁移

本节不承诺真正的 HMR,只做「模块边界 + 数据与代码分离 + 状态迁移」的数据路径:可替换模块把状态导出成纯数据加版本戳,替换时沿迁移链升到新版本再灌进新实例。状态迁移逻辑用 vitest 真跑,浏览器里的动态 import 与真实刷新时序标为 L4。

7.3 模块替换与状态迁移

7.2 让脚本能被调度,但脚本代码本身还写死在模块里:改一行就得重启。这一节处理运行时替换脚本模块——注意措辞,是「替换」不是「热更新」。

先把预期压低:本节不承诺真正的 HMR。完整的热模块替换要处理依赖图失效、副作用回滚、模块级单例重建,牵扯整个运行时。helio 只做其中真正能跑通、也最有价值的那部分——模块边界 + 数据 / 代码分离 + 状态迁移:代码怎么换是运行时的活,状态怎么搬才是引擎该负责的。把这条数据路径做扎实,剩下的换代码动作就只是时序问题。

数据与代码分离

要能替换模块,第一件事是让状态离开代码。如果脚本把 count 藏在闭包里、把回调挂在模块级单例上,替换时代码一换,状态就跟着没了。helio 的约束是:可替换模块必须能 captureState() 导出一份纯数据 + 版本戳——{ version, data },data 里只有数字、布尔、字符串、数组,没有函数、没有类实例、没有循环引用。这样它才能被 structuredClone 深拷贝,才能跨越「旧代码已卸载、新代码刚加载」的缝隙。

版本戳是关键。旧模块是 v1,新模块是 v2,中间字段可能改了名(step → stepSize)、加了新字段(paused)。直接灌进去新模块会读到 undefined。所以要有一条迁移链:从 v1 逐级升到 v2,缺环就抛错——绝不静默降级。这套思路与内核存档迁移(卷一第 17 章)同源,区别只在触发时机:存档迁移在加载时,这里在模块替换时。

下面 src/subsys/script/module-state.ts 层级 L1,完整可编译。它定义状态契约、可替换模块接口、迁移链,以及 replaceModule。

// file: src/subsys/script/module-state.ts
// tier: L1
// verify: npx vitest run src/subsys/script/module-state.test.ts
//
// helio 脚本子系统:模块替换的状态迁移(数据路径)。
// 本节不承诺真正的 HMR——只做「模块边界 + 数据 / 代码分离 + 状态迁移」:
// 可替换模块把状态导出成纯数据 + 版本戳,替换时旧状态沿迁移链升到新版本,
// 再灌进新模块实例。代码怎么换是运行时的活,状态怎么搬才是这里要跑通的。

/** 模块对外的状态契约:纯数据 + 版本戳,不含函数 / 类实例 / 循环引用。 */
export interface ModuleState {
  readonly version: number;
  readonly data: Record<string, unknown>;
}

/** 可热替换模块:能导出当前状态,也能从状态恢复。 */
export interface ReplaceableModule {
  readonly name: string;
  readonly version: number;
  captureState(): ModuleState;
  restoreState(state: ModuleState): void;
}

/** 一步迁移:把某版本的数据改成下一版本。 */
export type StateMigration = (data: Record<string, unknown>) => Record<string, unknown>;

/** 迁移链:登记 v -> v+1 的迁移,沿链把数据升到目标版本。 */
export class StateMigrationChain {
  #steps = new Map<number, StateMigration>();

  add(from: number, step: StateMigration): void {
    if (this.#steps.has(from)) throw new Error(`Duplicate migration from v${from}`);
    this.#steps.set(from, step);
  }

  /** 从 fromVersion 逐级升到 toVersion;缺环即抛错,绝不静默降级。 */
  upgrade(
    data: Record<string, unknown>,
    fromVersion: number,
    toVersion: number,
  ): { data: Record<string, unknown>; applied: string[] } {
    if (fromVersion > toVersion) {
      throw new Error(`Cannot downgrade state: v${fromVersion} -> v${toVersion}`);
    }
    let cur = { ...data };
    const applied: string[] = [];
    for (let v = fromVersion; v < toVersion; v++) {
      const step = this.#steps.get(v);
      if (step === undefined) throw new Error(`No migration from v${v} to v${v + 1}`);
      cur = step(cur);
      applied.push(`${v}->${v + 1}`);
    }
    return { data: cur, applied };
  }
}

/** 深拷贝:用 structuredClone 保证导出的是快照而非活引用。 */
function cloneState(data: Record<string, unknown>): Record<string, unknown> {
  return structuredClone(data);
}

/**
 * 用旧模块的状态换上新模块的代码:
 * 1. 导出旧状态(深拷贝,隔离活引用);2. 沿链升到新模块版本;
 * 3. 灌进新模块实例;4. 返回新状态与实际走过的迁移链。
 */
export function replaceModule(
  old: ReplaceableModule,
  next: ReplaceableModule,
  chain: StateMigrationChain,
): { state: ModuleState; applied: string[] } {
  const captured = old.captureState();
  const { data, applied } = chain.upgrade(
    cloneState(captured.data),
    captured.version,
    next.version,
  );
  const state: ModuleState = { version: next.version, data };
  next.restoreState(state);
  return { state, applied };
}

replaceModule 的四步就是整条数据路径。第一步的 cloneState 不能省:captureState 返回的若是活引用,旧模块在替换后继续跑一帧就会把新模块的状态改脏。深拷贝切断了这条线。第四步返回的 applied 是「实际走过的迁移链」,它会写进编辑器日志——替换时看到 1->2->3,就知道这份状态跨了三个版本。

可运行产物:状态迁移真跑

状态迁移是纯逻辑,可以在 Node 里真跑。下面 src/subsys/script/module-state.test.ts 层级 L1,命令 npx vitest run src/subsys/script/module-state.test.ts。

// file: src/subsys/script/module-state.test.ts
// tier: L1
// verify: npx vitest run src/subsys/script/module-state.test.ts
import { describe, expect, it } from 'vitest';
import {
  StateMigrationChain,
  replaceModule,
  type ModuleState,
  type ReplaceableModule,
} from './module-state.ts';

/** v1 的伤害模块:字段 count / step。 */
class DamageV1 implements ReplaceableModule {
  readonly name = 'damage';
  readonly version = 1;
  count = 0;
  step = 10;
  captureState(): ModuleState {
    return { version: this.version, data: { count: this.count, step: this.step } };
  }
  restoreState(s: ModuleState): void {
    this.count = s.data.count as number;
    this.step = s.data.step as number;
  }
}

/** v2:step 改名 stepSize,新增 paused。 */
class DamageV2 implements ReplaceableModule {
  readonly name = 'damage';
  readonly version = 2;
  count = 0;
  stepSize = 0;
  paused = false;
  captureState(): ModuleState {
    return { version: this.version, data: { count: this.count, stepSize: this.stepSize, paused: this.paused } };
  }
  restoreState(s: ModuleState): void {
    this.count = s.data.count as number;
    this.stepSize = s.data.stepSize as number;
    this.paused = s.data.paused as boolean;
  }
}

/** 一条从 v1 到 v2 的迁移:step -> stepSize,补 paused=false。 */
function v1to2(): StateMigrationChain {
  const chain = new StateMigrationChain();
  chain.add(1, (d) => ({ count: d.count, stepSize: d.step, paused: false }));
  return chain;
}

describe('迁移链', () => {
  it('逐级升级并记录走过的链', () => {
    const chain = new StateMigrationChain();
    chain.add(1, (d) => ({ ...d, b: 1 }));
    chain.add(2, (d) => ({ ...d, c: 2 }));
    const r = chain.upgrade({ a: 0 }, 1, 3);
    expect(r.data).toEqual({ a: 0, b: 1, c: 2 });
    expect(r.applied).toEqual(['1->2', '2->3']);
  });

  it('缺环抛错,不静默跳过;降级被拒绝', () => {
    const chain = new StateMigrationChain();
    chain.add(1, (d) => d);
    expect(() => chain.upgrade({}, 1, 3)).toThrow(/No migration from v2/);
    expect(() => chain.upgrade({}, 3, 1)).toThrow(/Cannot downgrade/);
  });
});

describe('模块替换', () => {
  it('v1 -> v2:字段改名与新增都被迁移', () => {
    const old = new DamageV1();
    old.count = 5;
    old.step = 25;
    const next = new DamageV2();
    const { state, applied } = replaceModule(old, next, v1to2());
    expect(applied).toEqual(['1->2']);
    expect(state.version).toBe(2);
    expect(next.count).toBe(5);
    expect(next.stepSize).toBe(25);
    expect(next.paused).toBe(false);
  });

  it('导出的是快照:替换后改旧模块不影响新模块', () => {
    const old = new DamageV1();
    old.count = 1;
    const next = new DamageV2();
    const { state } = replaceModule(old, next, v1to2());
    old.count = 999; // 旧模块继续活着也不该污染已迁走的状态
    expect(next.count).toBe(1);
    expect(state.data.count).toBe(1);
  });

  it('同版本替换:零迁移,状态原样搬过去', () => {
    const old = new DamageV2();
    old.count = 3;
    old.stepSize = 7;
    old.paused = true;
    const next = new DamageV2();
    const { applied } = replaceModule(old, next, v1to2());
    expect(applied).toEqual([]);
    expect(next.count).toBe(3);
    expect(next.stepSize).toBe(7);
    expect(next.paused).toBe(true);
  });

  it('captureState 结果可 structuredClone(无函数 / 类实例泄漏)', () => {
    const st = new DamageV1().captureState();
    expect(() => structuredClone(st)).not.toThrow();
    expect(JSON.stringify(st)).toContain('"step":10');
  });
});

本机实跑:Test Files 1 passed、Tests 6 passed;tsc --noEmit 零错误。三条最值钱的是:v1 -> v2 的字段改名被正确迁移、快照隔离(旧模块继续跑也不污染新模块)、以及同版本替换零迁移。

真实刷新时序:L4,命令行不可复现

上面的数据路径是纯逻辑,但把它接到真实的模块加载需要浏览器。ModuleReloader 给模块 URL 追加一个单调递增的 ?v=<stamp> 绕开缓存,用动态 import() 拉回新模块,再用 replaceModule 把状态迁过去。

下面 src/subsys/script/module-reloader.ts 层级 L4——它依赖浏览器的模块加载器与缓存行为,本节需在浏览器中手动验证,命令行不可复现。该文件只保证通过 tsc 类型检查,不在 Node 里运行。

// file: src/subsys/script/module-reloader.ts
// tier: L4
//
// 真实模块替换的时序:给模块 URL 加版本戳绕开缓存,用动态 import() 重新拉取,
// 拉回后用 replaceModule 把旧状态迁到新模块实例上。
// 它依赖浏览器的模块加载器与缓存行为——命令行不可复现,
// 需在浏览器中手动验证(本文件仅通过 tsc 类型检查,不在 Node 里运行)。

import {
  StateMigrationChain,
  replaceModule,
  type ReplaceableModule,
} from './module-state.ts';

/** 从拉回来的模块命名空间里造出一个可替换模块实例。 */
export type ModuleFactory = (mod: unknown) => ReplaceableModule;

export interface ReloadResult {
  /** 实际走过的迁移链,如 ["1->2"]。 */
  readonly applied: string[];
  readonly version: number;
  /** 拉取 + 迁移 + 换上花掉的墙钟毫秒(浏览器实测,命令行不可复现)。 */
  readonly elapsedMs: number;
}

/** 模块替换器:持有当前实例与迁移链,负责「拉新的 + 迁状态」。 */
export class ModuleReloader {
  #current: ReplaceableModule;
  readonly #chain: StateMigrationChain;
  #stamp = 0;

  constructor(initial: ReplaceableModule, chain: StateMigrationChain) {
    this.#current = initial;
    this.#chain = chain;
  }

  get current(): ReplaceableModule {
    return this.#current;
  }

  /**
   * 重新加载模块:给 URL 追加 ?v=<stamp> 绕开浏览器缓存,动态 import 回来,
   * 用 factory 造实例,再把旧状态迁过去。stamp 单调递增,保证每次都是新 URL。
   */
  async reload(url: string, factory: ModuleFactory): Promise<ReloadResult> {
    const t0 = performance.now();
    this.#stamp++;
    const sep = url.includes('?') ? '&' : '?';
    const stamped = `${url}${sep}v=${this.#stamp}`;
    const mod: unknown = await import(/* @vite-ignore */ stamped);
    const next = factory(mod);
    const { state, applied } = replaceModule(this.#current, next, this.#chain);
    this.#current = next;
    return { applied, version: state.version, elapsedMs: performance.now() - t0 };
  }
}

时序上要留意:import() 是异步的,替换期间旧模块可能还在被某个回调引用。所以 reload 只在帧与帧之间调用,且调用方负责在拉取完成前不销毁旧实例——replaceModule 内部深拷贝了状态,正因如此旧实例可以安全地多活一会儿。elapsedMs 在浏览器里才有意义,命令行拿不到真实的网络与解析耗时。

小结

  • 本节不承诺真正的 HMR,只做「模块边界 + 数据 / 代码分离 + 状态迁移」这条能跑通的数据路径。
  • 可替换模块必须能 captureState() 导出纯数据 + 版本戳;structuredClone 保证是快照而非活引用。
  • StateMigrationChain 沿链逐级升级,缺环抛错、降级拒绝;迁移思路与卷一第 17 章的存档迁移同源。
  • replaceModule 的四步(导出 → 迁移 → 灌入 → 返回 applied)是整条路径;浏览器里的真实刷新时序是 L4,需在浏览器中手动验证,命令行不可复现。

至此脚本绑定三节闭环:属性可读写(7.1)→ 回调有生命周期(7.2)→ 代码可换、状态不丢(7.3)。下一章转向编辑器本身:先厘清编辑器与运行时的关系。

阅读导航:上一节:7.2 组件脚本的生命周期 · 下一节:8.1 编辑器与运行时的关系 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径