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)大致是:
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 |
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 是把这两组实现「合流」的地方:
ts
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)是另一个重要设计:
ts
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 依次做四件事:
ts
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,正是要被「认领」的。
ts
app.mount = (containerOrSelector): any => {
const container = normalizeContainer(containerOrSelector)
if (container) {
return mount(container, true, resolveRootNamespace(container))
}
}
ensureHydrationRenderer 有个细节:
ts
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)解决的是「组件树根节点的命名空间」问题:
ts
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 侧能力」:
ts
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
ts
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):
ts
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 表达两种插入
ts
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>
ts
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
ts
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 的两只脚
ts
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
ts
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...次渲染时不再重新创建这些节点,而是克隆它们。
ts
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
ts
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 链。理解它的关键是分支的先后顺序,本身就是语义优先级:
ts
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:
ts
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」的那一步:
ts
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 是对象时,先做「删除差分」------
ts
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)是真正写值的地方:
ts
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)实现了浏览器前缀自动补齐与缓存:
ts
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。
ts
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)负责把事件名拆成「事件类型 + 监听器选项」:
ts
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)是最核心的一段:
ts
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。它们同样做了「记忆化」:
ts
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:
ts
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):
ts
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"> 为例,编译器会产出大致这样的代码:
js
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):
ts
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):
ts
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)。逻辑是:
ts
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」的方向:
ts
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 监听器。逻辑按模型分三支:
ts
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:
ts
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):
ts
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)三行核心逻辑:
ts
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:
ts
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 本来就是用户操作后正确的),不等才重新同步。
ts
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:
ts
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):
ts
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):
ts
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):
ts
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):
ts
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):
ts
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 不展开类型体操),真正实现是:
ts
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:
ts
const BaseClass = (typeof HTMLElement !== 'undefined' ? HTMLElement : class {}) as typeof HTMLElement
这个守卫是为了让 apiCustomElement.ts 能被非浏览器环境 (如 Node 里跑 SSR 逻辑)安全 import------此时退化为空类。构造函数(apiCustomElement.ts:251-281):
ts
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):
ts
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)用了延迟卸载:
ts
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 类型转换,然后挂载」:
ts
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:
ts
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:
ts
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)是双向的核心:
ts
_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):
ts
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:
ts
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 内:
ts
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:
ts
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)解决这个问题:
ts
_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:
ts
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):
ts
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。 所以常见模式是:
js
if (!customElements.get('my-element')) {
customElements.define('my-element', MyElement)
}
要注意的是:defineCustomElement 本身不做注册,也没有全局去重表------每个类都是新的。这意味着「热更新时反复 define 同名标签」会抛错,是 HMR 需要处理的问题。
17.6.12 自定义元素生命周期时序
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 定义了渲染器与过渡系统之间的契约:
ts
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):
csharp
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):
scss
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 过渡的运行时状态机
状态由每元素上的 enterCbKey/leaveCbKey 承载------Vue 没有集中式状态机对象,而是把状态「附着」在 DOM 元素上。好处是同一元素的身份天然唯一,坏处是必须靠 symbol 防命名冲突(普通属性可能与用户代码或其它库撞名)。
17.8 KeepAlive 深度:一块带 LRU 的 vnode 缓存
<KeepAlive> 让组件在切走时不被卸载,而是把整棵子树「搬进」一个离屏容器保存;切回来时再搬回来,从而保留组件状态(表单输入、滚动位置、内部 timer 等)。
17.8.1 三个数据结构
runtime-core/src/components/KeepAlive.ts:111 起:
ts
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)里:
ts
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):
ts
vnode.el = cachedVNode.el
vnode.component = cachedVNode.component
vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
keys.delete(key); keys.add(key) // 移到 Set 末尾 = 最新
未命中且超限时(KeepAlive.ts:364):
ts
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 组件时调用:
ts
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 全流程时序
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 实战:四类内置能力的最小示例
js
// 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/函数/数组四种形态,并自动转发异步包装组件。