15 KiB
插件内部机制
插件看起来只是「在三个时机里写代码」,但实际执行顺序不取决于它在数组里的位置。两个插件互不依赖时,列表顺序只决定平局顺序;一旦声明了 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 导览。