template/docs/advanced/iii/plugin-internals.md

15 KiB
Raw Blame History

插件内部机制

插件看起来只是「在三个时机里写代码」,但实际执行顺序不取决于它在数组里的位置。两个插件互不依赖时,列表顺序只决定平局顺序;一旦声明了 dataDeps,顺序就由依赖图决定。依赖写错时,系统也不会抛一个明确的异常,而是把插件及其下游从执行集合里移除。

本篇只讲机制:插件表怎么变成确定顺序、状态怎么推进、三个阶段为什么不能互换、失败时作者会看到什么。怎么写一个插件见插件系统,本篇不重复插件类写法。

系统模型

组成 负责什么
插件表 按 id 保存插件与当前状态
依赖图 把 dataDeps 展开成「谁必须先于谁」
拓扑序 把依赖图压成一份固定执行顺序
状态机 记录每个插件走到哪个阶段
三阶段 按同一顺序执行装配、加载前与加载后回调
flowchart TD
    A[插件表<br/>id 与 dataDeps] --> B[依赖图<br/>缺失与环检测]
    B --> C[拓扑序<br/>构造期只算一次]
    C --> D[状态机<br/>每个阶段只处理前一状态]
    D --> E[awake<br/>同步、不等待]
    E --> F[initialize<br/>加载前、await]
    F --> G[afterLoad<br/>加载后、await]

插件表在 CoreState 构造时建立。依赖图只由插件的 id 与 dataDeps 构成,不读运行期状态;拓扑序也只算一次,三个阶段复用同一份结果。这样插件顺序在启动前就是确定的,运行期修改 dataDeps 不会改变已经算好的顺序。

三个阶段

三个阶段共享同一份拓扑序,差别在时机与是否等待:

阶段 时机 是否等待 适合做什么
awake 主对象构造期 否,同步返回 创建插件自己的对象、注册可存档内容
initialize 加载开始前 是 异步准备、追加加载配置
afterLoad 加载完成后 是 读取加载内容、创建依赖内容的初始状态

awake 的返回值不会被等待,写 async awake 也不会阻塞后续插件;需要等待的事情放到 initialize。initialize 与 afterLoad 都会 await,只要有一个不兑现,后面的插件与加载就不会继续。

三个阶段都保证:被依赖的插件先完成同一阶段。插件 B 的 awake 开始时,A 的 awake 一定已经完成;不构成依赖关系的插件之间,不要假定谁先谁后。

三个阶段收到的依赖是解析后的包装数组,形如 { status, plugin },顺序与 dataDeps 一致。需要对方能力时取 .plugin,不要把它当成第二份依赖声明。依赖缺失或成环的插件不会进入执行顺序,所以拿到依赖后可以直接使用,不需要在回调里再判空。

::: warning MainDataPlugin 不是隐式前置,它只是插件列表里的普通插件。标准入口把它放在最前,不等于插件系统承诺它先执行;用到它注册的存档 id、默认装配或初始化结果时,必须在 dataDeps 里写 'main-data-plugin'。 :::

三个阶段为什么不能互换

三个阶段的差别不只是「先后」,而是「这时能看到什么」:

  • awake 发生在四层构造完成之后、加载开始之前。它能创建对象、注册可存档内容、给已有系统注入默认实现,但读不到核心配置与地图内容;把读取配置的逻辑放进来,只会得到 null。
  • initialize 在加载开始前执行,适合做异步准备与追加加载配置。它可以等待网络或文件,但不能读取尚未加载的内容;这里不兑现,加载不会开始。
  • afterLoad 在加载完成后执行,核心配置、地图与内容都可读。它适合创建依赖内容的初始状态;但它也在启动路径上,放太多重活会推迟游戏开始。

如果把 afterLoad 的逻辑提前到 awake,读取配置会失败;如果把 awake 的同步装配挪到 initialize,依赖它的插件在 awake 阶段就拿不到对象。三个时机各自对应一段确定的时间线,不能靠「反正都会执行」来互换。

awake 不等待返回值,是这条时间线上最容易踩的坑:写成 async awake 不会让后续插件等它。需要等待的事情放进 initialize,需要读取加载内容的事情放进 afterLoad。

一个最小依赖声明

下面这个插件只做一件事:声明自己依赖主插件,然后在 awake 里创建自己的管理器。它展示了依赖声明与阶段方法的最小形状。

import { ICoreState, IDataPluginDefine } from '@user/data-state';

/** 插件自己的管理器;具体逻辑不属于本页 */
class QuestManager {
    constructor(readonly state: ICoreState) {}
}

/** 对外接口,依赖它的插件按这个类型取用 */
export interface IQuestPlugin extends IDataPluginDefine {
    readonly manager: QuestManager;
}

/** 任务插件:显式依赖主插件,避免用到尚未注册的默认装配 */
export class QuestPlugin implements IQuestPlugin {
    readonly id: string = 'quest-plugin';
    // 主插件不是隐式前置,用到它的默认装配就必须写在这里
    readonly dataDeps: readonly string[] = ['main-data-plugin'];

    manager!: QuestManager;

    awake(state: ICoreState): void {
        // 此时四层与主插件的 awake 都已完成,可以安全创建自己的对象
        this.manager = new QuestManager(state);
    }

    async initialize(): Promise<void> {}

    async afterLoad(): Promise<void> {}
}

这个类没有构造器,也没有把 state 存成公共字段;需要主对象时在 awake 里接收。dataDeps 只写 id,不写插件对象;三个阶段收到的依赖参数是引擎解析后的包装数组,不是声明时写的东西。

依赖参数怎么用

三个阶段收到的依赖数组有三个约束:

  • 顺序与 dataDeps 一致:第 i 个元素对应 dataDeps[i],不要按插件在列表里的位置索引。
  • 元素是包装对象:形如 { status, plugin },取能力要读 .plugin;status 会随阶段推进更新。
  • 不要修改数组或往里塞对象:它只是本次回调的解析结果,不是第二份依赖声明;需要新依赖时改 dataDeps,不要在下游临时拼。

启动阶段优先用依赖参数,让引擎保证「同一阶段内依赖先完成」;运行期才用 getDataPlugin 按 id 取回。把依赖参数缓存到字段里再长期使用,等价于持有一个会变化的状态包装对象;需要长期引用时缓存 .plugin,并在使用前确认插件已经到达你需要的阶段。

依赖参数不需要判空。被阻断的插件不会进入执行顺序,回调根本不会执行;如果回调执行了,说明依赖已经解析成功。对依赖参数写 ?. 只会掩盖 dataDeps 的声明错误,让本应被阻断的插件带着空依赖继续跑。启动阶段唯一需要检查的是「依赖是否已经到达你需要的阶段」,不是「依赖是否存在」。

阶段推进的可见性

三个阶段的推进是「整段推进」,不是按依赖链各自跑到底:所有可执行插件的 awake 先完成,才开始 initialize;所有 initialize 兑现后,才开始加载;加载完成后才进入 afterLoad。同一阶段内按拓扑序执行,所以依赖先完成;不同依赖链之间可能有交错,但不会出现「A 的 initialize 在 B 的 awake 之前」。

awake 不等待返回值,意味着 async awake 里第一个 await 之后的代码可能与后续插件的 awake 或 initialize 并发。不要把需要顺序的异步逻辑放在 awake;需要等待的事情放 initialize,需要读加载内容的事情放 afterLoad。

状态机

每个插件在注册表里带一个状态,四个状态与推进条件如下:

状态 含义 进入条件
NotAwakened 还没执行装配期回调 注册时的初始状态
Awakened 已执行 awake awake 正常返回
Initialized 已执行 initialize initialize 兑现
Loaded 三阶段都完成 afterLoad 兑现

每个阶段只处理处于前一状态的插件:awake 只处理 NotAwakened,initialize 只处理 Awakened,afterLoad 只处理 Initialized。状态不符会跳过并告警 80。状态只前进不后退,同一个实例不会重新走一遍阶段,也没有运行期重置。

被阻断的插件不会进入执行顺序,因此永远停在 NotAwakened。getDataPlugin 只返回包装对象与状态,不提供「为什么被阻断」的查询;看到 NotAwakened 时,无法仅凭它区分「还没开始」与「被依赖阻断」。排查要回到控制台里的 76 / 77 提示,再顺着依赖链找。

状态是每个插件各自的,不是全局阶段计数器。依赖数组里的 { status, plugin } 是同一个包装对象,它的 status 会随阶段推进更新:你在自己 awake 里看到的依赖状态是 Awakened,在 afterLoad 里看到的通常已经是 Loaded。不要缓存依赖包装对象再假设状态会停在某个值;需要判断时重新读 status。

与 CoreState 的分工

CoreState 负责四层对象图与构造顺序,插件系统负责在这张图上继续装配。插件不往 CoreState 上加新字段,也不修改四层构造;它把自己的对象挂在插件实例上,需要跨插件访问时通过 getDataPlugin 或 dataDeps 取。

这条分工决定了两件事:

  • 插件可以在 awake 里访问任意一层,因为四层已经全部构造完成;但它不能假设某个「默认装配」已经存在,除非显式依赖提供它的插件。
  • 插件的对象不进入 CoreState 的静态类型;依赖方要拿到能力,必须通过插件接口断言,而不是 state.somePluginField。这也让插件可以被移除或替换,而不改变数据端主对象的形状。

CoreState 的构造器建立插件表与拓扑序,并在最后同步执行 awake;initialize 与 afterLoad 由加载流程驱动。插件系统因此可以独立于具体插件存在,测试时也可以只构造插件表验证排序。

失败时作者看到什么

情况 作者要避免 控制台看到 结果
依赖成环 两个插件互相声明依赖,或链路上绕回自己 76,列出环上的 id 环上插件与下游都不执行,停在 NotAwakened
依赖缺失 id 拼错、对方改名、忘记把插件放进列表 77,插件 id + 缺失的依赖 id 该插件与下游都不执行
重复 id 两个插件使用同一个 id 78 只保留第一个,后一个被跳过
阶段状态不符 假设某个阶段可以重跑、手动推进或从中间插入 80,插件 id 与期望 / 实际状态 该阶段跳过该插件,后续阶段也不会补跑

这些失败不会抛出异常,也不会自动重试。设计上,一个插件的依赖写错不应该让整个数据端无法构造;引擎选择把出错的插件与下游排除,让其余插件继续启动。代价是错误被推迟:作者看到的常见现象不是「插件报错」,而是「某个对象不存在」或「某个 id 没有注册」。被阻断的插件不会执行任何阶段,依赖它的下游也不会执行;所以错误往往在下游第一次访问时才发现,而不是在出错的插件本身。

依赖关系在启动前固定,运行期修改 dataDeps 不会改变已经算好的顺序;也没有接口在运行时追加插件。新增插件要改列表并重新创建实例,不要指望在 load() 之后再插一个。

getDataPlugin 返回的 NotAwakened 不是「还没轮到」的同义词:它既可能是执行顺序还没走到,也可能是插件被 76 / 77 阻断。插件系统没有提供「阻断原因」的查询接口,所以看到这个状态时,先查控制台里的环或缺失提示,再检查依赖链上的每个 id,而不是在调用点反复判空。

取回一个插件

按 id 取回用 state.getDataPlugin<T>(id),返回只读包装或 null。启动阶段优先用 dataDeps 让引擎保证顺序;运行期才按 id 取回,并且必须判空、检查状态。

import { ICoreState, PluginStatus } from '@user/data-state';
import { IMyPlugin } from './types';

/** 启动完成后取回插件:判空与状态检查都不能省 */
export function useMyPlugin(state: ICoreState): void {
    const holder = state.getDataPlugin<IMyPlugin>('my-plugin');
    if (!holder) {
        // 没有注册,或 id 拼错
        return;
    }
    if (holder.status !== PluginStatus.Loaded) {
        // 还没走到你要的阶段,此时调用可能读到未装配好的字段
        return;
    }
    holder.plugin.doSomething();
}

getDataPlugin 只查注册表,不保证插件已经加载完成。需要依赖某个插件的能力时,优先在 dataDeps 里声明;按 id 取回更适合可选的、运行中才发生的调用。

如果取回的目的是启动装配,先考虑声明依赖;按 id 取回会把顺序责任转移到调用方,而调用方通常不知道目标插件走到哪一阶段。

依赖排序只做一次

排序在构造期完成一次,之后三个阶段都复用同一份结果。作者不需要理解排序过程,只需要在 dataDeps 里声明依赖:被依赖的插件会先完成同一阶段。成环或缺失依赖会阻断该插件及其下游,它们不会进入执行顺序。运行期不能追加插件,也不能通过修改 dataDeps 改变已经算好的顺序;需要不同的顺序,只能改列表并重新创建数据端。

边界与注意

  • 依赖在启动前固定:构造期把 dataDeps 抄进依赖表,之后修改数组不会改变顺序。
  • 不支持运行期追加插件:注册表只在构造期建立,没有追加接口;新增插件要改列表并重新创建实例。
  • 环或缺失会阻断下游:不只是出错的插件本身,依赖它的插件也不会执行任何阶段。
  • 重复 id 只保留第一个:构造期告警并跳过后一个,注册表里不会出现两个同 id 实例。
  • 依赖主插件要显式声明:用到 main-data-plugin 注册的默认装配、存档 id 或初始化结果时,在 dataDeps 里写 'main-data-plugin'。
  • getDataPlugin 要判空并检查状态:它只查注册表,不保证插件已进入你需要的阶段;数据端与渲染端的 dataDeps / clientDeps 也是两套,不要混用。

返回高级 III 导览。