docs(10-17): 编写钩子文档

This commit is contained in:
unanmed 2026-10-20 13:00:01 +08:00
parent bd46e5c2f4
commit e074c73433

View File

@ -0,0 +1,435 @@
# 钩子深入
中级教程的[钩子](../intermediate/hooks.md)篇回答的是「什么需求该用哪个钩子」;这一篇回答另一个问题:当同一个钩子类被多个地图、多个图层、多个移动器触发时,你怎么知道这次回调是不是你关心的那个对象。
钩子本身不难,难在<strong>身份</strong>。一张地图有多个图层,一个楼层集合里又有多张地图;给其中一个图层挂的钩子,不会自动只对那个图层生效,除非你在注册方式或回调里把身份限定住。限定错了,效果就会加到别的楼层或图层上。
阅读前需要会基础语法:`class`、`new`、`import`、`async` / `await`。本篇只讲数据端,示例都按当前源码里的接口书写。
## 钩子挂在实例上
一个钩子类只是模板。真正参与事件的是你用 `new` 创建出来的<strong>实例</strong>,以及它被注册到的那个主对象实例。
`Hookable` 的钩子列表是实例字段:每个地图、每个图层、每个移动器各自持有一份,而不是整个类共享一份。因此:
- 同一个钩子类可以 `new` 出很多个实例,分别注册到不同对象;
- 给图层 A 注册的钩子,不会出现在图层 B 上;
- 同一个实例也可以注册到多个对象,但这时它必须能在回调里区分身份,否则就会串。
以图层为例,`GameMap.addLayer(alias)` 每次调用都会创建一个新的 `MapLayer`。同一张地图的 `ground` 与 `wall` 是两个不同的 `MapLayer` 实例,各自有独立的钩子列表。地图对象本身也是 `Hookable`,它的钩子列表与图层完全分开。
这也是为什么「给地图加钩子」和「给图层加钩子」不能混:它们接收的是两套不同的接口,事件也从不同的对象发出。
## 多对象场景
先看几种容易出错的组合:
| 场景 | 实际存在的对象 |
| ------------------------------------ | --------------------------------------------------- |
| 一张地图有多个图层 | 每个图层一个 `MapLayer`,各自独立 |
| 楼层集合里有多张地图 | 每张地图一个 `IGameMap`,各自独立 |
| 场上有多个动态图块 | 每个动态图块一个 `IDynamicTile`,各自带一个 `mover` |
| 勇士与动态图块同时存在 | 勇士的移动器与图块的移动器是不同实例 |
| 同一张地图被重复打开或存在于多个沙盒 | 可能是多个 `IGameMap` 实例,名字相同但引用不同 |
钩子被触发,只能说明「它所属的实例发生了事件」,不能说明那就是你想要的那个实例。比如地图级钩子的 `onResizeLayer(layer, width, height)` 会收到该地图每个图层的尺寸变化;如果你不比较 `layer`,就会对每个图层都执行一次。
地图与图层之间还有一层内部转发:`GameMap.addLayer` 会为每个图层注册一个 `StateMapLayerHook`,把图层的 `onResize` 转发到地图的 `onResizeLayer`,并带上具体 `layer`。所以地图级钩子能拿到图层身份;图层级钩子反而拿不到「我是谁」,因为图层钩子的回调参数里没有 layer。
## 身份从哪里来
判断能不能在回调里做身份校验,先看这个钩子族有没有把身份传回来:
| 钩子族 | 回调里能拿到的身份 | 能否直接比较 |
| -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------- |
| `IMapLayerHooks.onResize` / `onUpdateArea` / `onUpdateBlock` | 没有 layer 参数,只有尺寸、区域或图块坐标 | 不能 |
| `IMapLayerHooks.onOpenDoor` / `onCloseDoor` | 没有 layer 参数,只有门的位置或图块数字 | 不能 |
| `IMapLayerHooks.onCreateDynamic` / `onDeleteDynamic` / `onUpdateDynamicPosition` | `tile`,而 `tile.layer` 是它所属的图层 | 可以比较 `tile.layer` |
| `IGameMapHooks.onChangeBackground` | 只有背景图块数字 | 不能判断是哪张地图 |
| `IGameMapHooks.onUpdateLayer` | `layerList`,当前地图的图层集合 | 只能看集合内容,不能直接判断是哪张地图触发 |
| `IGameMapHooks.onResizeLayer` | `layer`、宽度、高度 | 可以比较 `layer`;`layer.map` 还能拿到所属地图 |
| `IObjectMoverHooks` 的各方法 | `tile` 与 `mover` | 可以比较 `tile` 或 `mover` |
| `IHeroLocationHooks.onSetFloor` | `map`,即设置后的楼层对象 | 可以比较 `map` |
| `IHeroLocationHooks.onSetPos` | 只有坐标 | 不能;但勇士位置通常只有一个 |
| `IHeroStateHooks.onBeforeChangeFloor` / `onAfterChangeFloor` | `info`,其中 `info.target` 是目标楼层 | 可以比较 `info.target` |
这张表决定了两种做法:没有身份参数的钩子,只能靠「每个对象一个实例」把身份固定在注册处;有身份参数的钩子,才可以共享一个实例,在回调里用 `===` 比较。
### 不要用 alias 判断身份
`alias` 是图层的查找名,不是身份。两张不同的地图可以有同名图层;同一张地图上重复调用 `addLayer('ground')` 也会再建一个图层,并覆盖 `aliasLayerMap` 里的映射。用 `layer.alias === 'ground'` 只能说明「名字叫 ground」,不能说明「就是当初那个图层对象」。
要判断身份,用对象引用:`layer === this.targetLayer`、`map === this.targetMap`、`tile === this.targetTile`。引用比较不受重名影响。
## 做法 A:每个对象一个钩子实例
这是图层钩子的默认做法,也是最不容易出错的做法。
构造钩子时把目标对象保存为字段,注册时只注册到那个对象上。以图层为例,构造时保存 `readonly layer`:
```ts
// [模板] 每层一个实例的图层钩子
export class MyLayerHooks implements IMapLayerHooks {
constructor(
readonly state: ICoreState,
readonly layer: IMapLayer
) {}
// [需改] 这个方法没有 layer 参数;触发者就是注册时的 this.layer
onUpdateBlock(block: number, x: number, y: number): void {
// 只在这个图层上做事
}
// [需改] 动态图块钩子会回传 tile,可以再核对一次
onCreateDynamic(tile: IDynamicTile): void {
if (tile.layer !== this.layer) return;
// 只处理目标图层上的动态图块
}
}
```
注意 `onUpdateBlock` 的签名里没有 `layer`。所以不要在里面写 `if (layer !== this.layer) return`,那个 `layer` 根本不存在。这个实例只注册在 `this.layer` 上,触发者必然是它;身份在注册时就确定了。
`readonly layer` 的作用是让钩子能访问目标图层本身,例如读宽高、调用图层方法,或者在动态图块回调里核对 `tile.layer`。它不是用来比较一个不存在的参数。
如果钩子是通用的、构造时还拿不到图层,可以利用 `awake` 收到的控制器。`MapLayer` 的控制器是 `IMapLayerHookController`,带 `layer` 字段与 `getMapData()`:
```ts
// [模板] 在 awake 里取得所属图层
awake(controller: IMapLayerHookController): void {
// controller.layer 就是注册这个钩子的图层
}
```
这种做法适合「先写一个通用钩子类,注册到哪一层就服务哪一层」的工厂。无论用哪种方式,核心都是:<strong>一个实例只服务一个对象</strong>。
## 做法 B:共享实例,用 `===` 比较
如果钩子回调会回传身份,可以只创建一个实例,注册到多个对象,在回调里筛掉不关心的触发者。
例如地图级钩子 `IGameMapHooks.onResizeLayer(layer, width, height)` 会收到该地图所有图层的 resize。共享实例时,构造时保存目标图层 `targetLayer`:
```ts
// [模板] 共享实例:在回调里比较目标图层
export class MyMapResizeHooks implements IGameMapHooks {
constructor(
readonly state: ICoreState,
readonly targetLayer: IMapLayer
) {}
onResizeLayer(layer: IMapLayer, width: number, height: number): void {
// [需改] 只处理目标图层;其他图层直接返回
if (layer !== this.targetLayer) return;
this.state.emitTip(`目标图层变为 ${width} x ${height}`);
}
}
```
同样的思路可以用于:
- `IHeroLocationHooks.onSetFloor(map)`:比较 `map`;
- `IObjectMoverHooks` 的 `onMoveStart` / `onStepEnd` 等:比较 `tile` 或 `mover`;
- `IHeroStateHooks.onBeforeChangeFloor` / `onAfterChangeFloor`:比较 `info.target`;
- `IMapLayerHooks` 的动态图块方法:比较 `tile.layer`。
但 `IMapLayerHooks` 的 `onResize`、`onUpdateBlock`、`onOpenDoor` 等方法没有 layer 参数,不能用这种做法。它们必须走做法 A。
### 共享实例不要保存「当前对象」
共享实例只适合保存<strong>固定的目标</strong>,例如 `targetLayer`、`targetTile`。不要保存「当前正在处理的对象」:
```ts
// 不要这样:共享实例里保存“当前对象”
onResizeLayer(layer: IMapLayer): void {
this.currentLayer = layer;
// 后续异步逻辑再读 this.currentLayer
}
```
异步钩子可能交错执行:A 图层的事件还没处理完,B 图层的事件已经进来,`currentLayer` 会被覆盖。需要当前对象时,用回调参数或局部变量;需要跨异步步骤保留,就在回调开头把它存进局部常量。
## 同一钩子被多个对象复用
「能共享」不等于「应该共享」。按钩子族分开看:
| 钩子族 | 能否共享一个实例 | 条件 |
| --------------------------------------- | ---------------- | ------------------------------------------------------------ |
| `IMapLayerHooks`(无 layer 参数的方法) | 不能 | 回调无法区分图层,必须每层一个实例 |
| `IMapLayerHooks`(动态图块方法) | 可以 | 回调里比较 `tile.layer` |
| `IGameMapHooks.onResizeLayer` | 可以 | 回调里比较 `layer` |
| `IGameMapHooks.onChangeBackground` | 不建议 | 回调没有地图身份;跨地图共享会分不清是谁的背景变化 |
| `IGameMapHooks.onUpdateLayer` | 不建议 | 只有图层集合,无法可靠判断是哪张地图触发 |
| `IObjectMoverHooks` | 可以 | 回调里比较 `tile` 或 `mover`;若每个对象有独立状态则不要共享 |
| `IHeroLocationHooks.onSetFloor` | 可以 | 回调里比较 `map` |
| `IHeroStateHooks`(切层钩子) | 可以 | 回调里比较 `info.target`;若只服务一个勇士则无需共享 |
如果钩子内部要为每个对象保存不同状态(计数、上次位置、临时标记),即使接口允许共享,也建议每对象一个实例。共享实例的字段只有一份,很难避免互相覆盖。
为多个对象创建实例时,用一个工厂把「创建 + 注册」收在一起,避免漏注册或注册到错误对象:
```ts
// [模板] 为每个图层各建一个实例
function addLayerHook(state: ICoreState, layer: IMapLayer) {
return layer.addHook(new MyLayerHooks(state, layer));
}
```
调用方对每个目标图层调用一次即可。不要在事件回调里临时 `new` 钩子再注册;那样每次事件都会多一个钩子,且旧钩子很难清理。
## 注册、移除与生命周期
### addHook 返回控制器
`addHook` 会创建控制器、把钩子写入内部映射与列表、调用 `awake`,最后返回控制器:
```ts
// [模板] 注册并保留控制器
const hook = new MyLayerHooks(state, layer);
const controller = layer.addHook(hook);
```
控制器最重要的方法是 `unload()`,它会按控制器移除对应钩子。对图层来说,控制器还带 `layer` 与 `getMapData()`,可以在 `awake` 或后续逻辑里使用。
### awake 在加入已加载列表之前
`addHook` 的调用顺序是:
1. 创建控制器;
2. 写入映射与钩子列表;
3. 调用 `hook.awake(controller)`;
4. 把钩子加入已加载列表;
5. 返回控制器。
`forEachHook` 只遍历已加载列表。因此,在 `awake` 内部由其他代码触发的事件,不会回调到这个钩子——它还没进已加载列表。需要在启动时同步一次状态,就在 `awake` 里直接做,不要指望「在 `awake` 里调用 `setBlock` 会触发自己的 `onUpdateBlock`」。
### 移除需要原实例
`removeHook(hook)` 按钩子对象的引用查找。传入注册时的**同一个实例**才会移除;传一个新建的、字段相同的实例不会匹配,也不会报错:
```ts
// 正确:传回注册时的实例
layer.removeHook(hook);
// 也可以:用注册时返回的控制器卸载
controller.unload();
```
```ts
// 错误:这是一个新实例,removeHook 找不到它
layer.removeHook(new MyLayerHooks(state, layer));
```
移除时会调用钩子的 `destroy(controller)`,适合清理定时器、外部监听器等资源。`destroy` 与 `awake` 都收到控制器,可以在这里读取所属对象。
### forEachHook 的顺序不保证
引擎文档明确写了 `forEachHook` 的执行顺序不固定。不要把「我先注册,所以先执行」当成契约。需要固定顺序时,把步骤写进同一个钩子内部按 `await` 串行,或改用本身带顺序语义的机制;不要依赖多个钩子的注册顺序。
### 同一实例重复注册
`addHook` 不会去重。对同一个实例调用两次,会创建两个控制器、两条内部记录;结果是这个实例被调用两次,而且第一次的控制器仍然留在内部映射里:
```ts
// 不要这样:同一个实例注册两次
layer.addHook(hook);
layer.addHook(hook);
```
第二次注册会覆盖 `hookMap` 里「实例到记录」的映射,但第一次的记录仍在已加载列表里。之后 `removeHook(hook)` 只会移除第二次的记录;如果你没有保存第一次返回的控制器,第一次的记录就很难再按实例清理。这是 `addHook` 的行为事实,不是引擎缺陷;按「每个实例只注册一次」使用即可。
### 图层移除会卸载内部转发器
`GameMap.removeLayer(layer)` 会移除图层,并卸载 `addLayer` 时为它创建的内部 `StateMapLayerHook`。因此图层移除后,地图级钩子不会再收到这个图层的 resize。你自己挂在图层上的钩子会随图层对象一起失去作用;如果还持有控制器,也可以显式 `unload()`。
## 异步钩子
钩子分同步与异步两类。同步钩子在触发时立即执行;异步钩子返回 `Promise`,引擎会把本次触发的返回值收集起来,用 `Promise.all` 并发等待。
本篇核对过的异步钩子族:
| 钩子族 | 返回 `Promise` 的方法 | 引擎如何等待 |
| --------------------- | ------------------------------------------------------ | ---------------------------------------------------------------- |
| `IObjectMoverHooks` | `onMoveStart`、`onMoveEnd`、`onStepStart`、`onStepEnd` | 移动流程里用 `Promise.all` 等待 |
| `IMapLayerHooks` | `onOpenDoor`、`onCloseDoor`、`onDeleteDynamic` | `openDoor` / `closeDoor` / `deleteDynamic` 用 `Promise.all` 等待 |
| `IHeroStateHooks` | `onBeforeChangeFloor`、`onAfterChangeFloor` | `changeFloor` 用 `Promise.all` 等待 |
| `ICombatFlowHooks` | `onBeforeCombat`、`onAfterCombat` | 战斗流程用 `Promise.all` 等待 |
| `IReplaySandboxHooks` | `onStep` | 播放与单步流程用 `Promise.all` 等待 |
其余常用钩子,例如 `IMapLayerHooks.onUpdateBlock`、`IHeroLocationHooks.onSetPos`、`IGameEventExecutorHooks` 的事件前后钩子,都是同步的,触发时立即执行。
`Promise.all` 只保证「本次触发的所有异步钩子都完成」,不保证谁先完成。注册顺序、`forEachHook` 的遍历顺序,都不能决定异步完成顺序。两个钩子都实现 `onStepEnd` 时,一个内部 `await` 了 100ms,另一个没有等待,后者会先兑现;这并不表示引擎把后者排在了前面。
确实需要顺序时:
- 把有先后依赖的步骤写进<strong>同一个钩子</strong>,在方法体里按 `await` 串行;
- 战斗场景改用带 `priority` 的战斗脚本,脚本会按优先级依次执行(细节见 `高级教程 II · 战斗脚本与优先级`);
- 不要试图在多个独立钩子之间约定顺序,钩子没有跨实例的优先级接口。
这里有一处容易忽略的细节:`onDeleteDynamic` 在 `deleteDynamic` 这条异步删除路径上会被 `Promise.all` 等待;但 `toStatic` 是同步转换,那里触发的 `onDeleteDynamic` 不会等待返回值。不要把「声明为异步」误当成「所有调用路径都会等它」。
> 钩子在系统级流程中如何被消费、跨系统如何安排优先级,属于 `高级教程 II · 系统级实现`;钩子调度的底层原理属于 `高级教程 III · 架构原理`。本篇只写你能直接使用的那一层。
<!-- TODO(advanced-II): 钩子的系统级消费与优先级 -->
<!-- TODO(advanced-III): 钩子调度原理 -->
## 模板:一个带身份校验的钩子
<strong>目标</strong>:每层一个钩子实例,只对目标图层做事;如果回调回传了图块,再额外核对一次所属图层。
<strong>改哪里</strong>:`src/data/hook/` 下的现有钩子文件,或你自己的插件文件;类写好后,在能拿到目标图层的时机注册。
<strong>复制什么</strong>:下面是机制本身的通用骨架,复制后按 `[需改]` 修改。它不是某个具体效果的完整实现。
```ts
// [模板] 每层一个实例的图层钩子
export class MyLayerHooks implements IMapLayerHooks {
constructor(
readonly state: ICoreState,
readonly layer: IMapLayer
) {}
// [需改] 没有 layer 参数;触发者就是 this.layer
onUpdateBlock(block: number, x: number, y: number): void {
// 在这里做事
}
// [需改] 动态图块回调回传 tile,可再核对 tile.layer
onCreateDynamic(tile: IDynamicTile): void {
if (tile.layer !== this.layer) return;
// 在这里做事
}
}
```
<strong>改什么</strong>:
1. 类名 `MyLayerHooks` 换成你的名字;
2. `readonly layer` 保留,构造时传入目标图层;
3. 方法体换成你的处理;
4. 如果目标钩子不回传身份,不要把这个实例共享给多个图层,改为每层 `new` 一个。
<strong>怎么验证</strong>:见下面的「只处理指定图层」;核心是改目标图层会触发、改别的图层不触发。
<strong>注意</strong>:`readonly layer` 不是为了在 `onUpdateBlock` 里比较一个不存在的参数。身份来自「这个实例注册在哪个图层上」;动态图块方法里的 `tile.layer` 才是额外的运行时校验。
## 示例:只处理指定图层
<strong>目标</strong>:只对 `floor-1` 的 `ground` 图层响应图块更新,其他图层的变化不处理。
<strong>改哪里</strong>:在能拿到 `state` 与目标图层的时机注册,例如插件的 `afterLoad` 中;下面的片段省略了外层函数或方法,`return` 需要放在函数体内。
<strong>复制什么</strong>:
```ts
// [需改] 类名、提示内容
class GroundBlockHooks implements IMapLayerHooks {
constructor(
readonly state: ICoreState,
readonly layer: IMapLayer
) {}
onUpdateBlock(block: number, x: number, y: number): void {
this.state.emitTip(`ground 图层更新:(${x}, ${y}) -> ${block}`);
}
}
// [模板] 取楼层、取图层、注册
const map = state.maps.getMap('floor-1');
if (!map) return;
const layer = [...map.layerList].find(l => l.alias === 'ground');
if (!layer) return;
const hook = new GroundBlockHooks(state, layer);
const controller = layer.addHook(hook);
```
<strong>改什么</strong>:
- `'floor-1'` 换成你的楼层 id;
- `'ground'` 换成目标图层 alias;
- 提示内容换成你的处理;
- `layer.addHook(hook)` 的目标必须是 `layer`,不是 `map`。
<strong>怎么验证</strong>:
1. 对目标图层调用 `layer.setBlock(1, 0, 0)`,换一个与原来不同的图块数字,提示应出现;
2. 从 `map.layerList` 里取另一个图层,调用它的 `setBlock(1, 0, 0)`,提示不应出现;
3. 调用 `controller.unload()`,再改目标图层,提示不应再出现。
<strong>注意</strong>:`onUpdateBlock` 只在图块数字真的变化时触发;设置成相同数字不会触发。不要用 `alias` 当身份,也不要把同一个钩子实例注册到所有图层。
## 示例:地图级钩子里筛图层
<strong>目标</strong>:在地图级 `onResizeLayer` 里只处理目标图层。地图级钩子会收到该地图所有图层的尺寸变化,必须自己筛。
<strong>改哪里</strong>:在能拿到 `map` 与目标图层的时机注册;这次注册到 `map`,不是 `layer`。
<strong>复制什么</strong>:
```ts
// [需改] 类名与处理逻辑
class TargetLayerResizeHooks implements IGameMapHooks {
constructor(
readonly state: ICoreState,
readonly targetLayer: IMapLayer
) {}
onResizeLayer(layer: IMapLayer, width: number, height: number): void {
if (layer !== this.targetLayer) return;
this.state.emitTip(`目标图层尺寸:${width} x ${height}`);
}
}
// [模板] 取目标图层并注册到地图
const map = state.maps.getMap('floor-1');
if (!map) return;
const targetLayer = [...map.layerList].find(l => l.alias === 'ground');
if (!targetLayer) return;
map.addHook(new TargetLayerResizeHooks(state, targetLayer));
```
<strong>改什么</strong>:楼层 id、图层 alias、比较字段与提示内容。比较必须用 `layer !== this.targetLayer`,不要用 `layer.alias`。
<strong>怎么验证</strong>:调用 `map.resizeLayer(30, 30, true)`(换一个与当前不同的尺寸)。该地图的每个图层都会触发一次 `onResizeLayer`,但提示只应出现一次,且宽高是目标图层的新尺寸。把 `targetLayer` 换成另一个图层,提示应对应另一层。
<strong>注意</strong>:`onResizeLayer` 能拿到 layer,是因为 `GameMap.addLayer` 给每个图层装了内部转发器;`onChangeBackground` 没有地图参数,跨地图共享实例时无法判断是谁的背景变化。地图级钩子建议每张地图一个实例,除非你只使用带身份参数的方法。
## 改内置还是自己写
如果你只是给现有系统加行为,先看 `src/data/hook/*` 里已经注册的类。它们已经在 `src/data/hook/initialize.ts` 的 `initializeHooks` 中 `addHook`,改方法体即可,不需要自己写类,也不需要再注册。
地图与图层没有对应的内置 `src/data/hook` 类;这类需求需要自己写一个小类。把类写小:只做身份限定与转发,真正的系统逻辑放进插件或独立系统,不要让钩子类承担一整套功能。
如果这是一整套新系统,优先通过插件实现(见[插件系统](./plugin-system.md)),钩子在事件发生时调用它。这样新增逻辑留在 `src` 里,升级内置包时要跟的差异更少。
<strong>如果你不怎么懂代码,建议直接改内置钩子,而不是自己写新的类。</strong>内置类已经注册好,改方法体就能生效;自己写类还要处理身份、注册时机与移除,排查面更大。
## 常见错误
- <strong>把地图级钩子挂到图层上</strong>。`layer.addHook` 接收的是 `IMapLayerHooks`,地图级方法名对不上,永远不会被调用。反过来,把图层钩子挂到地图上也不会收到图层事件。
- <strong>用 alias 判断身份</strong>。alias 只是查找名,不同地图可以重名,重复 `addLayer` 还会覆盖映射。身份用对象引用比较。
- <strong>共享实例却保存当前对象</strong>。异步钩子交错时,后一个对象会覆盖前一个对象的值。共享实例只保存固定的目标字段。
- <strong>重复注册同一个实例</strong>。`addHook` 不去重,同一实例会被多次调用,旧控制器还留在内部列表里。每个实例只注册一次。
- <strong>依赖异步钩子的顺序</strong>。引擎用 `Promise.all` 并发等待,完成顺序不等于注册顺序。需要顺序就把步骤放进同一个钩子串行。
- <strong>在 awake 里等自己触发的事件</strong>。`awake` 调用时钩子还没加入已加载列表,`forEachHook` 不会遍历到它。初始同步直接在 `awake` 里做。
- <strong>移除时 new 了一个新实例</strong>。`removeHook` 按引用查找,新实例不会匹配,也不会报错;保存注册时的实例或控制器。
## 怎么验证
<strong>第一项:确认图层隔离</strong>。按「只处理指定图层」注册后,分别修改目标图层与另一个图层。目标图层应有提示,另一个图层不应有。把钩子注册到另一个图层,现象应对调。
<strong>第二项:确认地图级筛选</strong>。按「地图级钩子里筛图层」注册后,调用 `map.resizeLayer` 改一次尺寸。地图级钩子会对每个图层触发,但你的处理只应对目标图层生效。把比较条件删掉,提示会变成每个图层一次——这能反过来证明筛选确实在起作用。
<strong>第三项:确认移除生效</strong>。保存 `addHook` 返回的控制器,调用 `controller.unload()`,再触发同一事件,处理不应再执行。也可以改用 `removeHook(原实例)`,效果应一致;传一个新实例则不应生效。
<strong>第四项:确认异步完成顺序不等于注册顺序</strong>。注册两个都实现 `onStepEnd` 的异步钩子:第一个内部延迟 100ms,第二个不延迟。故意把慢的注册在前面,移动一步后,快的应先提示。这个现象只说明 `Promise.all` 并发等待,不说明引擎按注册顺序执行。
## 小结
- 钩子挂在**实例**上,不挂在类上;同一个类可以服务多个对象,但每个对象的钩子列表独立。
- 没有身份参数的钩子(如 `IMapLayerHooks.onUpdateBlock`)必须每对象一个实例;有身份参数的钩子(如 `onResizeLayer`、`IObjectMoverHooks`)才能共享实例并用 `===` 比较。
- 不要用 alias 判断身份;对象引用才唯一。
- `addHook` 返回控制器,`removeHook` 要传原实例,控制器可 `unload`;`awake` 在加入已加载列表前调用,`destroy` 在移除时调用。
- `addHook` 不去重,`forEachHook` 顺序不保证,异步钩子用 `Promise.all` 并发等待。