外观
Stage03|Runtime Objects & Assets
Lesson027|节点事件的捕获、目标和冒泡
Tags: #Creator2.x #Stage03 #NodeEvent #Capture #BubbleDifficulty: ⭐⭐⭐⭐☆
一、本课从“一次点击执行了两个命令”开始
商品列表结构如下:
text
ShopList
└── Item
├── Icon
└── BuyButton产品要求点击 Item 打开详情,点击 BuyButton 只购买。开发者分别给 Item 和 BuyButton 注册 touchend,结果点击购买时,详情页也打开了。
这不是引擎调用了错误函数,而是同一个节点事件沿层级继续传播:
text
触摸命中 BuyButton
→ BuyButton 执行购买
→ 事件向父节点 Item 冒泡
→ Item 执行打开详情本课要建立的核心认识是:
一个节点事件不只有“回调函数”,它还有目标、传播路径和传播阶段。
二、本课目标
完成本课后,你应该能够:
- 区分事件来源、
target与currentTarget。 - 解释捕获、目标、冒泡三个阶段的执行顺序。
- 在 Creator 2.4.x 中注册冒泡监听和捕获监听。
- 判断何时使用
stopPropagation,何时应重构交互边界。 - 避免重复注册、匿名函数无法取消和全局事件泄漏。
- 通过日志实验还原一条事件的真实传播路径。
三、回调之前,先发生了什么
用户触摸屏幕后,引擎并不是立刻调用你写的函数。教学上可以拆为:
text
平台产生触摸
→ 引擎换算触摸位置
→ UI 系统进行命中判断
→ 找到事件目标 target
→ 根据节点祖先建立传播路径
→ 调用各阶段监听器本课从“已经找到 target”之后开始。命中、遮挡和坐标转换将在 L028 深入讨论。
假设最终命中:
text
Canvas
└── ShopList
└── Item
└── BuyButton ← target传播路径就是从根方向到目标的祖先链,再从目标回到根方向。
四、捕获、目标和冒泡的顺序
先使用这个教学模型:
text
捕获阶段:Canvas → ShopList → Item → BuyButton
目标阶段:BuyButton
冒泡阶段:BuyButton → Item → ShopList → Canvas目标节点上的捕获监听和普通监听都可能执行;具体阶段常量与内部细节以 Creator 2.4.x 实际版本为准。课程首先要求掌握两个稳定方向:
text
捕获:从外向内,先让祖先看到事件
冒泡:从内向外,让目标处理后交给祖先Creator 2.4.x 中,Node 事件监听的第四个参数可指定捕获:
ts
this.node.on(
cc.Node.EventType.TOUCH_END,
this.onTouchEnd,
this,
true
);省略或传 false 时,是通常使用的目标/冒泡监听:
ts
this.node.on(
cc.Node.EventType.TOUCH_END,
this.onTouchEnd,
this
);取消监听时,事件类型、回调、target 和捕获设置都要对应:
ts
this.node.off(
cc.Node.EventType.TOUCH_END,
this.onTouchEnd,
this,
true
);五、target 和 currentTarget 为什么不同
在 Item 的冒泡监听里输出:
ts
private onItemTouch(event: cc.Event.EventTouch) {
cc.log('target =', event.target.name);
cc.log('currentTarget =', event.currentTarget.name);
}点击 BuyButton 时,通常可观察到:
text
target = BuyButton
currentTarget = Item含义是:
target:这次事件最初命中的目标;currentTarget:当前正在执行监听器的节点。
事件向上传播时,target 不会跟着每层变化,currentTarget 会变化。
父容器可以据此判断事件来源,但不要滥用字符串节点名:
ts
if (event.target.name === 'BuyButton') {
// 节点一改名,逻辑就可能失效
}“购买”和“打开详情”是两个清晰命令,应由各自组件负责,并明确控制传播。
六、修复贯穿案例:在命令边界停止冒泡
BuyButton 的处理:
ts
private onBuyTouch(event: cc.Event.EventTouch) {
event.stopPropagation();
this.buyCurrentItem();
}Item 的处理:
ts
private onItemTouch(event: cc.Event.EventTouch) {
this.openDetail();
}现在传播变为:
text
BuyButton 收到 touchend
→ 执行购买
→ stopPropagation
→ 不再冒泡到 Item这里停止传播是合理的,因为购买按钮表达一个完整、独立的用户命令。
但不要看到任何父子冲突都加 stopPropagation。如果父节点需要统一处理埋点、手势状态或辅助功能,随意截断会破坏合法监听。先回答:
text
谁是交互目标?
谁有权观察?
谁有权执行命令?
哪些祖先监听是业务必须的?七、停止传播不会撤销已经发生的事
调用 stopPropagation 后:
- 已经执行过的捕获监听不会被撤销;
- 当前监听器之前发生的业务副作用不会回滚;
- 它主要阻止事件沿后续传播路径继续传递;
- 它不一定能关闭另一个独立的全局输入系统;
- 它不会自动禁用 Button、手势识别或原生层输入。
所以:
text
停止节点事件传播 ≠ 暂停整个游戏输入弹窗要阻止底层操作,通常还需要遮罩命中、输入层级和全局输入状态协同处理。
八、Node 事件和 EventBus 不是一条传播链
Node 事件依赖节点树,EventBus 通常依赖订阅表:
| 对比项 | Node 事件 | 全局 EventBus |
|---|---|---|
| 路径 | 节点父子层级 | 订阅表 |
| target | 通常有命中目标 | 由业务协议决定 |
| active 影响 | 节点输入通常受层级激活影响 | 通常不会自动受影响 |
| stopPropagation | 可阻止当前事件继续传播 | 一般没有相同语义 |
| 清理 | 与节点监听生命周期配对 | 订阅者显式取消 |
因此隐藏节点不能保证这条全局订阅自动停止:
ts
EventBus.on('coin-changed', this.refresh, this);它需要在对应生命周期里 off。
九、“一次点击执行两次”的四种原因
原因 1:正常冒泡
子节点和父节点各执行一次,日志中的 currentTarget 不同。
原因 2:同一监听重复注册
ts
onEnable() {
this.node.on('touchend', this.onTouch, this);
}如果没有在 onDisable 取消,每次重新激活都可能再注册一次。
原因 3:Button ClickEvents 和触摸监听同时执行业务
Inspector 中 Button 的 Click Events 调用购买,脚本的 TOUCH_END 也调用购买,同一交互走了两条入口。
原因 4:两个独立输入系统都执行业务
Node 事件执行 UI 命令,全局触摸管理器也按坐标执行同一命令。两者并不共享 stopPropagation 状态。
诊断时不要只说“事件重复”,要用日志确定是哪一种。
十、匿名函数为什么难以取消
错误写法:
ts
onEnable() {
this.node.on('touchend', () => this.buy(), this);
}
onDisable() {
this.node.off('touchend', () => this.buy(), this);
}两个箭头函数是不同函数对象,off 找不到原监听器。
稳定写法:
ts
private onTouchEnd = (event: cc.Event.EventTouch) => {
this.buy();
};
onEnable() {
this.node.on(
cc.Node.EventType.TOUCH_END,
this.onTouchEnd,
this
);
}
onDisable() {
this.node.off(
cc.Node.EventType.TOUCH_END,
this.onTouchEnd,
this
);
}也可以用普通成员方法;关键是注册和取消拿到同一个函数身份。
十一、触摸开始、移动、结束和取消是一组协议
只监听 TOUCH_END 容易忽略手势状态:
text
TOUCH_START → 按下,记录候选
TOUCH_MOVE → 移动,可能转为拖拽
TOUCH_END → 正常结束,确认点击
TOUCH_CANCEL → 被系统或其他交互取消例如列表滚动:
text
按下 Item
→ 手指移动,ScrollView 开始滚动
→ 这不应继续被当作商品点击Button 和 ScrollView 已封装部分规则;自定义长按、拖拽和滑动时,必须设计完整状态,而不是把每个事件当成互不相关的回调。
十二、捕获监听适合做什么
捕获阶段适合:
- 输入调试和路径记录;
- 容器统一观察子项交互;
- 明确设计下的前置拦截;
- 教学中验证事件顺序。
它不适合成为所有业务按钮的默认做法。大量捕获监听会让子组件行为受到远处祖先影响,定位困难。
text
按钮命令 → 目标节点普通监听
父容器委托 → 冒泡监听
全局观察/特殊拦截 → 谨慎使用捕获十三、Creator 2.4.x 编辑器实验
通过 Creator 编辑器创建节点、挂载脚本并设置属性。不要手工编辑
.scene、.prefab或.meta。
实验结构
创建:
text
Canvas
└── Outer(400×400)
└── Middle(260×260)
└── Inner(120×120)给三个节点添加不同颜色的 Sprite 作为观察辅助,确保 Inner 位于最上层且可命中。
创建 EventPhaseProbe.ts
ts
const { ccclass, property } = cc._decorator;
@ccclass
export default class EventPhaseProbe extends cc.Component {
@property
label = '';
onEnable() {
this.node.on(
cc.Node.EventType.TOUCH_END,
this.onCapture,
this,
true
);
this.node.on(
cc.Node.EventType.TOUCH_END,
this.onBubble,
this
);
}
onDisable() {
this.node.off(
cc.Node.EventType.TOUCH_END,
this.onCapture,
this,
true
);
this.node.off(
cc.Node.EventType.TOUCH_END,
this.onBubble,
this
);
}
private onCapture(event: cc.Event.EventTouch) {
cc.log(
'CAPTURE label=' + this.label,
'target=' + event.target.name,
'current=' + event.currentTarget.name
);
}
private onBubble(event: cc.Event.EventTouch) {
cc.log(
'BUBBLE label=' + this.label,
'target=' + event.target.name,
'current=' + event.currentTarget.name
);
}
}脚本分别挂到 Outer、Middle、Inner,并在 Inspector 填入对应 label。
实验 A:观察完整路径
点击 Inner 中心。预期顺序的关键特征:
text
捕获监听:从 Outer 向 Inner
目标处理:发生在 Inner
冒泡监听:从 Inner 向 Outer
所有日志 target 都是 Inner
currentTarget 随监听节点变化没有注册监听器的路径节点不会打印日志。
实验 B:停止冒泡
在 Inner 的普通监听开头加入:
ts
event.stopPropagation();预期:捕获阶段日志仍存在,Inner 当前处理仍执行,Middle、Outer 后续冒泡日志不再出现。
实验 C:停止捕获阶段
只在 Outer 的捕获监听中停止传播,再记录哪些监听仍执行。这个实验用来证明:停止发生在哪个阶段,会改变后续路径。
实验 D:制造重复注册
临时注释 onDisable 中一个 off,反复切换节点 active,再点击一次。预期同一节点回调出现多次。实验结束后必须恢复正确代码。
十四、实验不符合预期时怎么查
只有 Inner 有日志
text
Outer/Middle 是否挂载探针
→ 组件是否 enabled
→ 节点是否 activeInHierarchy
→ useCapture 参数是否正确
→ 是否有更早监听停止传播点击 Inner,却显示 target 是其他节点
真正命中的不是预期节点。检查尺寸、层级、透明遮挡节点和交互组件,下一课会系统处理。
off 后仍然执行
text
事件名是否一致
→ 回调是否同一函数对象
→ target 参数是否一致
→ 捕获参数是否一致
→ 是否还有 Button ClickEvents 或第二套输入系统父节点执行两次
输出事件类型、target、currentTarget、回调入口名和组件实例编号。先证明是冒泡、重复注册还是两个入口。
十五、真实项目案例:关闭弹窗同时点中了地图
结构
text
Canvas
├── MapLayer
└── ModalLayer
├── Mask
└── Dialog
└── CloseButton现象
点击 CloseButton 后弹窗关闭,地图角色也移动到点击位置。
表面修复
CloseButton 调用 stopPropagation,但地图仍移动。
真正原因
地图移动不是由同一条 Node 冒泡事件触发,而是全局输入管理器直接监听触摸:
text
CloseButton Node 事件 → 已停止
全局 InputManager → 仍收到平台触摸 → 移动角色正确设计
text
Modal 打开
→ InputContext 进入 modal 模式
→ 地图命令暂停
→ Mask/CloseButton 处理节点事件
→ Modal 关闭
→ 恢复地图输入传播控制只对所属事件系统负责。跨系统互斥需要显式输入状态。
十六、常见误区
误区一:父节点收到事件,说明子节点没处理
子节点可以已经处理,事件随后继续冒泡。
误区二:所有事件都一定捕获和冒泡
不同事件类型可能有不同传播能力。以当前事件 API 和实验结果为准。
误区三:stopPropagation 能停止所有输入
它只影响当前事件后续路径,不会自动管理 EventBus 或全局输入器。
误区四:节点 inactive 后,全局订阅自动取消
Node 激活层级和自定义发布订阅系统没有这种自动契约。
误区五:不带回调的 off 越彻底越安全
批量取消可能移除其他组件监听。每个模块应清理自己注册的函数。
误区六:用节点名判断所有点击来源
节点名是脆弱的结构细节。优先使用组件边界、明确命令和稳定数据标识。
十七、版本边界
本课以 Creator 2.4.x 的 cc.Node.EventType、node.on/off 和 cc.Event.EventTouch 为主。
Creator 3.x 使用 ES Module 导入的类型,API 命名和类型位置不同,不要把 3.x 示例原样混入 2.4.x 项目。
事件是否传播、哪些节点参与命中、Button 与 ScrollView 的内部处理,可能随事件类型和小版本有差异。遇到争议时,用探针记录实际顺序。
官方验证入口:
十八、练习
A. 概念题
target和currentTarget分别表示什么?- 捕获和冒泡的方向有什么不同?
- 为什么停止冒泡后,捕获阶段副作用不会回滚?
- Node 事件与全局 EventBus 的生命周期有什么区别?
B. 应用题
列表 Item 中有 FavoriteButton。点击 Item 打开详情,点击 FavoriteButton 只收藏。请写出监听职责和传播策略。
C. 诊断题
页面每显示一次,确认按钮下一次点击就多执行一遍。给出至少五步排查过程。
D. 反例分析
项目在 Canvas 捕获阶段拦截所有触摸,并根据节点名分发业务命令。分析风险并给出替代设计。
十九、练习参考答案
A. 概念题答案
target是最初命中的目标;currentTarget是当前执行监听器的节点,传播期间会变化。- 捕获从祖先向目标传播,冒泡从目标向祖先传播。
- 停止传播只影响调用之后的路径,已经执行的代码不会自动撤销。
- Node 事件依赖节点树和命中路径;EventBus 依赖订阅表,通常不会随节点 inactive 自动取消。
B. 应用题答案
Item 的普通监听执行打开详情。FavoriteButton 执行收藏,并阻止这次节点事件冒泡到 Item。收藏状态通过数据层更新,不让父 Item 根据节点名猜命令。若父层有合法埋点需求,可在捕获阶段只观察、不执行业务。
C. 诊断题答案
text
1. 给组件实例编号,确认是否多个按钮重叠。
2. 记录每次 onEnable/onDisable。
3. 统计每次 on 调用和实际监听数。
4. 检查 off 的事件、函数、target、捕获参数。
5. 检查是否使用每次新建的匿名函数。
6. 检查 Button ClickEvents 是否也调用同一命令。
7. 检查父节点冒泡监听和全局 EventBus。
8. 反复显示五次,确认每次点击始终执行一次。D. 反例答案
Canvas 会知道所有业务节点名,形成远距离耦合;重命名即可破坏命令;捕获阶段提前执行业务会让子组件无法自治;弹窗和复用 Prefab 也难隔离。替代方式是交互组件处理自己的命令,父容器只做必要委托,全局输入层只维护模式和互斥。
二十、本课总结
text
事件先命中 target,再沿节点祖先链传播。
捕获从外向内,冒泡从内向外。
target 表示最初目标,currentTarget 表示当前监听节点。
stopPropagation 只控制当前事件后续路径。
重复响应可能来自冒泡、重复注册、Button 配置或第二套输入系统。
监听和取消必须保持函数身份及参数一致。二十一、下一课预告
text
Lesson028|触摸命中、事件遮挡与坐标转换下一课回到传播之前:引擎为什么选中了这个 target,透明节点为何也能挡住点击,以及屏幕坐标怎样正确变成节点局部坐标。
