基于
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
缓存
typescript
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
的解析
typescript
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
的解析
typescript
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
链路)
typescript
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
的打补丁
typescript
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
)------同步/异步错误封装
typescript
// 同步封装:统一 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
)------错误分发流程核心
typescript
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
)------错误类型标识(节选)
typescript
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
委托流程图
源码节选(运行时等价实现示意)------统一注册与统一分发
typescript
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)
,把已有单值升级为数组:
typescript
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
等)。
决策流程图
源码节选(文件:packages/compiler-vapor/src/transforms/vOn.ts
)------判定条件与清单(示意)
typescript
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
;示意实现)
typescript
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
了,但父节点原生监听仍被触发"的现象。
示例:
typescript
<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
)。
源码节选(等价实现)
源码节选(等价实现)
typescript
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
事件)。
源码节选(编译输出形态示意)
typescript
const root = t0()
const vnode = _createComponent(_ctx.DemoVue, {
onClick: () => _ctx.handleClick,
'onCustom-click': () => _ctx.handleCustomClick
})
八、自定义原生事件:跨层传递的一把好钥匙
用浏览器的 CustomEvent
可创建会冒泡 的原生事件,便于跨层传递(避免层层 props/emit
):
Demo.vue
typescript
<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
typescript
<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
编译要点(示意)
typescript
<!-- Vapor SFC:<script setup vapor> -->
<div class="row" @click="handleRow"></div>
<!-- 编译核心结果(示意) -->
_delegateEvents('click') // document 只绑一次
n0.$evtclick = _ctx.handleRow // 元素只存句柄
2. .stop
与原生监听混用的表现
typescript
<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
能带来切实的性能与体积收益。