mirror of
https://github.com/motajs/template.git
synced 2026-10-25 01:00:18 +08:00
docs(10-14): 编写插件系统
This commit is contained in:
parent
eb14b4daee
commit
1b92ee4c5e
451
docs/advanced/plugin-system.md
Normal file
451
docs/advanced/plugin-system.md
Normal file
@ -0,0 +1,451 @@
|
||||
# 插件系统
|
||||
|
||||
如果你已经用过中级教程里的[数据插件](../intermediate/data-plugins.md),你会知道插件能把一组功能打包、挂到 `state` 上,并按固定时机初始化。那一篇停在「会用」;这一篇讲「能自己设计并维护」。
|
||||
|
||||
插件是高级 I 的核心:后面的可存档内容、录像一致性、钩子深入,实际都挂在某个插件上。读完本篇,你应该能独立写出一个插件、让插件彼此协作,并知道为什么插件的 `id` 不能随便改。
|
||||
|
||||
阅读前需要会基础语法:`class`、`new`、`import`、`async` / `await`。不需要懂引擎内部实现;涉及内部机制的地方,会先说清「你能拿它做什么」。
|
||||
|
||||
## 插件解决什么问题
|
||||
|
||||
假设你要加一个任务系统:它有自己的状态、自己的管理器,还要在加载完地图后读一遍任务配置。不做成插件的话,这些代码会散在几个地方——对象在 `src/data/initialize.ts` 里 `new`,存档注册也写在那里,加载后的初始化再塞进某个钩子。功能一多,`initialize.ts` 会变成所有人的公共草稿纸。
|
||||
|
||||
插件把这一组东西收进一个独立单元:
|
||||
|
||||
- 自己的对象在自己的目录里创建;
|
||||
- 自己的存档内容在自己的 `awake` 里注册;
|
||||
- 自己的加载后逻辑在自己的 `afterLoad` 里写;
|
||||
- 启动时只需要在 `dataPlugins` 里 `new` 一次。
|
||||
|
||||
这和[声明式接口](./declarative-vs-imperative.md)是同一个思路:你不去改引擎的初始化流程,只声明「在哪个时机做什么」,时机由引擎统一调用。
|
||||
|
||||
还有一层现实理由:`src` 是你自己的代码,升级引擎时一般不用动;而内置包(`packages` / `packages-user`)在升级时可能会变。新增系统优先做成插件,等于把改动留在自己的地盘里,升级时要跟的差异更少。
|
||||
|
||||
## 最小插件
|
||||
|
||||
### IDataPluginDefine 的五个成员
|
||||
|
||||
一个插件实现 `IDataPluginDefine`:
|
||||
|
||||
| 成员 | 类型 | 说明 |
|
||||
| --------------------------------- | ---------------------------------------- | ---------------------------- |
|
||||
| `id` | `string` | 插件标识符,全局唯一 |
|
||||
| `dependencies` | `string[]` | 依赖的插件 id 数组 |
|
||||
| `awake(state, dependencies)` | `(state, dependencies) => void` | 同步初始化,主对象构造期调用 |
|
||||
| `initialize(state, dependencies)` | `(state, dependencies) => Promise<void>` | 异步初始化,加载开始前调用 |
|
||||
| `afterLoad(state, dependencies)` | `(state, dependencies) => Promise<void>` | 加载完毕后调用 |
|
||||
|
||||
最小实现如下。方法里用不到的参数可以省略,现有的 `DataShopPlugin` 就只收了 `state`:
|
||||
|
||||
```ts
|
||||
// [模板] 一个最小插件:只有 id、dependencies 和三个时机方法
|
||||
export class MyPlugin implements IDataPluginDefine {
|
||||
readonly id = 'my-plugin';
|
||||
readonly dependencies: string[] = [];
|
||||
|
||||
awake(state: ICoreState): void {
|
||||
// [需改] 在这里装配插件自己的对象
|
||||
}
|
||||
|
||||
async initialize(state: ICoreState): Promise<void> {}
|
||||
|
||||
async afterLoad(state: ICoreState): Promise<void> {}
|
||||
}
|
||||
```
|
||||
|
||||
这里最容易混淆的是 `dependencies` 的两种形态:
|
||||
|
||||
- <strong>定义插件时</strong>,`dependencies` 是依赖插件的 <strong>id 字符串数组</strong>;
|
||||
- <strong>三个时机方法收到时</strong>,`dependencies` 参数是这些 id 解析后的<strong>插件对象数组</strong>,元素类型是 `IDataPlugin`,含 `plugin` 与 `status` 两个字段。
|
||||
|
||||
所以「声明依赖」写的是 `id`,真正拿到的却是对象。两者不要混着写。
|
||||
|
||||
### 注册到 dataPlugins
|
||||
|
||||
插件写好后,在 `src/data/plugin/list.ts` 的 `dataPlugins` 数组里登记。文件顶部通常已经有 `IDataPluginDefine` 和你插件的 `import`,不要再粘;只改这一行:
|
||||
|
||||
```ts
|
||||
/** 数据端插件列表 */
|
||||
export const dataPlugins: IDataPluginDefine[] = [new MyPlugin()];
|
||||
```
|
||||
|
||||
数据端启动时会遍历这个数组,把插件收进注册表。同一个 `id` 出现两次时,后一个会被忽略,控制台报一条错误(错误码 `78`),所以请保证 `id` 唯一。
|
||||
|
||||
## 三个生命周期进阶
|
||||
|
||||
三个时机在启动流程里的位置是固定的。按 `CoreState` 的实际执行顺序,可以画成这样:
|
||||
|
||||
```text
|
||||
CoreState 构造
|
||||
└─ awake 同步执行,全部可初始化插件依次完成
|
||||
load() 开始
|
||||
├─ initialize 异步,加载开始前
|
||||
├─ loader.start() 真正加载数据、产出进度
|
||||
└─ afterLoad 异步,加载完毕后
|
||||
```
|
||||
|
||||
数据端主对象在构造时就会调用 `awake`;之后由 `load()` 依次执行 `initialize`、加载、`afterLoad`。`state` 本身的入口与常用对象见[数据端总览](../intermediate/data-state-overview.md)。
|
||||
|
||||
### awake:主对象构造期,同步执行
|
||||
|
||||
`awake` 在主对象构造过程中被调用,此时地图、配置都还没加载。它返回 `void`,<strong>引擎不会等待它的返回值</strong>——即使你把它写成 `async`,引擎也会立刻走到下一个插件。
|
||||
|
||||
所以 `awake` 适合做「不需要等外部数据」的装配:
|
||||
|
||||
- `new` 出插件自己的系统、管理器;
|
||||
- 把它们挂到插件对象上,供后续调用;
|
||||
- 调用 `state.addSaveableContent(id, content)` 注册需要进存档的对象。
|
||||
|
||||
需要在 `awake` 里等的东西(读文件、等网络、等加载器),都不属于这里,放到 `initialize`。
|
||||
|
||||
### initialize:加载开始前
|
||||
|
||||
`initialize` 在 `load()` 的最前面被调用,此时数据还没开始加载。它返回 `Promise`,引擎会 `await` 它。
|
||||
|
||||
只要这个 Promise 不兑现,后面的插件(包括依赖它的)都不会继续,`loader.start()` 也不会开始。所以这里可以放心做「加载前必须完成」的异步准备,但不要在里面等一个永远不会结束的任务。
|
||||
|
||||
### afterLoad:加载完毕后
|
||||
|
||||
`afterLoad` 在整个加载结束之后被调用,同样返回 `Promise` 并被等待。它适合「必须读到加载内容才能做」的初始化,例如读核心配置、读地图、根据数据创建对象。
|
||||
|
||||
`load()` 的最后一步就是 `await afterLoad()`:它不兑现,加载流程就不算结束。
|
||||
|
||||
### 状态与顺序
|
||||
|
||||
每个插件在注册表里都带着一个 `PluginStatus`,随阶段推进:
|
||||
|
||||
| 状态 | 含义 |
|
||||
| -------------------------- | ----------------------------- |
|
||||
| `PluginStatus.NotAwakened` | 还没执行 `awake` |
|
||||
| `PluginStatus.Awakened` | 已执行 `awake`,未异步初始化 |
|
||||
| `PluginStatus.Initialized` | 已执行 `initialize`,未加载完 |
|
||||
| `PluginStatus.Loaded` | 三个时机都已完成 |
|
||||
|
||||
三个阶段都走同一份依赖排序结果:被依赖的插件先完成同一阶段,依赖它的一方才会开始。也就是说,插件 B 的 `awake` 开始时,它声明的依赖插件 A 一定已经 `awake` 完毕。
|
||||
|
||||
## dependencies 与排序
|
||||
|
||||
### 排序规则
|
||||
|
||||
引擎内部用 `orderPlugins` 计算顺序,结果包含三样东西:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --------- | ------------------------------------------------------- |
|
||||
| `order` | 可以初始化的插件拓扑序,被依赖者在前 |
|
||||
| `cyclic` | 处于依赖环中的插件 id |
|
||||
| `missing` | 直接引用了不存在依赖的插件 id,以及它缺失的依赖 id 列表 |
|
||||
|
||||
排序只保证一件事:<strong>依赖关系成立时,被依赖的插件先完成,依赖它的后完成</strong>。不构成依赖关系的插件之间,引擎按登记顺序扫描,但这不是可以依赖的契约——需要谁先谁后,就显式声明依赖。
|
||||
|
||||
这份顺序在主对象构造时算一次,之后不会重算。启动后修改某个插件的 `dependencies` 数组不会改变顺序,也没有接口在运行时追加插件。依赖在启动前定好。
|
||||
|
||||
### 失败语义:循环依赖与缺失依赖
|
||||
|
||||
依赖写错不会「部分生效」,而是整条链路被阻断:
|
||||
|
||||
- <strong>循环依赖</strong>:能沿依赖边绕回自身的插件都会被记进 `cyclic`,控制台报一条错误(错误码 `76`),列出环上的 id;
|
||||
- <strong>缺失依赖</strong>:`dependencies` 里写了一个没有注册的 id,该插件会被记进 `missing`,控制台按「插件 id + 缺失的依赖 id」逐条报错(错误码 `77`);
|
||||
- <strong>下游阻断</strong>:依赖了上述插件的插件,也会一起被排除。被排除的插件不会进入 `order`,三个时机一个都不会执行。
|
||||
|
||||
也就是说,一处依赖写错,受影响的不只是那一个插件,而是依赖它的整条链。排查时先看控制台里的错误码和 id,再顺链往上找。
|
||||
|
||||
依赖尽量少:每多一条依赖,就多一个「对方改名或失败会拖住自己」的机会。只依赖你确实要调用的插件。
|
||||
|
||||
## 插件之间怎么调用
|
||||
|
||||
### 用依赖参数直接调用
|
||||
|
||||
声明了依赖,最直接的方式就是用方法参数里的插件对象。注意它是 `IDataPlugin` 包装对象,你要取 `.plugin`:
|
||||
|
||||
```ts
|
||||
// [需改] 插件 B 依赖插件 A,并在初始化时调用 A 的方法
|
||||
export class PluginB implements IDataPluginDefine {
|
||||
readonly id = 'plugin-b';
|
||||
readonly dependencies = ['plugin-a'];
|
||||
|
||||
awake(
|
||||
state: ICoreState,
|
||||
dependencies: IDataPlugin<IDataPluginDefine>[]
|
||||
): void {
|
||||
// [需改] 参数顺序与 dependencies 数组一致
|
||||
const a = dependencies[0]?.plugin as PluginA | undefined;
|
||||
if (!a) return;
|
||||
a.doSomething();
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {}
|
||||
|
||||
async afterLoad(): Promise<void> {}
|
||||
}
|
||||
```
|
||||
|
||||
`PluginA` 需要有能被外部调用的方法。通常的做法是像 `IShopPlugin` 那样,给自己的插件定一个接口:`interface IMyPlugin extends IDataPluginDefine { ... }`,把要暴露的方法写进去。
|
||||
|
||||
拿到的是注册表里同一个包装对象,它的 `status` 会随阶段推进更新。所以你在自己 `awake` 里看到的依赖状态是 `Awakened`,在 `afterLoad` 里看到的通常已经是 `Loaded`。
|
||||
|
||||
### 用 getDataPlugin 取回
|
||||
|
||||
没有声明依赖、或者在启动之后才按需访问时,用 `state.getDataPlugin(id)`。它返回 `Readonly<IDataPlugin<T>> | null`:
|
||||
|
||||
```ts
|
||||
// [需改] 取回插件,先判空,再确认状态
|
||||
const holder = state.getDataPlugin<PluginA>('plugin-a');
|
||||
if (holder && holder.status === PluginStatus.Loaded) {
|
||||
holder.plugin.doSomething();
|
||||
}
|
||||
```
|
||||
|
||||
两个检查都不能省:
|
||||
|
||||
- 返回 `null` 表示没有这个 `id`,可能是没注册,也可能是拼写错了;
|
||||
- `status` 没到你要的阶段,说明它还没执行到那一步,此时调用它的方法可能读到还没装配好的字段。
|
||||
|
||||
如果在启动阶段就要用某个插件,优先声明依赖,让引擎保证顺序;`getDataPlugin` 更适合可选的、运行中才发生的调用。
|
||||
|
||||
## 插件 id 稳定性与版本迁移
|
||||
|
||||
### 插件 id 与可存档 id 是两套命名
|
||||
|
||||
这是最容易混的一处。插件有插件 id,它注册的可存档内容另有存档 id,两者互不相干:
|
||||
|
||||
| 名称 | 例子 | 用在哪 |
|
||||
| ------- | ------------------ | ----------------------------------------------- |
|
||||
| 插件 id | `data-shop-plugin` | `dataPlugins` 注册表、其他插件的 `dependencies` |
|
||||
| 存档 id | `@plugin/shop` | `state.addSaveableContent(id, content)` |
|
||||
|
||||
商店插件就是现成的例子:它的插件 id 是 `data-shop-plugin`,在 `awake` 里注册的存档内容却是 `@plugin/shop`。这两个字符串没有任何推导关系,不要以为改了插件 id 存档 id 会跟着变,也不要从插件 id 猜存档 id。
|
||||
|
||||
### 迁移时的最小做法
|
||||
|
||||
插件 id 会被别的插件的 `dependencies` 引用,所以它是一份对外契约:
|
||||
|
||||
- <strong>不要随意改名</strong>。改名后,依赖它的插件会变成「缺失依赖」,整条链被阻断;
|
||||
- <strong>不要复用旧 id 表示新语义</strong>。旧依赖方仍然能找到这个 id,但拿到的是另一套对象,运行时会以「方法不存在」之类的形式炸掉,而且很难从依赖声明上看出来。
|
||||
|
||||
需要演进时,按改动大小选最小做法:
|
||||
|
||||
1. <strong>行为兼容的升级</strong>:保留原 `id`,只改实现。依赖方不需要动。
|
||||
2. <strong>语义确实变了</strong>:新增一个 id,不要复用旧 id;同时把所有依赖方的 `dependencies` 一起改掉,不要分几次发布。
|
||||
3. <strong>必须过渡</strong>:让旧 id 继续注册一个兼容实现(或做转发),等所有依赖方迁完再删。
|
||||
4. <strong>存档 id 尽量不动</strong>:读档时按存档 id 找内容,改了它,旧存档里的数据就对不上——当前对象会报「缺少存档数据」,旧数据则会成为多余键。确实要改时,需要自己写迁移逻辑(读档后从旧 id 取数据写进新对象),不要只改字符串。
|
||||
|
||||
## 注册与目录
|
||||
|
||||
插件放在 `src/data/plugin/` 下自己的目录里。现有商店插件的形状可以作为参考:
|
||||
|
||||
| 文件 | 放什么 |
|
||||
| --------------------------------------- | ------------------------------------ |
|
||||
| `plugin.ts` | 插件类本身,实现 `IDataPluginDefine` |
|
||||
| `types.ts` | 插件对外接口、存档数据结构 |
|
||||
| `state.ts` / `manager.ts` / `system.ts` | 插件自己的对象,按需创建 |
|
||||
| `index.ts` | 统一导出 |
|
||||
|
||||
然后在 `list.ts` 里 `new` 出来加进 `dataPlugins`。
|
||||
|
||||
这是少数<strong>应该新建文件与目录</strong>的场景:插件本身就是一个独立单元,把它的类、接口和对象放在一起,比全部塞进 `initialize.ts` 更容易维护。除此之外的普通效果仍然优先改内置文件,不要为每个效果新建目录。
|
||||
|
||||
## 模板:一个插件的最小骨架
|
||||
|
||||
<strong>目标</strong>:新建一个插件,能被启动流程加载,并拥有自己的对象与存档内容。
|
||||
|
||||
<strong>改哪里</strong>:在 `src/data/plugin/` 下新建一个目录,放入 `plugin.ts` 与 `index.ts`;再改 `src/data/plugin/list.ts`。
|
||||
|
||||
<strong>复制什么</strong>:下面是机制本身的通用骨架,复制后按 `[需改]` 修改。它不是任何一个具体插件,商店插件只是示例,不要拿它当模板。
|
||||
|
||||
```ts
|
||||
// [模板] 插件通用骨架:复制后按 [需改] 修改
|
||||
import { ICoreState, IDataPlugin, IDataPluginDefine } from '@user/data-state';
|
||||
|
||||
// [需改] 把 MyPlugin 换成你的插件类名
|
||||
export class MyPlugin implements IDataPluginDefine {
|
||||
// [需改] 插件 id:一旦发布就不要改,其他插件可能依赖它
|
||||
readonly id = 'my-plugin';
|
||||
// [需改] 依赖的插件 id;没有依赖就留空数组
|
||||
readonly dependencies: string[] = [];
|
||||
|
||||
// [需改] 插件自己的对象;没有就删掉这一行
|
||||
mySystem!: MySystem;
|
||||
|
||||
awake(state: ICoreState): void {
|
||||
// [需改] 装配对象,不要在这里 await
|
||||
this.mySystem = new MySystem(state);
|
||||
// [需改] 有需要存档的数据时注册;这里是存档 id,不是插件 id
|
||||
state.addSaveableContent('@plugin/my-plugin', this.mySystem);
|
||||
}
|
||||
|
||||
async initialize(
|
||||
state: ICoreState,
|
||||
dependencies: IDataPlugin<IDataPluginDefine>[]
|
||||
): Promise<void> {
|
||||
// [需改] 加载开始前要做的事;可以 await,但会阻塞加载
|
||||
}
|
||||
|
||||
async afterLoad(
|
||||
state: ICoreState,
|
||||
dependencies: IDataPlugin<IDataPluginDefine>[]
|
||||
): Promise<void> {
|
||||
// [需改] 需要读取加载内容时写在这里
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<strong>改什么</strong>:
|
||||
|
||||
1. <strong>类名与 id</strong>:`MyPlugin` 与 `my-plugin` 换成你的名字,`id` 全局唯一。
|
||||
2. <strong>dependencies</strong>:填依赖插件的 id 字符串;没有就保持空数组,不要填插件对象。
|
||||
3. <strong>awake 里的装配</strong>:换成你自己的对象。需要进存档的对象必须实现 `ISaveableContent`(`saveState` / `loadState`),细节见 `高级教程 I · 可存档内容`。
|
||||
4. <strong>存档 id</strong>:`@plugin/my-plugin` 换成你自己的存档 id,与插件 id 分开命名。
|
||||
5. <strong>initialize / afterLoad</strong>:用不到就留空方法;用到时按需加逻辑。
|
||||
|
||||
<!-- TODO(advanced-I): 可存档内容 -->
|
||||
|
||||
<strong>注意</strong>:`awake` 里的异步工作不会被等待;`initialize` 与 `afterLoad` 里启动的异步任务必须 `await`,否则阶段会提前结束。注册时只改 `list.ts` 的数组,不要另外找地方调用插件方法。
|
||||
|
||||
## 示例:读一个现有插件
|
||||
|
||||
### DataShopPlugin:awake 里装配并注册存档
|
||||
|
||||
商店插件是 `dataPlugins` 里当前唯一的独立插件,适合逐段对照。它的代码在 `src/data/plugin/shop/plugin.ts`:
|
||||
|
||||
```ts
|
||||
export class DataShopPlugin implements IShopPlugin {
|
||||
id: string = 'data-shop-plugin';
|
||||
dependencies: string[] = [];
|
||||
|
||||
shop!: IShopSystem;
|
||||
manager!: IShopManager;
|
||||
|
||||
/** 数据端主对象 */
|
||||
private state!: ICoreState;
|
||||
|
||||
createShopState(id: string): IShopState {
|
||||
return new ShopState(id, this.state);
|
||||
}
|
||||
|
||||
awake(state: ICoreState): void {
|
||||
this.state = state;
|
||||
this.shop = new ShopSystem(state);
|
||||
this.manager = new ShopManager(state);
|
||||
|
||||
state.addSaveableContent('@plugin/shop', this.manager);
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {}
|
||||
|
||||
async afterLoad(): Promise<void> {}
|
||||
}
|
||||
```
|
||||
|
||||
逐段看:
|
||||
|
||||
- `id` 是 `data-shop-plugin`,没有依赖,所以它会在 `awake` 的第一批里执行;
|
||||
- `awake` 先保存 `state`,再创建系统与管理器。这两个对象都需要主对象,所以只能在这里 `new`;
|
||||
- `state.addSaveableContent('@plugin/shop', this.manager)` 把管理器注册为存档内容。<strong>注册的是管理器,不是插件本身</strong>;存档 id 也另起了一套命名。这样读档时恢复的是商店数据,插件对象本身不需要进存档;
|
||||
- `initialize` 与 `afterLoad` 都是空的:商店不依赖加载内容,装配在 `awake` 就完成了。
|
||||
|
||||
`IShopPlugin` 在 `IDataPluginDefine` 之上加了 `shop`、`manager`、`createShopState`,这就是「插件对外暴露能力」的写法。别的插件依赖它时,就能直接调用这些成员。
|
||||
|
||||
### MainDataPlugin:afterLoad 读取加载后的配置
|
||||
|
||||
主插件 `src/data/initialize.ts` 的 `MainDataPlugin` 是 `afterLoad` 的典型用法。它的 `awake` 负责装配朝向、寻路、加载钩子与各系统;创建勇士则放到 `afterLoad`:
|
||||
|
||||
```ts
|
||||
async afterLoad(state: ICoreState): Promise<void> {
|
||||
// 核心配置只有在加载完成后才拿得到
|
||||
const coreConfig = state.loader.getConfig<ICoreStateCoreConfig>('core');
|
||||
if (!coreConfig) {
|
||||
logger.error(79, 'Data');
|
||||
return;
|
||||
}
|
||||
const dir = state.faceManager.get(this.heroFace);
|
||||
if (!dir) {
|
||||
logger.error(80);
|
||||
return;
|
||||
}
|
||||
state.hero.createAttribute(coreConfig.init.hero.attribute);
|
||||
const location: IFacedTileLocator = {
|
||||
x: coreConfig.init.hero.x,
|
||||
y: coreConfig.init.hero.y,
|
||||
direction: FaceDirection.Down
|
||||
};
|
||||
state.hero.createLocation(location, dir);
|
||||
}
|
||||
```
|
||||
|
||||
这段为什么不能放进 `awake`:`coreConfig` 来自 `core.jsonc`,要等加载器读完才有。`awake` 阶段 `getConfig` 拿不到内容,创建勇士就会失败。`afterLoad` 正好是「加载已完成、但游戏还没正式开始」的时机。
|
||||
|
||||
它同时说明了另一个点:`afterLoad` 拿到的 `state` 与 `awake` 是同一个主对象,`awake` 里存下的引用(如 `this.heroFace`)此时仍然可用。
|
||||
|
||||
## 改内置还是自己写
|
||||
|
||||
先分清两种改动:
|
||||
|
||||
- 只是给现有插件加一点行为,例如给商店多一个查询接口、调整某个系统的默认值——<strong>直接改现有插件的代码</strong>,在它的类里加方法或改实现;
|
||||
- 要加一组独立的功能,有自己的状态、自己的管理器、自己的存档内容——<strong>新建插件</strong>。
|
||||
|
||||
新建插件比改内置多出不少工作:目录、注册、依赖声明、存档 id、加载时机都要自己定。能改内置解决的问题,不值得再包一层。
|
||||
|
||||
<strong>如果你不怎么懂代码,建议直接改内置,而不是自己写新的插件</strong>。在内置类的方法里加几行,出问题时对照原有代码就能看出差异;新建插件则要同时处理注册、依赖与时机,排查面大得多。
|
||||
|
||||
## 常见错误
|
||||
|
||||
- <strong>在 awake 里 await</strong>。`awake` 的返回值会被忽略,写 `async awake` 也不会被等待。异步准备放到 `initialize`。
|
||||
- <strong>把 dependencies 写成对象</strong>。它必须是 id 字符串数组;插件对象是三个时机方法收到的参数,不是声明时写的东西。
|
||||
- <strong>依赖成环</strong>。环上的插件全部被排除,依赖它们的下游也不执行。依赖关系要单向。
|
||||
- <strong>依赖 id 拼错</strong>。插件不会报「找不到类」,而是被记为缺失依赖并从 `order` 中排除;控制台会给出插件 id 与缺失的 id。
|
||||
- <strong>用 getDataPlugin 不判空</strong>。它可能返回 `null`,也可能处于你还没等到阶段。
|
||||
- <strong>把插件 id 当显示名随意改</strong>。别的插件的 `dependencies` 会立刻失效,存档 id 也不会跟着变。
|
||||
- <strong>直接改其他系统的内部字段</strong>。插件之间应通过公开方法与接口协作;直接改私有字段会绕开对方维护的一致性逻辑。
|
||||
- <strong>重复注册同一个 id</strong>。后一个会被忽略并报错(`78`),你以为生效的插件其实没有进注册表。
|
||||
|
||||
## 怎么验证
|
||||
|
||||
<strong>第一项:确认三个时机按顺序发生</strong>。
|
||||
|
||||
在你插件的三个方法里各放一条可观察信号,例如:
|
||||
|
||||
```ts
|
||||
// [验证] 用提示确认时机顺序
|
||||
awake(state: ICoreState): void {
|
||||
state.emitTip('my-plugin: awake');
|
||||
}
|
||||
|
||||
async initialize(state: ICoreState): Promise<void> {
|
||||
state.emitTip('my-plugin: initialize');
|
||||
}
|
||||
|
||||
async afterLoad(state: ICoreState): Promise<void> {
|
||||
state.emitTip('my-plugin: afterLoad');
|
||||
}
|
||||
```
|
||||
|
||||
把插件加进 `dataPlugins` 后启动,三条提示应依次出现:`awake` 最早,`initialize` 在加载开始前,`afterLoad` 在加载结束后。用控制台输出也可以,只要顺序能被观察到。
|
||||
|
||||
<strong>第二项:确认插件已经能被取回</strong>。
|
||||
|
||||
在启动完成后执行:
|
||||
|
||||
```ts
|
||||
// [验证] 取回插件并检查状态
|
||||
const holder = state.getDataPlugin<MyPlugin>('my-plugin');
|
||||
console.log(holder?.status === PluginStatus.Loaded, holder?.plugin);
|
||||
```
|
||||
|
||||
`status` 应为 `Loaded`,`plugin` 应是你 `new` 的那个实例。如果是 `null`,先检查 `list.ts` 有没有真的 `new` 并放进数组。
|
||||
|
||||
<strong>第三项:存档后读档,确认插件注册的内容恢复</strong>。
|
||||
|
||||
让插件注册的可存档内容进入一个可观察状态(例如改一个计数),存档,再改动它,然后读档。读档后应回到存档时的值。没恢复时依次检查:对象有没有实现 `ISaveableContent`、有没有在 `awake` 里用 `state.addSaveableContent` 注册、`saveState` 返回的数据里有没有漏字段。
|
||||
|
||||
<strong>第四项:故意把依赖写错一次</strong>。
|
||||
|
||||
把 `dependencies` 里的某个 id 改成一个不存在的名字,重新启动。此时应出现两条现象:控制台报缺失依赖错误(错误码 `77`,带插件 id 与缺失的依赖 id),你的插件没有任何时机输出。改回正确 id 后应恢复正常。这一步能让你亲眼看到「阻断」不是只影响被写错的插件。
|
||||
|
||||
## 小结
|
||||
|
||||
- 插件把一组对象与它们的生命周期收进一个独立单元,启动时在 `dataPlugins` 里 `new` 一次。
|
||||
- `dependencies` 声明的是 id 字符串;三个时机方法收到的是解析后的插件对象。
|
||||
- `awake` 在构造期同步执行,不能等异步;`initialize` 在加载前、`afterLoad` 在加载后,二者都会阻塞。
|
||||
- 三个阶段都按依赖顺序走;循环依赖、缺失依赖会连同下游一起被阻断。
|
||||
- 插件 id 是对外契约,不要随意改名或复用;它与可存档 id 是两套命名。
|
||||
- 优先改内置;需要独立成组的功能时再写插件。
|
||||
Loading…
Reference in New Issue
Block a user