Vue 3 核心源码深度剖析系列(v3.5.44)-第 18 章 兼容层与全链路实战:vue-compat 与从模板到 DOM 的完整调用链

0 阅读41分钟

Vue 3 核心源码深度剖析系列(v3.5.44)-第 18 章 兼容层与全链路实战:vue-compat 与从模板到 DOM 的完整调用链

本章对应 packages/vue-compat/src/(index.ts、runtime.ts、createCompatVue.ts、dev.ts、esm-index.ts、esm-runtime.ts)与 packages/runtime-core/src/compat/(compatConfig.ts 560+ 行、global.ts 597 行、renderFn.ts 317 行、instance.ts 199 行、component.ts、componentAsync.ts、componentFunctional.ts、componentVModel.ts、customDirective.ts、data.ts、props.ts、attrsFallthrough.ts、instanceChildren.ts、instanceListeners.ts、instanceEventEmitter.ts、renderHelpers.ts、globalConfig.ts),以及编译器侧的 packages/compiler-core/src/compat/(compatConfig.ts、transformFilter.ts,共 149 + 192 行)。交叉引用 packages/vue/src/index.ts(107 行,全量入口)、packages/runtime-dom/src/index.ts(314 行)、packages/compiler-dom/src/index.ts(79 行)、packages/compiler-sfc/src/index.ts(90 行)、packages/runtime-core/src/renderer.ts、scheduler.ts、packages/reactivity/src/index.ts,以及构建脚本 rollup.config.js:131-201、scripts/aliases.js、scripts/dev.js:154。所有行号对应 vuejs/core@3.5.44。

读完本章你将能回答:为什么 Vue 3 要单独维护一个 @vue/compat 包,而不是把兼容逻辑塞进 vue 包用运行时开关控制?__COMPAT__ 这个编译期布尔到底是"零成本"还是"一个都不能少"——它凭什么让非兼容构建完全删除所有兼容分支?configureCompat 的配置对象在运行时需要同时覆盖"全局/编译器/组件/实例"四层来源,getCompatConfigForKey 与 isCompatEnabled 是如何用两次查表 + MODE 分支把优先级算清楚的?Vue.extend 的 SubVue.prototype = Object.create(Vue.prototype) 与 applySingletonPrototype 之间是怎么衔接的、为什么 app.config.globalProperties 要用 Object.create(Ctor.prototype) 而不是 extend?_compat_mount 为什么要"手工再造一遍" app.mount 的 selector 解析、in-DOM 模板提取、命名空间判定,而不是直接调用 mount?compatH 接受的 data.attrs/domProps/on/nativeOn/directives/slot/scopedSlots/model 七种旧字段是如何被翻译成 v3 的 props、withDirectives、函数式 slot 的?{{ msg | uppercase }} 里的 | 在 parseFilter 状态机里为什么要同时跟踪单引号/双引号/模板字符串/正则/圆括号/方括号/花括号七种嵌套深度?从 <template> 字符串到 DOM 落地的全链路里,究竟经过了哪些函数、每一项优化(静态提升、Block 树、patchFlag、LIS、depsTail)分别在第几章、第几行生效?


导语:为什么终章要讲"兼容层 + 全链路"

前十七章我们按主题域逐包拆解:响应式(02–04)、编译器(05–08)、SFC(09–10)、运行时(11–14)、SSR(15–16)、runtime-dom 与内置组件(17)。这些章节各自闭环,但留下两个"横切"的问题始终没有正面回答:

  1. 横切面一——兼容层。 Vue 3 不是"Vue 2 加几个新 API",而是一次不兼容的语义重写:响应式从 Object.defineProperty 换成 Proxy、事件 API 整块删除、render 函数签名变更、全局 API 从 Vue.xxx 迁到 app.xxx、v-model 语义重构、filters 移除……官方给出的迁移路径不是"你自己改",而是一个叫 @vue/compat 的迁移构建(Migration Build)。它把一个原本不存在的横切关注点——"同一个运行时如何同时服务 v2 语义与 v3 语义"——单独做成了一个包,并用一个编译期布尔 __COMPAT__ 把它与主线彻底隔离。这背后的工程决策本身就值得单列一章。
  2. 横切面二——全链路。 前十七章把流水线"切段"讲,读者容易记住"编译器的第 N 步"或"运行时的第 M 步",却难以在脑中形成一条从 <template> 字符串到浏览器 DOM 的连续调用链。而理解框架的真正门槛恰恰是这条链:一个插值表达式从 {{ msg }} 变成 _toDisplayString(_ctx.msg)、再变成 Text vnode、再变成 document.createTextNode、最后在 msg 变更时被 setElementText 精准更新——中间每一跳都对应不同章节的知识点。

本章的设计很明确:18.1–18.6 把兼容层从头到尾讲透(相当于第 17.5 章的"补章"),18.7 用一次完整的全链路追踪把前十七章的知识点"缝"成一条线,18.8–18.13 收束全系列(全量入口、构建形态、性能清单、调试手册、延伸阅读、要点回顾)。

需要提前建立一个核心判断:兼容层不是"给 v2 用户一个观望窗",而是 Vue 团队用一套"编译期开关 + 运行时查表"的机制,证明了"向后兼容"可以做成一个可完全删除的可选特性。 只要不带 __COMPAT__,所有 if (__COMPAT__) { ... } 分支都会被 rollup 的常量折叠 + tree-shaking 干掉,产物体积与 v3 原生构建一字不差。这就是本章反复要讲的"零成本兼容"。


18.1 @vue/compat 的定位:一个"编译期可选"的迁移构建

18.1.1 为什么需要兼容层:从"原子化迁移"到"渐进式迁移"

Vue 3.0 在 2020 年发布时,官方渲染了一个明确立场:大版本升级必然带来不兼容变更,但生态不能因此断档。 如果一个团队有 20 万行 Vue 2 代码、依赖十几个第三方库(其中若干已停止维护),"一次性重写"是不现实的。于是官方给出了两条路:

  • 硬迁移(v3 原生):升级依赖 → 修 main.js 全局 API → 改 v-model → 删 filters → 重写事件 bus → 逐个改 render 函数。适合新项目与小项目。
  • 软迁移(@vue/compat):把 vue 的导入别名指向 @vue/compat,先让整仓代码在 v3 运行时上跑起来(行为尽量模拟 v2),再打开 configureCompat({ MODE: 3 }) 逐个"点亮"不兼容点,最后移除依赖。这是官方主推路径,对应 v3-migration.vuejs.org 上那张"每配置一项、修一类代码"的清单。

@vue/compat 的价值就在第二条路:它把一个巨大的语义鸿沟分解成 40 多个可以独立开关的降级点(DeprecationTypes 枚举,见 18.2),让迁移从"瀑布式"变成"逐项式"。这一点在源码层面的直接体现是 packages/runtime-core/src/compat/compatConfig.ts:13-67 那个含有 40+ 成员的枚举——每一个成员都是一个"可单独降级到 v2 语义"的开关。

需要特别澄清一个常见误解:@vue/compat 不是"Vue 2 的运行时 + Vue 3 的 API",它是纯 Vue 3 运行时(createApp、Proxy 响应式、Block 树全部是 v3 的),只是在若干"语义分歧点"上,通过 isCompatEnabled(...) 检查来决定走 v2 分支还是 v3 分支。换句话说:

兼容层是 v3 运行时上的一层"行为覆写",不是两套运行时的混装。

18.1.2 与 Vue 2 的行为差异清单(兼容层负责抹平的部分)

要理解兼容层在做什么,先要有一张"v2 vs v3 差异表"。下表覆盖 @vue/compat 实际处理的全部差异点,左列是差异、右列是兼容层的处理函数与源码位置(后文 18.2–18.6 会逐个展开):

#差异点v2 行为v3 行为兼容层处理
1全局挂载new Vue({el}) / vm.$mount()createApp().mount()createCompatVue + installCompatMount(global.ts:143-179, 452-604)
2全局 API 宿主Vue.config / Vue.use / Vue.mixinapp.config / app.use / app.mixinglobal.ts:181-329、installLegacyAPIs(global.ts:367-388)
3Vue.prototype原型增强app.config.globalPropertiesapplySingletonPrototype(global.ts:428-450)
4Vue.set/delete/observable存在移除global.ts:284-297
5Vue.extend构造函数继承移除(defineComponent)extendCtor(global.ts:225-282)
6Vue.filter存在移除installFilterMethod(global.ts:352-365)
7实例生命周期名beforeDestroy/destroyedbeforeUnmount/unmountedapplyOptions 注册(componentOptions.ts:739-752)
8事件 API$on/$once/$off移除instanceEventEmitter.ts、instance.ts:120-122
9$children存在移除instanceChildren.ts
10$listeners独立对象合并进 $attrsinstanceListeners.ts、attrsFallthrough.ts
11$attrs 含 class/style不含含attrsFallthrough.ts:12-15、componentRenderUtils.ts:219-244
12$scopedSlots存在合并进 $slotsinstance.ts:115-118
13render 函数签名h(tag, data, children) + 旧 data 字段h(type, props, children)renderFn.ts:42-302
14函数式组件functional: true普通函数componentFunctional.ts
15异步组件返回 Promise 的工厂函数defineAsyncComponent()componentAsync.ts
16filters`{{ af }}``` 视为位运算编译器 transformFilter.ts + 运行时 resolveFilter
17v-model on 组件value + inputmodelValue + update:modelValuecomponentVModel.ts
18v-on keyCode 修饰符@keyup.13仅键名vOn.ts:97-155
19自定义指令钩子bind/inserted/update/unbindbeforeMount/mounted/updated/unmountedcustomDirective.ts:14-24
20data 可以是对象是必须函数global.ts:158-165、createCompatApp
21data 深度合并深合并浅合并data.ts、componentOptions.ts:1050-1052
22watch 数组隐式 deep是否componentOptions.ts:860-885
23props 默认值的 this可访问不可访问props.ts、componentProps.ts:481-488
24attrs: false 时 class/style 仍下落是否attrsFallthrough.ts、componentRenderUtils.ts
25<transition-group> 默认 span是否TransitionGroup.ts:156-159
26Transition 类名(v-enter 等)旧类名新类名(v-enter-from)Transition.ts:152-157
27枚举属性强转(contenteditable 等)强转为字符串原样/移除attrs.ts:54-94
28:attr="false"移除属性渲染 attr="false"props.ts:72-93、attrs.ts:80-93
29私有 API(_c,_s,$vnode,_data)存在移除instance.ts:159-199
30挂载容器上的指令容器视为模板一部分容器不再处理runtime-dom/src/index.ts:125-136(GLOBAL_MOUNT_CONTAINER)
31config.ignoredElements存在isCustomElementglobal.ts:413-422
32config.keyCodes存在移除renderHelpers.ts:138-156、vOn.ts
33config.optionMergeStrategies 内置策略可见是否globalConfig.ts:65-83

这张表里 33 个差异点,是本章 18.2–18.6 逐一拆解的骨架。

18.1.3 构建方式:__COMPAT__ 开关与 vue-compat 包

Vue 3 用了两类"编译期开关"(第 01 章已讲过 __DEV__/__FEATURE_*,这里补上 __COMPAT__):

// rollup.config.js:170-201(节选)
function resolveDefine() {
  const replacements = {
    __VERSION__: `"${masterVersion}"`,
    __TEST__: `false`,
    __BROWSER__: String(isBrowserBuild),
    __GLOBAL__: String(isGlobalBuild),
    __ESM_BUNDLER__: String(isBundlerESMBuild),
    __ESM_BROWSER__: String(isBrowserESMBuild),
    __CJS__: String(isCJSBuild),
    __SSR__: String(!isGlobalBuild),
    // 2.x compat build
    __COMPAT__: String(isCompatBuild),      // ← 本包的开关
    __FEATURE_SUSPENSE__: `true`,
    __FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__` : `true`,
    ...
  }
}

isCompatBuild 从哪来?rollup.config.js:139:

const isCompatBuild = !!packageOptions.compat

而 packageOptions.compat 来自 packages/vue-compat/package.json:27-40 的 buildOptions:

"buildOptions": {
  "name": "Vue",
  "filename": "vue",
  "compat": true,        // ← 这就是 isCompatBuild 的来源
  "formats": [
    "esm-bundler", "esm-bundler-runtime", "cjs", "global",
    "global-runtime", "esm-browser", "esm-browser-runtime"
  ]
}

注意 filename: "vue"——@vue/compat 的产物文件名与 vue 包刻意保持一致,这正是兼容层"可以无缝替换 vue 依赖"的工程基础:在 vite/webpack 里配一条 alias: { vue: '@vue/compat' } 即可,无需改动任何业务导入语句。

这里有一个关键的工程决策:为什么 __COMPAT__ 必须做成编译期常量,而不能做成运行时 app.config.compat = true?

答案是 tree-shaking 与零成本。兼容层涉及面极广:renderer.ts、componentProps.ts、componentOptions.ts、vnode.ts、directives.ts、componentPublicInstance.ts、componentEmits.ts、componentSlots.ts、componentRenderContext.ts、componentRenderUtils.ts——主线代码里散落着 100 多处 if (__COMPAT__) 分支。如果做成运行时可开关,这些分支(以及它们引用的全部 compat 模块:compatH、global.ts、instance.ts……)都必须打进产物,vue 包体积会暴涨,而且每次热路径(如 setFullProps 遍历 props 时)都要多一次布尔判断。做成编译期常量后:

  • rollup 的 @rollup/plugin-replace 把 __COMPAT__ 替换为字面量 false;
  • 于是 if (false) { convertLegacyVModelProps(vnode) } 被压缩成 if (false);
  • terser/esbuild 的 DCE 删除死分支,连带 convertLegacyVModelProps 及其整条 import 依赖链(compat/*)一起被 tree-shake 掉;
  • 最终 v3 原生构建里连一个字节的兼容代码都不存在。

这就是"零成本兼容"的实现。反过来说,@vue/compat 构建里 __COMPAT__ = true,全部分支保留。

18.1.4 六个构建形态与四个源码入口

@vue/compat 的 formats 列表(7 项,其中 esm-bundler-runtime/global-runtime/esm-browser-runtime 是"仅运行时"变体)对应源码里的 4 个入口文件:

入口文件用途对应产物格式
packages/vue-compat/src/index.ts全量(runtime + compiler),支持 template 选项运行时编译esm-bundler、cjs、global、esm-browser
packages/vue-compat/src/runtime.ts仅运行时*-runtime 四种格式
packages/vue-compat/src/esm-index.ts全量,但用 export default + export * 双导出esm-bundler(module/import 字段)
packages/vue-compat/src/esm-runtime.ts仅运行时,双导出esm-bundler-runtime

为什么 ESM 构建要单独用 esm-index.ts/esm-runtime.ts?看 rollup.config.js:161-168 的注释:

// the compat build needs both default AND named exports. This will cause
// Rollup to complain for non-ESM targets, so we use separate entries for
// esm vs. non-esm builds.
if (isCompatPackage && (isBrowserESMBuild || isBundlerESMBuild)) {
  entryFile = /runtime$/.test(format)
    ? `src/esm-runtime.ts`
    : `src/esm-index.ts`
}

以及 rollup.config.js:146 的 output.exports = isCompatPackage ? 'auto' : 'named'。

原因很直接:@vue/compat 必须同时提供:

  • 默认导出 export default Vue——让 import Vue from 'vue'(Vue 2 时代的写法)继续可用,因为 v2 用户大量使用 Vue.use()、new Vue()、Vue.component() 这些挂在这个"全局构造函数"上的 API;
  • 具名导出 export * from '@vue/runtime-dom'——让 import { createApp, ref } from 'vue'(Vue 3 写法)也可用,且让 tree-shaking 生效。

esm-index.ts(6 行)正是这两者的合体:

// packages/vue-compat/src/esm-index.ts
import Vue from './index'
export default Vue
export * from '@vue/runtime-dom'
const configureCompat: typeof Vue.configureCompat = Vue.configureCompat
export { configureCompat }

而 CJS/global 形态无法同时处理 default 与 named(rollup 会报错),所以它们直接用 index.ts(export default Vue,产物在 output.exports = 'auto' 下把 Vue 挂在 module.exports 上并附加具名导出)。runtime.ts 与 esm-runtime.ts 的关系同理,只是把 ./index 换成 ./runtime。

注意 esm-index.ts 末尾那三行——configureCompat 被单独再导出一遍:

const configureCompat: typeof Vue.configureCompat = Vue.configureCompat
export { configureCompat }

原因:export * from '@vue/runtime-dom' 不会导出 Vue.configureCompat(它是挂在默认导出的 Vue 对象上的方法)。迁移文档里 import { configureCompat } from 'vue' 是标准写法,所以必须显式补一个具名导出。


18.2 compatConfig.ts 深度:40+ 个降级点的对象模型与优先级算法

18.2.1 DeprecationTypes 枚举:把"不兼容点"编码成字符串常量

packages/runtime-core/src/compat/compatConfig.ts:13-67 是整个兼容层的"字典"。它把 40 多个不兼容点编码成字符串枚举,按前缀分成五组:

// packages/runtime-core/src/compat/compatConfig.ts:13-67(节选)
export enum DeprecationTypes {
  // 全局 API
  GLOBAL_MOUNT = 'GLOBAL_MOUNT',
  GLOBAL_MOUNT_CONTAINER = 'GLOBAL_MOUNT_CONTAINER',
  GLOBAL_EXTEND = 'GLOBAL_EXTEND',
  GLOBAL_PROTOTYPE = 'GLOBAL_PROTOTYPE',
  GLOBAL_SET = 'GLOBAL_SET',
  GLOBAL_DELETE = 'GLOBAL_DELETE',
  GLOBAL_OBSERVABLE = 'GLOBAL_OBSERVABLE',
  GLOBAL_PRIVATE_UTIL = 'GLOBAL_PRIVATE_UTIL',
  // 全局 config
  CONFIG_SILENT = 'CONFIG_SILENT',
  CONFIG_DEVTOOLS = 'CONFIG_DEVTOOLS',
  CONFIG_KEY_CODES = 'CONFIG_KEY_CODES',
  CONFIG_PRODUCTION_TIP = 'CONFIG_PRODUCTION_TIP',
  CONFIG_IGNORED_ELEMENTS = 'CONFIG_IGNORED_ELEMENTS',
  CONFIG_WHITESPACE = 'CONFIG_WHITESPACE',
  CONFIG_OPTION_MERGE_STRATS = 'CONFIG_OPTION_MERGE_STRATS',
  // 实例 API
  INSTANCE_SET = 'INSTANCE_SET',
  INSTANCE_DELETE = 'INSTANCE_DELETE',
  INSTANCE_DESTROY = 'INSTANCE_DESTROY',
  INSTANCE_EVENT_EMITTER = 'INSTANCE_EVENT_EMITTER',
  INSTANCE_EVENT_HOOKS = 'INSTANCE_EVENT_HOOKS',
  INSTANCE_CHILDREN = 'INSTANCE_CHILDREN',
  INSTANCE_LISTENERS = 'INSTANCE_LISTENERS',
  INSTANCE_SCOPED_SLOTS = 'INSTANCE_SCOPED_SLOTS',
  INSTANCE_ATTRS_CLASS_STYLE = 'INSTANCE_ATTRS_CLASS_STYLE',
  // options
  OPTIONS_DATA_FN = 'OPTIONS_DATA_FN',
  OPTIONS_DATA_MERGE = 'OPTIONS_DATA_MERGE',
  OPTIONS_BEFORE_DESTROY = 'OPTIONS_BEFORE_DESTROY',
  OPTIONS_DESTROYED = 'OPTIONS_DESTROYED',
  // watch / props
  WATCH_ARRAY = 'WATCH_ARRAY',
  PROPS_DEFAULT_THIS = 'PROPS_DEFAULT_THIS',
  // 指令 / 事件
  V_ON_KEYCODE_MODIFIER = 'V_ON_KEYCODE_MODIFIER',
  CUSTOM_DIR = 'CUSTOM_DIR',
  // 属性 coercion
  ATTR_FALSE_VALUE = 'ATTR_FALSE_VALUE',
  ATTR_ENUMERATED_COERCION = 'ATTR_ENUMERATED_COERCION',
  // trick
  TRANSITION_CLASSES = 'TRANSITION_CLASSES',
  TRANSITION_GROUP_ROOT = 'TRANSITION_GROUP_ROOT',
  // 组件形态
  COMPONENT_ASYNC = 'COMPONENT_ASYNC',
  COMPONENT_FUNCTIONAL = 'COMPONENT_FUNCTIONAL',
  COMPONENT_V_MODEL = 'COMPONENT_V_MODEL',
  RENDER_FUNCTION = 'RENDER_FUNCTION',
  FILTERS = 'FILTERS',
  // 私有 API(兜底)
  PRIVATE_APIS = 'PRIVATE_APIS',
}

设计要点:枚举值就是字符串本身(GLOBAL_SET = 'GLOBAL_SET')。这不是冗余——它让 configureCompat 接受的对象可以写成 { GLOBAL_SET: false } 这种"人类可读"形式,也让 warnDeprecation 拼接的警告消息 (deprecation GLOBAL_SET) ... 直接可用。同时,字符串枚举在运行时不会被完全擦除(不像数字枚举),DeprecationTypes.GLOBAL_SET 在使用处会被内联成 "GLOBAL_SET" 字面量。

第二个关键结构是 deprecationData(compatConfig.ts:74-426),它是一个 Record<DeprecationTypes, { message, link? }>,每个降级点的警告文案与迁移文档链接。message 既可以是字符串,也可以是函数(用于插值组件名、属性名、事件名等上下文),例如:

// packages/runtime-core/src/compat/compatConfig.ts:207-215
[DeprecationTypes.INSTANCE_EVENT_HOOKS]: {
  message: event =>
    `"${event}" lifecycle events are no longer supported. From templates, ` +
    `use the "vue:" prefix instead of "hook:". For example, @${event} ` +
    `should be changed to @vue:${event.slice(5)}. ...`,
  link: `https://v3-migration.vuejs.org/breaking-changes/vnode-lifecycle-events.html`,
},

deprecationData 还被复用为一个校验字典:validateCompatConfig 用它来判断用户传入的 key 是否是合法降级点(compatConfig.ts:525-545)。

18.2.2 CompatConfig 对象模型与"四个来源"的优先级

CompatConfig 的类型定义在 compatConfig.ts:495-499:

export type CompatConfig = Partial<
  Record<DeprecationTypes, boolean | 'suppress-warning'>
> & {
  MODE?: 2 | 3 | ((comp: Component | null) => 2 | 3)
}

每个降级点可以取三种值:

  • true——显式启用 v2 兼容行为;
  • false——显式禁用 v2 兼容行为(采用 v3 行为);
  • 'suppress-warning'——保持 v2 行为但不再打印该条警告("我知道但先不改"的过渡态)。

外加一个特殊的 MODE,取值 2 / 3 / (comp) => 2|3(函数形式支持"按组件类型动态决定模式")。

从来源看,配置有四个层次,优先级从低到高:

顺序来源存储位置设置途径作用域
1全局默认globalCompatConfig = { MODE: 2 }源码内置(compatConfig.ts:501-503)全应用默认进入 v2 模式
2全局用户配置globalCompatConfigconfigureCompat(config) → extend 覆盖整个 app
3编译器配置compilerOptions.compatConfig传给 @vue/compiler-dom 的 compatConfig编译产物(影响 transformFilter 等编译期降级)
4组件配置Component.compatConfig组件选项里的 compatConfig该组件及其子树

运行期读取时只涉及第 4 与第 2 层(第 3 层在编译期由编译器自己的 compiler-core/src/compat/compatConfig.ts 读取,见 18.6)。读取函数是 getCompatConfigForKey:

// packages/runtime-core/src/compat/compatConfig.ts:554-564
export function getCompatConfigForKey(
  key: DeprecationTypes | 'MODE',
  instance: ComponentInternalInstance | null,
): CompatConfig[DeprecationTypes | 'MODE'] {
  const instanceConfig =
    instance && (instance.type as ComponentOptions).compatConfig
  if (instanceConfig && key in instanceConfig) {
    return instanceConfig[key]
  }
  return globalCompatConfig[key]
}

算法只有两步,但第二步藏着一个容易忽略的正确性细节:

  1. 实例层优先:若存在 instance,且 instance.type.compatConfig 确实含该 key(key in instanceConfig,即 in 运算符而非常规的 hasOwn),返回实例配置的值;
  2. 全局兜底:否则返回 globalCompatConfig[key]。

为什么用 in 而不是 hasOwnProperty?因为组件作者可能把 compatConfig 写成继承自某个共享配置对象的形式(例如 compatConfig: Object.create(baseCompatConfig)),in 会沿原型链查找,能命中继承来的配置。这与 getCompatConfigForKey 在"组件层未命中就落到全局"的两级结构一致——组件层负责精确覆写、全局层负责统一默认。

易错点 1:getCompatConfigForKey 的实例参数在绝大多数调用点传的是 getCurrentInstance()(见 warnDeprecation 第 450 行 instance = instance || getCurrentInstance())。这意味着在渲染上下文之外调用(如定时器里读某个实例属性)可能读到 null,从而只走全局配置。理解这一点对排查"为什么 per-component compatConfig 不生效"很关键。

易错点 2:COMPONENT_V_MODEL 在 convertLegacyVModelProps(componentVModel.ts:22-31)里传的是一个伪实例 { type } as any——因为此处需要的是"被渲染的 v-model 组件自身的 compatConfig",而不是"正在渲染它的父实例"。

18.2.3 configureCompat 的合并算法与 validateCompatConfig

configureCompat 的实现只有 6 行,却承担了"入口 API"的全部职责:

// packages/runtime-core/src/compat/compatConfig.ts:505-510
export function configureCompat(config: CompatConfig): void {
  if (__DEV__) {
    validateCompatConfig(config)
  }
  extend(globalCompatConfig, config)
}

算法:

  1. 开发期校验(validateCompatConfig);
  2. extend(globalCompatConfig, config)——浅合并,后者覆盖前者。注意这里是就地合并(mutate globalCompatConfig),不是返回新对象。因此多次调用 configureCompat 是累加的:configureCompat({ MODE: 3 }) 之后 configureCompat({ GLOBAL_SET: false }),两项都会保留。

validateCompatConfig(compatConfig.ts:516-552)做了三件事:

const seenConfigObjects = /*@__PURE__*/ new WeakSet<CompatConfig>()
const warnedInvalidKeys: Record<string, boolean> = {}

export function validateCompatConfig(config, instance?) {
  if (seenConfigObjects.has(config)) return    // ① 同一对象只校验一次
  seenConfigObjects.add(config)

  for (const key of Object.keys(config)) {     // ② 逐 key 校验合法性
    if (key !== 'MODE' && !(key in deprecationData) && !(key in warnedInvalidKeys)) {
      if (key.startsWith('COMPILER_')) {
        if (isRuntimeOnly()) {
          warn(`Deprecation config "${key}" is compiler-specific and you are ` +
            `running a runtime-only build of Vue. ...`)
        }
      } else {
        warn(`Invalid deprecation config "${key}".`)
      }
      warnedInvalidKeys[key] = true
    }
  }

  if (instance && config[DeprecationTypes.OPTIONS_DATA_MERGE] != null) { // ③ 全局限定
    warn(`Deprecation config "OPTIONS_DATA_MERGE" can only be configured globally.`)
  }
}

三点值得展开:

  • ① 去重:seenConfigObjects 是 WeakSet。它解决的是"同一个 compatConfig 对象被多个组件共享"时的重复校验——例如 const cfg = { FILTERS: false } 被 100 个组件复用,若每次都遍历一遍对象既浪费又会重复告警。
  • ② 前缀分流:如果用户写了 COMPILER_FILTERS 这类 编译器专属 降级点(compiler-core/src/compat/compatConfig.ts 的 CompilerDeprecationTypes,见 18.6),而当前又是仅运行时构建(isRuntimeOnly(),即没带 @vue/compiler-dom),则给出专门的提示"应当通过构建工具(vue-loader/vite)传给编译器,而不是运行时 configureCompat"。这是一个非常贴心的人机工程细节:编译期降级项无法通过运行时配置改变,代码要提示用户去改构建配置。
  • ③ 全局限定校验:OPTIONS_DATA_MERGE(data 深度合并降级)只能在全局配置——因为 mergeDataFn(componentOptions.ts:1041-1058)在静态合并阶段(isCompatEnabled(..., null))判定,此时根本没有实例上下文,无法 per-component 覆写。

warnInvalidKeys(key 为 string)与 seenConfigObjects(WeakSet)的区别也值得注意:前者是字符串去重(同一个非法 key 只警告一次,跨所有 config 对象),后者是对象去重(同一 config 对象只校验一次)。二者组合覆盖了"同一个 key 从不同对象重复出现"与"同一对象重复传入"两种冗余。

18.2.4 isCompatEnabled:MODE 驱动的双向判定

真正决定"走 v2 分支还是 v3 分支"的是 isCompatEnabled(compatConfig.ts:566-588):

export function isCompatEnabled(
  key: DeprecationTypes,
  instance: ComponentInternalInstance | null,
  enableForBuiltIn = false,
): boolean {
  // 内置组件永远跳过 compat
  if (!enableForBuiltIn && instance && instance.type.__isBuiltIn) {
    return false
  }

  const rawMode = getCompatConfigForKey('MODE', instance) || 2
  const val = getCompatConfigForKey(key, instance)

  const mode = isFunction(rawMode)
    ? rawMode(instance && instance.type)
    : rawMode

  if (mode === 2) {
    return val !== false            // 默认开(除非显式 false)
  } else {
    return val === true || val === 'suppress-warning'   // 默认关(除非显式开)
  }
}

算法步骤:

  1. 内置组件短路:若 instance.type.__isBuiltIn(KeepAlive/Transition/TransitionGroup 在 compat 构建下被 decorate 打上此标记,见 KeepAlive.ts:386-388、Transition.ts:74-81、BaseTransition.ts:274-276)且 enableForBuiltIn 为 false,直接返回 false。原因:内置组件内部的实现是 v3 原生,不应被用户全局配置"误降级"。少数需要"连内置组件也降级"的场景(如 renderFn.ts:304-317 的 legacy vnode properties)显式传入 enableForBuiltIn = true。
  2. 读 MODE:getCompatConfigForKey('MODE', instance) || 2。默认是 2(v2 模式)——这正契合"进入 @vue/compat 默认先模拟 v2"的定位。
  3. MODE 可以是函数:isFunction(rawMode) 时以组件类型为参数求值,支持"按组件类型动态切换模式"这一高级用法。
  4. 双向默认值:
    • MODE === 2 时,判定逻辑是 val !== false——即默认开启兼容,只有显式 false 才关闭;
    • MODE === 3 时,判定逻辑是 val === true || val === 'suppress-warning'——即默认关闭兼容,只有显式 true(或 'suppress-warning')才开启。

这解释了两种迁移方向:

  • 默认模式(MODE 2):整仓代码"先旧行为跑起来",用户逐个 configureCompat({ X: false }) 关闭兼容行为,代码随之改成 v3 写法;
  • 反向模式(MODE 3):把整个应用切到 v3 行为,只对少数暂无法修改的第三方组件用组件级 compatConfig: { FILTERS: true } 开启兼容。

'suppress-warning' 在两种模式下都"启用兼容"(在 MODE 3 下它 === 'suppress-warning' 判真),但额外效果是 warnDeprecation 里读到该值就直接 return、不打印。它是"我暂时保留旧行为,但别刷我的控制台"的逃生舱。

18.2.5 四个断言助手:区分"必须启用"与"尽量启用"

compatConfig.ts:590-635 提供了四个用途不同的助手,命名与语义需要严格区分:

// ① 完全移除:禁用即抛错
export function assertCompatEnabled(key, instance, ...args): void {
  if (!isCompatEnabled(key, instance)) {
    throw new Error(`${key} compat has been disabled.`)      // ← 抛错
  } else if (__DEV__) {
    warnDeprecation(key, instance, ...args)
  }
}

// ② 旧用法仍可用,但禁用后可能运行时报错:总是警告
export function softAssertCompatEnabled(key, instance, ...args): boolean {
  if (__DEV__) warnDeprecation(key, instance, ...args)
  return isCompatEnabled(key, instance)
}

// ③ 语法相同但行为互斥(如 render 函数):启用才警告
export function checkCompatEnabled(key, instance, ...args): boolean {
  const enabled = isCompatEnabled(key, instance)
  if (__DEV__ && enabled) warnDeprecation(key, instance, ...args)
  return enabled
}

对照表:

助手禁用时的行为是否警告典型用例
assertCompatEnabled抛 Error是(仅启用时)Vue.extend、$set、$destroy、updateHook 等"完全移除"的 API
softAssertCompatEnabled返回 false,调用方决定总是警告functional 组件、beforeDestroy、ATTR_ENUMERATED_COERCION
checkCompatEnabled返回 false,静默仅启用时警告render 函数(convertLegacyRenderFn)、WATCH_ARRAY、v-model

这三者背后是同一套哲学:"是否启用"与"是否警告"是两个正交的维度。 assertCompatEnabled 关心前者(禁用即崩,因为它对应的 API 在非兼容构建里根本不存在,如 Vue.extend);softAssertCompatEnabled 关心"这条旧用法走不通"时给出提示但仍允许继续(如 ATTR_ENUMERATED_COERCION 只是行为差异,非致命);checkCompatEnabled 则用于"语法一样、语义不同"的 render 函数——因为在 V2 模式下一条 render(h) 是合法旧写法,只有在兼容模式开启时我们才提示"你在用旧签名"。

18.2.6 warnDeprecation:去重、抑制与"警告爆炸"治理

warnDeprecation(compatConfig.ts:438-493)是兼容层的"输出端",其去重算法值得细看:

const instanceWarned: Record<string, true> = Object.create(null)
const warnCount: Record<string, number> = Object.create(null)

export function warnDeprecation(key, instance, ...args): void {
  if (!__DEV__) return
  if (__TEST__ && !warningEnabled) return

  instance = instance || getCurrentInstance()

  // ① 用户配置抑制
  const config = getCompatConfigForKey(key, instance)
  if (config === 'suppress-warning') return

  const dupKey = key + args.join('')
  let compId = instance && formatComponentName(instance, instance.type)
  if (compId === 'Anonymous' && instance) compId = instance.uid

  // ② 同组件同警告只报一次
  const componentDupKey = dupKey + compId
  if (!__TEST__ && componentDupKey in instanceWarned) return
  instanceWarned[componentDupKey] = true

  // ③ 同警告不同组件:省略长文案,只报 key + 计数
  if (!__TEST__ && dupKey in warnCount) {
    warn(`(deprecation ${key}) (${++warnCount[dupKey] + 1})`)
    return
  }
  warnCount[dupKey] = 0

  // ④ 打印完整警告
  const { message, link } = deprecationData[key]
  warn(`(deprecation ${key}) ${typeof message === 'function' ? message(...args) : message}` +
       `${link ? `\n  Details: ${link}` : ``}`)

  // ⑤ 若该兼容行为实际已禁用,追加一条"将导致运行时错误"的提示
  if (!isCompatEnabled(key, instance, true)) {
    console.error(`^ The above deprecation's compat behavior is disabled and will likely lead to runtime errors.`)
  }
}

逐点:

  • ① 抑制:读该 key 的配置,若是 'suppress-warning',直接返回。注意这里读的是"当前实例(或全局)"的配置——所以 suppress-warning 可以按组件粒度设置。
  • ② 组件级去重:componentDupKey = dupKey + compId。compId 优先用组件名,匿名组件退化为 uid。这保证"同一个 $children 警告对同一个组件只报一次",即便它在多次渲染里反复触发。
  • ③ 计数折叠:如果 dupKey(不含 compId)已出现过(即不同组件触发了同一类警告),则不再打印长文案,改成 (deprecation FILTERS) (7) 这样的"折叠计数"。这是对付"一个页面 200 个组件都在用 filters"导致的控制台洪泛的关键设计。
  • ④ 文案拼接:message 为函数时以 args 插值,再附 Details: 链接。
  • ⑤ 一致性交叉检查:如果警告的这条兼容行为实际已被禁用(isCompatEnabled(key, instance, true) 为 false),说明"代码还走着旧路径但兼容层不让走了",后面大概率抛运行时错误,于是补一条 console.error。这是一个很妙的"预警"机制——代码路径与配置不一致时立刻点破。

此外还有测试专用开关 toggleDeprecationWarning(compatConfig.ts:434-436)与 __TEST__ 分支,以及文件末尾那段"测试默认跑在 v3 模式":

// packages/runtime-core/src/compat/compatConfig.ts:637-641
if (__TEST__) {
  configureCompat({ MODE: 3 })
}

因为 Vue 自身的单测大多写的是 v3 语义,默认 MODE: 3 可让它们在不显式配置时走 v3 行为。


18.3 global.ts:Vue 2 全局 API 的兼容与"单例 app"模型

18.3.1 createCompatVue:一个"假的构造函数"

global.ts:143-153 里有一段极其"反直觉"的代码:

// packages/runtime-core/src/compat/global.ts:143-153
export function createCompatVue(
  createApp: CreateAppFunction<Element>,
  createSingletonApp: CreateAppFunction<Element>,
): CompatVue {
  singletonApp = createSingletonApp({})

  const Vue: CompatVue = (singletonCtor = function Vue(
    options: ComponentOptions = {},
  ) {
    return createCompatApp(options, Vue)
  } as any)

关键在于:Vue 被设计成一个"可被 new 的函数",但 new Vue(...) 的实际返回值不是 this,而是 createCompatApp 返回的 root proxy。

在 JavaScript 里,构造函数若显式 return 一个对象,new 表达式的结果就是那个对象(而不是新创建的 this)。createCompatApp 返回的正是 app._createRoot(options) 的产物或 $mount() 的 proxy。于是 new Vue({...}) 在式样上与 Vue 2 完全一致,语义上却是:

new Vue(options)
  → createCompatApp(options, Vue)
    → assertCompatEnabled(GLOBAL_MOUNT)
    → 处理 options.data 是对象的情况(转函数)
    → createApp(options)                     // 真实 v3 app
    → applySingletonPrototype(app, Ctor)     // 把 prototype 迭代进 globalProperties
    → app._createRoot(options)               // 手工创建 root 实例(未挂载)
    → options.el ? vm.$mount(options.el) : vm

注意 Vue 同时被用作类型与值(TS 里的"双重身份"):作为值它是那个函数;作为类型 CompatVue 是 global.ts:67-134 声明的接口(Pick<App,...> & { new(options): LegacyPublicInstance; config; use; mixin; extend; set; ... })。这个接口是"迁移期类型脚手架"——它让 Vue.prototype.xxx = ...、Vue.component(...) 在 TS 下能通过类型检查,但注释里明写"these types are not meant for actual use"(global.ts:71-72)。

singletonApp 与 singletonCtor 是两个模块级变量:

// packages/runtime-core/src/compat/global.ts:136-140
export let isCopyingConfig = false
export let singletonApp: App
let singletonCtor: CompatVue

singletonApp 是全局单例 app——它承载 Vue.config、Vue.mixin、Vue.component、Vue.directive、Vue.filter 这些"全局注册"操作。每个通过 createApp/new Vue 创建的真实 app,在初始化时(installAppCompatProperties → applySingletonAppMutations)都会继承单例 app 的注册表与全局配置。这套机制让 Vue 2 的"全局注册 + 全局配置"语义在 v3 的"每个 app 独立 context"模型上得以重现。

18.3.2 Vue.config、Vue.use、Vue.mixin、Vue.component、Vue.directive

global.ts:181-221 把这批 API 逐个挂到 Vue 上:

Vue.version = `2.6.14-compat:${__VERSION__}`    // ← 关键:伪装成 2.6.14
Vue.config = singletonApp.config

Vue.use = (plugin, ...options) => {              // ① 兼容 install / 函数两种插件
  if (plugin && isFunction(plugin.install)) {
    plugin.install(Vue as any, ...options)
  } else if (isFunction(plugin)) {
    plugin(Vue as any, ...options)
  }
  return Vue
}

Vue.mixin = m => { singletonApp.mixin(m); return Vue }
Vue.component = ((name, comp) => {
  if (comp) { singletonApp.component(name, comp); return Vue }
  else { return singletonApp.component(name) }
}) as any
Vue.directive = ((name, dir) => {
  if (dir) { singletonApp.directive(name, dir as Directive); return Vue }
  else { return singletonApp.directive(name) }
}) as any

几点说明:

  • Vue.version 伪装成 2.6.14-compat:${__VERSION__}:这是给第三方库探测版本用的。很多 Vue 2 时代的库用 Vue.version.startsWith('2') 判断"我在 v2 环境",据此启用 v2 代码路径。把版本号前缀写成 2.6.14-compat:3.5.44,既满足"以 2 开头"的探测,又保留了真实 v3 版本信息(__VERSION__ 由 rollup 注入为该 master 版本)。
  • Vue.use 的"鸭子类型":插件既可以是 { install(app, ...options) } 对象,也可以是 function(app, ...options) 函数。注释里那句 "so that app.use() can work with legacy plugins that extend prototypes"(global.ts:370-371)点明了原因:v2 插件靠 Vue.prototype 扩展,而 v3 的 app.use 只认 install。Vue.use 直接调用插件并把 Vue 传进去,从而让 插件里 Vue.prototype.x = ... 这种写法能命中 applySingletonPrototype 的复制逻辑。
  • component/directive 的"双形态":Vue.component(name, comp) 注册、Vue.component(name) 查询,返回 Vue 以支持链式调用(v2 的 API 全都返回自身)。
  • Vue.mixin/Vue.component/Vue.directive/Vue.filter 全部代理到 singletonApp。因为它们注册的是"全局资产",而 singleton 是所有 app 的共同祖先。

18.3.3 Vue.extend:把"构造函数继承"翻译成 mergeOptions

Vue.extend 是 v2 里最核心的"类继承"API,v3 移除后用 defineComponent 替代。兼容层的 extendCtor(global.ts:225-282):

const extendCache = new WeakMap()

function extendCtor(this: any, extendOptions: ComponentOptions = {}) {
  assertCompatEnabled(DeprecationTypes.GLOBAL_EXTEND, null)     // 禁用即抛错
  if (isFunction(extendOptions)) extendOptions = extendOptions.options

  if (extendCache.has(extendOptions)) return extendCache.get(extendOptions)  // ① 缓存

  const Super = this
  function SubVue(inlineOptions?: ComponentOptions) {
    if (!inlineOptions) {
      return createCompatApp(SubVue.options, SubVue)                    // ② new SubVue()
    } else {
      return createCompatApp(
        mergeOptions(extend({}, SubVue.options), inlineOptions,
                     internalOptionMergeStrats as any),
        SubVue,
      )
    }
  }
  SubVue.super = Super
  SubVue.prototype = Object.create(Vue.prototype)     // ③ 原型链
  SubVue.prototype.constructor = SubVue

  // ④ 克隆非原始类型的基类选项(防止子类修改污染父类)
  const mergeBase: any = {}
  for (const key in Super.options) {
    const superValue = Super.options[key]
    mergeBase[key] = isArray(superValue) ? superValue.slice()
      : isObject(superValue) ? extend(Object.create(null), superValue) : superValue
  }

  SubVue.options = mergeOptions(mergeBase, extendOptions, internalOptionMergeStrats as any)
  SubVue.options._base = SubVue                              // ⑤ _base 指向自己
  SubVue.extend = extendCtor.bind(SubVue)                    // ⑥ 支持链式 extend
  SubVue.mixin = Super.mixin
  SubVue.use = Super.use
  SubVue.cid = ++cid                                         // ⑦ 自增组件 id

  extendCache.set(extendOptions, SubVue)
  return SubVue
}
Vue.extend = extendCtor.bind(Vue) as any

算法实质是"用 mergeOptions 模拟构造函数的 prototype 链":

  1. 缓存:同一个 extendOptions 对象只生成一个 SubVue(WeakMap)。这是副作用去重——若 Vue.extend(opts) 被调用两次,应当返回"同一个类"。
  2. SubVue(inlineOptions) 也是一个"假构造函数":无参数时 new SubVue() → createCompatApp(SubVue.options, SubVue);有参数时把 inline 选项与 SubVue.options 合并后创建。这与前面 Vue(...) 的模式完全一致。
  3. SubVue.prototype = Object.create(Vue.prototype):这是 v2 Vue.extend 的真原型链语义。它让 SubVue.prototype 继承 Vue.prototype 上的所有增强,因而 SubVue 的实例(其实是 proxy)在 globalProperties 上也能看到这些增强。注意这里的 Vue.prototype 指的是"构造函数对象上的 prototype 属性",而非 app.config.globalProperties——它们的连接点在 applySingletonPrototype。
  4. 克隆基类选项:for (const key in Super.options) 对数组 slice()、对象 extend(Object.create(null), superValue)。目的:mergeOptions 在合并时可能就地 mutate to(如往 to.components 里塞键),若不克隆,一个子类的注册会污染父类的 options.components。这是一个真实的"多级继承串味"边界处理。
  5. _base 指向 SubVue:v2 里 this.$options._base 是"Ctor",用于 _createElement 里按字符串解析组件。这里保持该字段以便第三方库读。
  6. SubVue.extend = extendCtor.bind(SubVue):支持 A.extend().extend()(多级继承)。
  7. cid 自增:v2 里 cid 是组件构造函数的唯一 id,用于 devtools/性能标记。

mergeOptions 这里被传了第三参 internalOptionMergeStrats as any——这是 componentOptions.ts 里内置的合并策略表(含 data、provide、watch、props…)。为什么显式传?因为默认的 optionMergeStrategies 在 compat 下被 installLegacyOptionMergeStrats 换成了一个 Proxy(globalConfig.ts:65-83),而 extend 需要"确定的、内置的"策略,故直接引用内部表。

18.3.4 Vue.set / Vue.delete / Vue.observable / Vue.filter / Vue.util

// packages/runtime-core/src/compat/global.ts:284-325
Vue.set = (target, key, value) => {
  assertCompatEnabled(DeprecationTypes.GLOBAL_SET, null)
  target[key] = value                       // ← v3 里普通赋值即可触发
}
Vue.delete = (target, key) => {
  assertCompatEnabled(DeprecationTypes.GLOBAL_DELETE, null)
  delete target[key]
}
Vue.observable = (target: any) => {
  assertCompatEnabled(DeprecationTypes.GLOBAL_OBSERVABLE, null)
  return reactive(target)                   // ← 桥接到 v3 reactivity
}
Vue.filter = ((name, filter?) => {
  if (filter) { singletonApp.filter!(name, filter); return Vue }
  else { return singletonApp.filter!(name) }
}) as any

const util = {
  warn: __DEV__ ? warn : NOOP,
  extend,
  mergeOptions: (parent, child, vm?) =>
    mergeOptions(parent, child, vm ? undefined : (internalOptionMergeStrats as any)),
  defineReactive,
}
Object.defineProperty(Vue, 'util', {
  get() {
    assertCompatEnabled(DeprecationTypes.GLOBAL_PRIVATE_UTIL, null)
    return util
  },
})
  • Vue.set/Vue.delete 真的只是普通赋值/delete:v3 的 Proxy 响应式能追踪"新增属性"与"删除属性",所以 v2 里需要 Vue.set 才能生效的场景在 v3 里天然可用。兼容层保留这两个 API 只是为了"不报错",实现即原语义。它们用 assertCompatEnabled(禁用即抛)——因为在非兼容构建里它们根本不存在。
  • Vue.observable 桥接到 reactive:这是兼容层里少见的"直接复用 v3 能力"——因为 v2 Vue.observable(obj) 的语义就是"返回响应式对象",与 reactive 等价。
  • Vue.util 用 getter 放行:Object.defineProperty + getter,访问时才 assertCompatEnabled。util 里包含 warn/extend/mergeOptions/defineReactive 四个内部工具(defineReactive 见 global.ts:618-668,它是一个"用 Object.defineProperty 手动给对象装 getter/setter 并内部走 track/trigger"的 v2 风格实现,用于让依赖 v2 Vue.util.defineReactive 的老库能蹭到 v3 的响应式)。
  • Vue.filter 代理到 singletonApp.filter,而复用了 installFilterMethod 里注入的 app.filter。

18.3.5 installAppCompatProperties:把一个"真实 app"接到单例模型上

installAppCompatProperties(app, context, render)(global.ts:332-350)是 apiCreateApp.ts:481-483 里被 __COMPAT__ 守卫调用的:

// packages/runtime-core/src/compat/global.ts:332-350
export function installAppCompatProperties(app, context, render): void {
  installFilterMethod(app, context)
  installLegacyOptionMergeStrats(app.config)

  if (!singletonApp) {
    // 这是创建 singleton 自身的调用,跳过后续
    return
  }

  installCompatMount(app, context, render)
  installLegacyAPIs(app)
  applySingletonAppMutations(app)
  if (__DEV__) installLegacyConfigWarnings(app.config)
}

逐行:

  • installFilterMethod(global.ts:352-365):给 context.filters = {} 初值,并把 app.filter 挂上。
  • installLegacyOptionMergeStrats(globalConfig.ts:65-83):把 app.config.optionMergeStrategies 换成一个 Proxy——读取内置策略时,若该 key 命中 internalOptionMergeStrats 且 CONFIG_OPTION_MERGE_STRATS 兼容开启,返回内置策略;否则返回 undefined。这是"让用户能读到 v2 内置策略"的兼容。
  • if (!singletonApp) 早退:当本次调用就是"创建 singletonApp 自身"(createSingletonApp({}) → createApp → installAppCompatProperties)时,singletonApp 还未赋值,跳过后续那些"从 singleton 复制到 app"的步骤,否则会死循环/读到 undefined。
  • installCompatMount(见 18.3.6)——注入 _createRoot。
  • installLegacyAPIs(global.ts:367-388)——把 Vue 上的 API 也挂到 app 上,供老插件通过 app.xxx 使用:
function installLegacyAPIs(app: App) {
  Object.defineProperties(app, {
    prototype: {                                  // app.prototype → globalProperties
      get() {
        __DEV__ && warnDeprecation(DeprecationTypes.GLOBAL_PROTOTYPE, null)
        return app.config.globalProperties
      },
    },
    nextTick: { value: nextTick },
    extend: { value: singletonCtor.extend },
    set: { value: singletonCtor.set },
    delete: { value: singletonCtor.delete },
    observable: { value: singletonCtor.observable },
    util: { get() { return singletonCtor.util } },
  })
}
  • applySingletonAppMutations(global.ts:390-426)——把单例的注册表与全局配置复制到当前 app:
function applySingletonAppMutations(app: App) {
  app._context.mixins = [...singletonApp._context.mixins]              // ① mixins 数组拷贝
  ;['components', 'directives', 'filters'].forEach(key => {
    app._context[key] = Object.create(singletonApp._context[key])       // ② 原型继承
  })

  isCopyingConfig = true
  for (const key in singletonApp.config) {                             // ③ 复制 config
    if (key === 'isNativeTag') continue
    if (isRuntimeOnly() && (key === 'isCustomElement' || key === 'compilerOptions')) continue
    const val = singletonApp.config[key as keyof AppConfig]
    app.config[key] = isObject(val) ? Object.create(val) : val

    // ignoredElements → isCustomElement 的运行时兼容
    if (key === 'ignoredElements' && isCompatEnabled(DeprecationTypes.CONFIG_IGNORED_ELEMENTS, null)
        && !isRuntimeOnly() && isArray(val)) {
      app.config.compilerOptions.isCustomElement = tag =>
        val.some(v => (isString(v) ? v === tag : v.test(tag)))
    }
  }
  isCopyingConfig = false
  applySingletonPrototype(app, singletonCtor)
}
  • ① mixins 用 [...] 拷贝:因为后续 app.mixin() 会往这个数组 push,不能共享数组引用。
  • ② 资产注册表用 Object.create(...) 原型继承:components/directives/filters 用原型链继承单例注册表。这样"全局 Vue.component('x', ...) 之后新建的 app 也能解析到 x",而"某个 app 自己再注册 'x'"只覆盖该 app 自己的表(子对象上的同名键遮蔽原型键),不影响单例或其他 app。这是"全局注册 + app 隔离"的精妙平衡。
  • ③ 复制 config:isCopyingConfig = true 是一个抑制标志——installLegacyConfigWarnings 给 config.silent 等属性装了 setter,setter 里 if (!isCopyingConfig) warnDeprecation(...)。复制阶段会触发 setter,若不抑制就会把"config 复制"误报成"用户用了 config.silent"。
  • ignoredElements 转换:把 v2 的 config.ignoredElements(字符串或正则数组)转换成 v3 的 config.compilerOptions.isCustomElement(函数)。这里体现了"仅在非 runtime-only 构建才转换"——因为 isCustomElement 只有带编译器的全量构建才在运行时读得到,runtime-only 构建必须通过构建工具传给编译器。

18.3.6 installCompatMount:手工复刻 app.mount 的"预创建实例"语义

Vue 2 允许"创建一个未挂载的实例"(const vm = new Vue(options),之后再 vm.$mount())。v3 没有这个概念(app.mount 一步到位)。installCompatMount(global.ts:452-604)通过重写 app._createRoot 来模拟它。

app._createRoot(options) 的核心(global.ts:464-604):

app._createRoot = options => {
  const component = app._component
  const vnode = createVNode(component, options.propsData || null)
  vnode.appContext = context

  const hasNoRender = !isFunction(component) && !component.render && !component.template
  const emptyRender = () => {}

  const instance = createComponentInstance(vnode, null, null)    // ① 手工创建实例
  if (hasNoRender) instance.render = emptyRender                  // ② 占位 render
  setupComponent(instance)                                        // ③ setup(不走 OPTIONS 应用)
  vnode.component = instance
  vnode.isCompatRoot = true                                       // ④ 标记 compat root

  instance.ctx._compat_mount = (selectorOrEl?) => { ... }         // ⑤ $mount 实现
  instance.ctx._compat_destroy = () => { ... }                    // ⑥ $destroy 实现

  return instance.proxy!
}
  • ① createComponentInstance(vnode, null, null):直接手工建实例(第 13 章的知识)。这里 parent = null、parentSuspense = null,因为它是 root。
  • ② instance.render = emptyRender:当组件没有 render/template/setup render 时,先塞一个空函数,避免 finishComponentSetup 在此时打印"缺少 render 函数"警告——因为真正的模板要等 $mount 时从容器 innerHTML 提取。
  • ③ setupComponent(instance):注意这里没有传 isSSR/optimized,且后续 mount 时又用 skipOptions 路径——原因见 18.3.6 末尾。
  • ④ vnode.isCompatRoot = true:这个标记在 renderer.ts:1234 与 1258 被读取,让渲染器知道"这个组件的实例已经被 compat 提前创建好了",从而跳过重复创建:
// packages/runtime-core/src/renderer.ts:1230-1258(节选)
const compatMountInstance =
  __COMPAT__ && initialVNode.isCompatRoot && initialVNode.component
const instance: ComponentInternalInstance =
  compatMountInstance ||
  (initialVNode.component = createComponentInstance(initialVNode, ...))
...
if (!(__COMPAT__ && compatMountInstance)) {
  ...
  setupComponent(instance, false, optimized)
}
  • ⑤ _compat_mount:这是 vm.$mount(selectorOrEl) 的真实实现(instance.ts:88-95 里 $mount 返回 i.ctx._compat_mount || NOOP)。它手工做了 app.mount 在 runtime-dom 里做的一切:
instance.ctx._compat_mount = (selectorOrEl?) => {
  if (isMounted) { __DEV__ && warn('Root instance is already mounted.'); return }

  let container: Element
  if (typeof selectorOrEl === 'string') {
    const result = document.querySelector(selectorOrEl)
    if (!result) { __DEV__ && warn(`Failed to mount root instance: selector "${selectorOrEl}" returned null.`); return }
    container = result
  } else {
    container = selectorOrEl || document.createElement('div')     // ← 无参时新建 div
  }

  // 命名空间判定(svg / mathml)
  let namespace: ElementNamespace
  if (container instanceof SVGElement) namespace = 'svg'
  else if (typeof MathMLElement === 'function' && container instanceof MathMLElement) namespace = 'mathml'

  // HMR root reload
  if (__DEV__) {
    context.reload = () => {
      const cloned = cloneVNode(vnode)
      cloned.component = null        // compat 模式必须重置,否则会复用旧实例
      render(cloned, container, namespace)
    }
  }

  // 若组件无 render 且实例仍是占位 render,则从容器 innerHTML 取模板
  if (hasNoRender && instance.render === emptyRender) {
    if (__DEV__) { /* 检查容器上的 v- 前缀属性 → GLOBAL_MOUNT_CONTAINER 警告 */ }
    instance.render = null
    ;(component as ComponentOptions).template = container.innerHTML
    finishComponentSetup(instance, false, true /* skip options */)
  }

  container.textContent = ''       // 挂载前清空
  render(vnode, container, namespace)   // ← 真正进入渲染器

  if (container instanceof Element) {
    container.removeAttribute('v-cloak')
    container.setAttribute('data-v-app', '')
  }

  isMounted = true
  app._container = container
  ;(container as any).__vue_app__ = app
  if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) devtoolsInitApp(app, version)

  return instance.proxy!
}

这里每个细节都对应对齐 runtime-dom/src/index.ts:113-149 的 app.mount。为什么不直接调用 mount? 因为 app.mount 内部会走 renderer.ts 的 mount(vnode, container),而那条路径对 root 组件会重新创建实例(createComponentInstance),与 compat 已经预创建的实例冲突。_compat_mount 绕开 app.mount 的"挂载前处理",直接把已经建好的 vnode(vnode.component 已指向预创建实例)交给底层 render(vnode, container, namespace),从而复用实例。这是"预创建实例语义"能在 v3 渲染器上跑通的唯一办法。

  • ⑥ _compat_destroy:vm.$destroy()(instance.ts:97-101 里返回 i.ctx._compat_destroy || NOOP)。它分两种情况:
instance.ctx._compat_destroy = () => {
  if (isMounted) {
    render(null, app._container)                 // 已挂载:走正常 unmount
    if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) devtoolsUnmountApp(app)
    delete app._container.__vue_app__
  } else {
    const { bum, scope, um } = instance         // 未挂载:手工触发生命周期
    if (bum) invokeArrayFns(bum)                // beforeUnmount
    if (isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance))
      instance.emit('hook:beforeDestroy')
    if (scope) scope.stop()                     // 停止 effect scope
    if (um) invokeArrayFns(um)                  // unmounted
    if (isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance))
      instance.emit('hook:destroyed')
  }
}

"未挂载即销毁"是 v2 的合法用法(创建了但从不挂载就销毁),v3 里若直接 render(null) 会因为没有真实容器而报错,所以手工触发 bum/um 并 scope.stop()。注意顺序严格是 beforeUnmount → hook:beforeDestroy → scope.stop → unmounted → hook:destroyed,与 v2 的 beforeDestroy → destroyed 对应。

至此 18.3 把全局 API 侧的兼容讲完。下一节进入实例侧。


18.4 compat/instance.ts 与生命周期映射:把实例 API 一个个接回来

18.4.1 installCompatInstanceProperties:往 publicPropertiesMap 里塞扩展

Vue 3 的实例 public 属性是通过一张静态表 publicPropertiesMap + 代理来提供的(第 13 章)。componentPublicInstance.ts:426-428 让 compat 构建往这张表里追加成员:

// packages/runtime-core/src/componentPublicInstance.ts:426-428
if (__COMPAT__) {
  installCompatInstanceProperties(publicPropertiesMap)
}

installCompatInstanceProperties(instance.ts:65-157)用 extend 往表里加了两批:

// packages/runtime-core/src/compat/instance.ts:77-157(节选)
extend(map, {
  $set: i => { assertCompatEnabled(DeprecationTypes.INSTANCE_SET, i); return set },
  $delete: i => { assertCompatEnabled(DeprecationTypes.INSTANCE_DELETE, i); return del },
  $mount: i => {
    assertCompatEnabled(DeprecationTypes.GLOBAL_MOUNT, null)
    return i.ctx._compat_mount || NOOP          // ← 来自 global.ts
  },
  $destroy: i => {
    assertCompatEnabled(DeprecationTypes.INSTANCE_DESTROY, i)
    return i.ctx._compat_destroy || NOOP
  },
  $slots: i => {
    if (isCompatEnabled(DeprecationTypes.RENDER_FUNCTION, i) && i.render && i.render._compatWrapped) {
      return new Proxy(i.slots, legacySlotProxyHandlers)      // ① 旧式 $slots
    }
    return __DEV__ ? shallowReadonly(i.slots) : i.slots
  },
  $scopedSlots: i => {
    assertCompatEnabled(DeprecationTypes.INSTANCE_SCOPED_SLOTS, i)
    return __DEV__ ? shallowReadonly(i.slots) : i.slots        // ② = $slots
  },
  $on: i => on.bind(null, i),
  $once: i => once.bind(null, i),
  $off: i => off.bind(null, i),
  $children: getCompatChildren,
  $listeners: getCompatListeners,
  $options: i => { ... },                                       // ③ 注入 parent/propsData
} as PublicPropertiesMap)

这张表是惰性的:代理 get 命中某个 key 时,才调用 map[key](instance) 求值。所以 $set 这类"返回一个函数"的项写成 i => { ...; return set }——第一次访问返回函数本身,之后由用户调用。

  • ① $slots 覆写:正常情况下 $slots 是"函数式 slot 对象"。但如果实例的 render 是被 compatH 包装过的 v2 render(_compatWrapped),那么 v2 render 里访问 this.$slots.foo 会期望拿到已求值的 vnode 数组(v2 $slots 语义),而不是函数。于是返回 new Proxy(i.slots, legacySlotProxyHandlers)——legacySlotProxyHandlers 的 get 会在取到 slot 时立即调用它(componentFunctional.ts:15-20)。
  • ② $scopedSlots = $slots:v3 里 scoped slots 与 slots 已统一(第 13 章),所以直接返回同一对象即可。
  • ③ $options:见 18.4.6。

18.4.2 生命周期重命名映射:beforeDestroy/destroyed

v2 的两个生命周期钩子在 v3 改名(beforeDestroy → beforeUnmount、destroyed → unmounted)。映射在 applyOptions(componentOptions.ts:739-752):

// packages/runtime-core/src/componentOptions.ts:739-752
if (__COMPAT__) {
  if (beforeDestroy && softAssertCompatEnabled(DeprecationTypes.OPTIONS_BEFORE_DESTROY, instance)) {
    registerLifecycleHook(onBeforeUnmount, beforeDestroy)
  }
  if (destroyed && softAssertCompatEnabled(DeprecationTypes.OPTIONS_DESTROYED, instance)) {
    registerLifecycleHook(onUnmounted, destroyed)
  }
}

即"把 beforeDestroy 的值注册到 onBeforeUnmount 的钩子数组、把 destroyed 注册到 onUnmounted"。用的是 softAssertCompatEnabled(总是告警,但禁用时只是不注册、不抛错)。

另有 5 组"hook 事件"(把生命周期当作组件事件来触发),它们在 renderer.ts 里被 __COMPAT__ 守卫包裹。以 mountComponent 为例:

// packages/runtime-core/src/renderer.ts:1371-1376(beforeMount)
if (__COMPAT__ && isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)) {
  instance.emit('hook:beforeMount')
}
// :1464-1470(mounted → 后置队列)
if (__COMPAT__ && isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)) {
  queuePostRenderEffect(() => instance.emit('hook:mounted'), parentSuspense)
}
// :1484-1491(activated)
// :1552-1557(beforeUpdate,同步)
// :1606-1612(updated,后置队列)
// :2385-2390(beforeDestroy → beforeUnmount 阶段)
// :2412-2418(destroyed → unmounted 阶段)

完整映射表:

v2 hook 事件v3 触发位置时机队列
hook:beforeMountmountComponent挂载前同步
hook:mountedmountComponent 后挂载后post
hook:beforeUpdateupdateComponent更新前同步
hook:updatedupdateComponent 后更新后post
hook:beforeDestroyunmountComponent卸载前同步
hook:destroyedunmountComponent卸载后同步
hook:activated / hook:deactivatedKeepAlive 激活/停用—post

注意 v2 里这两个是通过 $on('hook:xxx') 注册的组件事件,所以用 instance.emit('hook:...') 触发。这一点与"生命周期选项"(beforeDestroy 写在选项中)是两条独立路径——前者走事件系统,后者走 registerLifecycleHook。

模板里还有一条路径:@hook:mounted="..." 会被编译成对 hook: 事件的监听;在 setFullProps 里有兼容检查:

// packages/runtime-core/src/componentProps.ts:396-407
if (__COMPAT__) {
  if (key.startsWith('onHook:')) {
    softAssertCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance, key.slice(2).toLowerCase())
  }
  if (key === 'inline-template') continue     // ← inline-template 兼容
}

注意 v2 模板里监听生命周期事件写作 @hook:mounted,编译后 props key 是 onHook:mounted(toHandlerKey('hook:mounted'))。这里检测 onHook: 前缀以告警,并在 componentEmits.ts:130-134 里允许 hook: 事件绕过 emits 声明校验。而 v3 的新写法是 @vue:mounted(onVnodeMounted)。同时 inline-template(v2 的模板内联)在这里被直接跳过,避免它被当成普通 attr。

18.4.3 $listeners / $attrs fallthrough:两个"下落"相关的兼容

(a) $listeners 重建。 v3 把父组件的 v-on 监听器合并进了 $attrs(不再单列 $listeners)。getCompatListeners(instanceListeners.ts:5-21):

export function getCompatListeners(instance) {
  assertCompatEnabled(DeprecationTypes.INSTANCE_LISTENERS, instance)
  const listeners: Record<string, Function | Function[]> = {}
  const rawProps = instance.vnode.props
  if (!rawProps) return listeners
  for (const key in rawProps) {
    if (isOn(key)) {
      listeners[key[2].toLowerCase() + key.slice(3)] = rawProps[key]   // onClick → click
    }
  }
  return listeners
}

从 instance.vnode.props 里筛出所有 on 前缀的键,把 onClick 还原成 click(key[2].toLowerCase() 取首字母小写,+ key.slice(3) 接后续),拼成 v2 的 $listeners 对象。注意 .once/.capture 后缀在属性名里是 onClickOnce,还原后是 clickOnce——v2 的 $listeners 不带这些后缀(见 componentProps.ts:302-303 会把 Native 后缀剥掉)。

(b) class/style/on* 的 attrs 下落。 v3 里 class/style 已并入 $attrs,且当 inheritAttrs: false 时不再下落;而 v2 里 class/style 即使 inheritAttrs: false 也仍会 fallthrough。兼容层通过两处实现:

第一处——setFullProps 里跳过某些键(componentProps.ts:301-307 与 423-429):

if (__COMPAT__) {
  if (isOn(key) && key.endsWith('Native')) {
    key = key.slice(0, -6)          // onXxxNative → onXxx(.native 兼容)
  } else if (shouldSkipAttr(key, instance)) {
    continue                        // ← 跳过 class/style/on*/is
  }
}

第二处——shouldSkipAttr(attrsFallthrough.ts:5-29):

export function shouldSkipAttr(key: string, instance: ComponentInternalInstance): boolean {
  if (key === 'is') return true                        // is → 特殊处理
  if ((key === 'class' || key === 'style') &&
      isCompatEnabled(DeprecationTypes.INSTANCE_ATTRS_CLASS_STYLE, instance)) {
    return true                                      // ① class/style 不进 attrs
  }
  if (isOn(key) && isCompatEnabled(DeprecationTypes.INSTANCE_LISTENERS, instance)) {
    return true                                      // ② on* 不进 attrs
  }
  // vue-router
  if (key.startsWith('routerView') || key === 'registerRouteInstance') return true
  return false
}
  • ① 在 compat 下 class/style 不进 $attrs(还原 v2 的 $attrs 不含 class/style),从而它们会走 componentRenderUtils 的独立下落逻辑(componentRenderUtils.ts:219-244):
if (__COMPAT__ && isCompatEnabled(DeprecationTypes.INSTANCE_ATTRS_CLASS_STYLE, instance) &&
    vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT &&
    root.shapeFlag & (ShapeFlags.ELEMENT | ShapeFlags.COMPONENT)) {
  const { class: cls, style } = vnode.props || {}
  if (cls || style) {
    if (__DEV__ && inheritAttrs === false) {
      warnDeprecation(DeprecationTypes.INSTANCE_ATTRS_CLASS_STYLE, instance, getComponentName(instance.type))
    }
    root = cloneVNode(root, { class: cls, style }, false, true)   // ← 强制下落
  }
}

这段代码实现了"即使 inheritAttrs: false,class/style 也下落"的 v2 行为(v3 里 inheritAttrs: false 会连 class/style 一起挡掉,这是一大坑)。它通过 cloneVNode 把 class/style 直接并到 root vnode 上。

  • ② on* 在 compat 下也不进 $attrs(还原 v2 的 $attrs 不含监听器),但仍会在 v3 的 inheritAttrs 机制下落到根元素(因为 isOn 的处理在别处)。同时 .native 后缀会被剥离(onXxxNative → onXxx)——v2 的 @click.native 语义即"绑定到组件根 DOM 上的原生监听",v3 里默认就是这个行为。
  • is 恒跳过:<component :is> 与自定义元素互操作相关。
  • vue-router 特判:routerView/registerRouteInstance 两个 key 被特殊豁免,这是为了配合 vue-router 4 在 compat 模式下的内部约定。

18.4.4 $children:从 vnode 树里"逆序扫描"出子组件实例

v3 移除了 $children(官方建议改用 ref 或 provide/inject)。getCompatChildren(instanceChildren.ts:7-27):

export function getCompatChildren(instance) {
  assertCompatEnabled(DeprecationTypes.INSTANCE_CHILDREN, instance)
  const root = instance.subTree
  const children: ComponentPublicInstance[] = []
  if (root) walk(root, children)
  return children
}
function walk(vnode: VNode, children: ComponentPublicInstance[]) {
  if (vnode.component) {
    children.push(vnode.component.proxy!)              // 命中组件:收集其 proxy
  } else if (vnode.shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    const vnodes = vnode.children as VNode[]
    for (let i = 0; i < vnodes.length; i++) walk(vnodes[i], children)   // 递归子节点
  }
}

算法:从 instance.subTree(该组件本次渲染产出的根 vnode)出发做 DFS:

  1. 若某 vnode 有 component(即它是一个组件根 vnode),把它的 proxy 收进数组,并停止下钻(if/else,不再进入该组件的子树)——这与 v2 的 $children 语义一致(只收直接子组件,不收孙组件);
  2. 若是数组 children,递归每个子 vnode。

注意它是每次访问实时计算的(不是缓存),且依赖 subTree 已存在(挂载之后才有)。代价是每次读 $children 都要遍历一遍子树。这也是 v3 移除它的原因之一。

18.4.5 $on / $once / $off:用 WeakMap 重建事件总线

v2 的实例事件 API 是"组件级事件总线"(常用于跨组件通信,虽不推荐)。v3 彻底移除。instanceEventEmitter.ts 用一个 WeakMap<instance, EventRegistry> + 三个函数重建:

// packages/runtime-core/src/compat/instanceEventEmitter.ts:11-92
const eventRegistryMap = new WeakMap<ComponentInternalInstance, EventRegistry>()
export function getRegistry(instance) {
  let events = eventRegistryMap.get(instance)
  if (!events) eventRegistryMap.set(instance, (events = Object.create(null)))
  return events!
}

export function on(instance, event, fn) {
  if (isArray(event)) { event.forEach(e => on(instance, e, fn)) }
  else {
    if (event.startsWith('hook:')) {
      assertCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance, event)
    } else {
      assertCompatEnabled(DeprecationTypes.INSTANCE_EVENT_EMITTER, instance)
    }
    const events = getRegistry(instance)
    ;(events[event] || (events[event] = [])).push(fn)
  }
  return instance.proxy
}

export function once(instance, event, fn) {
  const wrapped = (...args) => { off(instance, event, wrapped); fn.apply(instance.proxy, args) }
  wrapped.fn = fn            // ← 记录原函数,供 off 匹配
  on(instance, event, wrapped)
  return instance.proxy
}

export function off(instance, event?, fn?) {
  assertCompatEnabled(DeprecationTypes.INSTANCE_EVENT_EMITTER, instance)
  const vm = instance.proxy
  if (!event) { eventRegistryMap.set(instance, Object.create(null)); return vm }  // 清空全部
  if (isArray(event)) { event.forEach(e => off(instance, e, fn)); return vm }
  const events = getRegistry(instance)
  const cbs = events[event!]
  if (!cbs) return vm
  if (!fn) { events[event!] = undefined; return vm }        // 移除该事件全部
  events[event!] = cbs.filter(cb => !(cb === fn || (cb as any).fn === fn))   // 精确移除
  return vm
}

export function emit(instance, event, args) {
  const cbs = getRegistry(instance)[event]
  if (cbs) {
    callWithAsyncErrorHandling(cbs.map(cb => cb.bind(instance.proxy)), instance,
      ErrorCodes.COMPONENT_EVENT_HANDLER, args)
  }
  return instance.proxy
}

设计要点:

  • 注册表挂在 WeakMap 上而非实例字段:避免污染实例对象、也避免内存泄漏风险(实例被回收时注册表随之回收)。
  • hook: 事件走 INSTANCE_EVENT_HOOKS,普通事件走 INSTANCE_EVENT_EMITTER——两条独立的降级开关。因为 v2 里"用 $on('hook:mounted') 监听生命周期"是常见模式,出于兼容它们被单独归类。
  • once 的 wrapped.fn = fn:off(instance, event, fn) 时既可能传原函数也可能传 wrapper,靠 .fn 字段双向匹配。
  • emit 用 cbs.map(cb => cb.bind(instance.proxy)):v2 里回调的 this 是实例,这里通过 bind(instance.proxy) 还原。同时用 callWithAsyncErrorHandling 走 v3 的错误处理(异步回调、错误边界)。
  • emit 与组件 emits 的关系:v3 的 instance.emit(componentEmits.ts)在 compat 下会额外调用 compatInstanceEmit:
// packages/runtime-core/src/componentEmits.ts:231-234
if (__COMPAT__) {
  compatModelEmit(instance, event, args)
  return compatInstanceEmit(instance, event, args)
}

也就是说 emit('foo') 既会触发 v3 的 props 事件监听(onFoo),也会触发 compat 事件注册表里 $on('foo') 注册的回调。这让 v2 代码里 this.$emit('foo') + 父组件 @foo 或 vm.$on('foo') 两种监听方式都生效。

18.4.6 私有 API 表:让老库与 v2 render helper 能"跑起来"

v2 编译器生成的 render 函数会调用一批带下划线的私有 helper(_c、_v、_s、_l…),很多老库也直接读实例上的私有字段(_self、_data、$vnode…)。v3 全部移除。instance.ts:159-199 用一张表把它们接回来:

const privateAPIs = {
  // needed by many libs / render fns
  $vnode: i => i.vnode,                 // v2 的组件占位 vnode
  _self: i => i.proxy,
  _setupProxy: i => i.setupState,
  _uid: i => i.uid,
  _data: i => i.data,
  _isMounted: i => i.isMounted,
  _isDestroyed: i => i.isUnmounted,
  // v2 render helpers
  $createElement: () => compatH,        // 别名 _c / h
  _c: () => compatH,
  _o: () => legacyMarkOnce,             // v-once(v2 版,空实现)
  _n: () => looseToNumber,
  _s: () => toDisplayString,
  _l: () => renderList,
  _t: i => legacyRenderSlot.bind(null, i),
  _q: () => looseEqual,
  _i: () => looseIndexOf,
  _m: i => legacyRenderStatic.bind(null, i),
  _f: () => resolveFilter,              // filters 解析
  _k: i => legacyCheckKeyCodes.bind(null, i),
  _b: () => legacyBindObjectProps,
  _v: () => createTextVNode,
  _e: () => createCommentVNode,
  _u: () => legacyResolveScopedSlots,
  _g: () => legacyBindObjectListeners,
  _d: () => legacyBindDynamicKeys,
  _p: () => legacyPrependModifier,
} as PublicPropertiesMap

for (const key in privateAPIs) {
  map[key] = i => {
    if (isCompatEnabled(DeprecationTypes.PRIVATE_APIS, i)) {
      return privateAPIs[key](i)            // 仅 PRIVATE_APIS 开启时暴露
    }
  }
}

这张表是理解 v2 编译产物如何在 v3 上运行的关键。v2 的 render 函数形如:

with(this){return _c('div',{staticClass:"app"},[
  _c('p',[_v("count: "+_s(count))]),                    // _v 文本、_s 字符串化
  _c('ul',_l(items,function(item){return _c('li',{key:item.id},[_v(_s(item.name))])})),  // _l 列表
  _c('slot-name',{scopedSlots:_u([{key:"default",fn:function(p){...}}])}),               // _u 作用域插槽
  _c('my-comp',{attrs:{a:b},on:{click:c},nativeOn:{mouseover:d},directives:[{name:"x",value:y}]})  // compatH
])}

这里 _c 就是 compatH(见 18.5),_v/_s/_l/_u/_t/_m/_b/_g/_d/_p 分别在 renderHelpers.ts 里有 v3 实现。这套 helper 的存在让**"用 v2 编译器编译、但跑在 v3 运行时"**这个组合(也就是 vue-loader 4 + @vue/compat 的典型场景)成为可能。

legacyRenderStatic(renderHelpers.ts:122-136)值得一提,因为它带实例级缓存:

const staticCacheMap = new WeakMap<ComponentInternalInstance, any[]>()
export function legacyRenderStatic(instance, index) {
  let cache = staticCacheMap.get(instance)
  if (!cache) staticCacheMap.set(instance, (cache = []))
  if (cache[index]) return cache[index]
  const fn = (instance.type as any).staticRenderFns[index]     // v2 的静态 render 函数
  const ctx = instance.proxy
  return (cache[index] = fn.call(ctx, null, ctx))
}

v2 编译器会把纯静态子树抽出成 staticRenderFns 数组(v2 的静态提升),运行时用 _m(index) 取。这里按 (instance, index) 缓存求值结果,等价于 v2 的静态树复用。第 07 章讲过 v3 的 hoistStatic/createStaticVNode,这里恰好是"同一优化思想在 v2 产物里的另一种表达"。

$options 的兼容(instance.ts:129-156)也要单独说明:

$options: i => {
  if (!isCompatEnabled(DeprecationTypes.PRIVATE_APIS, i)) return resolveMergedOptions(i)
  if (i.resolvedOptions) return i.resolvedOptions
  const res = (i.resolvedOptions = extend({}, resolveMergedOptions(i)))
  Object.defineProperties(res, {
    parent: { get() { warnDeprecation(DeprecationTypes.PRIVATE_APIS, i, '$options.parent'); return i.proxy!.$parent } },
    propsData: { get() { warnDeprecation(DeprecationTypes.PRIVATE_APIS, i, '$options.propsData'); return i.vnode.props } },
  })
  return res
}

v2 的 $options 里包含 parent 与 propsData(vuex 等库依赖 this.$options.parent)。这里在 compat 下给合并后的 options 补上这两个 getter,并在访问时告警(因为它们是私有约定)。


18.5 renderFn.ts:v2 风格 render 函数与 compatH 的翻译层

18.5.1 convertLegacyRenderFn:如何判定"这是 v2 render"

v2 与 v3 的 render 函数只有一处"表面相同、语义不同"的地方——h 的签名。v2 是 h(tag, data, children)(data 里装 attrs/on/props/...),v3 是 h(type, props, children)(props 直接是 vnode props)。为此兼容层需要在实例 setup 时判断 render 函数是不是 v2 的,若是则包装(convertLegacyRenderFn,renderFn.ts:42-70):

// packages/runtime-core/src/compat/renderFn.ts:42-70
export function convertLegacyRenderFn(instance: ComponentInternalInstance): void {
  const Component = instance.type as ComponentOptions
  const render = Component.render as InternalRenderFunction | undefined

  // v3 runtime compiled, or already checked / wrapped
  if (!render || render._rc || render._compatChecked || render._compatWrapped) {
    return
  }

  if (render.length >= 2) {
    // v3 pre-compiled function, since v2 render functions never need more than
    // 2 arguments, and v2 functional render functions would have already been
    // normalized into v3 functional components
    render._compatChecked = true
    return
  }

  // v2 render function, try to provide compat
  if (checkCompatEnabled(DeprecationTypes.RENDER_FUNCTION, instance)) {
    const wrapped = (Component.render = function compatRender() {
      // @ts-expect-error
      return render.call(this, compatH)
    })
    // @ts-expect-error
    wrapped._compatWrapped = true
  }
}

判定算法:

  1. 早退条件:没有 render、或 render 带 _rc(Runtime Compiled,v3 运行时编译产物,见 18.8)/_compatChecked/_compatWrapped 标记,都直接返回。
    • _rc 很关键:packages/vue/src/index.ts:102 里 compileToFunction 生成的 render 上会打 _rc = true,表示"这是 v3 编译器产物,签名已是 v3"。
  2. render.length >= 2 判定为 v3 预编译:因为 v2 的 render 函数最多只声明 1 个参数(function render(h){},h 由 _c/$createElement 全局提供,或 render(h) 显式接收);而 v3 预编译产物形如 function render(_ctx, _cache){...}(2 个参数,第 07 章讲过 _cache)。所以 length >= 2 → v3,标 _compatChecked = true 后返回。
    • 这是一个极其聪明的启发式:它不依赖任何显式标记,仅靠参数个数区分两代产物。
  3. render.length <= 1 判定为 v2 render:用 checkCompatEnabled(RENDER_FUNCTION, instance) 检查是否启用兼容(见 18.2.5,启用才告警),然后包一层:
const wrapped = function compatRender() {
  return render.call(this, compatH)     // ← 把 v3 调用的 (ctx, cache) 转成 v2 的 compatH
}
wrapped._compatWrapped = true

包装的语义:v3 渲染器调用 instance.render.call(proxy, ctx, cache) 时,compatRender 忽略传入的 _ctx/_cache,改为 render.call(this, compatH)——即把 compatH 作为 v2 render 的 h 参数传进去,this 仍是 proxy(v2 render 里用 this.xxx 访问数据)。这样 v2 render 里所有 h(...) 调用都走 compatH 的翻译逻辑。

_compatWrapped = true 会被 instance.ts:106-108 的 $slots 使用(见 18.4.1):如果是包过的 v2 render,$slots 要返回"取值即求值"的 proxy。

18.5.2 compatH:v2 data 对象的七字段翻译

compatH(renderFn.ts:108-169)是 v2 h/_c 的 v3 实现。它接受 (type, propsOrChildren, children?),其中第二个参数在 v2 里是那个复杂的 data 对象:

export function compatH(type: any, propsOrChildren?: any, children?: any): VNode {
  if (!type) type = Comment                          // ① 空 type → 注释节点
  // 支持 v2 的字符串组件名查找
  if (typeof type === 'string') {
    const t = hyphenate(type)
    if (t === 'transition' || t === 'transition-group' || t === 'keep-alive') {
      type = `__compat__${t}`                        // ② 内置组件重定向
    }
    type = resolveDynamicComponent(type)             // ③ 按字符串解析
  }

  const l = arguments.length
  const is2ndArgArrayChildren = isArray(propsOrChildren)
  if (l === 2 || is2ndArgArrayChildren) {
    if (isObject(propsOrChildren) && !is2ndArgArrayChildren) {
      if (isVNode(propsOrChildren)) {
        return convertLegacySlots(createVNode(type, null, [propsOrChildren]))   // 单个 vnode child
      }
      return convertLegacySlots(convertLegacyDirectives(
        createVNode(type, convertLegacyProps(propsOrChildren, type)), propsOrChildren))
    } else {
      return convertLegacySlots(createVNode(type, null, propsOrChildren))       // 省略 props
    }
  } else {
    if (isVNode(children)) children = [children]
    return convertLegacySlots(convertLegacyDirectives(
      createVNode(type, convertLegacyProps(propsOrChildren, type), children), propsOrChildren))
  }
}

分支逻辑(对齐 v2 h/_c 的"第二参是 data 还是 children"重载):

调用形式处理
h('div')createVNode('div')(l===1,propsOrChildren undefined)
h('div', [children])第二参是数组 → 视为 children(l===2 && isArray)
h('div', vnode)第二参是 vnode → [vnode] 作 children
h('div', {data})第二参是对象非数组非 vnode → 作为 data,转 props;无 children
h('div', {data}, children)l===3 → data + children
  • ① 空 type → Comment:v2 里 _e() 生成空注释节点,_c() 在 type 为空时也走注释。对应 v3 的 Comment 符号。
  • ② 内置组件重定向:v2 字符串 'transition'/'transition-group'/'keep-alive' 需要在 v3 里解析到内置组件,但 runtime-core 不能直接 import runtime-dom 的 Transition(第 17 章的组件在 runtime-dom)。于是 createCompatVue.ts:26-37 在 wrappedCreateApp 里注册了带 __compat__ 前缀的组件:
// packages/vue-compat/src/createCompatVue.ts:23-39
function wrappedCreateApp(...args: any[]) {
  const app = createApp(...args)
  if (compatUtils.isCompatEnabled(DeprecationTypes.RENDER_FUNCTION, null)) {
    // 注册内置组件,使其能被 v2 h() 的字符串形式解析
    app.component('__compat__transition', Transition)
    app.component('__compat__transition-group', TransitionGroup)
    app.component('__compat__keep-alive', KeepAlive)
    // 内置指令(v3 里没有按字符串解析指令的 render API)
    app._context.directives.show = vShow
    app._context.directives.model = vModelDynamic
  }
  return app
}

这里前缀 __compat__ 的作用在 renderFn.ts:130-135 注释里说得很清楚:避免 v3 的 h() 也受影响——只有走 compatH 的 v2 render 才会把 'transition' 改写为 '__compat__transition',v3 的 h('transition') 不受影响。

  • ③ resolveDynamicComponent(type):把字符串按"当前实例的 components 表 → app 全局表 → 原生标签"顺序解析(helpers/resolveAssets.ts)。

18.5.3 convertLegacyProps:data 七字段的逐项翻译

convertLegacyProps(renderFn.ts:175-223)是 compatH 的核心翻译器。v2 的 data 对象字段与 v3 vnode props 的对应关系:

v2 data 字段语义v3 处理
attrs普通属性extend(converted, legacyProps.attrs)
domPropsDOM propertyextend(converted, legacyProps.domProps)
props组件 propsextend(converted, legacyProps.props)
on事件监听键经 convertLegacyEventKey → toHandlerKey
nativeOn原生事件监听同上 + Native 后缀
staticClass静态 classnormalizeClass([staticClass, class])
staticStyle静态 stylenormalizeStyle([staticStyle, style])
directives自定义指令withDirectives + resolveDirective
slot / scopedSlots插槽convertLegacySlots
model组件 v-model展开为 prop + compatModelEventPrefix+event
key / ref / refInFor保留键直接透传

代码:

// packages/runtime-core/src/compat/renderFn.ts:175-223
const skipLegacyRootLevelProps = makeMap('staticStyle,staticClass,directives,model,hook')

function convertLegacyProps(legacyProps, type): (Data & VNodeProps) | null {
  if (!legacyProps) return null
  const converted: Data & VNodeProps = {}
  for (const key in legacyProps) {
    if (key === 'attrs' || key === 'domProps' || key === 'props') {
      extend(converted, legacyProps[key])               // ① 三类合并
    } else if (key === 'on' || key === 'nativeOn') {
      const listeners = legacyProps[key]
      for (const event in listeners) {
        let handlerKey = convertLegacyEventKey(event)   // ② 事件键转换
        if (key === 'nativeOn') handlerKey += `Native`
        const existing = converted[handlerKey]
        const incoming = listeners[event]
        if (existing !== incoming) {
          if (existing) converted[handlerKey] = [].concat(existing, incoming)  // ③ 合并数组
          else converted[handlerKey] = incoming
        }
      }
    } else if (!skipLegacyRootLevelProps(key)) {
      converted[key] = legacyProps[key]                 // ④ 其余透传
    }
  }
  if (legacyProps.staticClass) {
    converted.class = normalizeClass([legacyProps.staticClass, converted.class])
  }
  if (legacyProps.staticStyle) {
    converted.style = normalizeStyle([legacyProps.staticStyle, converted.style])
  }
  if (legacyProps.model && isObject(type)) {            // ⑤ 组件 v-model
    const { prop = 'value', event = 'input' } = (type as any).model || {}
    converted[prop] = legacyProps.model.value
    converted[compatModelEventPrefix + event] = legacyProps.model.callback
  }
  return converted
}
  • ① 三类合并:attrs/domProps/props 直接 extend 进同一个 props 对象。v3 不再区分"属性/property/props",统一成一个 props 对象,由 patchProp 在运行时判断该走 property 还是 attribute(第 17 章)。
  • ② 事件键转换:见下。
  • ③ 数组合并:同一事件可能从 on 与 nativeOn 各来一份,或同一 key 重复,用 [].concat 合成数组(v3 的事件值可以是数组,第 11 章)。注意 if (existing !== incoming) 的守卫避免同函数重复入数组。
  • ④ 其余透传:skipLegacyRootLevelProps 把四个"根级特殊字段"排除(staticStyle/staticClass/directives/model)——它们有专门处理;hook 是 v2 的渲染钩子,v3 已无对应,丢弃。
  • ⑤ 组件 v-model 展开:v2 编译 <comp v-model="x"> 得到 data.model = { value, callback, expression }。这里把 value 拆成本组件 model 选项声明的 prop(默认 value)、把 callback 挂到 onModelCompat:${event}(compatModelEventPrefix,见 18.5.6 与 componentVModel.ts:13)。

convertLegacyEventKey(renderFn.ts:225-237)处理 v2 的前缀式修饰符:

function convertLegacyEventKey(event: string): string {
  if (event[0] === '&') event = event.slice(1) + 'Passive'   // & → .passive
  if (event[0] === '~') event = event.slice(1) + 'Once'      // ~ → .once
  if (event[0] === '!') event = event.slice(1) + 'Capture'   // ! → .capture
  return toHandlerKey(event)
}

v2 编译器把 @click.passive 编码成 on: { '&click': fn }、.once → '~click'、.capture → '!click'。这里把前缀符号转成 v3 的后缀关键字(Passive/Once/Capture),再用 toHandlerKey(toHandlerKey('click') = 'onClick')拼成 v3 的 props key。同时 .native 的处理在 convertLegacyProps 里加 Native 后缀(onClickNative),后续在 componentProps.ts:302-303 与 423-425 会被剥离并当作普通 onClick。

18.5.4 convertLegacyDirectives 与 convertLegacySlots

convertLegacyDirectives(renderFn.ts:239-257)把 v2 的 data.directives(数组 {name, value, arg, modifiers})翻译成 withDirectives 的调用:

function convertLegacyDirectives(vnode: VNode, props?: LegacyVNodeProps): VNode {
  if (props && props.directives) {
    return withDirectives(vnode, props.directives.map(({ name, value, arg, modifiers }) => {
      return [resolveDirective(name)!, value, arg, modifiers] as DirectiveArguments[number]
    }))
  }
  return vnode
}

withDirectives(directives.ts,第 13 章)把指令绑定塞进 vnode 的 dirs 字段,[dir, value, arg, modifiers] 是它的绑定元组格式。resolveDirective(name) 按字符串解析指令(renderFn.ts:23 import 自 helpers/resolveAssets)。

convertLegacySlots(renderFn.ts:259-302)处理 v2 的两种插槽写法:

function convertLegacySlots(vnode: VNode): VNode {
  const { props, children } = vnode
  let slots: Record<string, any> | undefined

  if (vnode.shapeFlag & ShapeFlags.COMPONENT && isArray(children)) {
    slots = {}
    // 检查 children 里的 "slot" 属性,转成 v3 函数式 slot
    for (let i = 0; i < children.length; i++) {
      const child = children[i]
      const slotName = (isVNode(child) && child.props && child.props.slot) || 'default'
      const slot = slots[slotName] || (slots[slotName] = [] as any[])
      if (isVNode(child) && child.type === 'template') {
        slot.push(child.children)          // <template> 包裹的内容
      } else {
        slot.push(child)
      }
    }
    if (slots) {
      for (const key in slots) {
        const slotChildren = slots[key]
        slots[key] = () => slotChildren      // 包成函数式 slot
        slots[key]._ns = true                // 标记 non-scoped slot
      }
    }
  }

  const scopedSlots = props && props.scopedSlots
  if (scopedSlots) {
    delete props!.scopedSlots
    if (slots) extend(slots, scopedSlots)
    else slots = scopedSlots
  }

  if (slots) normalizeChildren(vnode, slots)
  return vnode
}

算法:

  1. v2 的"具名 slot 就是带 slot 属性的子 vnode":v2 里 <template slot="header"> 或 <div slot="header"> 会被编译成"带 slot: 'header' prop 的普通子节点"。这里遍历 children,按 props.slot 分组,缺省归 default:
  2. <template> 节点展开:child.type === 'template' 时推入 child.children(<template slot="x"> 的内容),否则推入 vnode 本身。
  3. 每个 slot 包成函数:v3 的 slot 是"返回 vnode 数组的函数"。这里 slots[key] = () => slotChildren,并打 _ns = true(non-scoped slot)——这个标记被 withCtx(第 11 章)读取,用于区分"v2 的非作用域插槽"与"v3 编译的作用域插槽",从而在 withCtx 里设置 _ns(componentRenderContext.ts:122-124)。
  4. scopedSlots 合并:data.scopedSlots 本身就是"函数对象"({ default: fn }),直接 extend 进 slots 即可,然后删掉 vnode.props 上的 scopedSlots(避免它被当成普通 prop)。
  5. normalizeChildren(vnode, slots):把 slots 对象写回 vnode.children 并设置 shapeFlag 的 slots 位(第 11 章)。

18.5.5 函数式组件:convertLegacyFunctionalComponent

v2 的函数式组件声明为 { functional: true, props, render(h, ctx) },ctx 里有 props/children/data/scopedSlots/parent/slots()/listeners/injections。v3 里函数式组件就是"普通函数 (props, { slots, emit, attrs }) => vnode"。convertLegacyFunctionalComponent(componentFunctional.ts:22-65):

export function convertLegacyFunctionalComponent(comp: ComponentOptions): FunctionalComponent {
  if (normalizedFunctionalComponentMap.has(comp)) return normalizedFunctionalComponentMap.get(comp)!
  const legacyFn = comp.render as any

  const Func: FunctionalComponent = (props, ctx) => {
    const instance = getCurrentInstance()!
    const legacyCtx = {
      props,
      children: instance.vnode.children || [],
      data: instance.vnode.props || {},
      scopedSlots: ctx.slots,
      parent: instance.parent && instance.parent.proxy,
      slots() { return new Proxy(ctx.slots, legacySlotProxyHandlers) },
      get listeners() { return getCompatListeners(instance) },
      get injections() {
        if (comp.inject) { const injections = {}; resolveInjections(comp.inject, injections); return injections }
        return {}
      },
    }
    return legacyFn(compatH, legacyCtx)          // ← v2 函数式 render(h, ctx)
  }
  Func.props = comp.props
  Func.displayName = comp.name
  Func.compatConfig = comp.compatConfig
  Func.inheritAttrs = false                       // v2 函数式组件不继承 attrs
  normalizedFunctionalComponentMap.set(comp, Func)
  return Func
}

要点:

  • legacyCtx 逐字段对齐 v2 的 ctx:props(v3 传入)、children/data(取自 vnode)、scopedSlots(v3 的 ctx.slots)、parent(父 proxy)、slots()($slots 的"取值即求值"版本)、listeners(getCompatListeners)、injections(复用 resolveInjections)。
  • legacyFn(compatH, legacyCtx):v2 函数式 render 的两个参数就是 h(这里是 compatH)与 ctx。
  • Func.inheritAttrs = false:这是必须显式设置的关键点——v2 函数式组件不自动继承 attrs,而 v3 函数式组件默认 inheritAttrs: true。若不设 false,attrs 会自动落到函数返回的 vnode 根上,行为与 v2 不一致。
  • Func.compatConfig = comp.compatConfig:把组件级配置传递给"转换后的函数",这样内部 compatH 触发 isCompatEnabled(...) 时读到的仍是同一份配置。
  • 缓存:normalizedFunctionalComponentMap 保证同一个 comp 只转换一次。

注意这个转换的触发点在 vnode.ts:605-608:

// packages/runtime-core/src/vnode.ts:605-608
if (__COMPAT__) {
  type = convertLegacyComponent(type, currentRenderingInstance)
}

convertLegacyComponent(component.ts:11-57)按顺序处理三种"v2 组件形态":

  1. v2 构造函数(isFunction(comp) && comp.cid):把 comp.options 展开,并搬运 render/__file/__hmrId/__scopeId 等 SFC 编译器注入的字段;
  2. v2 异步组件(isFunction(comp) && COMPONENT_ASYNC 开启)→ convertLegacyAsyncComponent(componentAsync.ts);
  3. v2 函数式组件(isObject(comp) && comp.functional)→ convertLegacyFunctionalComponent。

convertLegacyAsyncComponent(componentAsync.ts:26-60)要把 v2 的"返回 Promise 的工厂函数"翻译成 defineAsyncComponent。它必须先调用一次 comp(resolve, reject)(注释:v2's API won't expose the options until we call it),根据返回值形态分四种:

  • 返回 Promise → defineAsyncComponent(() => res);
  • 返回 { component, loading, error, delay, timeout } 对象 → defineAsyncComponent({...}) 映射各字段;
  • 返回 null → 用 fallbackPromise(一个永不 resolve 的 Promise,直到工厂函数稍后调用 resolve);
  • 其他 → 当作 v3 函数式组件原样返回。

18.5.6 defineLegacyVNodeProperties:给 vnode 装上 v2 的"只读视图"

v2 里 vnode 有一批属性名(tag、data、elm、componentInstance、child、text、context、componentOptions),v3 的 vnode 字段名不同(type、props、el、component…)。defineLegacyVNodeProperties(renderFn.ts:304-346)用 Object.defineProperties 给 vnode 挂上 getter,把 v2 名映射到 v3 字段:

export function defineLegacyVNodeProperties(vnode: VNode): void {
  if (isCompatEnabled(DeprecationTypes.RENDER_FUNCTION, currentRenderingInstance, true /* enable for built-ins */) &&
      isCompatEnabled(DeprecationTypes.PRIVATE_APIS, currentRenderingInstance, true /* enable for built-ins */)) {
    const context = currentRenderingInstance
    const getInstance = () => vnode.component && vnode.component.proxy
    let componentOptions: any
    Object.defineProperties(vnode, {
      tag: { get: () => vnode.type },
      data: { get: () => vnode.props || {}, set: p => (vnode.props = p) },
      elm: { get: () => vnode.el },
      componentInstance: { get: getInstance },
      child: { get: getInstance },
      text: { get: () => (isString(vnode.children) ? vnode.children : null) },
      context: { get: () => context && context.proxy },
      componentOptions: { get: () => {
        if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
          if (componentOptions) return componentOptions
          return (componentOptions = { Ctor: vnode.type, propsData: vnode.props, children: vnode.children })
        }
      } },
    })
  }
}

它被 vnode.ts:540-543(createBaseVNode 末尾)与 vnode.ts:753-755(cloneVNode 末尾)调用。注意两点:

  • enableForBuiltIn = true:这里传了第三个参数,使得内置组件也允许挂 legacy 属性(与前文 isCompatEnabled 的内置短路相反)。因为 v2 里 _e()/_v() 等 helper 可能对内置组件产出的 vnode 也读这些属性。
  • componentOptions 惰性构造并缓存:v2 devtools/渲染库常读 vnode.componentOptions.Ctor,这里按需拼一个 { Ctor, propsData, children } 并缓存到闭包变量 componentOptions。

18.5.7 componentVModel.ts:双向的 v-model 翻译

v2 的组件 v-model 用 value prop + input 事件,v3 用 modelValue + update:modelValue。兼容层在两个方向上翻译:

方向一:v3 编译产物 → v2 组件 props(convertLegacyVModelProps,componentVModel.ts:17-64,被 vnode.ts:540-542 调用):

export const compatModelEventPrefix = `onModelCompat:`

export function convertLegacyVModelProps(vnode: VNode): void {
  const { type, shapeFlag, props, dynamicProps } = vnode
  const comp = type as ComponentOptions
  if (shapeFlag & ShapeFlags.COMPONENT && props && 'modelValue' in props) {
    if (!isCompatEnabled(DeprecationTypes.COMPONENT_V_MODEL, { type } as any)) return
    if (__DEV__ && !warnedTypes.has(comp)) { pushWarningContext(vnode); warnDeprecation(...); popWarningContext(); warnedTypes.add(comp) }

    // v3 编译的 model 代码 → v2 compat props
    //   modelValue          → value
    //   onUpdate:modelValue → onModelCompat:input
    const model = comp.model || {}
    applyModelFromMixins(model, comp.mixins)
    const { prop = 'value', event = 'input' } = model
    if (prop !== 'modelValue') { props[prop] = props.modelValue; delete props.modelValue }
    if (dynamicProps) dynamicProps[dynamicProps.indexOf('modelValue')] = prop
    props[compatModelEventPrefix + event] = props['onUpdate:modelValue']
    delete props['onUpdate:modelValue']
  }
}

即:当 vnode props 里出现 modelValue(v3 编译产物)而组件 compat 配置要求 v2 行为时,把 modelValue 改名成组件 model 选项声明的 prop(默认 value),把 onUpdate:modelValue 改名成 onModelCompat:input。applyModelFromMixins 递归合并 mixins 里的 model 选项。dynamicProps(动态 prop 列表,第 11 章)也要同步改名字,否则 patch 阶段会失效。

方向二:v2 组件 emit input → 触发 v3 update:modelValue(compatModelEmit,componentVModel.ts:75-93,被 componentEmits.ts:231-233 调用):

export function compatModelEmit(instance, event, args): void {
  if (!isCompatEnabled(DeprecationTypes.COMPONENT_V_MODEL, instance)) return
  const props = instance.vnode.props
  const modelHandler = props && props[compatModelEventPrefix + event]   // 找 onModelCompat:input
  if (modelHandler) {
    callWithErrorHandling(modelHandler, instance, ErrorCodes.COMPONENT_EVENT_HANDLER, args)
  }
}

即 v2 组件 this.$emit('input', val) 时,若父级(v3 编译的)v-model 把回调挂在 onModelCompat:input 上,就调用它。注意这里没有真去触发 onUpdate:modelValue——所以它只单向工作(v3 父组件 → v2 子组件)。若子组件也是 v2 编译产物,其 update:modelValue 监听在 componentEmits 的正常路径上仍有(componentEmits.ts:156-161 的 isCompatModelListener),因此两条方向都能通。

componentEmits.ts:156-161 还有一个细节:

const isCompatModelListener = __COMPAT__ && compatModelEventPrefix + event in props
const isModelListener = isCompatModelListener || event.startsWith('update:')
const modifiers = isCompatModelListener ? props.modelModifiers : isModelListener && getModelModifiers(props, event.slice(7))

v2 的 .number/.trim 修饰符放在 modelModifiers 上,这里区分"compat model 监听"与"v3 原生 update: 监听",各自取修饰符来源。


18.6 filters:被删除的"管道",如何用状态机"复活"

18.6.1 filters 为什么必须删,又为什么必须兼容

filters 是 v2 模板里最"讨喜"也最"危险"的语法糖:{{ msg | uppercase }}、{{ price | currency('¥') }}。它危险在两点:(1) 它是一个只存在于模板编译器的概念,运行时完全不认识 |;(2) | 与 JS 的按位或 a | b 字面同形。v3 直接删除 filters,语义上把 {{ a | b }} 还原成"位或表达式",并在编译期给出 COMPILER_FILTERS 警告(packages/compiler-core/src/compat/compatConfig.ts:85-91)。但对存量项目,"逐个把 filter 改写为方法/computed"是机械劳动,兼容层必须让 | 继续可用——于是有了 transformFilter。

18.6.2 编译器侧:transformFilter 的接入点

packages/compiler-core/src/compile.ts:42 用条件展开把该 transform 塞进流水线:

...( __COMPAT__ ? [transformFilter] : [])

注意它注入的是 nodeTransforms(节点级变换),且注释强调"filter rewrite is applied before expression transform so only simple expressions are possible at this stage"(transformFilter.ts:25-27)——即 transformFilter 必须排在 transformExpression 之前。理由很硬核:filter 重写做的是字符串级文本改写(node.content = expression),一旦 transformExpression 先把 _ctx.msg 这类前缀注入进来,| 两侧的文本就不再是原始表达式,parseFilter 的字符状态机将无法正确切分。顺序即正确性。

该 transform 只处理两类节点(transformFilter.ts:24-38):

  • NodeTypes.INTERPOLATION({{ ... }})→ 重写 node.content;
  • NodeTypes.ELEMENT(元素)→ 遍历其 props,对所有"非 v-for 且带 exp"的指令(v-bind、v-on、v-if 等)重写 prop.exp。

rewriteFilter(transformFilter.ts:41-57)则做递归下钻:SIMPLE_EXPRESSION 直接进 parseFilter;COMPOUND_EXPRESSION 递归;INTERPOLATION 取出 content 再递归。这解决了 v-bind="[a | f1, b | f2]" 这类复合表达式。

18.6.3 parseFilter 的字符状态机:为什么要跟踪七种嵌套

核心难点在 parseFilter(transformFilter.ts:59-172)。它不能用 exp.split('|')——因为 pipe 会出现在字符串、正则、以及 || 逻辑或里。于是它逐字符扫描,维护以下状态:

变量含义触发/复位字符
inSingle / inDouble单/双引号字符串' "(且前一字符非 \)
inTemplateString模板字符串`
inRegex正则字面量/(需前一个非空白字符不匹配 validDivisionCharRE,见 transformFilter.ts:17,140-142)
curly / square / paren{}/[]/() 嵌套计数成对自增自减

只有"当前不在任何引号/正则/括号内,且 | 前后都不是 |"时(transformFilter.ts:86-93),才认定为filter 分隔符。这解释了为什么 {{ a || b }} 不会被误判(前后同为 |),也解释了 {{ obj['a|b'] }} 为何安全(square > 0)。

状态机维护三个游标:lastFilterIndex(上一段起点)、i(当前索引)、expression(首次遇 pipe 时切出的"值表达式")。首个 pipe 把 exp.slice(0, i).trim() 存为 expression(transformFilter.ts:94-97),后续 pipe 只 pushFilter(),把每段 filter 名压入 filters[]。循环结束后若从未见 pipe,则整串即表达式。

18.6.4 wrapFilter:从 | uppercase 到 _filter_uppercase(expr)

拿到 filters[] 后,wrapFilter(transformFilter.ts:174-192)从内到外逐层包裹(注意循环顺序 for i 是正向,但每层把上一层结果作为 exp 传入,天然形成"值先过第一个 filter、再过第二个"的嵌套):

  • 无参数 filter:toValidAssetId('uppercase', 'filter') → _filter_uppercase,结果 `${id}(${exp})`;
  • 带参数 filter:以 ( 切出 name 与 args,结果 `${id}(${exp},${args}`(args 已含右括号或不含,见 transformFilter.ts:188-190)。

同时 context.helper(RESOLVE_FILTER) 把解析助手登记进 helpers,并把 filter 名加入 context.filters。transform.ts:353-355 在 transform 收尾时把它固化到根节点:root.filters = [...context.filters!]。

关键:_filter_uppercase 不是一个真正存在的运行时变量,而是一个由 codegen 现场生成的局部 const。codegen.ts:365-369 检测到 ast.filters 非空就调用 genAssets(ast.filters, 'filter', context),后者(codegen.ts:537-563)对每个 filter 名生成一行:

const _filter_uppercase = _resolveFilter("uppercase")

其中 _resolveFilter 就是 RESOLVE_FILTER 帮手(codegen.ts:542-547 三元里 __COMPAT__ && type === 'filter' 分支)。生成后,表达式里的 _filter_uppercase 便能命中该局部变量。transformExpression.ts:310-313 里那段"若标识符以 _filter_ 开头就 return(不注入 _ctx. 前缀)"正是与之配套:它保证重写产物原样保留,不被二次前缀化。

18.6.5 运行期:resolveFilter 与 app.filter 注册表

运行时 resolveFilter(packages/runtime-core/src/helpers/resolveAssets.ts:54-56)是 resolveAsset(FILTERS, name) 的薄封装。其解析顺序(resolveAssets.ts:103-108):组件局部注册(instance[FILTERS],来自 filters 选项经由 options 合并)优先 → 全局注册(instance.appContext[FILTERS])兜底;同名 filter 先局部后全局。找不到时在 DEV 下 warn('Failed to resolve filter: xxx')(resolveAssets.ts:115-122)。

全局 filters 从哪来?global.ts:352-365 的 installFilterMethod:

context.filters = {}
app.filter = (name, filter?) => {
  assertCompatEnabled(DeprecationTypes.FILTERS, null)
  if (!filter) return context.filters![name]
  if (__DEV__ && context.filters![name]) warn(`Filter "${name}" has already been registered.`)
  context.filters![name] = filter
  return app
}

app.filter 是"注册即返回 app 以便链式"的 v2 风格;applySingletonAppMutations(global.ts:390-396)把单例 app 的 filters 用 Object.create 拷给每个子 app,保证 Vue.filter() 注册的 filter 对所有 app 可见。

18.6.6 全链路:{{ msg | uppercase }} 的完整旅程

  1. baseParse 产出 INTERPOLATION 节点,content.content 为字符串 "msg | uppercase";
  2. transformFilter(__COMPAT__ 时在 nodeTransforms 首位)→ parseFilter 切出 expression="msg"、filters=["uppercase"] → wrapFilter 得 _filter_uppercase(msg),写回 node.content,广播 COMPILER_FILTERS 警告;
  3. transformExpression 跳过 _filter_ 前缀,只把 msg 前缀成 _ctx.msg;
  4. root.filters=["uppercase"] → genAssets 生成 const _filter_uppercase = _resolveFilter("uppercase"),并在 helpers 里带上 resolveFilter;
  5. 运行期 resolveFilter("uppercase") 经 resolveAsset(FILTERS,...) 命中 app._context.filters;
  6. _toDisplayString(_filter_uppercase(_ctx.msg)) 求值 → 结果参与文本 vnode 生成。

易错点:_resolveFilter 在模块作用域调用(每次 render 函数定义时执行一次),而 filter 解析依赖 currentRenderingInstance/currentInstance(resolveAssets.ts:83)。若在组件外直接调用 resolveFilter 会命中 DEV 警告"can only be used in render() or setup()"。此外 filter 名里含 - 会被 toValidAssetId 转成下划线+Unicode 码点(utils.ts:529-531),故 | foo-bar 生成的是 _filter_foo_bar,与函数名无关,仅作标识。


18.7 全链路实战:从 <template> 字符串到 DOM 的一次完整追踪

18.7.1 总调用链图(节点标注章节号)

下面这张图把前十七章与本章的兼容分支缝成一条线。括号内的数字是对应章节号,[C] 表示该跳仅在 __COMPAT__ 构建中存在。

flowchart TD
  A[&#34; 字符串&#34;] --> B[&#34;baseParse 解析 (05)&#34;]
  B --> C[&#34;transform 节点变换 (05–06)&#34;]
  C -. &#34;[C] transformFilter (18.6)&#34; .-> C
  C --> D[&#34;transformExpression 前缀化 (06)&#34;]
  D --> E[&#34;codegen 生成 render (07)&#34;]
  E -. &#34;[C] genAssets 生成 _filter_* (18.6)&#34; .-> E
  E --> F[&#34;compileToFunction / registerRuntimeCompiler (18.8)&#34;]

  G[&#34;createApp(rootComponent)&#34;] --> H[&#34;createVNode → shapeFlag (11)&#34;]
  F --> H
  H --> I[&#34;app.mount → normalizeContainer (17)&#34;]
  I -. &#34;[C] installCompatMount/_createRoot (18.3.6)&#34; .-> I
  I --> J[&#34;render → patch (13)&#34;]
  J --> K{&#34;vnode 类型?&#34;}
  K -->|Component| L[&#34;mountComponent → setupComponent (12–13)&#34;]
  L -. &#34;[C] convertLegacyRenderFn / compatH (18.5)&#34; .-> L
  L --> M[&#34;renderComponentRoot → 执行 render&#34;]
  M --> N[&#34;patch 子节点 / mountElement (13)&#34;]
  K -->|Element| N
  N --> O[&#34;runtime-dom hostCreateElement (17)&#34;]
  O --> P[&#34;document.createElement + append (DOM)&#34;]

  Q[&#34;reactive/ref 变更 (02–03)&#34;] --> R[&#34;track/trigger → effect (03)&#34;]
  R --> S[&#34;scheduler queueJob → flushJobs (14)&#34;]
  S --> T[&#34;componentUpdateFn (13)&#34;]
  T --> U[&#34;patchElement + patchFlag 精确更新 (11,13)&#34;]
  U --> P
  T -. &#34;[C] 生命周期 beforeDestroy→beforeUnmount (18.4.2)&#34; .-> T

18.7.2 编译期逐帧(05–07、18.6、18.8)

以 <div id="app">{{ msg | uppercase }}</div> 为例:

帧函数(路径:行号)状态变化
1baseParse(compiler-core/parser.ts)产出 RootNode,含 ELEMENT + INTERPOLATION
2transform(transform.ts:334+)遍历节点,创建 TransformContext(__COMPAT__ 时 context.filters = new Set(),transform.ts:315-317)
3transformFilter(compat/transformFilter.ts:19)INTERPOLATION 被重写为 _filter_uppercase(msg)
4transformExpression_filter_ 跳过;msg → _ctx.msg,得 _filter_uppercase(_ctx.msg)
5transform 收尾(transform.ts:353-355)root.filters = ['uppercase']
6genAssets(codegen.ts:365-369/537-563)生成 const _filter_uppercase = _resolveFilter("uppercase")
7generate拼出 _toDisplayString(_filter_uppercase(_ctx.msg)) 的 render 函数字符串

18.7.3 挂载期逐帧(11–13、17、18.3.6)

帧函数(路径:行号)关键动作
1createApp(runtime-dom/src/index.ts)建 app、AppContext;__COMPAT__ 下 installAppCompatProperties
2app.mount(rootContainer)(runtime-dom/index.ts:220+)解析 selector、取 innerHTML 作 in-DOM 模板
2'[C] _compat_mount(global.ts,见 18.3.6)手工复刻 selector/in-DOM/namespace 后调 _createRoot
3createVNode(rootComponent)(vnode.ts)打 shapeFlag;isCompatRoot 时预创建实例
4render(vnode, container)(renderer.ts)patch(null, vnode, container)
5patch → processComponent走 mountComponent
6setupComponent + setupRenderEffect建 instance,包 ReactiveEffect(13 章)
7renderComponentRoot(componentRenderUtils.ts)[C] 先 convertLegacyRenderFn,再执行 render 得 vnode 树
8patch 元素 → mountElementhostCreateElement(runtime-dom/nodeOps.ts)→ document.createElement
9hostInsert(nodeOps.ts)parent.insertBefore 落地 DOM

18.7.4 更新期逐帧(02–03、11、13–14)

msg 变更触发的链路:reactive.msg setter → trigger(reactivity/effect.ts)→ 命中依赖的 ReactiveEffect → effect.scheduler → queueJob(scheduler.ts)→ 微任务 flushJobs → componentUpdateFn → shouldUpdateComponent 判定 → renderComponentRoot 重新执行 → patchElement。patchElement 里(renderer.ts)先比 patchFlag(如 TEXT),命中则 hostSetElementText(el, newText) 直接改文本节点,不重建 DOM——这就是编译期 PatchFlags 与运行期 diff 的配合(11 章)。

[C] 兼容分支在此阶段的介入点:生命周期 beforeUpdate/updated 经 18.4.2 的重命名映射触发;v2 render 产物经 compatH 生成的 vnode 仍带正常 patchFlag,因此更新路径与 v3 一致。

18.7.5 卸载期与 hydration 对照(13、16、18.4.2)

卸载:unmountComponent → unmountComponentSubTree → 触发 beforeUnmount/unmounted([C] 由 beforeDestroy/destroyed 映射而来)→ 递归 unmount 子节点 → hostRemove。SSR hydration:hydrate 走 hydrateNode/hydrateElement,与客户端 patch 共享 vnode 结构,差异仅在 hostCreateElement 被替换为"复用已有 DOM"(16 章)。[C] 兼容层在 hydration 下无需特殊处理,因为 vnode 结构一致。


18.8 全量入口:packages/vue/src/index.ts 如何把"运行时 + 编译器"缝成一个包

18.8.1 三个职责:re-export、注册编译器、编译器缓存

packages/vue/src/index.ts(107 行)是用户日常 import { createApp } from 'vue' 命中的入口。它只做三件事:

  1. re-export:export * from '@vue/runtime-dom'(index.ts:107),把 createApp/h/ref 等全部转出;
  2. 注册运行时编译器:registerRuntimeCompiler(compileToFunction)(index.ts:104)——让 @vue/runtime-dom 里那个"空壳"编译器变成真编译器,从而支持 template 选项编译(__DEV__ 下 initDev,index.ts:24-26);
  3. 暴露 compile:export { compileToFunction as compile }(index.ts:106)。

18.8.2 compileToFunction 的四个细节

  • 模板来源多态(index.ts:30-59):入参可为字符串、DOM 元素(取 innerHTML),或以 # 开头的选择器(document.querySelector);
  • 缓存(index.ts:28,43-47,101):compileCache 用 genCacheKey(template, options) 做键,避免同一模板重复编译;
  • 默认选项(index.ts:61-72):hoistStatic: true、DEV 下 onError 带 codeframe;若平台支持 customElements 且未显式配置,则用 tag => !!customElements.get(tag) 自动识别自定义元素;
  • 新函数构造(index.ts:94-96):__GLOBAL__ 构建直接 new Function(code)()(Vue 是全局变量);其它构建走 new Function('Vue', code)(runtimeDom)——把整个 runtime-dom 命名空间作为 Vue 注入,使编译产物里的 Vue.createElementVNode 等引用可用。

18.8.3 _rc 标记:运行时编译函数的"身份证"

index.ts:99 给生成的 render 打上 _rc = true,并在组件里被检测(componentRenderUtils/componentOptions 侧):带 _rc 的函数代表"模板在运行期才编译",用于 DEV 下判断 template 是否可编译、以及避免把已编译产物再次当作模板处理。

18.8.4 tree-shaking:为什么"只用了 ref"不会带回编译器

vue 包的 runtime 子入口(esm-bundler-runtime/global-runtime,见 rollup.config.js:76-87)只含 runtime-dom,不含 compiler-dom。全量入口里 compile 是顶层 import(@vue/compiler-dom),但打包器的副作用分析 + /*@__PURE__*/ 标注让它可被摇掉——所以 import { ref } from 'vue' + 未使用 template 选项时,编译器代码不会进最终产物。这也是官方推荐"构建时编译(SFC)优于运行期编译"的原因:运行期编译等于把编译器打进产物。

18.9 三个构建形态与四个入口:__COMPAT__ 的来源

18.9.1 形态矩阵

rollup.config.js:58-88 定义七种 PackageFormat,对用户可感知的是三类:

形态文件特征
esm-bundlervue.esm-bundler.js__DEV__ 保留为 process.env.NODE_ENV,feature flags 保留为 __VUE_OPTIONS_API__ 等,交由打包器替换
esm-browservue.esm-browser.js自包含,__DEV__ 硬编码,process.env 被替换为 ({})
cjs/globalvue.cjs.js / vue.global.jsNode / IIFE(output.name = 'Vue',rollup.config.js:155-157);生产版另有 .prod.js 与压缩版

18.9.2 __COMPAT__ 的唯一来源

rollup.config.js:170-191 的 resolveDefine() 里:

const isCompatBuild = !!packageOptions.compat   // rollup.config.js:139
// ...
__COMPAT__: String(isCompatBuild),              // rollup.config.js:188

即 __COMPAT__ 不是命令行开关,而是由包自身的 package.json > buildOptions.compat 决定。packages/vue-compat/package.json:30 写着 "compat": true,故只有 @vue/compat 的构建产出 __COMPAT__=true;packages/vue 不带该字段,故 vue 包所有 if (__COMPAT__) 分支被常量折叠为空。这就是 18.1.3 所说"零成本兼容"的机制来源。

18.9.3 四个源码入口(compat 专属)

普通包只有 src/index.ts(全量)与 src/runtime.ts(仅运行)。compat 包额外拆出 ESM 变体:rollup.config.js:159-168 判断"compat 包 + ESM 目标"时改用 src/esm-index.ts / src/esm-runtime.ts。原因是 @vue/compat 需要同时提供 default 与 named 导出,而 Rollup 对非 ESM 目标的这种导出组合会告警,故用独立入口规避;output.exports = isCompatPackage ? 'auto' : 'named'(rollup.config.js:146)与之配套。于是 compat 的四个入口为:index.ts、runtime.ts、esm-index.ts、esm-runtime.ts。

18.9.4 alias 与 dev.js 的联动

scripts/aliases.js 把 @vue/* 源别名指向各 packages/*/src;scripts/dev.js:154 在生产 DEV 构建时注入 __DEV__=false。二者共同决定:调试 @vue/compat 时,__COMPAT__ 恒为真(来自 buildOptions.compat),而 __DEV__ 随构建模式切换——因此兼容层的全部 warnDeprecation 都包在 __DEV__ && 之下,生产版一条警告都不会打进包(例如 transformFilter.ts:158-164、compatConfig.ts 的警告路径)。


18.10 性能优化清单:每条优化对应的章节与源码

18.10.1 编译期优化(05–11)

优化作用章节源码锚点
静态提升 hoistStatic把无动态绑定的 vnode 提到 render 外,复用同一 vnode06/07compileToFunction 默认 hoistStatic: true(vue/src/index.ts:63);transform.ts hoist(transform.ts:291-302)
constantCache/CAN_CACHE纯静态表达式折叠为常量06transform.ts:121,298
Block 树 + PatchFlags只 diff"动态"部分,跳过静态07/11createVNodeCall、PatchFlags(ast.ts / vnode.ts)
patchFlag 位运算比对TEXT=1/CLASS=2/PROPS=8… 精确路由更新11/13patchElement(renderer.ts)
cacheHandlers(useCache)内联事件函数缓存,避免每次 render 新建闭包06/07createCacheExpression(ast.ts)
SSR 静态提升 ssrTransform服务端直出字符串15compiler-ssr
filter 重写仅在 compat非 compat 构建完全无此项18.6compile.ts:42 三元

18.10.2 运行期与响应式优化(02–04、13–14)

优化作用章节源码锚点
Proxy 惰性递归只在访问嵌套对象时才 reactive 子对象02reactivity/reactive.ts
依赖去重 + depsTail 链表避免重复订阅、降低 GC03Dep/Link(effect.ts)
effect.scheduler + 微任务批处理同一 tick 多次变更只重渲染一次03/14queueJob/flushJobs(scheduler.ts)
shapeFlag 位标记单整数编码 vnode 类型,省分支11ShapeFlags(vnode.ts)
diffChildren + LIS 最长递增子序列keyed diff 时最少移动13renderer.ts getSequence
compatH 惰性翻译仅 v2 render 才转换,v3 render 零开销18.5convertLegacyRenderFn(renderFn.ts:42)

18.11 调试与诊断手册:线上高频问题复盘

18.11.1 "改了数据但视图不更新"

  • v3 原生:多半是给 ref 对象整体替换 / 解构丢失 value,或用了非响应式对象;检查是否在 setup 外访问。
  • compat 迁移中:若组件用 v2 data() + this.xxx 赋值,v3 的 data 返回对象被 reactive 包裹,新增属性不再需要 Vue.set(global.ts 的 defineReactive 已改为 reactive),但若你手动 Object.defineProperty 覆盖,会破坏 Proxy 追踪——这是 18.3.4 Vue.set/delete 兼容分支要提醒的点。

18.11.2 "重复渲染 / 无限循环"

  • patchFlag 失效导致整树重建:检查 render 是否返回新对象引用、或依赖了未追踪的变量。
  • compat 下 $listeners fallthrough(18.4.3)若把 onXxx 误当 attr 落到底层元素,可能引发意外的事件触发循环。

18.11.3 "内存泄漏"

  • $on 监听未在 beforeUnmount 解绑:compat 的 instanceEventEmitter(WeakMap 实现,18.4.5)不会自动清理,v2 代码里 $off 的调用不能删。
  • legacyRenderStatic 的 staticCacheMap(renderHelpers.ts:117-136)是 WeakMap,随实例回收,不泄漏。

18.11.4 "hydration mismatch"(16 章)

服务端与客户端首帧 vnode 结构不一致时,DEV 弹 warning。compat 场景下高发原因是:用了运行期编译但服务端与客户端模板不同、或 v-html/filters 在两端行为不一。排查:__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__ 打开后获得精确节点路径。

18.11.5 "watch 不触发 / 只触发一次"

  • watch 默认浅层,深层对象需 { deep: true };
  • 监听 reactive 属性应写成 watch(() => obj.a, cb)(getter 形式);
  • compat 下若把 v2 的 $watch 用法迁移,注意 v3 watch 返回 stop 句柄而 v2 返回 unwatch 函数,语义不同。

18.12 全系列总结与延伸阅读方向

18.12.1 全系列地图

章节主题一条主线
01总览与仓库结构monorepo、包依赖、构建流水
02–04响应式系统Proxy → track/trigger → effect/computed/watch
05–08编译器baseParse → transform → codegen;Block/patchFlag 优化
09–10SFCparse → compileTemplate/Script/Style → compileScript
11–14运行时vnode/组件/渲染器 diff/调度器
15–16SSRrenderToString 与 hydrate
17runtime-dom 与内置组件nodeOps、Transition/KeepAlive
18(本章)兼容层与全链路@vue/compat、__COMPAT__、端到端调用链

18.12.2 延伸阅读方向

  • 生态分层:vue-router(依赖 app.use 与注入)、pinia(基于 effectScope)、@vitejs/plugin-vue(调用 compiler-sfc);读源时应能一眼定位它们复用了本篇的哪些章节能力。
  • 跨端/自定义渲染器:@vue/runtime-test 与 createRenderer 的 nodeOps/patchProp 抽象,是 13/17 章的最小复刻。

18.13 本章要点回顾

  1. 零成本兼容的机制:__COMPAT__ 由 packageOptions.compat 决定(rollup.config.js:139/188),非 compat 构建所有兼容分支被常量折叠 + tree-shaking 删除,产物与 v3 原生一致。
  2. 配置模型:DeprecationTypes(40+ 字符串枚举)+ CompatConfig 四来源(global 默认 / 用户全局 / 编译器 / 组件级),isCompatEnabled 按 MODE 双向判定,四个断言助手区分"必须/尽量/检查"。
  3. 全局 API:createCompatVue 造"假构造函数",以 singletonApp 承载 Vue.config/use/mixin/component/directive/extend/set/delete/observable/filter;installCompatMount 手工复刻 app.mount 的 selector/in-DOM/namespace 与"预创建实例"语义。
  4. 实例与生命周期:installCompatInstanceProperties 补 $listeners/$attrs/$children/$on/$once/$off 与私有 API 表;beforeDestroy/destroyed 映射到 beforeUnmount/unmounted。
  5. render 翻译:convertLegacyRenderFn 以 render.length >= 2 判定 v2 render;compatH 把 data 七字段翻译为 v3 props/指令/函数式 slot;componentVModel 用 onModelCompat: 前缀桥接双向绑定。
  6. filters:编译器 transformFilter 的 parseFilter 字符状态机(跟踪引号/正则/三种括号 + || 排除)切出 filter 链,wrapFilter 生成 _filter_*,codegen 生成 _f("name") 绑定,运行期 resolveFilter 按"局部→全局"查表。
  7. 全链路:baseParse→transform→codegen→compileToFunction 与 createApp→mount→patch→mountElement→DOM 两段流水由响应式 trigger→scheduler→update 收尾,前十七章知识点在此汇成一条线。

终章至此。一个框架的伟大,不只在于它提供了什么新能力,更在于它如何优雅地让旧世界的代码继续活着,同时不为此让新世界付出任何体积代价——@vue/compat 正是这句话最完整的工程注脚。