Vue 3 核心源码深度剖析系列(v3.5.44)-第 17 章 运行时内幕(五):runtime-dom、自定义元素与内置组件
本章对应
packages/runtime-dom/src/index.ts(314 行)、nodeOps.ts(139 行)、patchProp.ts(174 行)、modules/{class,style,attrs,props,events}.ts、directives/{vModel,vShow,vOn}.ts、helpers/{useCssVars,useCssModule}.ts、components/{Transition,TransitionGroup}.ts、apiCustomElement.ts(843 行);以及packages/runtime-core/src/components/{BaseTransition.ts(610 行),KeepAlive.ts(486 行),Teleport.ts(596 行),Suspense.ts(976 行)}、runtime-core/src/rendererTemplateRef.ts(214 行)、runtime-core/src/directives.ts(200 行)。交叉引用runtime-core/src/renderer.ts的createRenderer/baseCreateRenderer、queuePostRenderEffect、MoveType、invalidateMount,runtime-core/src/vnode.ts的ShapeFlags、PatchFlags、cloneVNode,shared/src/normalizeProp.ts、shared/src/domAttrConfig.ts、shared/src/looseEqual.ts。所有行号对应vuejs/core@3.5.44。读完本章你将能回答:
runtime-core已经定义了渲染器,runtime-dom到底多做了什么、少做了什么?rendererOptions = extend({ patchProp }, nodeOps)这一行里藏着怎样的接口契约?insert为什么只用了insertBefore一个原生 API 就能同时表达「插入到末尾」?insertStaticContent的缓存命中路径为什么要求start === end || start.nextSibling?patchProp里key[0] === '.'与key[0] === '^'这两个前缀分别代表什么强制语义?为什么<input>的value要同时写 DOM property、_value私有字段、以及(在非自定义元素上)还得写 attribute?shouldSetAsProp里那六七个「特例 tag」各自对应哪个历史 issue?v-model为什么在beforeUpdate里要先判断rootNode.activeElement === el?vShow把「原始 display」存进了哪个 symbol?defineCustomElement的VueElement为什么会有_patching/_dirty两个布尔位?Transition组件里nextFrame为什么连用两层requestAnimationFrame?TransitionGroup的「三个循环」为什么刻意把 DOM 读与 DOM 写分开?KeepAlive的 LRU 为什么用Map+Set两个容器?Teleport的pendingMount与deferred是怎么配合 Suspense 的?Suspense的deps计数为什么要在patchSuspense里先++再--?templateRef的setRef为什么把非空赋值排进queuePostRenderEffect、却把置空同步执行?
导语:runtime-core 是「骨架」,runtime-dom 是「皮革」
到第 11–14 章为止,我们已经完整走过 runtime-core:VNode 的数据结构、renderer 的 patch 主流程、diff 与最长递增子序列、组件实例与调度器。但如果你把 runtime-core 单独打包,你会得到一堆完全无法在浏览器里跑起来的抽象:它知道「要往容器里插入一个节点」,却不知道用 parent.insertBefore 还是 parent.append;它知道「要给元素打补丁」,却不知道 class 在 DOM 里是 className 字符串、在 SVG 里是 setAttribute('class');它知道「这个 vnode 的 type 是 Transition」,却不知道 Transition 在浏览器里其实是「给元素加 v-enter-from 之类的 class,再监听 transitionend」。
runtime-dom 的工作,就是把 runtime-core 里那些抽象的「动词」,逐一落地成浏览器的「实现」。它做的事情可以精确归纳为三类:
- 提供宿主环境(host env)实现:
nodeOps把RendererOptions里那些「操作节点」的抽象方法映射到真实 DOM API;patchProp把「给元素设置属性」这一个抽象方法,分派到class/style/事件/属性/特例上的具体实现。 - 把渲染器「装配」起来并暴露给用户:
createApp、createSSRApp、render、hydrate都是在这一层导出的;createApp还会额外包装app.mount,让它承担「从选择器取容器」「把容器innerHTML当模板」「清理v-cloak、打上data-v-app」这些只有 DOM 环境才有意义的工作。 - 实现「只在 DOM 上成立」的内置能力:
Transition/TransitionGroup(它们的BaseTransition在 core,但 CSS class 钩子在 dom)、v-model/v-show(指令的 DOM 侧分支)、defineCustomElement(把 Vue 组件包装成自定义元素)、useCssVars/useCssModule。
本章的核心张力可以一句话概括:
runtime-core定义「渲染器应该能做什么」,runtime-dom回答「在浏览器里到底怎么做」;而内置组件(Transition/KeepAlive/Teleport/Suspense)的特殊之处在于,它们不是普通组件——渲染器必须认识它们的「标记位」,才能在正确的时机插入平台无关的状态机逻辑。
这句话里的每个从句都会在本章变成具体代码:
- 「渲染器应该能做什么」→
runtime-core的RendererOptions<T>接口(本章 17.1.1 会展开它有哪些字段)。 - 「在浏览器里到底怎么做」→
nodeOps.ts的 13 个方法(17.2 逐函数走读)与patchProp.ts的 7 层分派(17.3)。 - 「渲染器必须认识它们的标记位」→
__isKeepAlive、__isTeleport、__isSuspense、vnode.transition、ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE等(17.7–17.10)。
在读本章所有代码之前,请先建立一个判断:runtime-dom 里几乎每个函数,都是「一个抽象接口的实现」或「一个历史 bug 的补丁」。 前者解释它的结构(为什么 nodeOps 的每个方法都只有 1–3 行),后者解释它的细节(为什么 shouldSetAsProp 里有那么多 tag 特例、为什么 patchAttr 对 xlink: 要特殊处理、为什么 vModelText 的 beforeUpdate 里要判断 activeElement)。把每个特例还原到它服务的那类场景,这些看起来零碎的代码就会连成一张网。
本章结构是「职责总览 → DOM 原语 → 属性系统 → 指令 → 自定义元素 → 过渡 → 缓存与传送 → 异步边界 → ref → 实战」。17.1 讲清 runtime-dom 的边界与装配;17.2 逐函数走读 nodeOps;17.3 深入 patchProp 的五条分派支线;17.4–17.5 拆 v-model 四种元素分支与 v-show 的 display 保存恢复;17.6 是本章篇幅第二长的一节,走 defineCustomElement 从 class VueElement 到 customElements.define 的全流程;17.7 讲 BaseTransition 的运行时状态机与 Transition 的 CSS 钩子、TransitionGroup 的 FLIP;17.8 讲 KeepAlive 的 LRU;17.9 讲 Teleport 的跨容器与 Suspense 协作;17.10 是本章最长的一节,逐段推演 Suspense 的状态机;17.11 讲 templateRef 与 v-for 数组 ref;17.12 给出四类内置能力的最小可运行示例;17.13 收口。
17.1 runtime-dom 的职责:把抽象「落地」到浏览器
17.1.1 runtime-core 到底向平台索要了什么:RendererOptions 契约
要看懂 runtime-dom,必须先看它实现的那个接口。runtime-core 的 createRenderer 签名(packages/runtime-core/src/renderer.ts)大致是:
export function createRenderer<
HostNode = RendererNode,
HostElement = RendererElement,
>(options: RendererOptions<HostNode, HostElement>): Renderer<HostElement>
也就是说,渲染器本身是泛型的:它不假设宿主环境是浏览器,只要求调用方提供一份 RendererOptions。这份接口里包含两组东西:
(1)节点操作(nodeOps 对应)
| 抽象方法 | 语义 | 浏览器实现 |
|---|---|---|
insert(child, parent, anchor) | 把 child 插到 parent 中 anchor 之前 | parent.insertBefore(child, anchor || null) |
remove(child) | 从父节点移除 child | parent.removeChild(child) |
createElement(tag, namespace, is, props) | 创建元素 | createElementNS / createElement |
createText(text) | 创建文本节点 | createTextNode |
createComment(text) | 创建注释节点 | createComment |
setText(node, text) | 设置文本节点内容 | node.nodeValue = text |
setElementText(el, text) | 设置元素文本内容 | el.textContent = text |
parentNode(node) | 取父节点 | node.parentNode |
nextSibling(node) | 取下一兄弟 | node.nextSibling |
querySelector(selector) | 查询选择器 | document.querySelector |
setScopeId(el, id) | 设置 scoped 属性 | el.setAttribute(id, '') |
insertStaticContent(...) | 插入静态提升的内容 | template.innerHTML + cloneNode |
(2)属性、事件、指令等(patchProp 对应)
| 抽象方法 | 语义 | 浏览器实现 |
|---|---|---|
patchProp(el, key, prev, next, namespace, parentComponent) | 打属性补丁(唯一一个「大」方法) | patchProp.ts 的 patchProp |
可以看到,nodeOps 里几乎全是「一行转发」,而 patchProp 是唯一一个需要「大分派」的方法。这不是偶然:DOM 的节点操作 API 本来就与 runtime-core 的抽象一一对应(insertBefore、createTextNode……),而「给元素设属性」这件事在浏览器里有太多互相冲突的语义——class 是字符串、style 是对象或字符串、事件是函数或函数数组、id/href 是 attribute、value/checked 是 property、spellcheck/draggable 是 enumerated attribute 却有 boolean 的坑……所以 patchProp 被单独拎出来做成分派器。
17.1.2 装配:rendererOptions = extend({ patchProp }, nodeOps)
runtime-dom/src/index.ts:72 是把这两组实现「合流」的地方:
const rendererOptions = /*@__PURE__*/ extend({ patchProp }, nodeOps)
extend 就是 Object.assign(shared/src/general.ts:24)。这一行等价于把 nodeOps 的 13 个字段与 patchProp 这 1 个字段合并成一份完整的 RendererOptions。/*@__PURE__*/ 注解是给打包器的信号:如果这份对象没有副作用、且最终没被用到,可以整段 tree-shake 掉。
注意这里的顺序与挂载点:nodeOps 是「展开进来」的,patchProp 是「显式指定」的。nodeOps 用 Omit<RendererOptions<Node, Element>, 'patchProp'> 声明(nodeOps.ts:44)——它故意从类型里排除了 patchProp,因为它不负责这个字段;而 patchProp 则在 patchProp.ts:25 里以 DOMRendererOptions['patchProp'] 的方式声明它刚好是渲染器要的那个签名。类型上的这种「分工」与运行时的 extend 完美对应。
17.1.3 惰性创建渲染器:让 runtime-core 可被 tree-shake
紧接着的 ensureRenderer(index.ts:80-85)是另一个重要设计:
let renderer: Renderer<Element | ShadowRoot> | HydrationRenderer
let enabledHydration = false
function ensureRenderer() {
return (
renderer ||
(renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
)
}
renderer 是模块级变量,只有在第一次调用 render/createApp/hydrate 时才创建。注释写得很直白(index.ts:74-75):
lazy create the renderer - this makes core renderer logic tree-shakable in case the user only imports reactivity utilities from Vue.
设想一个只用了 reactive/effect 的项目:它 import { reactive } from 'vue'。如果 renderer 在模块顶层就 createRenderer(...),那整个 baseCreateRenderer(几千行)会被整包引入;而惰性创建 + @vue/runtime-dom 的 package.json sideEffects: false 意味着——只要没调用 createApp/render,渲染器代码就永远不会被实例化,可以被摇掉。
createApp 内部同样走 ensureRenderer().createApp(...)(index.ts:105)。也就是说:runtime-dom 提供的 createApp 不是重新实现,而是「取 core 的 createApp,再包装 mount」。 这正是 17.1.4 要讲的。
17.1.4 createApp 的 mount 增强:DOM 才有的四件事
index.ts:104-152 的 createApp 在拿到 core 的 app 之后,只做了一件事——替换 app.mount。替换后的 mount 依次做四件事:
app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
const container = normalizeContainer(containerOrSelector)
if (!container) return
const component = app._component
if (!isFunction(component) && !component.render && !component.template) {
// __UNSAFE__ 从 in-DOM template 取 innerHTML
component.template = container.innerHTML
// 2.x compat:容器上残留的 v-*/:*/@* 指令给出弃用警告
}
// clear content before mounting
if (container.nodeType === 1) {
container.textContent = ''
}
const proxy = mount(container, false, resolveRootNamespace(container))
if (container instanceof Element) {
container.removeAttribute('v-cloak')
container.setAttribute('data-v-app', '')
}
return proxy
}
(1)normalizeContainer(index.ts:234-257):把 string 选择器变成真实元素(document.querySelector),并对两种情况告警——选择器没命中、以及挂载目标是 mode: 'closed' 的 ShadowRoot(后者会导致不可预测的行为)。
(2)把容器 innerHTML 当作模板:这一段只在「组件不是函数式、没有 render、没有 template」时执行。它的含义是:如果你 createApp({ data(){...} }).mount('#app') 而组件没有模板,Vue 会把 #app 的 innerHTML 读出来编译成模板。这就是「完整版构建(含运行时编译器)」才支持的能力。注释里的 __UNSAFE__ 是安全提醒:这段 HTML 会被当作 Vue 模板编译执行,必须是可信内容;如果它是服务端渲染出来的,就绝不能包含未转义的用户数据(否则就是一个模板注入点)。
(3)清空容器:container.textContent = ''(仅对元素节点)。注意这一步在 mount 之前——所以即便模板是从 innerHTML 取的,取完也会被清掉,真正的 DOM 由渲染器重新创建。
(4)收尾:移除 v-cloak(这是「配合 CSS [v-cloak]{display:none} 消除未编译模板闪烁」的约定),并打上 data-v-app(便于 devtools/样式识别)。
也就是说,createApp 包装的 mount 承担了**「DOM 环境特有的容器准备与模板来源」**;组件实例的创建、patch 的过程,全都在 core 里。
17.1.5 createSSRApp 与 resolveRootNamespace
createSSRApp(index.ts:154-171)几乎与 createApp 对称,区别只有两点:
- 它走
ensureHydrationRenderer()(index.ts:87-93),最终用的是createHydrationRenderer,于是app.mount走的是 hydration 路径(第二个参数true)。 - 它不覆盖容器
innerHTML模板、不清空容器——因为 SSR 场景下容器里已经是服务端渲染好的 DOM,正是要被「认领」的。
app.mount = (containerOrSelector): any => {
const container = normalizeContainer(containerOrSelector)
if (container) {
return mount(container, true, resolveRootNamespace(container))
}
}
ensureHydrationRenderer 有个细节:
function ensureHydrationRenderer() {
renderer = enabledHydration
? renderer
: createHydrationRenderer(rendererOptions)
enabledHydration = true
return renderer as HydrationRenderer
}
enabledHydration 是「一旦切成 hydration 渲染器就锁定」的开关:hydration 渲染器额外携带了 hydrate/hydrateNode(见第 15 章),比普通渲染器大,所以只有确实用到 createSSRApp/hydrate 时才创建;创建后,同一个 renderer 变量会被复用,避免重复构建。
第三参数 resolveRootNamespace(container)(index.ts:173-185)解决的是「组件树根节点的命名空间」问题:
function resolveRootNamespace(container): ElementNamespace {
if (container instanceof SVGElement) return 'svg'
if (typeof MathMLElement === 'function' && container instanceof MathMLElement) {
return 'mathml'
}
}
如果 mount 的容器本身是 <svg>/<math>,那么子树里所有元素都应该继承 svg/mathml 命名空间,而 createElement 需要用 createElementNS(见 17.2.3)。
17.1.6 app 的配置注入与导出清单
createApp 与 createSSRApp 在 __DEV__ 下都会调用两个「注入配置」的函数:
injectNativeTagCheck(app)(index.ts:187-194):用Object.defineProperty把app.config.isNativeTag定义成一个只读函数,判定「这个标签名是不是原生 HTML/SVG/MathML 标签」。它服务的是「组件名合法性校验」(dev only)。injectCompilerOptionsCheck(app)(index.ts:197-232):只在 runtime-only 构建里生效。它把isCustomElement和compilerOptions两个配置改成带get/set的访问器——读或写时都打印警告,因为 runtime-only 构建里没有编译器,这两个配置根本没地方生效。这条注释(index.ts:213-220)是很好的「运行时 vs 完整版」教学。
最后看导出清单(index.ts:260-314),它几乎定义了「一个 Vue 应用能用到的全部 DOM 侧能力」:
export { defineCustomElement, defineSSRCustomElement, useShadowRoot, useHost, VueElement, ... } from './apiCustomElement'
export { useCssModule } from './helpers/useCssModule'
export { useCssVars } from './helpers/useCssVars'
export { Transition, TransitionGroup } from './components/Transition' / './components/TransitionGroup'
export { vModelText, vModelCheckbox, vModelRadio, vModelSelect, vModelDynamic } from './directives/vModel'
export { withModifiers, withKeys } from './directives/vOn'
export { vShow } from './directives/vShow'
export * from '@vue/runtime-core'
export * from './jsx'
注意两点:
export * from '@vue/runtime-core':runtime-dom是runtime-core的超集。用户import { h, reactive, nextTick } from 'vue',最终都从这一行透传出去。这也是为什么packages/vue/src/index.ts主要是「带上编译器地把 runtime-dom 再导出一次」。initDirectivesForSSR(index.ts:295-308):它是个__SSR__编译开关保留的函数。因为v-model/v-show的客户端实现依赖真实 DOM(addEventListener、el.style),而 SSR 时它们需要的是getSSRProps——把 vnode 的value/checked/style转成 SSR 的 props。所以initVModelForSSR()/initVShowForSSR()在 SSR 构建时才被挂上去,且只做一次。这是一种「按编译目标切换实现」的技巧,与第 15 章的__SSR__一脉相承。
vOn.ts 里的 withModifiers/withKeys 之所以在这里导出,是因为它们其实是编译产物调用的运行时 helper:模板里的 @click.stop.prevent="fn" 会被编译成 withModifiers(fn, ['stop','prevent'])。它们放在 runtime-dom(而不是 core),因为它们编码的是 DOM 事件的语义(e.stopPropagation、e.key)。17.3.4 会细讲。
17.2 nodeOps 逐函数:DOM 原语与它们的性能考量
nodeOps 是本章篇幅最短、但最该逐个字读的文件。它一共只导出 13 个方法(nodeOps.ts:44-138),其中每个方法都对应渲染器的一个「抽象动作」。本节按「节点创建 → 节点插入/移除 → 属性 vs 文本 → 静态内容」的顺序走读,并逐个指出性能/兼容性的考量。
17.2.1 顶部两个常量:svgNS/mathmlNS 与 unsafeToTrustedHTML
export const svgNS = 'http://www.w3.org/2000/svg' // nodeOps.ts:37
export const mathmlNS = 'http://www.w3.org/1998/Math/MathML' // nodeOps.ts:38
const doc = (typeof document !== 'undefined' ? document : null) as Document // nodeOps.ts:40
const templateContainer = doc && /*@__PURE__*/ doc.createElement('template') // nodeOps.ts:42
doc 与 templateContainer 都在模块加载时创建。doc 用三元守卫 typeof document !== 'undefined' 是因为 runtime-dom 也可能被非浏览器环境(如某些 SSR 的工具链)加载。templateContainer 是一个复用的 <template> 元素,专门为 insertStaticContent 服务(见 17.2.9)——复用它可以省下每次创建 <template> 的开销。
顶部还有一段 Trusted Types 处理(nodeOps.ts:9-35):
const tt = typeof window !== 'undefined' && (window as any).trustedTypes
if (tt) {
try {
policy = tt.createPolicy('vue', { createHTML: val => val })
} catch (e) { __DEV__ && warn(`Error creating trusted types policy: ${e}`) }
}
export const unsafeToTrustedHTML = policy ? val => policy.createHTML(val) : val => val
Trusted Types 是浏览器的 CSP 机制:在开启 require-trusted-types-for 'script' 的页面上,任何给 innerHTML 赋字符串都会抛错,必须赋一个「策略产出的 TrustedHTML」。runtime-dom 于是创建一个名为 vue 的策略(createHTML 是恒等函数),并用 unsafeToTrustedHTML 统一做类型转换。注意 try/catch:如果页面上已经有人用同名策略注册、且 CSP 没开 allow-duplicates,createPolicy 会抛 TypeError——这里捕获并降级。这是一个「即使没有 Trusted Types 也能跑、有了也能通过 CSP」的自适应设计。 函数名带 unsafe 前缀是刻意的:它只做「类型层面的转换」,安全性完全取决于调用方传入的内容。
17.2.2 insert:只用 insertBefore 表达两种插入
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor || null) // nodeOps.ts:45-47
},
DOM 里「插到末尾」和「插到某节点之前」是两个 API(append/appendChild vs insertBefore)。runtime-core 的抽象 insert(child, parent, anchor) 用 anchor 统一表达:当 anchor 为 null 时,insertBefore(child, null) 的语义就是「插到末尾」。 这样渲染器只需记忆一个 API,而不用在每个调用点判断「是 append 还是 insertBefore」。这是个很典型的「把两个分支压成一个」的接口设计。
17.2.3 createElement:三种命名空间 + is + <select multiple>
createElement: (tag, namespace, is, props): Element => {
const el =
namespace === 'svg'
? doc.createElementNS(svgNS, tag)
: namespace === 'mathml'
? doc.createElementNS(mathmlNS, tag)
: is
? doc.createElement(tag, { is })
: doc.createElement(tag)
if (tag === 'select' && props && props.multiple != null) {
;(el as HTMLSelectElement).setAttribute('multiple', props.multiple)
}
return el
},
三层分支对应三件事:
- 命名空间:HTML 用
doc.createElement(tag);SVG/MathML 必须用createElementNS(ns, tag)。在 SVG 里用createElement会得到错误的(HTML 命名空间)元素,浏览器可能不识别。 命名空间从 vnode 的namespace字段透传过来,而它的源头是resolveRootNamespace(17.1.5)与渲染器在 patch 时的 namespace 传播(比如 Teleport 从非 SVG 树传进 SVG 树时,见 17.9)。 - 自定义内置元素(customized built-in element):
is是is="my-button"这种「定制内置元素」语法。此时必须用createElement(tag, { is })(注意是两参数形式),浏览器才知道要按my-button的行为实例化。普通的 autonomous 自定义元素(<my-el>)不需要这一步。 <select multiple>特例:<select multiple>编译成{ multiple: '' }。如果这里不主动setAttribute('multiple', props.multiple),那么在创建时该<select>还不是 multiple,后续挂载<option>时的默认选中逻辑就可能出错。注释没有明说,但代码意图是「在子节点插入前,先把 select 的模式定下来」。注意props.multiple != null的守卫:只有显式传了才设。
createElement 的第四参数 props 是后来加的(历史实现只有三个参数),它体现了「创建时就需要知道的信息」与「创建后再 patch 的信息」的分离——multiple 之所以要在创建时设,正因为它影响子节点插入。
17.2.4 createText/createComment/setText/setElementText
createText: text => doc.createTextNode(text), // nodeOps.ts:73
createComment: text => doc.createComment(text), // nodeOps.ts:75
setText: (node, text) => { node.nodeValue = text }, // nodeOps.ts:77-79
setElementText: (el, text) => { el.textContent = text }, // nodeOps.ts:81-83
这四个是纯粹的转发。值得注意的设计差异在 setText 用 nodeValue 而 setElementText 用 textContent:
- 文本节点的内容 API 是
nodeValue(或data);textContent对文本节点虽然也可用,但语义上是「元素」的属性。 - 元素的文本内容 API 是
textContent;渲染器在 patch children 时,若新旧都是纯文本,会调用setElementText把整个textContent一次性替换——这比「先清空子节点再插文本」更快。
17.2.5 parentNode/nextSibling:渲染器遍历 DOM 的两只脚
parentNode: node => node.parentNode as Element | null, // nodeOps.ts:85
nextSibling: node => node.nextSibling, // nodeOps.ts:87
这两个方法看起来微不足道,但它们是渲染器在非受控 DOM 上「爬行」的唯一手段。最典型的用户是 moveTeleport(需要沿兄弟链找锚点)与 hydration(第 15 章,需要按 DOM 顺序与 vnode 顺序对齐)。它们没有缓存、没有分支,因为它们是「每次 patch 都可能调用上百次」的热路径。
17.2.6 querySelector 与 setScopeId
querySelector: selector => doc.querySelector(selector), // nodeOps.ts:89
setScopeId(el, id) { el.setAttribute(id, '') }, // nodeOps.ts:91-93
querySelector是Teleport解析to="selector"的依赖(17.9)。注意渲染器只在确实用到 Teleport 字符串目标时才会调用它;如果宿主环境没提供querySelector,resolveTarget会给出警告(Teleport.ts:52-57)。setScopeId把 scoped 样式属性(如data-v-7ba5bd90)设置到元素上,值是空字符串——这是「属性存在即生效」的约定,配合 CSS 属性选择器[data-v-7ba5bd90]。
17.2.7 insertStaticContent:静态提升内容的插入与缓存复用
这是 nodeOps 里最复杂的一个方法(nodeOps.ts:99-138),它服务的是编译器的「静态提升」优化:一段全是静态的模板(如 <div class="a">hello</div>)会被编译器提升成一个字符串常量,并在 vnode 上记录下「首次渲染产生的 first/last 节点」。渲染器在第 2、3…次渲染时不再重新创建这些节点,而是克隆它们。
insertStaticContent(content, parent, anchor, namespace, start, end) {
// <parent> before | first ... last | anchor </parent>
const before = anchor ? anchor.previousSibling : parent.lastChild
// #5308 can only take cached path if:
// - has a single root node
// - nextSibling info is still available
if (start && (start === end || start.nextSibling)) {
// cached
while (true) {
parent.insertBefore(start!.cloneNode(true), anchor)
if (start === end || !(start = start!.nextSibling)) break
}
} else {
// fresh insert
templateContainer.innerHTML = unsafeToTrustedHTML(
namespace === 'svg' ? `<svg>${content}</svg>`
: namespace === 'mathml' ? `<math>${content}</math>`
: content,
) as string
const template = templateContainer.content
if (namespace === 'svg' || namespace === 'mathml') {
const wrapper = template.firstChild!
while (wrapper.firstChild) template.appendChild(wrapper.firstChild)
template.removeChild(wrapper)
}
parent.insertBefore(template, anchor)
}
return [before ? before.nextSibling! : parent.firstChild!, anchor ? anchor.previousSibling! : parent.lastChild!]
}
逐步拆解:
before先记下来:它是「插入位置的前一个兄弟」,用来在插入完成后定位新内容的第一个节点。(Node.insertBefore一个DocumentFragment会把它摊平插入,插入后没法直接拿到「第一个插入的节点」,所以需要靠before.nextSibling反推。)- 缓存命中路径:条件
start && (start === end || start.nextSibling)对应注释里的两条:#5308 要求「只有一个根节点」或「nextSibling 信息仍有效」。满足时,从start开始沿nextSibling链逐个cloneNode(true)深拷贝并插入,直到到达end。这是「复用」的核心:它拷贝的是上一次渲染真实产生的 DOM 节点,因此保留了浏览器解析出的一切(属性、文本、甚至表单状态之外的结构)。 - 未命中路径:把
content塞进复用templateContainer的innerHTML。注意 SVG/MathML 要包一层<svg>/<math>再解析——因为innerHTML在 HTML 上下文里不会给 SVG 标签正确的命名空间;解析后再把 wrapper 的 children 搬出来、移除 wrapper。 - 返回
[first, last]:渲染器会把这两个节点记到 vnode 上,供下次渲染当作start/end。它们通过before.nextSibling与anchor.previousSibling计算得到。
templateContainer 是模块级复用的(17.2.1),所以这里的 innerHTML 赋值会反复覆盖同一个 template 的 content。因为 parent.insertBefore(template, anchor) 会把 template 的 content 子节点「搬」进 parent(DocumentFragment 语义),template 自身随后就空了,可以安全复用。 这是这里能省一端 .content 拷贝的原因。
unsafeToTrustedHTML 的 __UNSAFE__ 注释(nodeOps.ts:95-98)重申:这段内容只能是编译器生成的、可信的静态模板。
17.2.8 remove:为什么必须先取 parentNode
remove: child => {
const parent = child.parentNode
if (parent) parent.removeChild(child)
},
现代 DOM 有 child.remove()(自移除)。但 runtime-dom 保留了「先取 parentNode 再 removeChild」的老写法,因为在很多历史环境(老 IE、某些 JSDOM 版本)里 remove() 不一定可用,而 removeChild 是万无一失的。if (parent) 的守卫则是幂等处理:一个已经被移走的节点再次 remove 不应抛错。
17.2.9 nodeOps 小结:一份「最小 DOM 子集」
把 13 个方法连起来看,nodeOps 实际上定义了一份「渲染器需要的 DOM 子集」:它只要「创建/插入/移除/查询/遍历」这五个能力,绝不用 append、remove、innerHTML(除了静态内容那一个特例)、replaceChild。这份克制带来两个好处:
- 渲染器的所有行为都能被这 13 个方法完整复现,因此理论上你可以写一个「用 canvas 当作 DOM」的
nodeOps,让同一套渲染器跑在非浏览器环境——这正是runtime-test包与自定义渲染器(如@vue/runtime-vapor之类)能成立的基础。 - 每个方法都足够小,可以被内联/优化,热路径上没有多余分发。
17.3 patchProp 深度:一个方法,五条支线
17.3.1 主分派:class/style/事件/prop/attr 的优先级
patchProp 的骨架(patchProp.ts:25-82)是一个 if-else if-else 链。理解它的关键是分支的先后顺序,本身就是语义优先级:
export const patchProp: DOMRendererOptions['patchProp'] = (
el, key, prevValue, nextValue, namespace, parentComponent,
) => {
const isSVG = namespace === 'svg'
if (key === 'class') {
patchClass(el, nextValue, isSVG)
} else if (key === 'style') {
patchStyle(el, prevValue, nextValue)
} else if (isOn(key)) {
if (!isModelListener(key)) {
patchEvent(el, key, prevValue, nextValue, parentComponent)
}
} else if (
key[0] === '.' ? ((key = key.slice(1)), true)
: key[0] === '^' ? ((key = key.slice(1)), false)
: shouldSetAsProp(el, key, nextValue, isSVG)
) {
patchDOMProp(el, key, nextValue, parentComponent)
if (!el.tagName.includes('-') && (key === 'value' || key === 'checked' || key === 'selected')) {
patchAttr(el, key, nextValue, isSVG, parentComponent, key !== 'value')
}
} else if ((el as VueElement)._isVueCE && (...)) {
patchDOMProp(el, camelize(key), nextValue, parentComponent, key)
} else {
if (key === 'true-value') (el as any)._trueValue = nextValue
else if (key === 'false-value') (el as any)._falseValue = nextValue
patchAttr(el, key, nextValue, isSVG, parentComponent)
}
}
逐支线拆解:
| 顺序 | 条件 | 处理函数 | 语义 |
|---|---|---|---|
| 1 | key === 'class' | patchClass | 归一化后的 class 字符串,走 className/setAttribute |
| 2 | key === 'style' | patchStyle | 对象差分或 cssText 整体替换 |
| 3 | isOn(key) 且非 isModelListener | patchEvent | 以 invoker 缓存挂事件 |
| 4 | .prop 前缀 / ^attr 前缀 / shouldSetAsProp | patchDOMProp | 走 DOM property |
| 5 | Vue 自定义元素的已声明 prop | patchDOMProp | CE 强制 property |
| 6 | 其余 | patchAttr | 走 setAttribute |
几个细节:
.prop与^attr前缀:模板里:foo.prop="x"会被编译成 key'.foo',^attr是新语法。第 4 分支用了一个「逗号表达式」技巧:key[0] === '.' ? ((key = key.slice(1)), true) : ...。如果 key 以.开头,就切掉前缀并返回true(强制走 property);以^开头则切掉前缀并返回false(强制走 attribute)。也就是说,这两个前缀是「强行覆盖shouldSetAsProp的自动判断」的转义阀。isModelListener(key):shared/src/general.ts:21定义key.startsWith('onUpdate:')。v-model会把onUpdate:modelValue这类监听器放在 props 里(因为它是组件 props 的约定),而它绝不能被当作原生事件挂到元素上——v-model指令自己会用getModelAssigner去读它。所以这里显式跳过。value/checked/selected的双写:进入第 4 分支后,若元素非自定义元素(!tagName.includes('-'))且 key 是三者之一,还要额外调一次patchAttr。这是 #6007 的修复:<input type="reset">或某些浏览器扩展/库是通过读 attribute 来同步表单状态的,只写 property 不够。注意最后一个参数key !== 'value':它决定patchAttr的isBoolean。checked/selected是 boolean attribute(写空字符串),value不是。true-value/false-value:<input type="checkbox" v-model>配:true-value/:false-value时,值可能是对象,直接当 attribute 会被字符串化,所以它们被存进元素的_trueValue/_falseValue私有字段(patchProp.ts:75-79),供vModelCheckbox读(17.4.3)。
17.3.2 class:normalizeClass 与「过渡类保护」
class 在 runtime-dom 里的处理分两段:编译期/normalizeProps 期与运行时 patchClass。
编译器在遇到同一元素上既有静态 class 又有 :class 时,会把它们归一成一个绑定(class.ts:3-4 注释)。运行时的归一化函数是 shared/src/normalizeProp.ts:65-84 的 normalizeClass:
export function normalizeClass(value: unknown): string {
let res = ''
if (isString(value)) {
res = value
} else if (isArray(value)) {
for (let i = 0; i < value.length; i++) {
const normalized = normalizeClass(value[i])
if (normalized) res += normalized + ' '
}
} else if (isObject(value)) {
for (const name in value) {
if (value[name]) res += name + ' '
}
}
return res.trim()
}
它把三种输入递归地转成空格分隔字符串:字符串原样、数组递归拼接、对象取 truthy 的键。对象形式是 :class="{ active: isActive }" 的实现基础——注意它是「取 key」,因此 key 就是 class 名。
patchClass(class.ts:5-26)则是「写进 DOM」的那一步:
export function patchClass(el, value, isSVG) {
const transitionClasses = (el as ElementWithTransition)[vtcKey]
if (transitionClasses) {
value = (value ? [value, ...transitionClasses] : [...transitionClasses]).join(' ')
}
if (value == null) {
el.removeAttribute('class')
} else if (isSVG) {
el.setAttribute('class', value)
} else {
el.className = value
}
}
三个要点:
- 性能:注释(
class.ts:10-12)说「直接设className理论上比setAttribute快」。对 HTML 元素走el.className,对 SVG 元素必须走setAttribute(因为 SVGElement 没有className字符串属性)。 - 过渡类保护:
vtcKey是Symbol('_vtc')(Transition.ts:35),Transition会把「正在生效的临时过渡 class」(如v-enter-active)记录到这个 Set 里。当元素正在过渡、而业务代码又恰好改了:class时,patchClass必须把过渡类拼回最终值,否则过渡会「突然中断」。这就是value = [value, ...transitionClasses].join(' ')。这个设计的精妙在于:过渡类不放在class属性里被 patch,而是「旁路记录 + 每次 patch 时重新合成」。 value == null时removeAttribute而非className = '':前者让:class="null"时元素真的没有class属性,与「未绑定」视觉上一致。
17.3.3 style:对象差分、CSS 变量、自动前缀与 v-show 联动
style 比 class 复杂得多,因为它要处理「对象 → 对象的差分」。patchStyle(style.ts:14-81)分为两条主路径:对象路径与字符串路径。
(1)对象路径:当 next 是对象时,先做「删除差分」——
if (prev) {
if (!isString(prev)) {
for (const key in prev) if (next[key] == null) setStyle(style, key, '')
} else {
for (const prevStyle of prev.split(';')) {
const key = prevStyle.slice(0, prevStyle.indexOf(':')).trim()
if (next[key] == null) setStyle(style, key, '')
}
}
}
即「上一个值里有、新值里没有的键」要清空(setStyle(style, key, ''))。注意 prev 也可能是字符串(style="color:red"),此时要先把字符串拆成键——这解释了为什么 style 的 prev/next 类型是 string | Record | null。
然后遍历 next 的每个键写入,同时维护一个 hasControlledDisplay 标志(当遇到 display 键时置 true,见 17.5 与 v-show 的联动)。
(2)字符串路径:next 是字符串时,若 prev !== next 就 style.cssText = next。这里有个 #9821 特例:如果元素之前通过 useCssVars 往 style 上挂了 CSS_VAR_TEXT(一段 --x: y; 拼成的文本,见 useCssVars.ts:108),那么在整体替换 cssText 时要把这段 CSS 变量拼回去,否则 SFC 的 v-bind() 变量会被冲掉。
setStyle(style.ts:86-124)是真正写值的地方:
function setStyle(style, name, val) {
if (isArray(val)) {
val.forEach(v => setStyle(style, name, v))
} else {
if (val == null) val = ''
if (__DEV__ && semicolonRE.test(val)) {
warn(`Unexpected semicolon at the end of '${name}' style value: '${val}'`)
}
if (name.startsWith('--')) {
if (importantRE.test(val)) style.setProperty(name, val.replace(importantRE, ''), 'important')
else style.setProperty(name, val)
} else {
const prefixed = autoPrefix(style, name)
if (importantRE.test(val)) {
style.setProperty(hyphenate(prefixed), val.replace(importantRE, ''), 'important')
} else {
style[prefixed as any] = val
}
}
}
}
要点:
- 数组值:
style值可以是数组(如['-webkit-box', '-ms-flexbox']),会展开成多次setStyle——后者覆盖前者,于是起到「依次尝试、失败就用下一个」的效果(浏览器不支持的值会被忽略)。 __DEV__分号校验:semicolonRE = /[^\\];\s*$/,值是\结尾(转义)除外;若值末尾有多余分号就告警。因为对象形式里分号是多余的(键值对已经分隔)。- CSS 变量(
--前缀):必须用style.setProperty(name, val),因为style['--x']中括号访问不生效。!important通过第三参数传。 - 自动前缀:非变量走
autoPrefix。 !important:importantRE = /\s*!important$/,命中时用setProperty并传'important',属性名要hyphenate成连字符格式。
autoPrefix(style.ts:129-146)实现了浏览器前缀自动补齐与缓存:
const prefixes = ['Webkit', 'Moz', 'ms']
const prefixCache: Record<string, string> = {}
function autoPrefix(style, rawName): string {
const cached = prefixCache[rawName]
if (cached) return cached
let name = camelize(rawName)
if (name !== 'filter' && name in style) return (prefixCache[rawName] = name)
name = capitalize(name)
for (let i = 0; i < prefixes.length; i++) {
const prefixed = prefixes[i] + name
if (prefixed in style) return (prefixCache[rawName] = prefixed)
}
return rawName
}
算法步骤:
- 查缓存
prefixCache,命中直接返回(前缀补齐是个「每个属性名一次」的昂贵探测,必须缓存)。 camelize(rawName):把background-color变成backgroundColor(编写者可能用连字符写法)。name !== 'filter'的特殊守卫:filter这个属性有个历史坑——标准filter与老 IE 的滤镜filter同名字,'filter' in style会误判。所以对filter跳过「无前缀」的短路,强制去试WebkitFilter等。- 若无前缀版本可用,就试
Webkit/Moz/ms前缀(注意前缀写法 +capitalize)。命中就缓存。 - 全都不命中,返回原名(写入时浏览器自行忽略)。
shouldPreserveTextareaResizeStyle(style.ts:153-165)是 3.5 新增的边界修复:浏览器在用户手动拖拽 <textarea> 改变大小时,会直接改它的 width/height(通过行内样式)。如果此时 Vue 正巧用与上次相同的值 patch width/height,直接写回会打断用户拖拽。所以当 el.tagName === 'TEXTAREA'、key 是 width/height、且 prev === next 时,跳过写入。
17.3.4 事件:patchEvent 的 invoker 记忆化
事件是 patchProp 里最值得单独讲的一支。它的核心设计是**「invoker 记忆化(memoization)」**:不改用一个「每次更新都换掉的事件处理函数」,而是创建一个稳定的 invoker 包装函数,只替换它内部的 value。
const veiKey: unique symbol = Symbol('_vei')
export function patchEvent(el, rawName, prevValue, nextValue, instance = null) {
const invokers = el[veiKey] || (el[veiKey] = {})
const existingInvoker = invokers[rawName]
if (nextValue && existingInvoker) {
// patch:只更新 value
existingInvoker.value = __DEV__ ? sanitizeEventValue(nextValue, rawName) : nextValue
} else {
const [name, options] = parseName(rawName)
if (nextValue) {
// add
const invoker = (invokers[rawName] = createInvoker(
__DEV__ ? sanitizeEventValue(nextValue, rawName) : nextValue, instance,
))
addEventListener(el, name, invoker, options)
} else if (existingInvoker) {
// remove
removeEventListener(el, name, existingInvoker, options)
invokers[rawName] = undefined
}
}
}
三个状态:
| 旧 invoker | 新值 | 动作 |
|---|---|---|
| 有 | 有 | patch:只改 invoker.value,不碰 addEventListener |
| 无 | 有 | add:创建 invoker 并挂上 |
| 有 | 无 | remove:removeEventListener 并清空槽位 |
这个记忆化的价值在于:组件每次更新都会重新创建一个内联箭头函数作为 handler(@click="() => count++"),但底层的 addEventListener 只会被调用一次,因为 invoker 是稳定的、只是内部 value 变了。这避免了「每次更新都 remove + add 事件监听器」的开销,也避免了「监听器身份变化导致的浏览器优化失效」。
parseName(events.ts:73-86)负责把事件名拆成「事件类型 + 监听器选项」:
const optionsModifierRE = /(Once|Passive|Capture)$/
const optionsModifierEventRE = /^on:?(?:Once|Passive|Capture)$/
function parseName(name) {
let options; let m
while ((m = name.match(optionsModifierRE)) && !optionsModifierEventRE.test(name)) {
if (!options) options = {}
name = name.slice(0, name.length - m[1].length)
;(options as any)[m[1].toLowerCase()] = true
}
const event = name[2] === ':' ? name.slice(3) : hyphenate(name.slice(2))
return [event, options]
}
- 选项后缀:
onClickOnce/onClickPassive/onClickCapture会从尾部被剥下来,转成{ once: true }/{ passive: true }/{ capture: true }。while允许叠加(如onClickCaptureOnce,虽然少见)。 optionsModifierEventRE的守卫:阻止把名为once/passive/capture的真事件误剥(例如addEventListener('once')是合法的自定义事件名)。- 事件名解析:JSX 风格
onClick(第 2 字符不是:且后面是大小写混合)→name.slice(2)再hyphenate,得到click;而模板编译产物是onClick还是on:click?两种都支持——name[2] === ':'时取name.slice(3)(即已经显式带了事件名)。
sanitizeEventValue(events.ts:154-165)是 dev-only 的防御:如果 handler 不是函数或函数数组(比如用户忘了写 @/:,把 onClick="doSomething" 当成 prop 传了字符串),就告警并返回 NOOP,避免运行时崩溃。
createInvoker(events.ts:95-152)是最核心的一段:
const p = Promise.resolve()
const getNow = () => cachedNow || (p.then(() => (cachedNow = 0)), (cachedNow = Date.now()))
function createInvoker(initialValue, instance) {
const invoker = (e) => {
if (!e._vts) {
e._vts = Date.now()
} else if (e._vts <= invoker.attached) {
return
}
const value = invoker.value
if (isArray(value)) {
const originalStop = e.stopImmediatePropagation
e.stopImmediatePropagation = () => { originalStop.call(e); (e as any)._stopped = true }
const handlers = value.slice()
for (let i = 0; i < handlers.length; i++) {
if ((e as any)._stopped) break
const handler = handlers[i]
if (handler) callWithAsyncErrorHandling(handler, instance, ErrorCodes.NATIVE_EVENT_HANDLER, [e])
}
} else {
callWithAsyncErrorHandling(value, instance, ErrorCodes.NATIVE_EVENT_HANDLER, [e])
}
}
invoker.value = initialValue
invoker.attached = getNow()
return invoker
}
(1)时间戳去抖(_vts/attached):invoker.attached 记录监听器挂载的时刻;e._vts 是「这个事件第一次被 Vue 处理时」的时间戳。当事件传播路径上,内层元素的监听器触发了 patch、patch 又给外层元素挂了新监听器、而同一个事件继续冒泡到外层时——外层刚挂上的监听器不应该响应这个「在它挂载之前就已经发生」的事件。判断 e._vts <= invoker.attached 就跳过。这个机制也顺带规避了「不同 iframe/实现的时间戳不一致」(#2513)。getNow 的缓存技巧(同一 tick 内所有 invoker 共享一个 Date.now(),靠一个微任务重置)则是性能优化:避免每个 invoker 都调一次 Date.now()。
(2)数组 handler(value 可以是数组):当有多个 handler 时(如 @click="[a, b]" 或 HOC 合并多个监听器),逐个执行。stopImmediatePropagation 被临时劫持:原生 stopImmediatePropagation 会让「同一元素上的其他监听器」也不再执行——但这里我们想要的是「停止后续的数组 handler」。于是用一个 _stopped 标志代理它,一旦某个 handler 调用了 stopImmediatePropagation,循环 break。value.slice() 先复制数组,防止执行中 value 被改(比如 handler 内部触发更新改了 invoker.value)。
(3)错误处理:通过 callWithAsyncErrorHandling 执行,ErrorCodes.NATIVE_EVENT_HANDLER 让错误能被 app.config.errorHandler 捕获——这也是 createInvoker 需要 instance 参数的原因。
(4)withModifiers/withKeys(vOn.ts:52-71、91-162)是编译产物调用的 helper。它们同样做了「记忆化」:
export const withModifiers = (fn, modifiers) => {
if (!fn) return fn
const cache = fn._withMods || (fn._withMods = {})
const cacheKey = modifiers.join('.')
return cache[cacheKey] || (cache[cacheKey] = ((event, ...args) => {
for (let i = 0; i < modifiers.length; i++) {
const guard = modifierGuards[modifiers[i]]
if (guard && guard(event, modifiers)) return
}
return fn(event, ...args)
}) as T)
}
modifierGuards(vOn.ts:30-47)是一张「修饰符 → 守卫函数」表:stop→e.stopPropagation();prevent→e.preventDefault();self→e.target !== e.currentTarget;ctrl/shift/alt/meta→取反判断(返回真值即「不匹配、直接 return」);left/middle/right→e.button 判断;exact→检查系统修饰键是否有「未被声明的」。守卫函数返回 true 表示「拦截」,于是跳过业务 handler。
withKeys(vOn.ts:91-162)判断 event.key:把 modifiers 与 hyphenate(event.key) 比对(esc→escape、space→' ' 等通过 keyNames 映射)。它同样有 _withKeys 记忆化缓存,避免每次渲染都重建包装函数。
关键点:withModifiers/withKeys 的「记忆化」是在 handler 函数对象自身上挂属性(fn._withMods),而不是挂在元素上。因为编译产物里 handler 通常是组件作用域里的稳定函数,所以「同一函数 + 同一 modifiers 组合」只包装一次。
17.3.5 DOM property vs attribute:shouldSetAsProp 的特例清单
第 4 分支的判断核心是 shouldSetAsProp(patchProp.ts:84-160)。它回答一个问题:某个 key 应该写进 DOM property(el[key] = v)还是 attribute(setAttribute)?
默认规则是「key in el 就写 property」(patchProp.ts:159)——因为 property 是最直接的。但有一长串特例强制走 attribute,原因分几类:
| key / 条件 | 决定 | 原因 |
|---|---|---|
SVG 下 innerHTML/textContent | property | 这两个在 SVG 里也应作为 property |
SVG 下要原生 onclick 且值为函数 | property | SVG 元素要加原生 onclick 函数只能用 property |
| SVG 下其余 key | attribute | SVG 大多数属性只能作为 attribute 生效 |
spellcheck/draggable/translate/autocorrect | attribute | enumerated attribute,其 DOM property 是布尔,写字符串 "false" 会被强转 true |
sandbox 且 IFRAME | attribute | #13946:property 设 null 会变成字符串 'null',空串会启用限制模式 |
form | attribute | #1787/#2840:表单元素的 form property 只读 |
list 且 INPUT | attribute | #1526:<input list> 必须 attribute |
type 且 TEXTAREA | attribute | #2766 |
width/height 且 IMG/VIDEO/CANVAS/SOURCE | attribute | #8780:内嵌标签的宽高必须 attribute |
原生 onclick 且值为字符串 | attribute | 字符串形式的原生 onclick 只能走 attribute(property 期望函数) |
这里最值得讲的是 enumerated attribute 一类。 spellcheck 这类属性的「逻辑类型」是枚举(true/false/''),但对应的 DOM property 恰好是 boolean。如果把 spellcheck="false" 写进 property(el.spellcheck = 'false'),JS 会把非空字符串强转为 true,于是「想关闭拼写检查」反而开了。所以必须走 attribute。
同理,shouldSetAsProp 返回 false 后,patchProp 会把它交给 patchAttr(patchProp.ts:80)。因为 attr 值是字符串,setAttribute('spellcheck', 'false') 才是正确语义。
17.3.6 boolean attribute:includeBooleanAttr 与 patchAttr
patchAttr(attrs.ts:16-47)处理 attribute:
export function patchAttr(el, key, value, isSVG, instance, isBoolean = isSpecialBooleanAttr(key)) {
if (isSVG && key.startsWith('xlink:')) {
if (value == null) el.removeAttributeNS(xlinkNS, key.slice(6, key.length))
else el.setAttributeNS(xlinkNS, key, value)
} else {
if (__COMPAT__ && compatCoerceAttr(el, key, value, instance)) return
if (value == null || (isBoolean && !includeBooleanAttr(value))) {
el.removeAttribute(key)
} else {
el.setAttribute(key, isBoolean ? '' : isSymbol(value) ? String(value) : value)
}
}
}
boolean attribute 的规则:includeBooleanAttr(value)(domAttrConfig.ts:33-35)定义为 !!value || value === ''——即「逻辑真值或空字符串」才算「存在」。于是:
<input disabled>编译成disabled: ''→includeBooleanAttr('') === true→setAttribute('disabled', '')。:disabled="false"→includeBooleanAttr(false) === false→removeAttribute('disabled')。
isSpecialBooleanAttr(domAttrConfig.ts:15-17)只列「名字和对应 DOM property 不一样的 boolean attribute」:itemscope, allowfullscreen, formnovalidate, ismap, nomodule, novalidate, playsinline, readonly。之所以只关心这些「特殊」的,注释解释得很清楚(domAttrConfig.ts:4-14):客户端只需要处理「没有同名 DOM property 可替代」的那些;其余 boolean attribute 因为 key in el 会命中,本就走 property 路径。只在 SSR 时才需要完整的 isBooleanAttr 列表(因为 SSR 要把正确标记序列化进 HTML)。
17.3.7 xlink 与命名空间
SVG 里表示超链接的属性历史上有两种:xlink:href(旧命名空间属性)与 href(SVG2)。xlink: 前缀的属性不能用普通 setAttribute,必须用带命名空间的 setAttributeNS。
patchAttr 里(attrs.ts:14、24-29):
export const xlinkNS = 'http://www.w3.org/1999/xlink'
if (isSVG && key.startsWith('xlink:')) {
if (value == null) el.removeAttributeNS(xlinkNS, key.slice(6, key.length))
else el.setAttributeNS(xlinkNS, key, value)
}
key.slice(6) 是把 xlink: 前缀切掉得到「本地名」(如 href),因为 removeAttributeNS 需要的是 (ns, localName) 而不是带前缀的全名;而 setAttributeNS 允许传带前缀的全名。这套处理只在 isSVG 下生效。
另外注意 createElement 的命名空间(17.2.3)与 patchProp 的 isSVG(patchProp.ts:33)是同一份 namespace 的两个视角:前者决定「怎么创建元素」,后者决定「class/attr 怎么写」。namespace 在渲染器里会沿 patch 路径传播(元素子节点继承父元素的 namespace;Teleport 会按目标容器的类型强制切换,见 17.9)。
17.4 v-model 指令(DOM 侧):四种元素,四套状态机
17.4.1 v-model 的本质:编译产物 + 运行时指令
v-model 不是「一个指令」,而是编译产物 + 运行时指令的组合。以 <input v-model="text"> 为例,编译器会产出大致这样的代码:
withDirectives(
h('input', {
'onUpdate:modelValue': $event => (text.value = $event),
modelValue: text.value, // 视元素不同而不同(checkbox 可能不带)
type: 'text',
}),
[[vModelText, text.value, void 0, { /* modifiers */ }]],
)
也就是说:「把新值写回响应式数据」的桥梁是 onUpdate:modelValue 这个 prop,而「把响应式数据映射到 DOM」的工作由运行时指令 vModelText 承担。 这解释了 17.3.1 里 patchProp 为什么必须跳过 isModelListener——因为这个 prop 只能由指令读,不能当作原生事件挂上。
桥接函数是 getModelAssigner(vModel.ts:21-26):
const getModelAssigner = (vnode: VNode): AssignerFn => {
const fn = vnode.props!['onUpdate:modelValue'] || (__COMPAT__ && vnode.props!['onModelCompat:input'])
return isArray(fn) ? value => invokeArrayFns(fn, value) : fn
}
它从 vnode 的 props 里取出 onUpdate:modelValue;如果是数组(HOC/v-model 与手动监听并存时会合并成数组),就包装成 invokeArrayFns 依次执行。每个 vModel* 指令在 created 里都会 el[assignKey] = getModelAssigner(vnode)——因为元素上后续的事件监听器(如 input 事件)需要回调这个「写回函数」,而元素本身不能直接从 vnode 拿到它。assignKey 是 Symbol('_assign')(vModel.ts:40)。
每个 vModel* 指令还会在 beforeUpdate 里重新执行 el[assignKey] = getModelAssigner(vnode)——因为 vnode.props['onUpdate:modelValue'] 每次渲染都可能是新函数。
17.4.2 vModelText:input/textarea 的完整时序
vModelText(vModel.ts:61-148)覆盖 <input type="text|number|..."> 与 <textarea>。它的三个钩子分工如下:
created(vModel.ts:65-97) 做了五件事:
- hydration 初始值处理(
vModel.ts:66-75):如果el.parentNode存在(说明是 hydration,元素已在 DOM 中),则记录初始值到el[initialValueKey]:input[type=text]的value会剥掉 CR/LF,但defaultValue保留,所以要defaultValue.replace(/[\r\n]/g, '');textarea的value会把 CRLF/CR 规范成 LF,所以defaultValue.replace(/\r\n?/g, '\n')。 这个记录是为了mounted时能对比「浏览器自动填充/服务端预填的值」是否与初始值不同。
- 绑定 assigner:
el[assignKey] = getModelAssigner(vnode)。 - 判断是否转数字:
const castToNumber = number || (vnode.props && vnode.props.type === 'number')——.number修饰符或type="number"都会触发。 - 挂主监听器:
addEventListener(el, lazy ? 'change' : 'input', e => { if (target.composing) return; el[assignKey](castValue(el.value, trim, castToNumber)) })。注意这里有if (target.composing) return——IME 组合输入期间不写回(见下)。 - 补充监听器:
trim || castToNumber时再挂change:把el.value就地按 trim/number 规范化一次(因为input事件写回时值会变,但 DOM 里显示的还没变)。- 非
lazy时挂compositionstart/compositionend与一个额外的change。
castValue(vModel.ts:53-57)就是 trim/number 的封装:trim ? value.trim() : value,number ? looseToNumber(value) : value。looseToNumber(shared/src/general.ts:170-173)用 parseFloat,非数字字符串原样返回。
IME 组合输入(vModel.ts:28-38):
function onCompositionStart(e) { (e.target as any).composing = true }
function onCompositionEnd(e) {
const target = e.target as any
if (target.composing) {
target.composing = false
target.dispatchEvent(new Event('input'))
}
}
输入中文/日文时,浏览器会在组合期间不断触发 input 事件(且 el.value 是「拼音」之类的中间值)。若此时就写回 v-model,用户每敲一个字母都会污染数据;而 presentation 上也会出现「光标跳到末尾」等问题。解决方案:
compositionstart时给元素打composing = true;- 组合期间的
input事件被主监听器里的if (target.composing) return拦截; compositionend时把composing = false,然后手动派发一个input事件——这样主监听器会再执行一次,把最终值写回。
change 事件也挂了 onCompositionEnd:注释(vModel.ts:91-95)解释了原因——Safari < 10.2 与 UIWebView 在「组合未确认就切换焦点」时不派发 compositionend;某些浏览器(iOS Chrome)在自动填充时会派发 change 而非 input。所以 change 也被用作 compositionend 的兜底。
mounted(vModel.ts:99-112):注释说「set value on mounted so it's after min/max for type="range"」(range 类型的 min/max 先设置好再设 value)。逻辑是:
mounted(el, { value, modifiers: { trim, number } }) {
const newValue = value == null ? '' : value
const initialValue = el[initialValueKey]
delete el[initialValueKey]
if (initialValue !== undefined && (el.type === 'text' || el.type === 'textarea') && el.value !== initialValue) {
// 浏览器/服务端预填的值与初始值不同 → 说明是用户/浏览器改的,写回
el[assignKey](castValue(el.value, trim, number))
} else {
el.value = newValue
}
}
beforeUpdate(vModel.ts:113-147) 是「数据变了 → 更新 DOM」的方向:
beforeUpdate(el, { value, oldValue, modifiers: { lazy, trim, number } }, vnode) {
el[assignKey] = getModelAssigner(vnode)
if (el.composing) return // IME 中不更新
const elValue = (number || el.type === 'number') && !/^0\d/.test(el.value)
? looseToNumber(el.value) : el.value
const newValue = value == null ? '' : value
if (elValue === newValue) return // 值没变,跳过
const rootNode = el.getRootNode()
if ((rootNode instanceof Document || rootNode instanceof ShadowRoot) &&
rootNode.activeElement === el && el.type !== 'range') {
if (lazy && value === oldValue) return // #8546 lazy:焦点中且旧值未变,不覆盖
if (trim && el.value.trim() === newValue) return // trim:等价,不覆盖
}
el.value = newValue
}
几个精妙点:
!/^0\d/.test(el.value):像"0123"这样的前导零字符串,looseToNumber会变成123,但这可能与「用户输入的身份证号/电话号码」冲突;所以对前导零的字符串不做 number 转换,避免误判elValue === newValue。elValue === newValue的「避免光标跳动」:如果直接把el.value设成相同的字符串,某些浏览器会把光标移到末尾。所以值相等就 return。activeElement分支(#8546):当元素正被聚焦时,lazy修饰符(旧值未变)或trim后等价(" foo "vs"foo")时也不覆盖,因为聚焦状态下覆盖会打断用户正在进行的输入。getRootNode():同时兼容Document与ShadowRoot(自定义元素/Shadow DOM 内的输入框),这是 17.6 里VueElement场景的必要支持。
17.4.3 vModelCheckbox:数组 / Set / 布尔三种模型
vModelCheckbox(vModel.ts:150-189)比 text 复杂,因为它有三种数据模型:boolean、array、Set。注意它声明了 deep: true(vModel.ts:152)——注释说「#4096 array checkboxes need to be deep traversed」。因为 withDirectives 会对 dir.deep 的值调用 traverse(value)(directives.ts:154-156),强制追踪数组内部元素的变化。
created(vModel.ts:153-182):绑 change 监听器。逻辑按模型分三支:
addEventListener(el, 'change', () => {
const modelValue = el._modelValue
const elementValue = getValue(el)
const checked = el.checked
const assign = el[assignKey]
if (isArray(modelValue)) {
const index = looseIndexOf(modelValue, elementValue)
const found = index !== -1
if (checked && !found) assign(modelValue.concat(elementValue))
else if (!checked && found) {
const filtered = [...modelValue]; filtered.splice(index, 1); assign(filtered)
}
} else if (isSet(modelValue)) {
const cloned = new Set(modelValue)
if (checked) cloned.add(elementValue); else cloned.delete(elementValue)
assign(cloned)
} else {
assign(getCheckboxValue(el, checked))
}
})
- 数组模型:
checked且未包含 →concat新值(不改原数组,创建新数组);未 checked 且已包含 → 复制并splice删除。 - Set 模型:克隆一份 Set 后
add/delete,保证响应式触发(改原 Set 会绕过 Vue 的响应式)。 - 布尔模型:调
getCheckboxValue。
getValue(vModel.ts:348-350)是「取元素的原始值」:'_value' in el ? el._value : el.value。这正是 17.3.1 里 patchDOMProp 写 el._value 的原因——:value 绑定的原值可能是对象/数组/数字,如果只读 el.value(字符串)就丢信息了。
getCheckboxValue(vModel.ts:353-359)处理 :true-value/:false-value:
function getCheckboxValue(el, checked) {
const key = checked ? '_trueValue' : '_falseValue'
return key in el ? el[key] : checked
}
_trueValue/_falseValue 是 17.3.1 里 patchProp 存进去的(patchProp.ts:75-79)。若没设置,就退化为布尔 checked。
mounted/beforeUpdate 共用 setChecked(vModel.ts:191-214):
function setChecked(el, { value, oldValue }, vnode) {
el._modelValue = value // 存一份供 change 监听器读
let checked
if (isArray(value)) checked = looseIndexOf(value, vnode.props!.value) > -1
else if (isSet(value)) checked = value.has(vnode.props!.value)
else {
if (value === oldValue) return
checked = looseEqual(value, getCheckboxValue(el, true))
}
if (el.checked !== checked) el.checked = checked
}
- 数组/Set 模型下,
checked由「vnode.props.value(即:value绑定值)是否在集合里」决定。 - 布尔模型下,用
looseEqual(value, getCheckboxValue(el, true))——即比较value与_trueValue(可能是对象)。 el.checked !== checked才写:避免无谓的 DOM 写。mounted: setChecked而非在created里做,注释解释「set initial checked on mount to wait for true-value/false-value」(vModel.ts:183)——因为:true-value/:false-value是在 patch 属性时写的,必须等它们就位。
17.4.4 vModelRadio:最简单的一个
vModelRadio(vModel.ts:216-230)三行核心逻辑:
created(el, { value }, vnode) {
el.checked = looseEqual(value, vnode.props!.value)
el[assignKey] = getModelAssigner(vnode)
addEventListener(el, 'change', () => el[assignKey](getValue(el)))
},
beforeUpdate(el, { value, oldValue }, vnode) {
el[assignKey] = getModelAssigner(vnode)
if (value !== oldValue) el.checked = looseEqual(value, vnode.props!.value)
},
- 是否选中取决于「v-model 值 ===
:value绑定值」,用looseEqual(宽松相等,允许'1'与1匹配)。 - 变化时把
getValue(el)(原始值,可能是对象)写回。
17.4.5 vModelSelect:三钩子 + _pendingValue 防抖
<select> 特殊在「它的值由子 <option> 决定」,所以 setSelected 必须在子节点都挂载后才执行——这解释了它的 mounted 与 updated。它同样声明 deep: true(vModel.ts:234,注释 <select multiple> value need to be deep traversed)。
created(vModel.ts:235-270) 绑 change:
addEventListener(el, 'change', () => {
const selectedVal = Array.prototype.filter
.call(el.options, (o) => o.selected)
.map((o) => number ? looseToNumber(getValue(o)) : getValue(o))
const multiple = el.multiple
const assignedValue = multiple
? isSet(el._modelValue) ? new Set(selectedVal) : selectedVal
: selectedVal[0]
const pending = (el._pendingValue = [multiple, multiple
? (isArray(assignedValue) ? selectedVal.slice() : selectedVal)
: assignedValue])
try { el[assignKey](assignedValue) }
finally {
nextTick(() => { if (el._pendingValue === pending) el._pendingValue = undefined })
}
})
从所有 option 中筛出 selected 的,取它们的原始值:
- 单选:取第一个。
- 多选 + 数组模型:直接用
selectedVal数组。 - 多选 + Set 模型:克隆成新
Set。
_pendingValue(vModel.ts:251-267)是本节的精髓:它是 [multiple, value] 元组。因为在多选场景下,selectedVal 数组会被业务代码(assign 后的更新回调)原地修改,而 Vue 的更新是异步的(下一个 tick 才 updated)。于是:
- 调
assign之前,先记录_pendingValue,其中数组值拷贝了一份selectedVal.slice()(快照); assign触发数据更新 → 下一个 tick 走updated钩子;updated(vModel.ts:280-291)里比较「当前value是否等于_pendingValue记录的值」;相等就跳过setSelected(因为 DOM 本来就是用户操作后正确的),不等才重新同步。
updated(el, { value }) {
const pending = el._pendingValue
el._pendingValue = undefined
if (!pending || pending[0] !== el.multiple || !isSameSelectValue(value, pending[1], pending[0])) {
setSelected(el, value)
}
}
isSameSelectValue(vModel.ts:293-308)对三种模型做相等判断:单选 looseEqual;数组模型 looseEqual;Set 模型比较 size 与成员包含。这套「快照 + 比对」的设计,避免了「用户刚选完,Vue 更新又把它覆盖回去」的竞态——这是 v-model 在 select 上最容易踩的坑。
mounted(vModel.ts:272-275) 与 beforeUpdate(vModel.ts:276-279):mounted 调 setSelected(el, value);beforeUpdate 更新 _modelValue 与 assigner(不立即 setSelected,因为 option 可能还没 patch 完)。
setSelected(vModel.ts:310-345)遍历所有 option:
for (let i = 0, l = el.options.length; i < l; i++) {
const option = el.options[i]
const optionValue = getValue(option)
if (isMultiple) {
if (isArrayValue) {
const optionType = typeof optionValue
if (optionType === 'string' || optionType === 'number') {
option.selected = value.some(v => String(v) === String(optionValue)) // 快路径
} else {
option.selected = looseIndexOf(value, optionValue) > -1
}
} else option.selected = value.has(optionValue)
} else if (looseEqual(getValue(option), value)) {
if (el.selectedIndex !== i) el.selectedIndex = i
return
}
}
if (!isMultiple && el.selectedIndex !== -1) el.selectedIndex = -1
注意多选里对 string/number 用 String(v) === String(optionValue) 的快路径(避免 looseEqual 的完整对象比较开销),单选里用 selectedIndex 而非逐个 option.selected(selectedIndex 语义更清晰,也会自动取消其他选中)。
17.4.6 动态 v-model 与 SSR:vModelDynamic 与 getSSRProps
vModelDynamic(vModel.ts:361-376)的存在是因为 <input :type="dynamicType" v-model="x"> 这种动态类型。它不能预先决定用哪个子指令,于是把四个钩子都转发给 callModelHook(vModel.ts:396-409),运行时用 resolveDynamicModel(tagName, type)(vModel.ts:378-394)分派:
| tag/type | 使用的模型 |
|---|---|
SELECT | vModelSelect |
TEXTAREA | vModelText |
type="checkbox" | vModelCheckbox |
type="radio" | vModelRadio |
| 其余 | vModelText |
SSR 侧(initVModelForSSR,vModel.ts:413-449):给每个 vModel* 挂 getSSRProps,把「本应由 DOM 指令在运行时做的事」转成 SSR 阶段的 props:
vModelText:({ value }) => ({ value })——直接把值序列化成value属性。vModelRadio:值相等时返回{ checked: true }。vModelCheckbox:数组/Set/布尔三分支决定checked。vModelDynamic:运行时按vnode.type分派到子模型的getSSRProps。
这正是 17.1.6 里 initDirectivesForSSR 在 __SSR__ 构建时调用的东西。
17.4.7 v-model 四元素分支对照表
| 维度 | vModelText | vModelCheckbox | vModelRadio | vModelSelect |
|---|---|---|---|---|
| 适用元素 | input(非 checkbox/radio)、textarea | input[type=checkbox] | input[type=radio] | select |
deep | 否 | 是(数组) | 否 | 是(多选) |
| 主监听事件 | input(.lazy 为 change) | change | change | change |
| 写回值来源 | el.value(trim/number 转换) | 数组/Set/_trueValue/_falseValue | getValue(el) | 选中 option 的值 |
mounted 工作 | 首次设 el.value(含 hydration 初始值比对) | setChecked(等 :true-value 就位) | 无 | setSelected(等 option 就位) |
beforeUpdate 工作 | 比对 elValue/newValue,含光标保护 | setChecked | value !== oldValue 时更新 checked | 更新 _modelValue/assigner |
| 特殊机制 | IME composing、initialValueKey | _modelValue、looseIndexOf | looseEqual | _pendingValue 快照防竞态 |
17.5 vShow 指令:display 的保存、恢复与过渡集成
17.5.1 两个 symbol:_vod 与 _vsh
vShow 的核心难点是「隐藏后再显示时,要恢复成元素原本的 display,而不是写死 block」——因为一个 <span> 的默认 display 是 inline,<table> 是 table,直接写 'block' 会破坏布局。
解决方案是把原始 display 存到一个 symbol 上(vShow.ts:3-4):
export const vShowOriginalDisplay: unique symbol = Symbol('_vod') // vue original display
export const vShowHidden: unique symbol = Symbol('_vsh')
vShowHidden 记录「当前是否处于隐藏状态」。它有两个用途:(1)patchStyle 里读到它,就知道要强制 display: none(见 17.3.3);(2)TransitionGroup 用来跳过隐藏节点(见 17.7.5)。
17.5.2 三个钩子的完整时序
vShow(vShow.ts:12-53)声明了 name: 'show'(用于 hydration 的属性不匹配检查)。
beforeMount(vShow.ts:15-23):
beforeMount(el, { value }, { transition }) {
el[vShowOriginalDisplay] = el.style.display === 'none' ? '' : el.style.display
if (transition && value) {
transition.beforeEnter(el)
} else {
setDisplay(el, value)
}
}
- 保存原始 display:如果当前
style.display是'none',存''(表示「没有显式 display,交给浏览器默认」);否则存当前值。这就是「原始 display」的来源。 - 如果有过渡且初始值是真,走
transition.beforeEnter(el)(只播 enter 的前半段,完整 enter 在mounted),否则直接setDisplay。
mounted(vShow.ts:24-28):if (transition && value) transition.enter(el)——补上 enter 的后半段。
updated(vShow.ts:29-44):
updated(el, { value, oldValue }, { transition }) {
if (!value === !oldValue) return // 逻辑值没变,跳过
if (transition) {
if (value) { transition.beforeEnter(el); setDisplay(el, true); transition.enter(el) }
else transition.leave(el, () => setDisplay(el, false))
} else setDisplay(el, value)
}
注意 !value === !oldValue 是「布尔化后相等」——因为 v-show 的值可能从 1 变成 true,语义没变就不该重播过渡。
过渡与 v-show 的配合:v-show 是「切换 display 而不移除元素」,而 Transition 默认的 leave 会移除元素。所以 BaseTransition 有一个 persisted 概念(BaseTransition.ts:40-43):当 Transition 包裹 v-show 元素时,过渡是「持久化」的——同一元素被 toggle,渲染器不插入/移除,而是由 v-show 手动调 transition.beforeEnter/enter/leave。leave 的第二个参数(回调)会在过渡结束后执行 setDisplay(el, false),所以「隐藏」发生在过渡结束之后。
beforeUnmount(vShow.ts:45-47):setDisplay(el, value)——卸载前把 display 恢复成「当前值对应的状态」,其实是个「把内联样式清干净」的操作(若 value 真则恢复原始 display,否则 none)。
setDisplay(vShow.ts:50-53):
function setDisplay(el, value) {
el.style.display = value ? el[vShowOriginalDisplay] : 'none'
el[vShowHidden] = !value
}
17.5.3 与 patchStyle 的双向联动
vShow 与 patchStyle 有一个微妙的双向关系(见 17.3.3 末尾):
patchStyle读vShowOriginalDisplay:如果一个元素同时有:style和v-show,那么当:style里包含display时,v-show的「原始 display」应该跟随更新(el[vShowOriginalDisplay] = hasControlledDisplay ? style.display : '',style.ts:71-80);如果元素当前是隐藏的(vShowHidden),则v-show优先级更高,强制display: none。- 这保证了「
:style="{display: 'flex'}"+v-show」不会因为v-show恢复成空字符串而丢掉flex。
17.5.4 SSR 下的 v-show
initVShowForSSR(vShow.ts:57-63):
export function initVShowForSSR() {
vShow.getSSRProps = ({ value }) => {
if (!value) return { style: { display: 'none' } }
}
}
SSR 时 v-show 无法访问真实 el.style,于是只做最简单的映射:值为假就把 style: { display: 'none' } 作为 SSR props 输出。注意这会让 SSR 的检查逻辑用「属性存在」判断绑定,接入了 dev 的 hydration mismatch 检查(第 15 章),而 name: 'show' 正是给那个检查用的。
17.6 defineCustomElement 深度:把 Vue 组件包装成自定义元素
17.6.1 三重载与 VueCustomElement 外壳
defineCustomElement(apiCustomElement.ts:65-186)有三组 TS 重载(17.6.1 不展开类型体操),真正实现是:
export function defineCustomElement(options, extraOptions?, _createApp?) {
let Comp = defineComponent(options, extraOptions) as any
if (isPlainObject(Comp)) Comp = extend({}, Comp, extraOptions)
class VueCustomElement extends VueElement {
static def = Comp
constructor(initialProps?: Record<string, any>) {
super(Comp, initialProps, _createApp)
}
}
return VueCustomElement
}
要点:
- 先把 options 规范化成组件定义
Comp(复用defineComponent的类型/规范化);如果Comp是普通对象(而非函数),再把extraOptions合并进去。 - 返回的不是
VueElement,而是一个继承自VueElement的「具体类」。每个defineCustomElement调用都产出独立的类,因为customElements.define(name, Class)需要每个标签名对应一个独立的 constructor(同一个VueElement类只能被 define 一次)。static def = Comp把组件定义挂在类上,供_processMutations之类的静态访问。 - 构造函数把
Comp、initialProps、_createApp传给super。_createApp是给defineSSRCustomElement用的(第 17.1.5 里createSSRApp的角色)。
defineSSRCustomElement(apiCustomElement.ts:189-195)就是「用 createSSRApp 作为 _createApp」的 defineCustomElement——它让自定义元素内部用一个 hydration 渲染器。
17.6.2 VueElement 与 Shadow DOM:构造时的三种根
VueElement(apiCustomElement.ts:203-814)是整个文件的绝对主体。它继承 BaseClass:
const BaseClass = (typeof HTMLElement !== 'undefined' ? HTMLElement : class {}) as typeof HTMLElement
这个守卫是为了让 apiCustomElement.ts 能被非浏览器环境(如 Node 里跑 SSR 逻辑)安全 import——此时退化为空类。构造函数(apiCustomElement.ts:251-281):
constructor(private _def: InnerComponentDef, private _props = {}, private _createApp = createApp) {
super()
if (this.shadowRoot && _createApp !== createApp) {
this._root = this.shadowRoot // SSR 预声明的 shadow root,且是互操作/水合模式
} else {
if (__DEV__ && this.shadowRoot) {
warn(`Custom element has pre-rendered declarative shadow root but is not defined as hydratable. Use defineSSRCustomElement.`)
}
if (_def.shadowRoot !== false) {
this.attachShadow(extend({}, _def.shadowRootOptions, { mode: 'open' }) as ShadowRootInit)
this._root = this.shadowRoot!
} else {
this._root = this // 不用 shadow DOM,直接挂到宿主元素
}
}
}
三种「渲染根(_root)」:
| 场景 | _root | 说明 |
|---|---|---|
| 已有声明式 shadow root + 非默认 createApp | this.shadowRoot | 服务端预渲染了 <template shadowrootmode>,客户端要 hydrate 它 |
shadowRoot !== false(默认) | 新建的 shadow root | 用 attachShadow({mode:'open', ...}) |
shadowRoot: false | this(宿主元素自身) | 不用 shadow DOM,子内容直接挂在自定义元素下 |
注意 mode: 'open' 是被合并覆盖的:即便用户传了 shadowRootOptions: { mode: 'closed' },extend 之后 mode: 'open' 会覆盖它。原因在 normalizeContainer 的同类警告里说过——closed shadow root 会让外部无法访问,导致不可预测的问题。
17.6.3 connectedCallback 与 disconnectedCallback:挂载与延迟卸载
connectedCallback(apiCustomElement.ts:283-325):
connectedCallback(): void {
if (!this.isConnected) return
if (!this.shadowRoot && !this._resolved) this._parseSlots()
this._connected = true
// 沿 DOM 树向上找最近的 Vue 自定义元素父级(provide/inject 用)
let parent: Node | null = this
while ((parent = parent && ((parent as Element).assignedSlot || parent.parentNode || (parent as ShadowRoot).host))) {
if (parent instanceof VueElement) { this._parent = parent; break }
}
if (!this._instance) {
if (this._resolved) {
this._mount(this._def)
} else {
if (parent && parent._pendingResolve) {
this._pendingResolve = parent._pendingResolve.then(() => {
this._pendingResolve = undefined
if (this.isConnected) return this._resolveDef()
})
} else {
this._resolveDef()
}
}
}
}
几个要点:
if (!this.isConnected) return:connectedCallback可能在元素尚未真正连入文档时被触发(如创建后立刻插入),提前返回避免无谓解析。- 找父级用了一个
while循环:它会穿越assignedSlot(被 slot 分发时)、parentNode、host(进入 shadow root 时)三种边界,直到找到一个VueElement。这解决了「provide/inject 能不能跨自定义元素边界」的问题——instance.parent = parent._instance(apiCustomElement.ts:327-332)。 - 异步定义的串行化:如果父元素还在
_pendingResolve(即父元素的组件定义是异步组件、正交涉中),子元素会「等父元素解析完再解析」,避免 provide/inject 的时序问题。这是_pendingResolve链的用途。 - 若已经 mount 过(
_resolved),直接_mount(重新连入时复用);否则_resolveDef。
disconnectedCallback(apiCustomElement.ts:345-363)用了延迟卸载:
disconnectedCallback(): void {
this._connected = false
nextTick(() => {
if (!this._connected) {
if (this._ob) { this._ob.disconnect(); this._ob = null }
this._app && this._app.unmount()
if (this._instance) this._instance.ce = undefined
this._app = this._instance = null
if (this._teleportTargets) { this._teleportTargets.clear(); this._teleportTargets = undefined }
}
})
}
为什么延迟到 nextTick?因为「元素被移动」在 DOM 里表现为「先 disconnected 再 connected」。如果立即卸载,那么把它从一个父节点移到另一个父节点时,Vue 实例会被销毁重建。用 nextTick + _connected 标志:如果下一个 tick 之前元素又连上了(_connected 被重新置 true),就跳过卸载。这是一个非常经典的「用微任务延迟补偿 DOM 语义」的技巧。同时它还会断开 MutationObserver、清空 _teleportTargets。
17.6.4 _resolveDef:属性初始化、异步组件与 number props
_resolveDef(apiCustomElement.ts:374-441)负责「拿到组件定义、完成 prop 类型转换,然后挂载」:
private _resolveDef() {
if (this._pendingResolve) return this._pendingResolve
// 1. 把已存在的 attribute 灌进 _props
for (let i = 0; i < this.attributes.length; i++) this._setAttr(this.attributes[i].name)
// 2. 监听未来 attribute 变化
this._ob = new MutationObserver(this._processMutations.bind(this))
this._ob.observe(this, { attributes: true })
const resolve = (def: InnerComponentDef, isAsync = false) => {
this._resolved = true
this._pendingResolve = undefined
const { props, styles } = def
// 3. 把「解析前设进来的 Number 类型 prop」做数值转换
let numberProps
if (props && !isArray(props)) {
for (const key in props) {
const opt = props[key]
if (opt === Number || (opt && opt.type === Number)) {
if (key in this._props) this._props[key] = toNumber(this._props[key])
;(numberProps || (numberProps = Object.create(null)))[camelize(key)] = true
}
}
}
this._numberProps = numberProps
this._resolveProps(def)
// 4. 注入样式
if (this.shadowRoot) this._applyStyles(styles)
else if (__DEV__ && styles) warn('Custom element style injection is not supported when using shadowRoot: false')
// 5. 若仍 connected,则挂载(可能在异步定义加载期间被移除)
if (this._connected) this._mount(def)
}
const asyncDef = (this._def as ComponentOptions).__asyncLoader
if (asyncDef) {
this._pendingResolve = asyncDef().then((def) => {
def.configureApp = this._def.configureApp
resolve((this._def = def), true)
})
return this._pendingResolve
} else {
resolve(this._def)
}
}
要点:
- attribtue → prop 的初始灌入:遍历
this.attributes,逐个_setAttr。这是「HTML 属性先于 JS 存在」的情况(用户写<my-el foo="bar">)。 - MutationObserver:观察自身
attributes,任何后来的属性变化都走_processMutations→_setAttr。这是「HTML attribute → Vue prop」的持续同步通道。 - Number 类型 prop 的特殊处理:之所以需要
_numberProps,是因为<my-el count="3">里count是字符串"3",而组件声明count: Number。在定义解析后,要把已经存在_props里的字符串转成数字,并记录哪些 camelCase key 是 number 类型(供将来_setAttr时转换)。 - 异步组件:
__asyncLoader存在时,先await它,拿到真正的 def 后替换this._def再 resolve。这里def.configureApp = this._def.configureApp是把配置函数「转运」到内层 def——因为异步包装组件的configureApp需要在内层被调用。 this._connected守卫:如果「异步定义加载期间元素被移除了」,则跳过挂载;等它重新连上时由connectedCallback再挂。
17.6.5 _resolveProps/_setAttr/_setProp/_getProp:双向映射与反射
_resolveProps(apiCustomElement.ts:473-501)为每个声明的 prop 定义 getter/setter:
private _resolveProps(def: InnerComponentDef) {
const { props } = def
const declaredPropKeys = isArray(props) ? props : Object.keys(props || {})
// 属性可能「先于升级」被设置到实例上(如 myEl.foo = 1 在 customElements.define 之前)
for (const key of Object.keys(this)) {
if (key[0] !== '_' && declaredPropKeys.includes(key)) this._setProp(key, this[key])
}
for (const key of declaredPropKeys.map(camelize)) {
if (__DEV__ && key in Object.getPrototypeOf(this)) {
warn(`Custom element prop "${key}" conflicts with an existing property on the element and will overwrite it.`)
}
Object.defineProperty(this, key, {
get() { return this._getProp(key) },
set(val) { this._setProp(key, val, true, !this._patching) },
})
}
}
这里处理了两个「升级时机」问题:
- pre-upgrade property:在
customElements.define之前,用户可能已经写了document.createElement('my-el').foo = 1。此时实例上有个普通自有属性foo。_resolveProps把这个自有属性读出来灌进_props。(customElements规范叫 "lazy properties" 模式。) - getter/setter:为每个 camelCase key 定义访问器。
get读_props;set调_setProp(key, val, true, !this._patching)——shouldReflect=true(要把值反射回 attribute),shouldUpdate=!this._patching(正在 patch 时不触发更新,避免循环)。
_setAttr(apiCustomElement.ts:503-512)是 attribute → prop:
protected _setAttr(key: string): void {
if (key.startsWith('data-v-')) return // 忽略 scoped 属性
const has = this.hasAttribute(key)
let value = has ? this.getAttribute(key) : REMOVAL
const camelKey = camelize(key)
if (has && this._numberProps && this._numberProps[camelKey]) value = toNumber(value)
this._setProp(camelKey, value, false, true) // shouldReflect=false(本就是 attr 来的),shouldUpdate=true
}
REMOVAL(apiCustomElement.ts:47)是个哨兵对象,表示「属性被移除了」。shouldReflect=false 关键——attribute 变化由 HTML 发起,不需要再反射回去(否则会死循环)。shouldUpdate=true 让 Vue 实例立即更新。
_setProp(apiCustomElement.ts:524-561)是双向的核心:
_setProp(key, val, shouldReflect = true, shouldUpdate = false): void {
if (val !== this._props[key]) {
this._dirty = true
if (val === REMOVAL) delete this._props[key]
else {
this._props[key] = val
if (key === 'key' && this._app) this._app._ceVNode!.key = val // key 特殊性
}
if (shouldUpdate && this._instance) this._update()
if (shouldReflect) {
const ob = this._ob
if (ob) { this._processMutations(ob.takeRecords()); ob.disconnect() } // 避免观察到自己写入的 attr
if (val === true) this.setAttribute(hyphenate(key), '')
else if (typeof val === 'string' || typeof val === 'number') this.setAttribute(hyphenate(key), val + '')
else if (!val) this.removeAttribute(hyphenate(key))
ob && ob.observe(this, { attributes: true })
}
}
}
反射(reflect)逻辑:prop 的布尔真值 → 空字符串 attribute;字符串/数字 → String(val);其他 falsy → removeAttribute。注意对象/数组/函数类型的 prop 不会被反射(因为 attribute 只能存字符串)。
MutationObserver 的「临时摘除」技巧:_setProp 在写 attribute 之前先 ob.disconnect(),写完再 ob.observe。目的是避免 MutationObserver 把「自己刚反射出去的 attribute」当作外部变化、触发 _setAttr 而形成回环。中途还 _processMutations(ob.takeRecords()) 把队列里已有的记录消化掉。
_getProp(apiCustomElement.ts:517-519)就是 return this._props[key]。
17.6.6 _mount/_createVNode:把自定义元素变成一个 Vue 应用
_mount(apiCustomElement.ts:443-471):
private _mount(def: InnerComponentDef) {
if ((__DEV__ || __FEATURE_PROD_DEVTOOLS__) && !def.name) def.name = 'VueElement'
this._app = this._createApp(def)
this._inheritParentContext()
if (def.configureApp) def.configureApp(this._app)
this._app._ceVNode = this._createVNode()
this._app.mount(this._root)
// expose
const exposed = this._instance && this._instance.exposed
if (!exposed) return
for (const key in exposed) {
if (!hasOwn(this, key)) Object.defineProperty(this, key, { get: () => unref(exposed[key]) })
else if (__DEV__) warn(`Exposed property "${key}" already exists on custom element.`)
}
}
- 每个自定义元素实例创建一个独立的 app(
this._createApp(def))。这与「一个页面一个 app」的普通模式不同——自定义元素的粒度就是一个 app。 _inheritParentContext(apiCustomElement.ts:334-343):把当前 app 的_context.provides对象的原型设为父元素实例的provides。这样在子自定义元素里能inject到父元素的 provide。configureApp:用户可通过该选项在挂载前配置 app(如注册组件、插件)。- expose:组件
expose出来的东西被定义到自定义元素实例上(get: () => unref(...),自动解包 ref)。这解释了「为什么自定义元素上能直接访问组件内部方法」。
_createVNode(apiCustomElement.ts:569-621)构造 ceVNode 并注册 vnode.ce:
private _createVNode(): VNode<any, any> {
const baseProps: VNodeProps = {}
if (!this.shadowRoot) {
baseProps.onVnodeMounted = baseProps.onVnodeUpdated = this._renderSlots.bind(this)
}
const vnode = createVNode(this._def, extend(baseProps, this._props))
if (!this._instance) {
vnode.ce = instance => {
this._instance = instance
instance.ce = this
instance.isCE = true // for vue-i18n backwards compat
// HMR(仅 dev)
if (__DEV__) {
instance.ceReload = newStyles => {
if (this._styles) { this._styles.forEach(s => this._root.removeChild(s)); this._styles.length = 0 }
this._styleAnchors.delete(this._def)
this._applyStyles(newStyles)
this._instance = null
this._update()
}
}
const dispatch = (event: string, args: any[]) => {
this.dispatchEvent(new CustomEvent(event,
isPlainObject(args[0]) ? extend({ detail: args }, args[0]) : { detail: args }))
}
// 拦截 emit:同时派发原始与连字符事件名
instance.emit = (event: string, ...args: any[]) => {
dispatch(event, args)
if (hyphenate(event) !== event) dispatch(hyphenate(event), args)
}
this._setParent()
}
}
return vnode
}
vnode.ce是渲染器的一个魔术字段:渲染器在挂载组件时会调用vnode.ce(instance)(见第 13 章)。自定义元素正是利用它「在组件实例创建的那一刻拿到它」。instance.ce = this:把VueElement挂到组件实例上,让useHost/useShadowRoot(17.6.10)能拿到宿主元素。- emit 拦截:自定义元素没有 Vue 的父子通信,只有 DOM
CustomEvent。所以把instance.emit覆盖成「派发 DOM 事件」。dispatch会把参数包进detail;如果第一个参数是普通对象,还会把它的键合并进事件对象(extend({ detail: args }, args[0]))。同时派发原始名与连字符名(someEvent与some-event),因为 HTML 属性里事件名约定是连字符,而 JS 里习惯用驼峰。 !this.shadowRoot时给onVnodeMounted/onVnodeUpdated:因为不用 shadow DOM 时,slot 分发要靠手动_renderSlots(17.6.8)。_setParent(apiCustomElement.ts:327-332):把实例的parent设为父自定义元素实例,并继承 context。
17.6.7 _applyStyles:Shadow DOM 内的样式注入与锚点管理
_applyStyles(apiCustomElement.ts:623-670)把 SFC 编译出的 <style> 字符串注入到 shadow root 内:
private _applyStyles(styles, owner?, parentComp?) {
if (!styles) return
if (owner) {
if (owner === this._def || this._styleChildren.has(owner)) return
this._styleChildren.add(owner)
}
const nonce = this._nonce
const root = this.shadowRoot!
const insertionAnchor = parentComp
? this._getStyleAnchor(parentComp) || this._getStyleAnchor(this._def)
: this._getRootStyleInsertionAnchor(root)
let last: HTMLStyleElement | null = null
for (let i = styles.length - 1; i >= 0; i--) {
const s = document.createElement('style')
if (nonce) s.setAttribute('nonce', nonce)
s.textContent = styles[i]
root.insertBefore(s, last || insertionAnchor)
last = s
if (i === 0) {
if (!parentComp) this._styleAnchors.set(this._def, s)
if (owner) this._styleAnchors.set(owner, s)
}
if (__DEV__) { /* 记录到 _styles / _childStyles,供 HMR */ }
}
}
要点:
owner去重:_styleChildren是个WeakSet,记录「已经注入过样式的子组件」。同一个子组件被多次实例化时,样式只注入一次。- 倒序遍历插入:从最后一个样式往前插(
i--,每次都insertBefore(s, last)),这样最终顺序与styles数组一致。insertBefore(s, insertionAnchor)保证新的<style>插在「锚点之前」——锚点是「第一个非<style>节点」(_getRootStyleInsertionAnchor,apiCustomElement.ts:686-694)。 _styleAnchors(WeakMap<ConcreteComponent, HTMLStyleElement>):记录「某组件的样式锚点」,让后续子组件样式能插到父组件样式之前/之后,维持层叠顺序(_getStyleAnchor,apiCustomElement.ts:672-684)。nonce:用于 CSP 场景(<style nonce="...">)。- HMR:
_styles(根样式)与_childStyles(按__hmrId分组的子样式)在 dev 下记录,配合ceReload清理重注入。
17.6.8 Slots(shadowRoot: false 场景)
当 shadowRoot: false 时,自定义元素没有 shadow DOM,所以 <slot> 的原生分发机制不可用。Vue 用一套手动方案:
_parseSlots(apiCustomElement.ts:699-708):遍历宿主元素的所有子节点,按 slot 属性分组,然后把它们摘下来(removeChild)暂存到 this._slots:
private _parseSlots() {
const slots = (this._slots = {})
let n
while ((n = this.firstChild)) {
const slotName = (n.nodeType === 1 && n.getAttribute('slot')) || 'default'
;(slots[slotName] || (slots[slotName] = [])).push(n)
this.removeChild(n)
}
}
_renderSlots(apiCustomElement.ts:713-740)在组件 mounted/updated 时把暂存内容插回 <slot> 元素的位置:
_getSlots(apiCustomElement.ts:745-760)收集宿主 + 所有_teleportTargets里的<slot>元素。- 对每个 outlet,把它对应的
content插到<slot>之前,然后移除<slot>。 :slotted样式的 scopeId 处理:如果组件有 scopeId,给插入的内容节点打上scopeId + '-s'(-s后缀表示 slotted),并用TreeWalker深度遍历所有后代也打上。
这套「手动 slot + -s scopeId」正是 shadowRoot: false 模式能支持 <style scoped> 里 :slotted 选择器的原因。
17.6.9 _beginPatch/_endPatch/_dirty:批处理属性更新
考虑一个自定义元素同时收到多个 prop 变化(如 el.a = 1; el.b = 2)。如果每次都 _update(),就会有多次渲染。_beginPatch/_endPatch(apiCustomElement.ts:775-788)解决这个问题:
_beginPatch(): void { this._patching = true; this._dirty = false }
_endPatch(): void {
this._patching = false
if (this._dirty && this._instance) this._update()
}
_setProp的shouldUpdate参数是!this._patching——patch 期间不立即更新,只置_dirty。_endPatch时若有_dirty才统一更新一次。渲染器在挂载自定义元素时会调用这两个方法(配合vnode.ce)。
_update(apiCustomElement.ts:563-567)用 render() 重新渲染 ceVNode:
private _update() {
const vnode = this._createVNode()
if (this._app) vnode.appContext = this._app._context
render(vnode, this._root)
}
注意 _createVNode 每次都会重建一个带最新 _props 的 vnode;但 if (!this._instance) 保证了 ce 回调只在首次注册,后续 vnode 复用同一个 _instance。
17.6.10 useHost/useShadowRoot
useHost(apiCustomElement.ts:816-834):
export function useHost(caller?: string): VueElement | null {
const instance = getCurrentInstance()
const el = instance && (instance.ce as VueElement)
if (el) return el
else if (__DEV__) { /* 两种告警:无实例 / 不在 defineCustomElement 组件里 */ }
return null
}
它读 instance.ce(17.6.6 里由 vnode.ce 设置),是「在组件里拿到宿主自定义元素」的唯一入口。useShadowRoot(apiCustomElement.ts:840-843)就是 useHost().shadowRoot。
17.6.11 customElements.define 与「去重」
defineCustomElement 返回的是一个类,用户需要自己调 customElements.define('my-element', MyElement)。浏览器规范规定:同一个标签名不能重复 define,否则抛 NotSupportedError。 所以常见模式是:
if (!customElements.get('my-element')) {
customElements.define('my-element', MyElement)
}
要注意的是:defineCustomElement 本身不做注册,也没有全局去重表——每个类都是新的。这意味着「热更新时反复 define 同名标签」会抛错,是 HMR 需要处理的问题。
17.6.12 自定义元素生命周期时序
sequenceDiagram
participant HTML as 浏览器 HTML 解析
participant CE as VueElement
participant VN as vnode.ce 回调
participant App as 内部 Vue App
HTML->>CE: constructor(def, props, createApp)
CE->>CE: attachShadow / _root
HTML->>CE: connectedCallback()
CE->>CE: _parseSlots + 找父 VueElement
CE->>CE: _resolveDef() → MutationObserver + 初始 attr
Note over CE: 若 __asyncLoader,await
CE->>CE: _resolveProps + _applyStyles
CE->>App: _mount → createApp + mount(_root)
App->>VN: 挂载组件时调用 vnode.ce(instance)
VN->>CE: _instance/ce 绑定, 拦截 emit, _setParent
HTML->>CE: el.foo = 1 (setter)
CE->>CE: _setProp(foo, 1, reflect=true, update=true)
CE->>CE: setAttribute('foo', '1') (反射)
HTML->>CE: disconnectedCallback()
CE->>CE: nextTick 延迟判断 → app.unmount()
17.7 BaseTransition 与 Transition:CSS 过渡的运行时状态机
过渡系统是「分层」的:runtime-core 的 BaseTransition 只负责何时调钩子,runtime-dom 的 Transition/TransitionGroup 负责怎么用 CSS 实现动画。这层分离让 BaseTransition 能在自定义渲染器上复用,而 Transition 可放心依赖 classList、getComputedStyle、requestAnimationFrame。
17.7.1 分层:BaseTransition 只谈钩子,Transition 才谈 CSS
BaseTransition 是一个普通组件(runtime-core/src/components/BaseTransition.ts:143),它的 setup 返回渲染函数,做三件事:
- 从
slots.default()里筛出唯一非注释子节点(findNonCommentChild,BaseTransition.ts:278);<transition>只能包一个元素/组件,多个非注释子节点在__DEV__下告警。 - 为子节点调用
resolveTransitionHooks生成一组TransitionHooks,挂到child.transition上(setTransitionHooks,BaseTransition.ts:539)。渲染器在挂载/卸载该 vnode 时读这个字段来执行动画。 - 处理
mode:如果新子树与旧子树类型不同,为旧子树也生成一份 leaving hooks,并据此决定是「先出后进」还是「先进后出」。
Transition 则是一个函数式组件(runtime-dom/src/components/Transition.ts:87),实际就是 h(BaseTransition, resolveTransitionProps(props), slots)——把 DOM 专属的 name/type/duration/各类 *-class 翻译成 BaseTransition 认识的 onBeforeEnter/onEnter/onLeave 等钩子(Transition.ts:121)。
17.7.2 TransitionHooks 接口
BaseTransition.ts:63 定义了渲染器与过渡系统之间的契约:
export interface TransitionHooks<HostElement = RendererElement> {
mode: BaseTransitionProps['mode']
persisted: boolean
beforeEnter(el: HostElement): void
enter(el: HostElement): void
leave(el: HostElement, remove: () => void): void
clone(vnode: VNode): TransitionHooks<HostElement>
afterLeave?(): void // mode=out-in 时,leave 结束后触发的回调
delayLeave?(el, earlyRemove, delayedLeave): void // mode=in-out 的延迟卸载
delayedLeave?(): void
}
关键点:enter 与 leave 都是幂等且可被取消的。每个元素上用两个 symbol 存放「当前进行中回调」:
enterCbKey(BaseTransition.ts:31):元素正在进场时挂的回调,参数是cancelled?。leaveCbKey(BaseTransition.ts:30,被导出,TransitionGroup也引用):元素正在离场时挂的回调。
调用 el[enterCbKey](true) 即「取消本次进场」,会触发 onEnterCancelled;调用 el[leaveCbKey]() 即「立即完成离场」。这正是同一元素上「进场没完就要离场」时避免状态错乱的核心机制。
17.7.3 三种 mode 的切换逻辑(BaseTransition.ts:209-267)
当 oldInnerChild(旧子树根)与 innerChild(新子树根)类型不同时,setup 渲染函数按 mode 分支:
mode | 行为 | 关键实现 |
|---|---|---|
default | 旧树与新树同时在 DOM 中,各自播放 enter/leave | 仅设置 oldInnerChild = undefined,不做延迟 |
out-in | 先把旧树离场完,再挂载新树 | state.isLeaving = true,返回空占位;leavingHooks.afterLeave 里把 isLeaving 复位、instance.update() 重新渲染 |
in-out | 新树先入场,完成后旧树再离场 | 给 leaving hooks 挂 delayLeave:入场未完成前不调用 delayedLeave |
out-in 的实现很精妙:instance.subTree 是新树(含新内容),但 state.isLeaving 为真时渲染函数直接返回 emptyPlaceholder(BaseTransition.ts:182),于是这次 patch 实际上什么都不挂载;等旧树的 afterLeave 触发,instance.update() 再跑一次,此时 isLeaving 已复位,才真正挂载新树。emptyPlaceholder 对 KeepAlive 有特判(BaseTransition.ts:502):把 KeepAlive 的 children 置空克隆,避免 KeepAlive 实例在离场阶段被卸载。
in-out 的 delayLeave(BaseTransition.ts:239)会登记到 state.leavingVNodes(按 vnode.key 索引),并设置 el[leaveCbKey] 用于「提前移除」。入场完成后 enter 里的 el[enterCbKey] 回调会调用 hooks.delayedLeave(),此时才真正把旧树移除。
17.7.4 Transition 的 CSS 类名时序
resolveTransitionProps(Transition.ts:121)把默认类名按 name(默认 v)展开:v-enter-from / v-enter-active / v-enter-to,leave 与 appear 同理。四类钩子的类名操作时序如下(Transition.ts:230-297):
onBeforeEnter(el): add enter-from, add enter-active
onEnter(el): callHook(onEnter) // 用户钩子
nextFrame(() => {
remove enter-from
add enter-to
if (用户钩子没声明 done 参数) whenTransitionEnds(el, ...)
})
finishEnter(el): remove enter-to, remove enter-active → onAfterEnter
onLeave 更讲究(Transition.ts:249):
add leave-from
if (!el._enterCancelled) { forceReflow(el); add leave-active }
else { add leave-active; forceReflow(el) }
nextFrame(() => {
if (!el._isLeaving) return // 期间被取消
remove leave-from; add leave-to
if (!hasExplicitCallback(onLeave)) whenTransitionEnds(...)
})
两处注释值得抄下来:forceReflow 在 add leave-from 之后调用,是为了让 *-leave-from 的初始态立即生效(否则浏览器会把起始样式与结束样式合并,看不到过渡,#2593);而 el._enterCancelled 分支先加 leave-active 再 reflow,是为了让「进场被取消后紧接离场」的场景下最终态不被吃掉(#10677)。
hasExplicitCallback(Transition.ts:111)判断用户钩子是否声明了 done 参数(hook.length > 1):若声明了,说明用户要手动控制结束时刻,运行时不自动调用 whenTransitionEnds。
17.7.5 whenTransitionEnds / getTransitionInfo / forceReflow / nextFrame
nextFrame(Transition.ts:340)是双层 requestAnimationFrame:一层让浏览器完成「旧样式已应用」的绘制,第二层再改类——只有跨过一次绘制帧,transition 才会被真正触发。
whenTransitionEnds(Transition.ts:348)用 el._endId = ++endId 做「世代标记」,resolveIfNotStale 只在 id 未被后续调用覆盖时才 resolve——这正是「同一元素连续多次过渡时,旧过渡的回调不得 resolve 新过渡」的保护。逻辑分三层:
- 有显式
duration→setTimeout(resolveIfNotStale, explicitTimeout)。 - 否则
getTransitionInfo探测transitionend/animationend事件数与超时,监听endEvent,用propCount计数;加timeout + 1的兜底setTimeout(浏览器可能因 tab 隐藏等不触发transitionend)。 - 完全探测不到过渡(
!type)→ 立即resolve()。
getTransitionInfo(Transition.ts:400)用 getComputedStyle 读 transitionDelay/Duration/Property 与 animation*,getTimeout 把 delay+duration 相加取最大;hasTransform 用 /\b(?:transform|all)(?:,|$)/ 判断——该标记被 TransitionGroup 复用。
forceReflow(Transition.ts:478)就一行:读 body.offsetHeight 强制同步布局。它同时服务于「让起始类立即生效」与「FLIP 中把元素先摆回原位再动画」。
17.7.6 TransitionGroup 的 FLIP(runtime-dom/src/components/TransitionGroup.ts)
<TransitionGroup> 没有 mode(decorate 里 delete t.props.mode,TransitionGroup.ts:66),它要解决的是列表增删后其余元素的位置补间。这用经典 FLIP(First-Last-Invert-Play)实现,三张表分工:
positionMap(TransitionGroup.ts:46):渲染(patch 前)记录每个旧子节点的位置getBoundingClientRect。newPositionMap(TransitionGroup.ts:47):onUpdated里记录位置。pendingGroups(TransitionGroup.ts:52):已重渲染但尚未跑 move 的TransitionGroup实例 → 它的willMove(),供嵌套分组查询。
onUpdated(TransitionGroup.ts:104)的执行顺序被刻意拆成三个循环,注释点明是为了「避免每次迭代里混合 DOM 读写,防止 layout thrashing(布局抖动)」:
prevChildren.forEach(callPendingCbs):先结算上一轮的 move/enter 回调。prevChildren.forEach(c => recordPosition(c, shift)):读取新位置(shift见下)。prevChildren.filter(applyTranslation):写入transform。
applyTranslation(TransitionGroup.ts:240)计算 dx/dy,并处理元素自身 scale(rect.width / el.offsetWidth)避免缩放噪声,写入 translate(dx/scaleX, dy/scaleY) 与 transitionDuration='0s'——先「把新位置视觉上拉回旧位置」。随后 forceReflow,再给这些元素加 moveClass(默认 v-move)并清空 transform:起始帧在旧位置,结束帧回到新位置,CSS 里 v-move { transition: transform } 就自动补间。监听 transitionend 后移除 moveClass(TransitionGroup.ts:135),且用 el[moveCbKey] 防止重复监听。
getOrigins/getOriginShift(TransitionGroup.ts:275/306)处理嵌套 TransitionGroup:外层分组自身的位移要扣掉,否则内层元素双重动画。hasCSSTransform(TransitionGroup.ts:311)克隆节点、只留 moveClass 量测样式,判断该分组是否真定义了 move 过渡(否则整条 FLIP 短路)——这也是 willMove 懒探测(TransitionGroup.ts:90)的原因。
17.7.7 vtcKey 与 v-show 的 persisted 模式
addTransitionClass/removeTransitionClass(Transition.ts:321/329)在改 classList 的同时,把类名记进 el[vtcKey](一个 Set,Transition.ts:35)。为什么?因为过渡期间如果在同一元素上又发生 patchProp 的 class 更新,DOM 上的 class 可能被整体覆盖,过渡类会丢失。patchClass 在写入前会保护 vtcKey 里记录的类(详见 17.3.2)。
<Transition> 与 v-show 配合依赖 persisted 模式:BaseTransitionProps.persisted(BaseTransition.ts:42)为真时,说明这个「过渡」不真的插入/删除元素,而是切换显示状态;渲染器会跳过 enter/leave 的插入逻辑,改由 v-show 指令主动调用注入的钩子。vShow 在元素挂载时把 transition.beforeEnter 与 transition.enter 包装后分别赋给 transition.enter 与 transition.afterLeave(细节见 17.5.2),从而在第一次显示时也能播进场动画。
17.7.8 过渡的运行时状态机
stateDiagram-v2
[*] --> Mounting
Mounting --> Entering: renderer 读 vnode.transition.enter()
Entering --> Entered: whenTransitionEnds → resolve → onAfterEnter
Entering --> Entered: 用户钩子显式 done()
Entered --> Leaving: patch 移除/切换 → transition.leave()
Leaving --> Left: remove() + onAfterLeave
Entering --> Cancelled: 同元素再次 leave → el[enterCbKey](true)
Cancelled --> Leaving
Leaving --> Left: el[leaveCbKey]() 提前完成
状态由每元素上的 enterCbKey/leaveCbKey 承载——Vue 没有集中式状态机对象,而是把状态「附着」在 DOM 元素上。好处是同一元素的身份天然唯一,坏处是必须靠 symbol 防命名冲突(普通属性可能与用户代码或其它库撞名)。
17.8 KeepAlive 深度:一块带 LRU 的 vnode 缓存
<KeepAlive> 让组件在切走时不被卸载,而是把整棵子树「搬进」一个离屏容器保存;切回来时再搬回来,从而保留组件状态(表单输入、滚动位置、内部 timer 等)。
17.8.1 三个数据结构
runtime-core/src/components/KeepAlive.ts:111 起:
const cache: Cache = new Map() // key → vnode
const keys: Keys = new Set() // 记录 key 的新旧顺序(LRU 依据)
let current: VNode | null = null // 当前正在展示的 vnode
外加一个 storageContainer = createElement('div')(KeepAlive.ts:129):离屏容器,被缓存但当前不展示的组件就「住」在这里。注意它是个游离的 div,不插入文档;组件 DOM 只是被 move 进这个容器,因此不会渲染、但实例与 DOM 都保留。
17.8.2 缓存 key 与 pendingCacheKey
在渲染函数(KeepAlive.ts:281)里:
const key = vnode.key == null ? comp : vnode.key // 无 key 时以组件类型为 key
用组件类型对象作 Map 的 key(KeepAlive.ts:334),无 key 时「同类型只缓存一个」。这里不立即 cache.set,而记下 pendingCacheKey,等 onMounted/onUpdated 再缓存 instance.subTree(cacheSubtree,KeepAlive.ts:245):vnode 可能因 attr fallthrough 或 scopeId 被克隆,缓存最终子树才可靠(#1511)。子树是 Suspense 时还要等其解析后再缓存(KeepAlive.ts:250)。
17.8.3 LRU 推演
max 属性触发 LRU。命中缓存时(KeepAlive.ts:351):
vnode.el = cachedVNode.el
vnode.component = cachedVNode.component
vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
keys.delete(key); keys.add(key) // 移到 Set 末尾 = 最新
未命中且超限时(KeepAlive.ts:364):
keys.add(key)
if (max && keys.size > parseInt(max, 10)) {
pruneCacheEntry(keys.values().next().value!) // 淘汰 Set 首个 = 最旧
}
keys 用 Set 顺序天然表达「插入序」,把命中项删了再加即等价于把链表节点移到尾部——这就是 O(1) 的 LRU 维护,无需手写双向链表。pruneCacheEntry(KeepAlive.ts:218)对非当前项直接 unmount,对当前项只 resetShapeFlag(因为此刻不能卸载,否则页面会闪),但仍 cache.delete/keys.delete。
17.8.4 include/exclude
matches(KeepAlive.ts:399)支持字符串(逗号分隔精确匹配)、正则(lastIndex = 0 后 test)、数组(递归 some)。名字来自 getComponentName;若是异步包装组件,取 __asyncResolved(KeepAlive.ts:316)以拿到内层真实名字(#11717)。
过滤逻辑(KeepAlive.ts:324):(include && 不匹配) || (exclude && 匹配) → 不缓存,仍原样渲染并清除 COMPONENT_SHOULD_KEEP_ALIVE 位。响应式改 include/exclude 触发 watch(KeepAlive.ts:233,flush: 'post'、deep)调 pruneCache,在渲染后执行以确保 current 已更新。
17.8.5 activate/deactivate 与 shapeFlag 位
KeepAlive 自己不 import 渲染器,而是渲染器把内部方法(patch/move/unmount 等)通过 instance.ctx 注入(KeepAlive.ts:100),再由 KeepAlive 暴露 activate/deactivate(KeepAlive.ts:131/169)。这是为了解决 tree-shaking:渲染器里只用 isKeepAlive(检查 type.__isKeepAlive,KeepAlive.ts:76)判断,不直接 import 组件。
两个 ShapeFlags 位:COMPONENT_SHOULD_KEEP_ALIVE(卸载时改由 KeepAlive 存起来)、COMPONENT_KEPT_ALIVE(不是新挂载,走 activate 复用分支)。
activate 先 move 回原容器、再 patch 一次(props 可能变了),最后 post-render 清 isDeactivated 并触发 onActivated(KeepAlive.ts:131);deactivate 先 invalidateMount 掉 m/a(避免重复触发 mounted/activated),move 进 storageContainer,post-render 触发 onDeactivated;卸载前先 resetShapeFlag(KeepAlive.ts:197)。
17.8.6 onActivated/onDeactivated 的 __wdc 包装
registerKeepAliveHook(KeepAlive.ts:426)用 __wdc(with deactivation check)缓存包装函数:执行前沿 parent 链向上检查,只要某层 isDeactivated 为真就跳过——保证被缓存子树里注册的 activated 钩子不在展示时机被吞掉。hook 还会向上注入到最近的 KeepAlive 根实例(injectToKeepAliveRoot,KeepAlive.ts:464),缩短重新激活的调用链;__wdc 的相同引用也让 hook 在调度器里能正确去重。
17.9 Teleport 深度:把 vnode 的「家」搬到另一个容器
<Teleport to="#modal"> 让内容在 DOM 里渲染到别处,但在组件逻辑上仍属于原组件(props、事件、provide/inject 都不变)。runtime-core/src/components/Teleport.ts 通过 process/remove/move/hydrate 四个入口接入渲染器。
17.9.1 target 解析
resolveTarget(Teleport.ts:45):to 为字符串时用渲染器的 querySelector 查找(找不到且未 disabled 在 __DEV__ 告警);to 直接是元素则原样返回。
mount 时把 target 缓存在 vnode.target,并由 prepareAnchor(Teleport.ts:576)在目标容器里插入两个空文本节点 targetStart/targetAnchor 作为边界。targetStart[TeleportEndKey] 指向 targetAnchor(Teleport.ts:588)——这个 symbol 让渲染器在 nextSibling 搜索时能跳过整段 teleport 内容(详见 17.2.5)。命名空间还会按目标是否 SVG/MathML 自动切换(Teleport.ts:137),支持「从 HTML 树 teleport 进 SVG 树」。
17.9.2 disabled 与 defer
disabled(isTeleportDisabled,Teleport.ts:33,true或''都算):内容不搬走,留在原地(主视图)渲染。defer(Teleport.ts:36)或父 Suspense 处于 pending:挂载被推迟,通过queuePendingMount(Teleport.ts:160)把一个SchedulerJob塞进 post-render 队列(pendingMountsWeakMap 记录),等队列 flush 时再mountToTarget。
17.9.3 process 的分支
首次挂载(n1 == null,Teleport.ts:178):在主视图插入注释占位 n2.el 与 n2.anchor(dev 下是 teleport start/teleport end),disabled 则内容挂到主视图 mainAnchor 之前,否则 mountToTarget 挂到目标。
更新(Teleport.ts:203):四种情形——
- 若
n1还有 pendingMount(deferred),把旧 job 标记DISPOSED后为新 vnode 重新排队(#见Teleport.ts:210)。 disabled由真变假 / 假变真 →moveTeleport(..., TOGGLE)。to变化 → 重新resolveTarget后moveTeleport(..., TARGET_CHANGE),先搬两个 target 锚点,再搬子节点。- 其余走
patchChildren/patchBlockChildren原地更新。 - 一个易错点:
disabled时若to变了,代码把n2.props.to改回旧值(Teleport.ts:275,#7835),否则重新启用时会找不到正确目标。
17.9.4 move / remove / hydrate
moveTeleport(Teleport.ts:378)的 TeleportMoveTypes(Teleport.ts:372)区分 TARGET_CHANGE/TOGGLE/REORDER:只有「非 reorder,或 disabled」时才搬子节点(否则内容已在目标里,只需搬主视图锚点)。remove(Teleport.ts:315)总是移除目标端的两个锚点;对 disabled 或已搬走的,则连同子节点一起 unmount。
hydrateTeleport(Teleport.ts:431)是 SSR 专属:从主视图节点 nextSibling 起找 teleport start anchor/teleport anchor 注释还原两个目标锚点;因为同一目标可能被多个 teleport 复用,用 target._lpa(last processed anchor,Teleport.ts:465)记住上次结束位置,从那里接着 hydrate。若 SSR 输出位置错乱(#11400),或 teleport 被 v-if 切换,需要手工 prepareAnchor 补锚点,否则后续无法卸载/移动。
updateCssVars(Teleport.ts:555)负责把 data-v-owner 打到 teleport 出去的元素上——因为 SFC 的 CSS 变量注入要靠这个属性找到「跑出去」的节点(详见 helpers/useCssVars.ts)。
17.10 Suspense 深度:异步依赖的收集、挂起与解析
Suspense.ts 有 932 行,是内置组件里最复杂的。它的核心难点在于:在离屏容器里预渲染一棵异步子树,同时把「谁在等待」这件事收集起来,等所有依赖 resolve 后再把整棵已渲染好的树「原子地」搬进真实容器。
17.10.1 SuspenseBoundary 字段逐个看
createSuspenseBoundary(Suspense.ts:473)构造的边界对象(接口见 Suspense.ts:438):
| 字段 | 含义 |
|---|---|
vnode / parent / parentComponent / namespace | 边界 vnode、父边界(可嵌套)、所属组件、命名空间 |
container / hiddenContainer | 真实容器;离屏容器(初始 createElement('div'),Suspense.ts:180) |
activeBranch / pendingBranch | 当前正在展示的树;正在预渲染的树 |
deps / pendingId | 尚未 resolve 的异步依赖计数;每次切换自增,用于作废过期回调 |
timeout / isInFallback | 超时阈值(props.timeout,默认 -1);当前是否展示 fallback |
isHydrating / isUnmounted | SSR hydration 中;已卸载 |
effects | 预渲染期间被缓冲的 post-render 回调,resolve 时统一 flush |
pendingId + instance.suspenseId(Suspense.ts:755)是整个异步链的「防串号」机制:任何 async setup 的回调执行前先核对自己登记的 suspenseId 是否仍等于边界的 pendingId,不等就直接 return(Suspense.ts:755)。这样「异步还没回来,用户又切了分支」时,旧回调被安全丢弃。
17.10.2 挂载:先离屏渲染,再决定 resolve 还是 fallback
mountSuspense(Suspense.ts:165):
- 创建边界,
hiddenContainer是游离 div。 patch(null, vnode.ssContent, hiddenContainer, ...)在离屏里预渲染 default 子树。- 渲染过程中,任何 async setup 组件会调用
registerDep(见下)令suspense.deps++。 deps > 0→ 触发onPending/onFallback,把ssFallback挂到真实容器并setActiveBranch。deps === 0→ 没有异步依赖,直接suspense.resolve(false, true)(sync = true)。
ssContent/ssFallback 由 normalizeSuspenseChildren(Suspense.ts:891)在 vnode 创建时算好:default/fallback slot 各规范化为单根 vnode(多根告警),并保留 block 追踪(Suspense.ts:902)。
17.10.3 registerDep:异步组件的「报到」
registerDep(Suspense.ts:739)被渲染器在遇到 async setup 组件时调用:
const isInPendingSuspense = !!suspense.pendingBranch
if (isInPendingSuspense) suspense.deps++ // 进入 pending 状态才计数
instance.asyncDep!.catch(handleError).then(asyncSetupResult => {
if (instance.isUnmounted || suspense.isUnmounted ||
suspense.pendingId !== instance.suspenseId) return // 过期,丢弃
unsetCurrentInstance()
if (!instance.scope.active) { // 已卸载:只减 dep 让边界能解析
if (isInPendingSuspense && --suspense.deps === 0) suspense.resolve()
return
}
instance.asyncResolved = true
handleSetupResult(instance, asyncSetupResult, false)
setupRenderEffect(instance, vnode, parentNode(...), ..., suspense, namespace, optimized)
if (placeholder) { vnode.placeholder = null; remove(placeholder) } // 移除注释占位
updateHOCHostEl(instance, vnode.el)
if (isInPendingSuspense && --suspense.deps === 0) suspense.resolve()
})
注意 unsetCurrentInstance()(Suspense.ts:762)与 instance.scope.active 检查:withAsyncContext 会把清理推迟到微任务,此刻 currentInstance 可能仍指向别的组件,必须显式清空;scope 若已 stop(卸载)则只释放 dep、不再渲染。
依赖 resolve 让 deps 归零时 resolve() 被触发——但 只在 isInPendingSuspense 为真时才减 dep,避免「边界已经不在 pending 状态」时把计数减成负数。
17.10.4 resolve:把离屏子树原子搬入
resolve(Suspense.ts:539)的分支:
isHydrating→ 直接清标志,DOM 已是 SSR 产物(Suspense.ts:565)。- 旧树带
mode: 'out-in'过渡(delayEnter)→ 把「搬入 + flush effects」推迟到activeBranch.transition.afterLeave(Suspense.ts:573)。 - 非延迟:
unmount(activeBranch, ..., true)卸掉旧树,再move(pendingBranch, container, anchor, MoveType.ENTER)整棵搬入。 - 随后
setActiveBranch、清pendingBranch、isInFallback = false、isFallbackMountPending = false。 - effects 合并:若存在仍 pending 的祖先边界,把缓冲的 effects push 进祖先、随祖先一起 flush(
Suspense.ts:630);否则queuePostFlushCb(effects)。 - 若本边界
suspensible,再把父边界的 dep 减一,父边界 dep 归零且非 sync 时递归parentSuspense.resolve()。
queueEffectWithSuspense(Suspense.ts:938)是配套的:组件在 setup/render 期间触发的 post 队列回调(mounted、watch post、nextTick 等)若在有 pending 的边界上下文中,就不进主队列,而 push 进 suspense.effects——这样「子树已渲染但尚未展示」时,副作用不会提前执行。这是 Suspense 与调度器之间最关键的一处耦合。
17.10.5 fallback:从 pending 切到「加载中」
fallback(Suspense.ts:669)由「超时」或「无 timeout 立即」触发:isInFallback = true,unmount(activeBranch, ..., true) 卸掉展示树,再 patch(null, latestFallback, container, anchor, ...) 把 fallback 挂进真实容器。latestFallback 取的是 suspense.vnode.ssFallback(最新的),因为延迟挂载期间父组件可能已产生新的 fallback vnode(Suspense.ts:689)。若 fallback 也带 out-in 过渡,则把挂载推迟到 activeBranch.transition.afterLeave,并置 isFallbackMountPending = true——patchSuspense/resolve 里那些「跳过 fallback patch」的守卫就是为这个窗口服务的,防止 activeBranch 被错误劫持(Suspense.ts:283、Suspense.ts:333、Suspense.ts:596)。
17.10.6 patchSuspense:更新期的四种分支
patchSuspense(Suspense.ts:230)是更新路径,逻辑很密,按源码注释归纳:
| 场景 | 处理 |
|---|---|
| 有 pendingBranch 且新根同类型 | deps++ 后原地 patch(hold 住边界,避免嵌套分支过早 resolve),deps-- 归零则 resolve |
| 有 pendingBranch 但根类型变了 | pendingId = suspenseId++ 作废旧回调;hydrating 时把旧 DOM 设为 activeBranch 供后续卸载,否则直接 unmount;清 dep/effects,换新 hiddenContainer;再按是否已在 fallback、是否切回 active 分支分别处理 |
| 无 pendingBranch 且 active 根同类型 | 普通 patch,setActiveBranch |
| 无 pendingBranch 且根类型变了 | 触发 onPending,新树进 hiddenContainer,pendingId 取 KeepAlive 缓存值或自增;dep 归零则 resolve,否则按 timeout 调度 fallback |
process(Suspense.ts:77)还有一个特殊早退(Suspense.ts:113,#8678):若父边界仍 pending 且当前边界不在 fallback,直接复用 n1 的 suspense 与 el 并 return——避免「当前边界 patch 一次、又随父 pendingBranch 挂载一次」的重复挂载;但父边界处于 hydrating 时不能这么做(pending branch 是被收养的 SSR DOM,跳过后会残留过期内容)。
17.10.7 hydration 下的 Suspense
hydrateSuspense(Suspense.ts:839)创建 isHydrating = true 的边界,直接 hydrateNode 复用服务端 DOM 作为 pendingBranch。关键容错:客户端无法确知服务端当时是成功还是降级到 fallback,一律先按「成功」hydrate;若异步组件让 deps > 0 就保持 pending 等 resolve,若期间分支被切换,patchSuspense 的 isHydrating 分支把当前 DOM 设为 activeBranch 以便 resolve 时卸载(Suspense.ts:302)。deps === 0 时同步 resolve(false, true) 并清 isHydrating。
17.10.8 Suspense 全流程时序
sequenceDiagram
participant R as renderer
participant S as SuspenseBoundary
participant A as async setup 组件
R->>S: mountSuspense → createSuspenseBoundary
R->>R: patch(ssContent) into hiddenContainer
A->>S: registerDep → deps++
alt deps > 0
S->>R: patch(ssFallback) into container
Note over S: onPending / onFallback
A->>S: async 完成 → deps--
S->>S: deps==0 → resolve()
S->>R: unmount(activeBranch); move(pendingBranch→container)
S->>S: flush effects / 上报父边界
else deps == 0
S->>S: resolve(false, sync=true)
end
17.11 templateRef 与 ref 体系
模板里的 ref="foo" 交给 rendererTemplateRef.ts 的 setRef(rendererTemplateRef.ts:31)处理,它在挂载/更新/卸载时被渲染器调用。
- 取什么值:
vnode.shapeFlag & STATEFUL_COMPONENT→ 取getComponentPublicInstance(vnode.component)(组件实例代理);否则取vnode.el(元素/组件根 DOM)。卸载时value一律为null。 - 异步包装组件(
rendererTemplateRef.ts:51):若是isAsyncWrapper且非卸载,ref 会自动转发到内部已解析组件——若该 wrapper 被 KeepAlive 缓存且__asyncResolved存在,递归到vnode.component.subTree。 - 四种 ref 形态:字符串(写
owner.refs[key],若 key 在setupState中则是setupState[key])、ref 对象(写ref.value)、函数(callWithErrorHandling(ref, owner, FUNCTION_REF, [value, refs]))、以及rawRef.f为真时的数组模式(v-for场景,push/remove收集)。 - 动态 ref 变化:
oldRef !== ref时先invalidatePendingSetRef(oldRef)并把旧值置 null(rendererTemplateRef.ts:121)。 - 时机:
value为非空时(挂载/更新)不立即赋值,而是包成一个job.id = -1存入pendingSetRefMap,queuePostRenderEffect(job, parentSuspense)——保证 ref 在 DOM 与子组件挂载完成、post 队列 flush 后才可用(#1789);value为空(卸载)则先invalidatePendingSetRef再同步赋值,避免覆盖同 key 的新 ref。 useTemplateRef/knownTemplateRefs(isTemplateRefKey,rendererTemplateRef.ts:103)用于跳过对「由useTemplateRef创建」的 ref 的重复赋值。
易错点:canSetSetupRef(rendererTemplateRef.ts:85)在 __DEV__ 警告「ref 用在了非 ref 值上,生产构建将失效」——只有 setupState 里存的是 ref 时,模板 ref 才能被正常解包生效。
17.12 实战:四类内置能力的最小示例
// 1) 自定义元素:定义一个 <my-counter>,天然获得 Shadow DOM 隔离
import { defineCustomElement } from 'vue'
const MyCounter = defineCustomElement({
props: { step: { type: Number, default: 1 } },
emits: ['change'],
setup(props, { emit }) {
const n = ref(0)
watch(n, v => emit('change', v))
return () => h('button', { onClick: () => (n.value += props.step) }, n.value)
},
})
customElements.define('my-counter', MyCounter) // attributes 自动映射为 props
// 2) Transition + v-show:persisted 模式下靠指令驱动注入钩子
// <Transition name="fade"><div v-show="ok">A</div></Transition>
// 3) KeepAlive:切走缓存、切回复用,max 触发 LRU
// <KeepAlive :max="2"><component :is="cur" /></KeepAlive>
// 4) Suspense:默认 slot 的异步组件未就绪时展示 fallback
// <Suspense timeout="300">
// <template #default><AsyncView /></template>
// <template #fallback><p>loading…</p></template>
// </Suspense>
17.13 本章要点回顾
- 分层契约:
runtime-core只依赖RendererOptions;runtime-dom用extend({ patchProp }, nodeOps)落地,渲染器惰性创建以利 tree-shaking。 nodeOps13 个 DOM 原语,insert统一走insertBefore(anchor 为 null 即 append),静态内容有容器缓存与 Trusted Types 支持。patchProp五支线:class(vtcKey过渡类保护)、style(对象差分/CSS 变量/autoPrefix)、事件(invoker 记忆化 +_vts去抖 + 数组 handler 劫持)、prop vs attr(shouldSetAsProp特例表)、boolean attribute(includeBooleanAttr)。v-model四种元素四套状态机:文本用 IME composition 守卫,checkbox 支持数组/Set/布尔与_trueValue/_falseValue,select 用_pendingValue快照防竞态。v-show用_vod保存原display、_vsh标记隐藏,与patchStyle、Transition(persisted)双向联动。defineCustomElement走 connectedCallback 挂载、disconnectedCallback 延迟卸载,Shadow DOM 内注入样式,props 与 attribute 双向映射并靠 MutationObserver 同步。- 过渡 把状态挂在元素上(
enterCbKey/leaveCbKey),TransitionGroup用三循环 FLIP 防 layout thrashing。 KeepAlive用Map+Set实现 O(1) LRU,两个 shapeFlag 位区分「该保留」与「在复用」。Teleport用 target 锚点 +TeleportEndKey支持跨容器与nextSibling跳过;disabled/defer各有分支,hydration 靠_lpa续接。Suspense在离屏容器预渲染、用deps/pendingId收集与作废依赖、effects缓冲并向上合并,resolve 时原子搬入;hydration 下先按成功复用 DOM。templateRef的赋值走 post-render 队列,支持字符串/ref/函数/数组四种形态,并自动转发异步包装组件。