Vue 3 核心源码深度剖析系列(v3.5.44)-第 17 章 运行时内幕(五):`runtime-dom`、自定义元素与内置组件

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 里那些抽象的「动词」,逐一落地成浏览器的「实现」。它做的事情可以精确归纳为三类:

  1. 提供宿主环境(host env)实现 :nodeOps 把 RendererOptions 里那些「操作节点」的抽象方法映射到真实 DOM API;patchProp 把「给元素设置属性」这一个抽象方法,分派到 class/style/事件/属性/特例上的具体实现。
  2. 把渲染器「装配」起来并暴露给用户 :createApp、createSSRApp、render、hydrate 都是在这一层导出的;createApp 还会额外包装 app.mount,让它承担「从选择器取容器」「把容器 innerHTML 当模板」「清理 v-cloak、打上 data-v-app」这些只有 DOM 环境才有意义的工作。
  3. 实现「只在 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 对称,区别只有两点:

  1. 它走 ensureHydrationRenderer()(index.ts:87-93),最终用的是 createHydrationRenderer,于是 app.mount 走的是 hydration 路径(第二个参数 true)。
  2. 它不覆盖容器 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
},

三层分支对应三件事:

  1. 命名空间 :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)。
  2. 自定义内置元素(customized built-in element) :is 是 is="my-button" 这种「定制内置元素」语法。此时必须用 createElement(tag, { is })(注意是两参数 形式),浏览器才知道要按 my-button 的行为实例化。普通的 autonomous 自定义元素(<my-el>)不需要这一步。
  3. <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!]
}

逐步拆解:

  1. before 先记下来 :它是「插入位置的前一个兄弟」,用来在插入完成后定位新内容的第一个节点。(Node.insertBefore 一个 DocumentFragment 会把它摊平 插入,插入后没法直接拿到「第一个插入的节点」,所以需要靠 before.nextSibling 反推。)
  2. 缓存命中路径 :条件 start && (start === end || start.nextSibling) 对应注释里的两条:#5308 要求「只有一个根节点」或「nextSibling 信息仍有效」。满足时,从 start 开始沿 nextSibling 链逐个 cloneNode(true) 深拷贝并插入,直到到达 end。这是「复用」的核心:它拷贝的是上一次渲染真实产生的 DOM 节点,因此保留了浏览器解析出的一切(属性、文本、甚至表单状态之外的结构)。
  3. 未命中路径 :把 content 塞进复用 templateContainer 的 innerHTML。注意 SVG/MathML 要包一层 <svg>/<math> 再解析------因为 innerHTML 在 HTML 上下文里不会给 SVG 标签正确的命名空间;解析后再把 wrapper 的 children 搬出来、移除 wrapper。
  4. 返回 [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
  }
}

三个要点:

  1. 性能 :注释(class.ts:10-12)说「直接设 className 理论上比 setAttribute 快」。对 HTML 元素走 el.className,对 SVG 元素必须走 setAttribute(因为 SVGElement 没有 className 字符串属性)。
  2. 过渡类保护 :vtcKey 是 Symbol('_vtc')(Transition.ts:35),Transition 会把「正在生效的临时过渡 class」(如 v-enter-active)记录到这个 Set 里。当元素正在过渡、而业务代码又恰好改了 :class 时,patchClass 必须把过渡类拼回最终值 ,否则过渡会「突然中断」。这就是 value = [value, ...transitionClasses].join(' ')。这个设计的精妙在于:过渡类不放在 class 属性里被 patch,而是「旁路记录 + 每次 patch 时重新合成」。
  3. 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
}

算法步骤:

  1. 查缓存 prefixCache,命中直接返回(前缀补齐是个「每个属性名一次」的昂贵探测,必须缓存)。
  2. camelize(rawName):把 background-color 变成 backgroundColor(编写者可能用连字符写法)。
  3. name !== 'filter' 的特殊守卫 :filter 这个属性有个历史坑------标准 filter 与老 IE 的滤镜 filter 同名字,'filter' in style 会误判。所以对 filter 跳过「无前缀」的短路,强制去试 WebkitFilter 等。
  4. 若无前缀版本可用,就试 Webkit/Moz/ms 前缀(注意前缀写法 + capitalize)。命中就缓存。
  5. 全都不命中,返回原名(写入时浏览器自行忽略)。

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)。

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) 做了五件事:

  1. 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 时能对比「浏览器自动填充/服务端预填的值」是否与初始值不同。
  2. 绑定 assigner :el[assignKey] = getModelAssigner(vnode)。
  3. 判断是否转数字 :const castToNumber = number || (vnode.props && vnode.props.type === 'number')------.number 修饰符或 type="number" 都会触发。
  4. 挂主监听器 :addEventListener(el, lazy ? 'change' : 'input', e => { if (target.composing) return; el[assignKey](castValue(el.value, trim, castToNumber)) })。注意这里有 if (target.composing) return------IME 组合输入期间不写回(见下)。
  5. 补充监听器 :
    • 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 上也会出现「光标跳到末尾」等问题。解决方案:

  1. compositionstart 时给元素打 composing = true;
  2. 组合期间的 input 事件被主监听器里的 if (target.composing) return 拦截;
  3. 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)。于是:

  1. 调 assign 之前,先记录 _pendingValue,其中数组值拷贝了一份 selectedVal.slice()(快照);
  2. assign 触发数据更新 → 下一个 tick 走 updated 钩子;
  3. 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) },
    })
  }
}

这里处理了两个「升级时机」问题:

  1. pre-upgrade property :在 customElements.define 之前,用户可能已经写了 document.createElement('my-el').foo = 1。此时实例上有个普通自有属性 foo。_resolveProps 把这个自有属性读出来灌进 _props。(customElements 规范叫 "lazy properties" 模式。)
  2. 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 自定义元素生命周期时序

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 返回渲染函数,做三件事:

  1. 从 slots.default() 里筛出唯一 非注释子节点(findNonCommentChild,BaseTransition.ts:278);<transition> 只能包一个元素/组件,多个非注释子节点在 __DEV__ 下告警。
  2. 为子节点调用 resolveTransitionHooks 生成一组 TransitionHooks,挂到 child.transition 上(setTransitionHooks,BaseTransition.ts:539)。渲染器在挂载/卸载该 vnode 时读这个字段来执行动画。
  3. 处理 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 新过渡」的保护。逻辑分三层:

  1. 有显式 duration → setTimeout(resolveIfNotStale, explicitTimeout)。
  2. 否则 getTransitionInfo 探测 transitionend/animationend 事件数与超时,监听 endEvent,用 propCount 计数;加 timeout + 1 的兜底 setTimeout(浏览器可能因 tab 隐藏等不触发 transitionend)。
  3. 完全探测不到过渡(!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(布局抖动)」:

  1. prevChildren.forEach(callPendingCbs):先结算上一轮的 move/enter 回调。
  2. prevChildren.forEach(c => recordPosition(c, shift)):读取新位置(shift 见下)。
  3. 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 起:

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 队列(pendingMounts WeakMap 记录),等队列 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):

  1. 创建边界,hiddenContainer 是游离 div。
  2. patch(null, vnode.ssContent, hiddenContainer, ...) 在离屏里预渲染 default 子树。
  3. 渲染过程中,任何 async setup 组件会调用 registerDep(见下)令 suspense.deps++。
  4. deps > 0 → 触发 onPending/onFallback,把 ssFallback 挂到真实容器 并 setActiveBranch。
  5. 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 全流程时序

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 实战:四类内置能力的最小示例

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 本章要点回顾

  1. 分层契约 :runtime-core 只依赖 RendererOptions;runtime-dom 用 extend({ patchProp }, nodeOps) 落地,渲染器惰性创建以利 tree-shaking。
  2. nodeOps 13 个 DOM 原语,insert 统一走 insertBefore(anchor 为 null 即 append),静态内容有容器缓存与 Trusted Types 支持。
  3. patchProp 五支线:class(vtcKey 过渡类保护)、style(对象差分/CSS 变量/autoPrefix)、事件(invoker 记忆化 + _vts 去抖 + 数组 handler 劫持)、prop vs attr(shouldSetAsProp 特例表)、boolean attribute(includeBooleanAttr)。
  4. v-model 四种元素四套状态机:文本用 IME composition 守卫,checkbox 支持数组/Set/布尔与 _trueValue/_falseValue,select 用 _pendingValue 快照防竞态。
  5. v-show 用 _vod 保存原 display、_vsh 标记隐藏,与 patchStyle、Transition(persisted)双向联动。
  6. defineCustomElement 走 connectedCallback 挂载、disconnectedCallback 延迟卸载,Shadow DOM 内注入样式,props 与 attribute 双向映射并靠 MutationObserver 同步。
  7. 过渡 把状态挂在元素上(enterCbKey/leaveCbKey),TransitionGroup 用三循环 FLIP 防 layout thrashing。
  8. KeepAlive 用 Map+Set 实现 O(1) LRU,两个 shapeFlag 位区分「该保留」与「在复用」。
  9. Teleport 用 target 锚点 + TeleportEndKey 支持跨容器与 nextSibling 跳过;disabled/defer 各有分支,hydration 靠 _lpa 续接。
  10. Suspense 在离屏容器预渲染、用 deps/pendingId 收集与作废依赖、effects 缓冲并向上合并,resolve 时原子搬入;hydration 下先按成功复用 DOM。
  11. templateRef 的赋值走 post-render 队列,支持字符串/ref/函数/数组四种形态,并自动转发异步包装组件。
相关推荐
码银1 小时前
【web】基于HTML/CSS,JavaScript + Three.js做3D流程图
前端·css·html
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 13 章 运行时组件系统:实例、props、emit、slots 与生命周期
前端·vue.js·开源
Highcharts.js1 小时前
Highcharts React v5 正式发布:支持 ESM、更简洁的组件、更出色的开发体验
前端·react.js·前端框架·数据可视化·highcharts·前端可视化·esm
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 10 章 compiler-sfc(二)
前端·vue.js·开源
will_net1 小时前
Vue IIS部署 api 访问当前站点 代理跳转真实API 问题
前端·javascript·vue.js
weixin_449173651 小时前
react 的useEffect用法
前端·javascript·react.js
55873 生态系统1 小时前
九层之台,生态为体 —— 论 “55873 全域文明生态系统“ 与第 1 期 2000 多册开源大典
开源·55873全域文明生态体系·55873操作系统·全域文明生态系统·55873 数据开源大典
定栓1 小时前
Webpack迁到Vite第十节:vite.config.js 开发与构建流转全链路
vue.js·webpack·vite