基于 vue@3.6
(alpha
阶段)及 Vapor
的最新进展撰写;Vapor
仍在演进中,部分实现可能继续优化。
TL;DR(速览)
-
传统(≤3.5) :事件以元素为中心绑定;每个元素用
el._vei
保存 invoker
,运行时通过 addEventListener
直绑;调用走 callWithErrorHandling
,有错误上报链路。
-
Vapor
(3.6 引入) :全局事件委托;首次遇到某个事件类型,只在 document
绑定一次统一处理器;元素仅存 $evt{type}
句柄(可能是数组);非冒泡或带 once/capture/passive
的事件改为直绑。
-
注意:
.stop
在 Vapor
里只能阻断 Vapor
自己的委托分发,阻断不了你手动 addEventListener
的原生监听;且 Vapor
的统一处理器默认不包 try/catch
,异常可能中断委托链。
一、为什么 Vue
要引入 Vapor
模式?
1. 虚拟 DOM
的局限
虚拟 DOM
(VDOM
)带来抽象、跨平台与统一渲染接口的好处,但不是“零成本”:
- 每次更新往往重建整棵
VNode
树(JS
对象创建与 GC
压力显著)。
- 需要递归
diff
比较,天然多了一层计算。
- 在大规模、频繁更新的
UI
(如复杂表格、拖拽、实时刷新的仪表盘)中,这层开销会积累成瓶颈。
2. Vue
已有的优化手段
-
静态提升(
Static Hoisting
) :将不变节点提取出渲染循环。
-
Patch Flags
:编译时给动态片段打标,运行时只检查标记处。
-
事件/插槽缓存:减少重复创建。
这些措施让 VDOM
更高效,但结构性开销仍在。
3. Vapor
的设计动机
Vapor
是一种更激进的编译驱动策略:
-
绕过运行时
VDOM
,模板编译为“直接 DOM
操作”的最小更新程序。
-
依赖图直达
DOM
:每个响应式依赖对应精准更新逻辑。
- 减少遍历、对象创建与比较,贴近原生性能与体积。
4. 对事件机制的影响
- 传统模式:是否增删事件监听通常在
VNode diff
过程中决策。
-
Vapor
模式:编译期即可分析并决定“委托或直绑”,因此引入了全局事件委托方案来降低监听器数量与运行时成本。
二、传统事件机制回顾(≤3.5)
1. invoker
与 _vei
- 每个
DOM
元素挂载一个 el._vei = {}
,保存不同事件名的 invoker
。
- 绑定:若已有同名
invoker
,仅改 .value
;否则 addEventListener
新增。
- 卸载:找到
invoker
后移除监听并清理。
源码节选(文件:packages/runtime-dom/src/modules/events.ts
)——入口 patchEvent
与 _vei
缓存
const veiKey = Symbol('_vei')
export function patchEvent(
el: Element & { [veiKey]?: Record<string, Invoker | undefined> },
rawName: string,
prevValue: EventValue | null,
nextValue: EventValue | null,
instance: ComponentInternalInstance | null = null
) {
const invokers = el[veiKey] || (el[veiKey] = {})
const existingInvoker = invokers[rawName]
if (nextValue && existingInvoker) {
existingInvoker.value = nextValue // 直接改 invoker.value
} else {
const [name, options] = parseName(rawName)
if (nextValue) {
const invoker = (invokers[rawName] = createInvoker(nextValue, instance))
addEventListener(el, name, invoker, options)
} else if (existingInvoker) {
removeEventListener(el, name, existingInvoker, options)
invokers[rawName] = undefined
}
}
}
源码节选(同文件)——.once/.passive/.capture
的解析
const optionsModifierRE = /(?:Once|Passive|Capture)$/
function parseName(name: string): [string, EventListenerOptions | undefined] {
let options: EventListenerOptions | undefined
if (optionsModifierRE.test(name)) {
options = {}
let m
while ((m = name.match(optionsModifierRE))) {
name = name.slice(0, name.length - m[0].length)
;(options as any)[m[0].toLowerCase()] = true
}
}
const event = name[2] === ':' ? name.slice(3) : hyphenate(name.slice(2))
return [event, options]
}
源码节选(同文件)——.once/.passive/.capture
的解析
const optionsModifierRE = /(?:Once|Passive|Capture)$/
function parseName(name: string): [string, EventListenerOptions | undefined] {
let options: EventListenerOptions | undefined
if (optionsModifierRE.test(name)) {
options = {}
let m
while ((m = name.match(optionsModifierRE))) {
name = name.slice(0, name.length - m[0].length)
;(options as any)[m[0].toLowerCase()] = true
}
}
const event = name[2] === ':' ? name.slice(3) : hyphenate(name.slice(2))
return [event, options]
}
源码节选(同文件)——createInvoker
(纳入 error handling
链路)
function createInvoker(initialValue: EventValue, instance: ComponentInternalInstance | null) {
const invoker: Invoker = (e: Event & { _vts?: number }) => {
if (!e._vts) e._vts = Date.now()
else if (e._vts <= invoker.attached) return
callWithAsyncErrorHandling(
patchStopImmediatePropagation(e, invoker.value),
instance,
ErrorCodes.NATIVE_EVENT_HANDLER,
[e]
)
}
invoker.value = initialValue
invoker.attached = Date.now()
return invoker
}
源码节选(同文件)——成组处理函数与 stopImmediatePropagation
的打补丁
function patchStopImmediatePropagation(e: Event, value: EventValue): EventValue {
if (isArray(value)) {
const originalStop = e.stopImmediatePropagation
e.stopImmediatePropagation = () => {
originalStop.call(e)
;(e as any)._stopped = true
}
return value.map(fn => (e: Event) => !(e as any)._stopped && fn && fn(e))
} else {
return value
}
}
这几段充分证明“传统模式=元素直绑+统一 invoker
缓存+错误上报”的链路。
2. 错误处理链
事件处理调用通过 callWithErrorHandling
/ callWithAsyncErrorHandling
,触发 app.config.errorHandler
/ errorCaptured
。
源码节选(文件:packages/runtime-core/src/errorHandling.ts
)——同步/异步错误封装
// 同步封装:统一 try/catch 并转交给 handleError
export function callWithErrorHandling(
fn: Function,
instance: ComponentInternalInstance | null | undefined,
type: ErrorTypes,
args?: unknown[],
): any {
try {
return args ? fn(...args) : fn()
} catch (err) {
handleError(err, instance, type)
}
}
// 异步封装:在同步封装之上,对 Promise 结果做 catch 并转交 handleError
export function callWithAsyncErrorHandling(
fn: Function | Function[],
instance: ComponentInternalInstance | null,
type: ErrorTypes,
args?: unknown[],
): any {
if (isFunction(fn)) {
const res = callWithErrorHandling(fn, instance, type, args)
if (res && isPromise(res)) {
res.catch(err => { handleError(err, instance, type) })
}
return res
}
if (isArray(fn)) {
return fn.map(f => callWithAsyncErrorHandling(f, instance, type, args))
}
}
作用:无论是同步回调还是返回 Promise
的异步回调,最终都会被包进统一的错误处理通道。
源码节选(文件:packages/runtime-core/src/errorHandling.ts
)——错误分发流程核心
export function handleError(
err: unknown,
instance: ComponentInternalInstance | null | undefined,
type: ErrorTypes,
throwInDev = true,
): void {
const contextVNode = instance ? instance.vnode : null
const { errorHandler, throwUnhandledErrorInProduction } =
(instance && instance.appContext.config) || EMPTY_OBJ
if (instance) {
// 1) 自底向上调用父组件的 errorCaptured 钩子
let cur = instance.parent
const exposedInstance = instance.proxy
const errorInfo = __DEV__
? ErrorTypeStrings[type]
: `https://vuejs.org/error-reference/#runtime-${type}`
while (cur) {
const hooks = cur.ec
if (hooks) {
for (let i = 0; i < hooks.length; i++) {
if (hooks[i](err, exposedInstance, errorInfo) === false) return
}
}
cur = cur.parent
}
// 2) 应用级 errorHandler(app.config.errorHandler)
if (errorHandler) {
pauseTracking()
callWithErrorHandling(errorHandler, null, ErrorCodes.APP_ERROR_HANDLER, [
err, exposedInstance, errorInfo
])
resetTracking()
return
}
}
// 3) 最终兜底(开发环境抛出、生产环境 console.error 或按配置抛出)
logError(err, type, contextVNode, throwInDev, throwUnhandledErrorInProduction)
}
作用:组件级 errorCaptured
→ 应用级 errorHandler
→ 最终兜底 的三段式链路,正是你文中“错误处理链”的核心。
源码节选(文件:packages/runtime-core/src/errorHandling.ts
)——错误类型标识(节选)
export enum ErrorCodes {
SETUP_FUNCTION,
RENDER_FUNCTION,
NATIVE_EVENT_HANDLER = 5, // 重点:原生事件处理出错会标记为此
COMPONENT_EVENT_HANDLER,
/* ... */
APP_ERROR_HANDLER,
/* ... */
}
export const ErrorTypeStrings: Record<ErrorTypes, string> = {
/* ... */
[ErrorCodes.NATIVE_EVENT_HANDLER]: 'native event handler',
[ErrorCodes.COMPONENT_EVENT_HANDLER]: 'component event handler',
/* ... */
}
作用:当原生事件处理抛错时,会以 NATIVE_EVENT_HANDLER
类型进入上面的 handleError
流程,从而被 errorCaptured
/ app.config.errorHandler
捕获。这也能和你文中“传统模式下 invoker
会通过 callWithAsyncErrorHandling
进入错误链路”的描述首尾呼应。
三、Vapor
事件机制:只在 document
绑一次
1. 设计要点
-
首次遇见某个事件类型(如
click
),在 document
上 addEventListener
一次统一处理器,之后不解绑。
- 元素不再直绑处理函数,而是在节点对象上存一个私有字段,如
$evtclick
、$evtmousedown
等。
- 统一处理器根据事件的真实冒泡路径,自下而上查找每个节点的
$evt{type}
并触发。
- 若同一节点同一事件多次绑定(如不同修饰符),会把
$evt{type}
从单值升级为数组,依序执行。
2. Vapor
委托流程图
flowchart TD
A[用户触发事件] --> B{document 是否已绑定该事件}
B -- 否 --> C[调用 delegateEvents]
C --> C2[在 document 绑定全局监听]
B -- 是 --> D[统一处理函数 delegatedEventHandler]
C2 --> D
D --> E[确定起始节点 为 composedPath 首元素 或 target]
E --> F{当前节点是否存在}
F -- 否 --> Z[结束]
F -- 是 --> G{当前节点是否有事件句柄}
G -- 否 --> H[移动到父节点 或 host]
G -- 是 --> I{处理函数是否为数组}
I -- 是 --> J[依次调用 若 cancelBubble 为真 则返回]
I -- 否 --> K[调用处理函数 若 cancelBubble 为真 则返回]
J --> H
K --> H
H --> F
源码节选(运行时等价实现示意)——统一注册与统一分发
const delegatedEvents = Object.create(null)
const delegateEvents = (...names: string[]) => {
for (const name of names) {
if (!delegatedEvents[name]) {
delegatedEvents[name] = true
document.addEventListener(name, delegatedEventHandler)
}
}
}
const delegatedEventHandler = (e: Event) => {
let node = (e as any).composedPath?.()[0] || (e.target as Node)
if (e.target !== node) Object.defineProperty(e, 'target', { configurable: true, value: node })
Object.defineProperty(e, 'currentTarget', {
configurable: true,
get() { return node || document }
})
while (node) {
const handlers = (node as any)[`$evt${e.type}`]
if (handlers) {
if (Array.isArray(handlers)) {
for (const h of handlers) { if (!(node as any).disabled) { h(e); if ((e as any).cancelBubble) return } }
} else {
handlers(e); if ((e as any).cancelBubble) return
}
}
node = (node as any).host instanceof Node && (node as any).host !== node
? (node as any).host
: (node as any).parentNode
}
}
3. 同一事件多处理函数如何合并为数组
当同一元素的同一事件多次注册(例如不同修饰符)时,编译器多次调用 delegate(el, 'click', handler)
,把已有单值升级为数组:
function delegate(el: any, event: string, handler: Function) {
const key = `$evt${event}`
const existing = el[key]
if (existing) {
el[key] = Array.isArray(existing) ? (existing.push(handler), existing) : [existing, handler]
} else {
el[key] = handler
}
}
四、编译期“委托 or 直绑”的判定条件
Vapor
不会对所有事件都用委托;编译器(compiler-vapor
的 vOn
transform
)规则大致是:
-
静态事件名(不是
@[name]
动态);
-
没有事件选项修饰符:
once
/ capture
/ passive
;
- 事件在可委托清单内(常见如
click
、input
、keydown
、pointer*
、touch*
、focusin/out
、beforeinput
等)。
决策流程图
flowchart LR
A[监听表达式] --> B{事件名是否为静态}
B -- 否 --> X[直接绑定 addEventListener]
B -- 是 --> C{是否包含 once capture passive}
C -- 是 --> X
C -- 否 --> D{是否在可委托清单}
D -- 否 --> X
D -- 是 --> Y[使用 Vapor 委托 元素记录句柄 document 统一分发]
源码节选(文件:packages/compiler-vapor/src/transforms/vOn.ts
)——判定条件与清单(示意)
const delegatedEvents = /*#__PURE__*/ makeMap(
'beforeinput,click,dblclick,contextmenu,focusin,focusout,input,keydown,' +
'keyup,mousedown,mousemove,mouseout,mouseover,mouseup,pointerdown,' +
'pointermove,pointerout,pointerover,pointerup,touchend,touchmove,touchstart'
)
const delegate =
arg.isStatic && !eventOptionModifiers.length && delegatedEvents(arg.content)
五、事件修饰符的实现与 .stop
的“混用陷阱”
1. 修饰符由 withModifiers
包装
编译器将监听函数包到 withModifiers(fn, ['stop','alt',...])
中。运行时先执行“守卫”,不满足条件则直接 return
,否则再调用原处理函数。
源码节选(位置:runtime-dom
暴露 helpers
;示意实现)
const systemModifiers = ['ctrl', 'shift', 'alt', 'meta'] as const
const modifierGuards = {
stop: (e: Event) => e.stopPropagation(),
prevent: (e: Event) => e.preventDefault(),
self: (e: Event) => e.target !== (e as any).currentTarget,
ctrl: (e: KeyboardEvent) => !e.ctrlKey,
shift: (e: KeyboardEvent) => !e.shiftKey,
alt: (e: KeyboardEvent) => !e.altKey,
meta: (e: KeyboardEvent) => !e.metaKey,
left: (e: MouseEvent) => 'button' in e && e.button !== 0,
middle: (e: MouseEvent) => 'button' in e && e.button !== 1,
right: (e: MouseEvent) => 'button' in e && e.button !== 2,
exact: (e: any, mods: string[]) => systemModifiers.some(m => e[`${m}Key`] && !mods.includes(m))
}
export const withModifiers = (fn: Function, modifiers: string[]) => {
const cache = (fn as any)._withMods || ((fn as any)._withMods = {})
const key = modifiers.join('.')
return cache[key] || (cache[key] = (event: Event, ...args: any[]) => {
for (const m of modifiers) {
const guard = (modifierGuards as any)[m]
if (guard && guard(event, modifiers)) return
}
return fn(event, ...args)
})
}
2. .stop
的边界
-
.stop
的 e.stopPropagation()
在统一处理器阶段发生;它能阻断 Vapor
的委托分发;
- 但阻断不了你在元素或祖先上手写的原生
addEventListener
(那些在真实冒泡阶段就触发了);
- 混用传统与
Vapor
时,容易出现:“子节点 @click.stop
了,但父节点原生监听仍被触发”的现象。
示例:
<script setup lang="ts" vapor>
import { onMounted, useTemplateRef } from 'vue'
const elRef = useTemplateRef('elRef')
const add1 = () => console.log('add1 clicked')
onMounted(() => {
elRef.value?.addEventListener('click', () => {
console.log('native parent listener')
})
})
</script>
<template>
<div @click="add1" ref="elRef">
<div class="div1" @click.stop="add1">add1 按钮</div>
</div>
</template>
建议
- 需要
.stop
的路径上,尽量不要并行存在手写原生监听。
- 或将外层也改为
Vapor
统一委托体系,维持一致的冒泡控制。
六、非冒泡事件:直接绑定
像 blur
、mouseenter
等不冒泡事件,在 Vapor
下不走委托,直接绑到目标元素上(运行时 _on
/ addEventListener2
)。
源码节选(等价实现)
源码节选(等价实现)
function addEventListener2(el: Element, event: string, handler: any, options?: any) {
el.addEventListener(event, handler, options)
return () => el.removeEventListener(event, handler, options)
}
function on(el: Element, event: string, handler: any, options: any = {}) {
addEventListener2(el, event, handler, options)
if (options.effect) {
onEffectCleanup(() => {
el.removeEventListener(event, handler, options)
})
}
}
七、组件事件:仍按“传统”处理
- 组件事件视作
props
回调(如 onClick
)传入子组件;不走文档委托;
- 若父层写了
@click
但子组件未声明此事件,单根组件会透传到其根 DOM
;多根且未 v-bind="$attrs"
时会被丢弃;
-
组件自定义事件不冒泡(区别于
DOM
事件)。
源码节选(编译输出形态示意)
const root = t0()
const vnode = _createComponent(_ctx.DemoVue, {
onClick: () => _ctx.handleClick,
'onCustom-click': () => _ctx.handleCustomClick
})
八、自定义原生事件:跨层传递的一把好钥匙
用浏览器的 CustomEvent
可创建会冒泡的原生事件,便于跨层传递(避免层层 props/emit
):
Demo.vue
<script setup lang="ts" vapor>
import { useTemplateRef } from 'vue'
const catFound = new CustomEvent('animalfound', {
detail: { name: '猫' },
bubbles: true
})
const elRef = useTemplateRef('elRef')
setTimeout(() => elRef.value?.dispatchEvent(catFound), 3000)
</script>
<template>
<div ref="elRef">I am demo.vue</div>
</template>
App.vue
<script setup lang="ts" vapor>
import DemoVue from './Demo.vue'
const demoAnimalFound = (ev: CustomEvent<{ name: string }>) => console.log(ev)
const divAnimalFound = (ev: CustomEvent<{ name: string }>) => console.log(ev)
</script>
<template>
<div @animalfound="divAnimalFound">
<demo-vue @animalfound="demoAnimalFound" />
</div>
</template>
目前这类事件通常仍按普通原生事件直绑处理。若未来支持 .delegate
修饰符,开发者可“强制”走委托路径,让自定义冒泡事件同样享受监听器数量优化。
九、Vapor
vs 传统:关键差异
-
绑定位置:传统直绑在元素;
Vapor
在 document
绑一次、元素只存句柄。
-
寻找处理函数:传统靠
VNode diff
决策增删;Vapor
由统一处理器沿真实 DOM
冒泡路径查找 $evt{type}
。
-
修饰符:两者都有;
Vapor
修饰符实质是“守卫包装”。
-
.stop
行为:传统能阻断原生冒泡;Vapor
仅阻断 Vapor
的委托分发,对并行原生监听无能为力。
-
错误处理:传统有
callWithErrorHandling
链路;Vapor
统一处理器默认不包 try/catch
,需要业务自兜底。
-
非冒泡事件:两者都直绑。
-
组件事件:两者都按
props
回调处理;未声明的事件透传行为一致(单根透传、多根未 $attrs
丢弃)。
十、实践建议
-
优先场景:大列表/表格/密集交互页面,冒泡事件多、节点多,
Vapor
委托能显著减少监听器数量。
-
避免混用陷阱:同一路径尽量不要混用
Vapor
委托与手写原生监听;确需混用时,明确 .stop
的边界。
-
修饰符与选项:需要
once/capture/passive
的监听会强制直绑;仅在必要时使用这些选项。
-
非冒泡事件:按直绑心智处理即可。
-
异常兜底:关键处理函数加
try/catch
并上报,避免异常中断委托链且无人感知。
-
组件事件:遵循传统心智;多根组件注意
$attrs
透传。
十一、最小示例
1. Vapor
的 click
编译要点(示意)
<!-- Vapor SFC:<script setup vapor> -->
<div class="row" @click="handleRow"></div>
<!-- 编译核心结果(示意) -->
_delegateEvents('click') // document 只绑一次
n0.$evtclick = _ctx.handleRow // 元素只存句柄
2. .stop
与原生监听混用的表现
<script setup lang="ts" vapor>
import { onMounted, useTemplateRef } from 'vue'
const refEl = useTemplateRef('refEl')
const inner = () => console.log('inner clicked')
onMounted(() => {
// 原生监听:Vapor 的 .stop 无法阻断它
refEl.value?.addEventListener('click', () => console.log('native parent'))
})
</script>
<template>
<div ref="refEl">
<button @click.stop="inner">Click me</button>
</div>
</template>
结语
Vapor
的全局事件委托将“把监听器绑在每个元素上”的老路,升级为“在 document
上一次注册、统一分发”的新路,显著降低监听器数量和运行时开销;同时也带来与传统模式不同的错误传播路径与 .stop
边界。在 Vapor
与传统并存的阶段,建议你统一事件策略、避免混用陷阱,并在关键路径做好异常兜底。当你的页面以大量冒泡事件为主、且节点规模庞大时,Vapor
能带来切实的性能与体积收益。