说明:本文分析对象为开源项目,出于合规考虑,文中以「跨端框架 A」(一套代码编译到多端的框架)、「原生组件库 B」(某小程序平台官方 UI 组件库)等代称指代,代码均为重写后的简化伪代码,仅用于说明设计思路,与真实源码存在差异。
引言:你在 React 里写的 e.stopPropagation(),为什么在小程序里也能生效
先看一段每个 React 开发者都写过的代码:
// 业务代码:点击列表项,同时阻止外层容器的点击逻辑
function ListItem({ item, onSelect }) {
return (
<view onClick={() => {
onSelect(item)
}}>
<view className="delete" onClick={(e) => {
e.stopPropagation() // ← 关键:阻止冒泡
onDelete(item)
}}>删除</view>
<text>{item.name}</text>
</view>
)
}
把这段代码放进浏览器,e.stopPropagation() 天经地义——因为浏览器有 DOM,事件天然从最深的真实节点逐级向上传播,target 是被点的删除按钮,currentTarget 是绑了回调的节点,一切由浏览器内核免费提供。
问题来了:小程序环境里没有 DOM。 双线程架构下,逻辑层拿到的不是节点树,而是一个个「页面/组件实例」和一坨 JSON 化的事件对象。原生事件没有 target 树、没有 currentTarget、更没有冒泡链——stopPropagation 这个 API 在原生小程序的世界里压根不存在。
但用跨端框架 A 写的业务代码,上面那段 JSX 跑在小程序上一切正常:e.target.dataset 能取到,e.stopPropagation() 能拦住父级回调,事件委托的写法也能工作。
这篇文章只讲一件事:框架 A 的运行时是怎么在无 DOM 的环境里,把 DOM 事件语义一层层「骗」出来的。全文围绕四个问题展开:
以及最后和原生组件库 B 的对照:同一个「交互性能」问题,两条路线给出了截然相反的答案。
一、事件中心:无 DOM 环境里的第一道关卡
1.1 小程序事件的原始形态
先对齐事实。小程序原生事件传递到逻辑层时,长什么样(简化):
渲染层(用户点击)
│ 平台桥
▼
逻辑层收到:
{
type: 'tap',
detail: { x: 120, y: 340 },
target: { id: '', dataset: { sid: 'a-3f-12' } }, // 仅原样带回绑定时设置的 dataset
currentTarget: { dataset: { sid: 'a-3f-12' } },
…
}
注意两个致命缺陷:
- 这个 target 是渲染层模板节点的信息,不是逻辑层任何对象——逻辑层没有一棵树可以指着说「就是它」;
- 它不会冒泡到框架层。原生绑定哪个节点,回调就挂在哪个节点上,逐级向上传递绑定的语义需要框架自己组织。
所以跨端框架必须在逻辑层重建一个「接事件的入口」。框架 A 的做法是:运行时里有一个事件中心,所有平台原生事件先打到事件中心,再由它统一分发给框架层。
平台原生事件(tap/touchmove/…)
│
▼
┌─────────────────────────────┐
│ 事件中心(运行时的可替换组件) │
│ – 收事件 │
│ – 查节点缓存 │
│ – 构造合成事件对象 │
│ – 模拟捕获→冒泡 │
└─────────────────────────────┘
│
▼
框架层(React/Vue 的合成事件处理)
1.2 事件中心本身是个钩子注入点
这里有一个容易被忽略、但架构上非常重要的细节:事件中心不是运行时硬编码的,它本身就是一个可替换的钩子点。
框架 A 的运行时核心代码里找不到任何「具体 UI 框架」的 import——没有 React,没有 Vue。运行时定义了一组生命周期钩子,其中就包括事件系统的实现入口。各 UI 框架的桥接包在初始化时,通过 hooks.call(…) 把自己的事件处理实现注入进来:
// 简化伪代码:事件中心通过钩子系统获取实现
// 运行时核心(不认识任何 UI 框架)
class EventCenter {
handleNativeEvent(nativeEvent, page) {
// 通过钩子拿到「由 UI 框架桥接包注入」的事件处理实现
const dispatch = hooks.call('onEvent', nativeEvent, page)
return dispatch
}
}
// 简化伪代码:UI 框架桥接包注入实现
// 某个 UI 框架的桥接包初始化时
hooks.tap('onEvent', (nativeEvent, page) => {
// 框架自己的合成事件构造 + 分发逻辑
const syntheticEvent = buildSyntheticEvent(nativeEvent)
return dispatchThroughBubbles(syntheticEvent)
})
这样设计的收益是:运行时零依赖具体框架。 React 桥接包注入 React 的事件语义(比如合成事件池、onXxx 命名映射),Vue 桥接包注入 Vue 的语义。事件系统这种「每个框架行为都略有差异」的地方,恰恰是最需要可替换的——如果写死在运行时里,每接一个新框架就要改内核,插件式架构就名存实亡了。
一句话总结这一层的分工:
- 运行时提供「接事件 + 反查节点 + 构造通用合成事件」的底座;
- UI 框架桥接包决定「拿到合成事件后怎么分发、怎么映射到组件树回调」。
1.3 面试话术小结
一句话版:
“小程序逻辑层没有 DOM,事件只是 JSON 化的对象。框架 A 在运行时建了一个事件中心作为所有原生事件的统一入口,事件中心本身通过钩子系统注入实现,由各 UI 框架的桥接包替换,运行时核心零依赖具体框架。”
深入版:
“原生事件的 target 只是渲染层模板节点的 dataset 快照,逻辑层没有任何树结构可以对应。框架的做法是把事件中心设计成钩子注入点:运行时通过 hooks.call 拿到由桥接包注册的事件分发实现,React/Vue 各自注入自己的事件语义。这样事件系统这类框架间行为差异最大的模块天然可替换,是运行时不绑定 UI 框架的关键设计。”
二、合成事件对象:target/currentTarget 是「算出来」的
2.1 不是字段,是 getter
接住事件之后,第一件事是把原生 JSON 包装成 React/Vue 开发者熟悉的合成事件对象。框架 A 实现里有个非常值得细看的设计:target 和 currentTarget 不是简单的对象字段,而是用 getter 做惰性求值——访问时才计算,计算过就缓存。
// 简化伪代码:合成事件对象的惰性求值
class SyntheticEvent {
constructor(nativeEvent, eventPath) {
this.type = nativeEvent.type
this.detail = nativeEvent.detail
this._eventPath = eventPath // 后文讲到的冒泡路径(节点列表)
this._targetCache = null
this._currentTargetCache = null
}
get target() {
// 不访问就不计算;访问时把渲染层节点信息
// 翻译成虚拟树上真实的框架节点
if (this._targetCache === null) {
this._targetCache = resolveNode(this._eventPath[0])
}
return this._targetCache
}
get currentTarget() {
// 冒泡路径上「当前正在执行回调」的那个节点
if (this._currentTargetCache === null) {
this._currentTargetCache = resolveNode(this._eventPath[this._currentIndex])
}
return this._currentTargetCache
}
}
为什么这么设计?两个动机:
(1)反查是有成本的,没必要每个事件都做。 target 的计算依赖「渲染层标识 → 虚拟树节点」的反查(下一节展开)。很多事件处理函数根本不读 e.target——只调用一下 setState 就完了。惰性求值意味着:不访问 target,一次反查都不会发生。 高频事件(touchmove、滚动)下,这个省略是实打实的性能收益。
(2)缓存保证语义一致。 同一个事件对象在冒泡过程中会经过多个回调。第一个回调读了 e.target 触发计算并缓存,后续回调拿到的永远是同一个节点引用——和 DOM 事件「target 在整个传播过程中不变」的语义完全对齐。
2.2 currentTarget 是「随传播流动」的
对照一下 DOM 语义,这两者的区别是:
- target:触发事件的原始节点,整个传播过程中不变;
- currentTarget:当前正在执行回调的那个节点,冒泡到父级时,父级回调里读到的 currentTarget 是父节点。
在浏览器里这两个值由内核维护;在框架 A 里,target 取冒泡路径的第 0 个节点,currentTarget 取路径上「当前执行指针」指向的节点。冒泡循环每前进一层,指针右移一位——于是开发者写 e.currentTarget.dataset 时,拿到的正是「绑定这个回调的节点」的 dataset,和浏览器行为一致。
冒泡路径(捕获后从最深处向上):
[view-item] → [view-list] → [view-page]
▲ target 固定在这里
事件传播过程中 currentIndex 变化:
回调执行在 view-item → currentTarget = view-item
回调执行在 view-list → currentTarget = view-list
2.3 stopPropagation:一个标志位的事
有了冒泡循环,stopPropagation() 的实现反而最简单——它就是在合成事件对象上打一个标志位,冒泡循环每执行完一层回调就检查它:
// 简化伪代码:冒泡循环(简化版,完整版见第四节)
for (let i = path.length – 1; i >= 0; i—) {
if (event._stopped) break // ← stopPropagation 的生效点
event._currentIndex = i // currentTarget 指针右移
const node = path[i]
invoke(node, event) // 执行该节点上绑定的回调
}
回头看引言里的那段业务代码:e.stopPropagation() 在删除按钮的回调里被调用 → 标志位翻转 → 冒泡循环在执行到外层 view 前中断 → onSelect 不会执行。整条链路里没有一行平台代码参与,全部发生在逻辑层的模拟层里。
2.4 面试话术小结
一句话版:
“合成事件对象里 target 和 currentTarget 不是普通字段,是 getter 惰性求值加缓存:不读不反查、读了算一次后固化。target 固定指向冒泡路径起点,currentTarget 跟着传播指针流动,stopPropagation 就是一个标志位让冒泡循环提前退出。”
深入版:
“惰性求值的动机是反查有成本:touchmove 这类高频事件多数回调不读 target,getter 保证不访问就不计算。缓存还保证多次访问拿到同一节点引用,对齐 DOM 'target 传播过程中不变’的语义。currentTarget 的实现依赖冒泡路径上的执行指针——每前进一层指针右移,getter 读指针位置反查节点。stopPropagation 不需要平台任何配合,冒泡循环每层检查标志位即可,整个语义完全在逻辑层模拟出来。”
三、sid 反查:事件怎么找回虚拟树上的节点
3.1 问题:逻辑层收到的是「标识」,不是「节点」
现在到了整套机制最核心的难题。冒泡路径、target、currentTarget,全都指向一个前提:逻辑层要有一棵树,且事件能定位到树上的节点。
可原生事件带来的只有 JSON。渲染层知道你点了哪个模板节点,但它没办法把「节点」传过双线程——线程之间只传可序列化数据。
框架 A 的解法是经典的标识反查,分两步:
第一步:绑定发生时,把标识写到节点上。 事件绑定的本质是「在某个虚拟节点上挂一个回调」。框架在建立绑定时,给每个绑定了事件的节点生成一个稳定标识(下文统称 sid),并在把虚拟树序列化为渲染数据时,把 sid 塞进对应渲染节点的 dataset:
// 简化伪代码:绑定事件时登记标识
function bindEvent(node, eventType, handler) {
node._sid = node._sid || genId() // 节点首次绑定事件时分配 sid
node._handlers[eventType].push(handler)
// 同时在节点缓存表里登记:sid → 虚拟节点
nodeCache.set(node._sid, node)
return node._sid
}
// 虚拟树 → 渲染数据时,sid 随 dataset 下发
function serialize(node) {
return {
tag: node.tag,
dataset: { sid: node._sid }, // ← 渲染层模板上会带这个值
children: node.children.map(serialize)
}
}
第二步:事件触发时,从 dataset 里的 sid 反查回节点。 渲染层用户点击 → 原生事件对象的 target.dataset.sid 原样带回逻辑层 → 事件中心拿这个 sid 去节点缓存表里查,就找回了虚拟树上的真实节点:
// 简化伪代码:事件触发时的反查
function handleNativeEvent(nativeEvent) {
const sid = nativeEvent.target.dataset.sid
const targetNode = nodeCache.get(sid) // ← 反查回虚拟树节点
if (!targetNode) return // 节点已卸载则丢弃
const eventPath = buildBubblePath(targetNode) // 沿父链向上构造路径
dispatchSyntheticEvent(nativeEvent, eventPath)
}

整条链路串起来:
渲染层 逻辑层
────── ─────────────────────────────────
用户点击模板节点
dataset.sid = a-3f-12
│ 原生事件(JSON)
├──────────────────────► 事件中心
1. 取 target.dataset.sid
2. nodeCache 反查 → 虚拟节点
3. 沿父链构造冒泡路径
4. 构造合成事件对象
5. 捕获→冒泡循环分发
│
▼
框架层回调(React/Vue)
3.2 配合事件名与节点缓存,才是一条完整链路
单点看,sid 只解决「找到起点」。源码里把它和另外两件事配合,才拼出完整的事件链路:
(1)事件名参与匹配。 节点上登记的回调按事件类型分组——tap 的回调不会因为 touchmove 触发。分发时,事件中心用「sid + 事件名」共同定位要执行的回调集合,避免一个节点绑了十种事件时的遍历开销。
(2)节点缓存的生命周期管理。 nodeCache 是一张随虚拟树增删同步维护的表:节点插入树并首次绑定时登记,卸载时注销。这解释了一个边界行为——如果事件在飞行中(渲染层已发出、逻辑层还没处理)节点就被卸载,反查会落空,事件被静默丢弃。这不是 bug,而是「模拟层」与真实 DOM 的固有差异:浏览器的事件传播是内核同步完成的,而这里的传播隔了一次线程通信,快照可能过期。 处理「快速点击导致列表项立即删除」这类场景时,这个细节值得记在心里。
(3)冒泡路径的构造是纯逻辑层的。 反查拿到 target 节点后,沿 node.parent 逐级向上走到页面根,就得到一条完整的路径数组。注意:这条路径是虚拟树的路径,不是渲染层模板的路径。 渲染层只知道被点击的那个节点;「哪些祖先节点绑了同类型事件、按什么顺序执行」,全部由逻辑层根据虚拟树自己推导。也就是说,DOM 的传播语义在框架里被彻底「重建」了一遍——渲染层只负责报告「点了谁」。
3.3 dataset 语义的顺带修复
还有一个容易被忽视的收益。原生小程序的 dataset 有不少规矩:属性名全小写、连字符转驼峰、值必须是可序列化数据。开发者从 React 世界带来的 e.target.dataset 使用习惯(往节点上挂自定义数据、在委托回调里读取)在原生里经常踩坑。而框架 A 的方案里,e.target 已经被 resolve 成虚拟树节点,dataset 的取值走的是虚拟节点上的属性——DOM 的 dataset 心智被顺带保留了下来,而不是让 React 开发者去适应小程序的 dataset 怪癖。这也是「模拟层」的价值清单里常被漏掉的一项。
3.4 面试话术小结
一句话版:
“渲染层和逻辑层之间只能传可序列化数据,传不了节点。框架在绑定时给节点生成 sid 并写进渲染数据的 dataset,事件触发时从原生事件的 target.dataset.sid 反查节点缓存表找回虚拟节点,再沿父链构造冒泡路径,配合事件名分组匹配完成整个事件链路。”
深入版:
“sid 反查的关键约束是双线程只传 JSON:绑定发生时节点缓存表登记 sid 到虚拟节点的映射并随序列化下发 dataset;触发时用 sid 加事件名共同定位回调集合。冒泡路径在逻辑层沿虚拟树父链推导,渲染层只报告点击目标、不参与传播,DOM 传播语义被完全重建。边界是事件飞行中节点卸载会导致反查落空被静默丢弃——浏览器内核同步传播没有这个问题,模拟层隔一次线程通信,快照可能过期。另一个收益是 target 被 resolve 成虚拟节点后,dataset 心智也回到了 DOM 习惯。”
四、重建捕获-冒泡-委托:把 DOM 的传播语义拼完整
4.1 完整的传播循环
前三节的零件凑齐了:事件中心接住原生事件、sid 反查找回 target、冒泡路径沿父链构造、合成事件对象提供 target/currentTarget 与 stopPropagation。现在把它们组装成一个完整的传播循环:
// 简化伪代码:完整的事件传播模拟
function dispatchSyntheticEvent(nativeEvent, eventPath) {
const event = new SyntheticEvent(nativeEvent, eventPath)
// ① 捕获阶段:从根向下
for (let i = eventPath.length – 1; i >= 1; i—) {
invokeHandlers(eventPath[i], nativeEvent.type, event, 'capture')
}
// ② 目标阶段
invokeHandlers(eventPath[0], nativeEvent.type, event, 'target')
// ③ 冒泡阶段:从目标向上
for (let i = 1; i < eventPath.length; i++) {
if (event._stopped) break
event._currentIndex = i // currentTarget 指针
invokeHandlers(eventPath[i], nativeEvent.type, event, 'bubble')
}
}
三个阶段合起来,DOM 的完整传播模型就复刻了。注意这一切的落点:React 开发者日常写的东西——e.target.dataset、e.currentTarget、子元素回调里 e.stopPropagation()、把点击回调绑在父容器上做事件委托——全部逐项可用。 委托尤其值得点破:因为回调是绑在虚拟树的父节点上的,sid 反查找回的是最深的子节点,冒泡自然会路过父节点并执行其回调——「父节点收子事件」的委托模式不需要任何额外机制,是冒泡模拟的免费赠品。
4.2 事件处理函数怎么拿到页面上下文:Current 单例
还有一个配套设计容易被漏掉:业务回调里经常要用当前页面的路由参数、全局状态。React/Vue 的回调函数脱离了组件闭包后,框架怎么给它提供「我是哪个页面」的上下文?
框架 A 的答案是一个极简的全局单例:
// 简化伪代码:全局上下文单例
const Current = {
app: null, // 当前应用实例
router: null, // 当前路由器
page: null // 当前页面(虚拟树的根节点)
}
// 页面 onLoad 时切换上下文
function onPageLoad(page) {
Current.page = page
}
// 对外 API:回调里取当前实例
function getCurrentInstance() {
return { app: Current.app, page: Current.page, router: Current.router }
}
事件中心分发回调前,Current.page 已经指向当前活跃页面——于是任何一个事件处理函数里调 getCurrentInstance(),都能拿到正确上下文。设计只有二十几行,但它的正确性依赖一个时序前提:同一时刻只有一个页面在处理事件。 小程序的页面栈模型基本满足这个前提(后台页面的回调不会被触发),但如果你在自定义容器或预渲染场景里打破它,就要小心了——这也是读源码时才能意识到的隐含契约。
4.3 两条路线最锋利的对照
至此,可以放上本系列最值得反复品味的一组对照。同一个问题:交互的性能。
| 问题 | 逻辑层没有 DOM 事件,语义要重建 | 高频手势走双线程通信,延迟受不了 |
| 路线 | 在逻辑层重建完整事件模型 | 把逻辑推到渲染线程 |
| 手段 | 事件中心 + sid 反查 + 模拟捕获/冒泡 | 手势逻辑写进视图层脚本,在渲染线程执行 |
| 回传 | 不涉及(全程逻辑层内完成) | 桥接方法把结果传回逻辑层,再触发自定义事件 |
| 付的代价 | 反查/路径构造/对象包装的性能税 | 平台方言绑定 + 双份逻辑维护 |
原生组件库 B 处理高频手势(例如左滑出现操作按钮的滑动菜单)的思路完全相反:手势逻辑直接写在视图层脚本里,跑在渲染线程,触摸的每一帧都在渲染线程就地响应,不跨线程;滑动结束才通过桥接方法把结果(“用户选了删除”)传回逻辑层,触发自定义事件。框架 A 是把 DOM 语义搬进逻辑层,组件库 B 是把交互逻辑搬出逻辑层——方向恰好相反,但都精准打在各自要害上:框架 A 要的是 React/Vue 心智兼容,组件库 B 要的是触摸跟随零延迟。
4.4 面试话术小结
一句话版:
“框架用捕获-目标-冒泡三段循环加 currentTarget 指针重建完整 DOM 传播语义,事件委托是冒泡模拟的免费赠品;配套一个全局单例 Current 持有当前 app/router/page,让任何事件回调都能通过 getCurrentInstance 拿到页面上下文。原生路线则把手势逻辑写进视图层脚本跑在渲染线程,用桥接方法回传结果——同一个交互性能问题,一个向逻辑层搬语义,一个向渲染线程搬逻辑。”
深入版:
“三段循环里 currentTarget 的 getter 读执行指针,stopPropagation 是标志位让冒泡提前 break;委托模式不需要额外机制,因为回调绑在虚拟树父节点上、冒泡自然路过。Current 单例二十几行,正确性依赖’同一时刻只有一个页面在处理事件’的时序契约。两条路线对照的本质是目标不同:框架买的是 React 开发者的零成本迁移,代价是每个事件都要反查构造路径包装对象的性能税;原生组件库买的是触摸跟随的零延迟,代价是平台方言绑定和逻辑双份维护。选型时先问你要买的是什么。”
收尾:模拟层的账单
把这篇文章收敛成一张账单。
语义收益(买到的): e.target.dataset / e.currentTarget / e.stopPropagation() / 事件委托 / DOM 式 dataset 心智——React 开发者的整块肌肉记忆平移过来,零重学成本。这些语义没有任何一个是平台提供的,全部是模拟层的产出。
性能税(付出的): 每次事件都要经历「事件中心接住 → sid 反查 → 路径构造 → 合成对象包装 → 三段循环」。框架用两个设计把税压到最低:target/currentTarget 的 getter 惰性求值(不读不反查),以及 sid 加事件名定位回调集合(不遍历)。但模拟层改变不了一个事实:事件从物理点击到回调执行,永远比原生路线多走一层包装,且冒泡传播隔着一次线程通信,快照可能过期。
这组权衡是可以直接迁移到你自己的技术决策里的:当你需要在缺失某种底层能力的环境里兼容上层心智时,问三个问题——模拟点放在哪一层(可替换的钩子点 vs 硬编码)、模拟成本能不能惰性化(不访问不付费)、模拟语义的边界在哪(快照过期、时序契约)。 这三个问题分别对应本文的事件中心、getter 惰性求值和 Current 单例——三个答案都写在源码里,而不是文档里。
本文是「小程序双路线源码拆解」系列的又一篇单点深挖。下一篇我们继续单拆:增量更新调度器——路径去重的具体算法、为什么用 setTimeout 而不是微任务的时序证明、局部更新容器的查找与缓存,全部源码级展开。感兴趣的话关注一下,更新会第一时间推送。
本文涉及的代码均为基于源码设计思路重写的简化伪代码,仅用于技术交流,不代表任何项目的真实实现;文中代称与真实项目无关。
网硕互联帮助中心





评论前必须登录!
注册