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.ts560+ 行、global.ts597 行、renderFn.ts317 行、instance.ts199 行、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)。这些章节各自闭环,但留下两个"横切"的问题始终没有正面回答:
- 横切面一——兼容层。 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__把它与主线彻底隔离。这背后的工程决策本身就值得单列一章。 - 横切面二——全链路。 前十七章把流水线"切段"讲,读者容易记住"编译器的第 N 步"或"运行时的第 M 步",却难以在脑中形成一条从
<template>字符串到浏览器 DOM 的连续调用链。而理解框架的真正门槛恰恰是这条链:一个插值表达式从{{ msg }}变成_toDisplayString(_ctx.msg)、再变成Textvnode、再变成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.mixin | app.config / app.use / app.mixin | global.ts:181-329、installLegacyAPIs(global.ts:367-388) | ||
| 3 | Vue.prototype | 原型增强 | app.config.globalProperties | applySingletonPrototype(global.ts:428-450) | ||
| 4 | Vue.set/delete/observable | 存在 | 移除 | global.ts:284-297 | ||
| 5 | Vue.extend | 构造函数继承 | 移除(defineComponent) | extendCtor(global.ts:225-282) | ||
| 6 | Vue.filter | 存在 | 移除 | installFilterMethod(global.ts:352-365) | ||
| 7 | 实例生命周期名 | beforeDestroy/destroyed | beforeUnmount/unmounted | applyOptions 注册(componentOptions.ts:739-752) | ||
| 8 | 事件 API | $on/$once/$off | 移除 | instanceEventEmitter.ts、instance.ts:120-122 | ||
| 9 | $children | 存在 | 移除 | instanceChildren.ts | ||
| 10 | $listeners | 独立对象 | 合并进 $attrs | instanceListeners.ts、attrsFallthrough.ts | ||
| 11 | $attrs 含 class/style | 不含 | 含 | attrsFallthrough.ts:12-15、componentRenderUtils.ts:219-244 | ||
| 12 | $scopedSlots | 存在 | 合并进 $slots | instance.ts:115-118 | ||
| 13 | render 函数签名 | h(tag, data, children) + 旧 data 字段 | h(type, props, children) | renderFn.ts:42-302 | ||
| 14 | 函数式组件 | functional: true | 普通函数 | componentFunctional.ts | ||
| 15 | 异步组件 | 返回 Promise 的工厂函数 | defineAsyncComponent() | componentAsync.ts | ||
| 16 | filters | `{{ a | f }}` | ` | ` 视为位运算 | 编译器 transformFilter.ts + 运行时 resolveFilter |
| 17 | v-model on 组件 | value + input | modelValue + update:modelValue | componentVModel.ts | ||
| 18 | v-on keyCode 修饰符 | @keyup.13 | 仅键名 | vOn.ts:97-155 | ||
| 19 | 自定义指令钩子 | bind/inserted/update/unbind | beforeMount/mounted/updated/unmounted | customDirective.ts:14-24 | ||
| 20 | data 可以是对象 | 是 | 必须函数 | global.ts:158-165、createCompatApp | ||
| 21 | data 深度合并 | 深合并 | 浅合并 | data.ts、componentOptions.ts:1050-1052 | ||
| 22 | watch 数组隐式 deep | 是 | 否 | componentOptions.ts:860-885 | ||
| 23 | props 默认值的 this | 可访问 | 不可访问 | props.ts、componentProps.ts:481-488 | ||
| 24 | attrs: false 时 class/style 仍下落 | 是 | 否 | attrsFallthrough.ts、componentRenderUtils.ts | ||
| 25 | <transition-group> 默认 span | 是 | 否 | TransitionGroup.ts:156-159 | ||
| 26 | Transition 类名(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) | ||
| 31 | config.ignoredElements | 存在 | isCustomElement | global.ts:413-422 | ||
| 32 | config.keyCodes | 存在 | 移除 | renderHelpers.ts:138-156、vOn.ts | ||
| 33 | config.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 | 全局用户配置 | globalCompatConfig | configureCompat(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]
}
算法只有两步,但第二步藏着一个容易忽略的正确性细节:
- 实例层优先:若存在
instance,且instance.type.compatConfig确实含该 key(key in instanceConfig,即in运算符而非常规的hasOwn),返回实例配置的值; - 全局兜底:否则返回
globalCompatConfig[key]。
为什么用 in 而不是 hasOwnProperty?因为组件作者可能把 compatConfig 写成继承自某个共享配置对象的形式(例如 compatConfig: Object.create(baseCompatConfig)),in 会沿原型链查找,能命中继承来的配置。这与 getCompatConfigForKey 在"组件层未命中就落到全局"的两级结构一致——组件层负责精确覆写、全局层负责统一默认。
易错点 1:
getCompatConfigForKey的实例参数在绝大多数调用点传的是getCurrentInstance()(见warnDeprecation第 450 行instance = instance || getCurrentInstance())。这意味着在渲染上下文之外调用(如定时器里读某个实例属性)可能读到null,从而只走全局配置。理解这一点对排查"为什么 per-componentcompatConfig不生效"很关键。易错点 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)
}
算法:
- 开发期校验(
validateCompatConfig); extend(globalCompatConfig, config)——浅合并,后者覆盖前者。注意这里是就地合并(mutateglobalCompatConfig),不是返回新对象。因此多次调用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' // 默认关(除非显式开)
}
}
算法步骤:
- 内置组件短路:若
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。 - 读 MODE:
getCompatConfigForKey('MODE', instance) || 2。默认是2(v2 模式)——这正契合"进入@vue/compat默认先模拟 v2"的定位。 - MODE 可以是函数:
isFunction(rawMode)时以组件类型为参数求值,支持"按组件类型动态切换模式"这一高级用法。 - 双向默认值:
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 链":
- 缓存:同一个
extendOptions对象只生成一个SubVue(WeakMap)。这是副作用去重——若Vue.extend(opts)被调用两次,应当返回"同一个类"。 SubVue(inlineOptions)也是一个"假构造函数":无参数时new SubVue()→createCompatApp(SubVue.options, SubVue);有参数时把 inline 选项与SubVue.options合并后创建。这与前面Vue(...)的模式完全一致。SubVue.prototype = Object.create(Vue.prototype):这是 v2Vue.extend的真原型链语义。它让SubVue.prototype继承Vue.prototype上的所有增强,因而SubVue的实例(其实是 proxy)在globalProperties上也能看到这些增强。注意这里的Vue.prototype指的是"构造函数对象上的prototype属性",而非app.config.globalProperties——它们的连接点在applySingletonPrototype。- 克隆基类选项:
for (const key in Super.options)对数组slice()、对象extend(Object.create(null), superValue)。目的:mergeOptions在合并时可能就地 mutateto(如往to.components里塞键),若不克隆,一个子类的注册会污染父类的options.components。这是一个真实的"多级继承串味"边界处理。 _base指向SubVue:v2 里this.$options._base是"Ctor",用于_createElement里按字符串解析组件。这里保持该字段以便第三方库读。SubVue.extend = extendCtor.bind(SubVue):支持A.extend().extend()(多级继承)。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 能力"——因为 v2Vue.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 风格实现,用于让依赖 v2Vue.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:beforeMount | mountComponent | 挂载前 | 同步 |
hook:mounted | mountComponent 后 | 挂载后 | post |
hook:beforeUpdate | updateComponent | 更新前 | 同步 |
hook:updated | updateComponent 后 | 更新后 | post |
hook:beforeDestroy | unmountComponent | 卸载前 | 同步 |
hook:destroyed | unmountComponent | 卸载后 | 同步 |
hook:activated / hook:deactivated | KeepAlive 激活/停用 | — | 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:
- 若某 vnode 有
component(即它是一个组件根 vnode),把它的proxy收进数组,并停止下钻(if/else,不再进入该组件的子树)——这与 v2 的$children语义一致(只收直接子组件,不收孙组件); - 若是数组 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
}
}
判定算法:
- 早退条件:没有 render、或 render 带
_rc(Runtime Compiled,v3 运行时编译产物,见 18.8)/_compatChecked/_compatWrapped标记,都直接返回。_rc很关键:packages/vue/src/index.ts:102里compileToFunction生成的 render 上会打_rc = true,表示"这是 v3 编译器产物,签名已是 v3"。
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后返回。- 这是一个极其聪明的启发式:它不依赖任何显式标记,仅靠参数个数区分两代产物。
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不能直接 importruntime-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) |
domProps | DOM property | extend(converted, legacyProps.domProps) |
props | 组件 props | extend(converted, legacyProps.props) |
on | 事件监听 | 键经 convertLegacyEventKey → toHandlerKey |
nativeOn | 原生事件监听 | 同上 + Native 后缀 |
staticClass | 静态 class | normalizeClass([staticClass, class]) |
staticStyle | 静态 style | normalizeStyle([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
}
算法:
- v2 的"具名 slot 就是带
slot属性的子 vnode":v2 里<template slot="header">或<div slot="header">会被编译成"带slot: 'header'prop 的普通子节点"。这里遍历 children,按props.slot分组,缺省归default: <template>节点展开:child.type === 'template'时推入child.children(<template slot="x">的内容),否则推入 vnode 本身。- 每个 slot 包成函数:v3 的 slot 是"返回 vnode 数组的函数"。这里
slots[key] = () => slotChildren,并打_ns = true(non-scoped slot)——这个标记被withCtx(第 11 章)读取,用于区分"v2 的非作用域插槽"与"v3 编译的作用域插槽",从而在withCtx里设置_ns(componentRenderContext.ts:122-124)。 scopedSlots合并:data.scopedSlots本身就是"函数对象"({ default: fn }),直接extend进 slots 即可,然后删掉 vnode.props 上的scopedSlots(避免它被当成普通 prop)。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 组件形态":
- v2 构造函数(
isFunction(comp) && comp.cid):把comp.options展开,并搬运render/__file/__hmrId/__scopeId等 SFC 编译器注入的字段; - v2 异步组件(
isFunction(comp) && COMPONENT_ASYNC开启)→convertLegacyAsyncComponent(componentAsync.ts); - 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 }} 的完整旅程
baseParse产出INTERPOLATION节点,content.content为字符串"msg | uppercase";transformFilter(__COMPAT__时在nodeTransforms首位)→parseFilter切出expression="msg"、filters=["uppercase"]→wrapFilter得_filter_uppercase(msg),写回node.content,广播COMPILER_FILTERS警告;transformExpression跳过_filter_前缀,只把msg前缀成_ctx.msg;root.filters=["uppercase"]→genAssets生成const _filter_uppercase = _resolveFilter("uppercase"),并在 helpers 里带上resolveFilter;- 运行期
resolveFilter("uppercase")经resolveAsset(FILTERS,...)命中app._context.filters; _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[" 字符串"] --> B["baseParse 解析 (05)"]
B --> C["transform 节点变换 (05–06)"]
C -. "[C] transformFilter (18.6)" .-> C
C --> D["transformExpression 前缀化 (06)"]
D --> E["codegen 生成 render (07)"]
E -. "[C] genAssets 生成 _filter_* (18.6)" .-> E
E --> F["compileToFunction / registerRuntimeCompiler (18.8)"]
G["createApp(rootComponent)"] --> H["createVNode → shapeFlag (11)"]
F --> H
H --> I["app.mount → normalizeContainer (17)"]
I -. "[C] installCompatMount/_createRoot (18.3.6)" .-> I
I --> J["render → patch (13)"]
J --> K{"vnode 类型?"}
K -->|Component| L["mountComponent → setupComponent (12–13)"]
L -. "[C] convertLegacyRenderFn / compatH (18.5)" .-> L
L --> M["renderComponentRoot → 执行 render"]
M --> N["patch 子节点 / mountElement (13)"]
K -->|Element| N
N --> O["runtime-dom hostCreateElement (17)"]
O --> P["document.createElement + append (DOM)"]
Q["reactive/ref 变更 (02–03)"] --> R["track/trigger → effect (03)"]
R --> S["scheduler queueJob → flushJobs (14)"]
S --> T["componentUpdateFn (13)"]
T --> U["patchElement + patchFlag 精确更新 (11,13)"]
U --> P
T -. "[C] 生命周期 beforeDestroy→beforeUnmount (18.4.2)" .-> T
18.7.2 编译期逐帧(05–07、18.6、18.8)
以 <div id="app">{{ msg | uppercase }}</div> 为例:
| 帧 | 函数(路径:行号) | 状态变化 |
|---|---|---|
| 1 | baseParse(compiler-core/parser.ts) | 产出 RootNode,含 ELEMENT + INTERPOLATION |
| 2 | transform(transform.ts:334+) | 遍历节点,创建 TransformContext(__COMPAT__ 时 context.filters = new Set(),transform.ts:315-317) |
| 3 | transformFilter(compat/transformFilter.ts:19) | INTERPOLATION 被重写为 _filter_uppercase(msg) |
| 4 | transformExpression | _filter_ 跳过;msg → _ctx.msg,得 _filter_uppercase(_ctx.msg) |
| 5 | transform 收尾(transform.ts:353-355) | root.filters = ['uppercase'] |
| 6 | genAssets(codegen.ts:365-369/537-563) | 生成 const _filter_uppercase = _resolveFilter("uppercase") |
| 7 | generate | 拼出 _toDisplayString(_filter_uppercase(_ctx.msg)) 的 render 函数字符串 |
18.7.3 挂载期逐帧(11–13、17、18.3.6)
| 帧 | 函数(路径:行号) | 关键动作 |
|---|---|---|
| 1 | createApp(runtime-dom/src/index.ts) | 建 app、AppContext;__COMPAT__ 下 installAppCompatProperties |
| 2 | app.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 |
| 3 | createVNode(rootComponent)(vnode.ts) | 打 shapeFlag;isCompatRoot 时预创建实例 |
| 4 | render(vnode, container)(renderer.ts) | patch(null, vnode, container) |
| 5 | patch → processComponent | 走 mountComponent |
| 6 | setupComponent + setupRenderEffect | 建 instance,包 ReactiveEffect(13 章) |
| 7 | renderComponentRoot(componentRenderUtils.ts) | [C] 先 convertLegacyRenderFn,再执行 render 得 vnode 树 |
| 8 | patch 元素 → mountElement | hostCreateElement(runtime-dom/nodeOps.ts)→ document.createElement |
| 9 | hostInsert(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' 命中的入口。它只做三件事:
- re-export:
export * from '@vue/runtime-dom'(index.ts:107),把createApp/h/ref等全部转出; - 注册运行时编译器:
registerRuntimeCompiler(compileToFunction)(index.ts:104)——让@vue/runtime-dom里那个"空壳"编译器变成真编译器,从而支持template选项编译(__DEV__下initDev,index.ts:24-26); - 暴露
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-bundler | vue.esm-bundler.js | __DEV__ 保留为 process.env.NODE_ENV,feature flags 保留为 __VUE_OPTIONS_API__ 等,交由打包器替换 |
esm-browser | vue.esm-browser.js | 自包含,__DEV__ 硬编码,process.env 被替换为 ({}) |
cjs/global | vue.cjs.js / vue.global.js | Node / 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 外,复用同一 vnode | 06/07 | compileToFunction 默认 hoistStatic: true(vue/src/index.ts:63);transform.ts hoist(transform.ts:291-302) |
constantCache/CAN_CACHE | 纯静态表达式折叠为常量 | 06 | transform.ts:121,298 |
Block 树 + PatchFlags | 只 diff"动态"部分,跳过静态 | 07/11 | createVNodeCall、PatchFlags(ast.ts / vnode.ts) |
patchFlag 位运算比对 | TEXT=1/CLASS=2/PROPS=8… 精确路由更新 | 11/13 | patchElement(renderer.ts) |
cacheHandlers(useCache) | 内联事件函数缓存,避免每次 render 新建闭包 | 06/07 | createCacheExpression(ast.ts) |
SSR 静态提升 ssrTransform | 服务端直出字符串 | 15 | compiler-ssr |
| filter 重写仅在 compat | 非 compat 构建完全无此项 | 18.6 | compile.ts:42 三元 |
18.10.2 运行期与响应式优化(02–04、13–14)
| 优化 | 作用 | 章节 | 源码锚点 |
|---|---|---|---|
Proxy 惰性递归 | 只在访问嵌套对象时才 reactive 子对象 | 02 | reactivity/reactive.ts |
依赖去重 + depsTail 链表 | 避免重复订阅、降低 GC | 03 | Dep/Link(effect.ts) |
effect.scheduler + 微任务批处理 | 同一 tick 多次变更只重渲染一次 | 03/14 | queueJob/flushJobs(scheduler.ts) |
shapeFlag 位标记 | 单整数编码 vnode 类型,省分支 | 11 | ShapeFlags(vnode.ts) |
diffChildren + LIS 最长递增子序列 | keyed diff 时最少移动 | 13 | renderer.ts getSequence |
compatH 惰性翻译 | 仅 v2 render 才转换,v3 render 零开销 | 18.5 | convertLegacyRenderFn(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.4Vue.set/delete兼容分支要提醒的点。
18.11.2 "重复渲染 / 无限循环"
patchFlag失效导致整树重建:检查 render 是否返回新对象引用、或依赖了未追踪的变量。- compat 下
$listenersfallthrough(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用法迁移,注意 v3watch返回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–10 | SFC | parse → compileTemplate/Script/Style → compileScript |
| 11–14 | 运行时 | vnode/组件/渲染器 diff/调度器 |
| 15–16 | SSR | renderToString 与 hydrate |
| 17 | runtime-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 本章要点回顾
- 零成本兼容的机制:
__COMPAT__由packageOptions.compat决定(rollup.config.js:139/188),非 compat 构建所有兼容分支被常量折叠 + tree-shaking 删除,产物与 v3 原生一致。 - 配置模型:
DeprecationTypes(40+ 字符串枚举)+CompatConfig四来源(global 默认 / 用户全局 / 编译器 / 组件级),isCompatEnabled按MODE双向判定,四个断言助手区分"必须/尽量/检查"。 - 全局 API:
createCompatVue造"假构造函数",以singletonApp承载Vue.config/use/mixin/component/directive/extend/set/delete/observable/filter;installCompatMount手工复刻app.mount的 selector/in-DOM/namespace 与"预创建实例"语义。 - 实例与生命周期:
installCompatInstanceProperties补$listeners/$attrs/$children/$on/$once/$off与私有 API 表;beforeDestroy/destroyed映射到beforeUnmount/unmounted。 - render 翻译:
convertLegacyRenderFn以render.length >= 2判定 v2 render;compatH把data七字段翻译为 v3 props/指令/函数式 slot;componentVModel用onModelCompat:前缀桥接双向绑定。 - filters:编译器
transformFilter的parseFilter字符状态机(跟踪引号/正则/三种括号 +||排除)切出 filter 链,wrapFilter生成_filter_*,codegen 生成_f("name")绑定,运行期resolveFilter按"局部→全局"查表。 - 全链路:
baseParse→transform→codegen→compileToFunction与createApp→mount→patch→mountElement→DOM两段流水由响应式trigger→scheduler→update收尾,前十七章知识点在此汇成一条线。
终章至此。一个框架的伟大,不只在于它提供了什么新能力,更在于它如何优雅地让旧世界的代码继续活着,同时不为此让新世界付出任何体积代价——
@vue/compat正是这句话最完整的工程注脚。