Vue 3 设计与实现

第 2 章 Vue 3 源码全景图

作者 杨艺韬 · 11,486 字

第 2 章 Vue 3 源码全景图

本章要点

  • Vue 3 Monorepo 架构:20+ 个包的依赖拓扑与职责划分
  • 构建系统的演进:从 Rollup 到 esbuild 的工程抉择
  • 核心三角的协作模型:Compiler → Reactivity → Runtime
  • 从模板到像素:一次完整渲染的全链路图解
  • 三种高效调试源码的姿势

打开 Vue 3 的 GitHub 仓库,你会看到一个 packages/ 目录下整整齐齐排列着二十多个子包。如果你是第一次面对这样的代码量,大概会产生一种站在图书馆门口、不知道该先翻哪本书的茫然感。

这种茫然感是正常的。Vue 3 的 vuejs/core 仓库包含超过 10 万行 TypeScript 代码,涉及响应式系统、编译器、运行时、服务端渲染、开发工具等多个子系统。没有一张全景地图,贸然深入任何一个子系统都可能迷失方向。

本章的目标就是画出这张地图。

我们会从 Monorepo 的架构设计开始,理解 Vue 为什么选择将一个框架拆成 20+ 个包;然后纵览构建系统的演进,看到工程工具如何影响框架设计;接着深入核心三角——Compiler、Reactivity、Runtime——的协作流程;最后,我会手把手演示三种调试源码的方法,确保你在后续章节中能随时跳进源码,验证书中的每一个论断。

读源码是一门"反直觉"的手艺

在深入任何一个包之前,我想先给你打一个重要的预防针:读大型项目源码的乐趣,不在"从头读到尾"。很多第一次尝试读源码的同学会犯同一个错——从第一个文件开始,逐字逐句往下读,期待自己能"读完整个代码库"。这种读法几乎注定失败:Vue 3 有 10 万行代码,按每分钟读 50 行的速度(远快于正常读源码速度)读完需要 33 小时——而且读完后你会发现自己记不住任何细节。

正确的读源码姿势是带着问题读——"ref 的 .value 到底在哪里做了 unwrap?"、"v-for 的 key 是怎么被编译器捕获的?"、"Suspense 的 pending 状态如何触发 fallback?"——每个具体问题引导你进入源码的一小块、读懂这一小块、然后合上代码继续读书/写业务。这种"带问题定点钻井"的方式让你每次读源码都有明确收获,半年下来你读过的源码总量可能是硬啃读法的三倍,但理解深度是硬啃的十倍。本章给你的全景地图的主要用途就是——当你带着一个问题想钻进源码时,地图告诉你井应该打在哪里,你不用先把整个森林砍一遍。

2.1 Monorepo 架构:20+ 个包的依赖关系

Vue 3 的 20+ 个包不是随意堆砌——它们遵循一个精心设计的分层架构。读本节前,你可以先预判一下:如果让你把一个 UI 框架拆成多个包,你会怎么拆?是按功能拆(一个包负责路由、一个包负责状态)、按平台拆(一个包给浏览器、一个包给 Node)、还是按层次拆(底层、中层、上层)?Vue 团队的答案是混合策略——主线是按层次拆,横向又按平台做扩展包。我们看完这一节你会明白这个混合策略为什么最合适。

为什么是 Monorepo

"Monorepo" vs "Multirepo"是开源社区反复争论的话题。两种路线都有旗帜鲜明的大项目:Facebook、Google 是 Monorepo 的坚定信徒;Node.js 生态早期被 npm 包裹形成天然的 Multirepo 风格。前端框架里 Vue、React、Angular 都选了 Monorepo,Nx 和 Lerna 这类工具也为 Monorepo 提供了越来越成熟的基础设施。所以 Vue 的 Monorepo 选择并不惊人——惊人的是它做得多彻底、把"渐进式哲学"和 Monorepo 结合得多紧密。

Vue 3 使用 pnpm workspace 管理所有子包,遵循 monorepo(单仓多包)模式。这不是一个随意的工程决策——它直接反映了 Vue 的"渐进式框架"哲学。

Monorepo 不是 Vue 首创——React、Babel、Angular、Jest、Next.js 早就都在用这种结构。它在开源世界流行的根本原因是一个很朴素的观察:一个复杂的工具链项目,相关的子包改动几乎总是一起发生。改 Vue 编译器通常要同时改运行时、改测试、改文档——如果这些分别在不同仓库里,一次改动要开五个 PR、跨仓库协调合并时机、验证版本兼容,工作量和出错概率都翻倍。Monorepo 让这些相关改动变成一次提交——编译器、运行时、测试在同一个 diff 里一起改、一起 review、一起合并。这种"原子性的跨模块改动"是 Monorepo 相对 Multirepo 最关键的工程优势。

在 Vue 2 时代,所有代码都在一个巨大的 src/ 目录下。这种方式的问题在于:你无法单独使用 Vue 的响应式系统。想在一个非 Vue 项目中用 reactive()computed()?对不起,你必须引入整个 Vue。

Vue 3 的 Monorepo 架构解决了这个问题。每个子包都有自己的 package.json,可以独立安装和使用:

// 只使用响应式系统,不需要运行时和编译器
import { ref, computed, watch } from '@vue/reactivity'

const count = ref(0)
const doubled = computed(() => count.value * 2)

watch(count, (newVal) => {
  console.log(`count changed to ${newVal}`)
})

count.value++ // 输出: count changed to 1

这段代码不需要任何 DOM 环境,可以在 Node.js、Deno、甚至嵌入式 JavaScript 运行时中执行。@vue/reactivity 是一个零依赖(仅依赖 @vue/shared)的响应式库。

这种"可独立使用"的价值远超"减少 bundle size"——它的真正意义在于让 Vue 的响应式系统成为了前端之外的通用原语。Nuxt Server、Vitest、Astro 的岛屿架构、VS Code 的扩展系统——很多非 Vue 项目都直接用了 @vue/reactivity 来管理自己的状态。这是 Vue 团队做 monorepo 的意外收益:把好的底层暴露出去,整个 JavaScript 生态都能受益。和第 14 章讲的"借语言的台子"异曲同工——只不过这次角色反过来了:Vue 成了那个被别人借台子的"语言层底座"。能成为被别人借台子的项目,本身就是开源世界里的一种成就。

🔥 深度洞察

Monorepo 的真正价值不在于"代码放在一个仓库里"——这只是表象。它的核心价值在于强制解耦与显式依赖。当你把代码拆成独立的包时,包与包之间的依赖必须通过 importpackage.json 显式声明。这消除了隐式耦合——在 Vue 2 的单体架构中,运行时可以随意引用编译器的内部工具函数,因为它们在同一个 src/ 目录下。在 Vue 3 中,这种引用会导致循环依赖的编译错误,迫使开发者思考正确的依赖方向。

包的完整清单

让我们逐一梳理 packages/ 目录下的所有子包,按照依赖层次从底到顶排列:

层次 包名 职责 可独立使用
基础层 @vue/shared 共享工具函数(isObjectisArraymakeMap 等)
响应式层 @vue/reactivity 响应式核心(ref、reactive、computed、effect)
编译器层 @vue/compiler-core 模板编译核心(与平台无关)
@vue/compiler-dom DOM 平台的编译扩展
@vue/compiler-sfc 单文件组件(.vue)编译器
@vue/compiler-ssr SSR 编译优化
@vue/compiler-vapor Vapor Mode 编译器 🆕
运行时层 @vue/runtime-core 运行时核心(组件、生命周期、调度器)
@vue/runtime-dom DOM 平台运行时
@vue/runtime-vapor Vapor 运行时 🆕
服务端层 @vue/server-renderer 服务端渲染
入口层 vue 完整构建入口(编译器 + 运行时)

依赖拓扑图

这些包之间的依赖关系形成了一个精心设计的有向无环图(DAG):

graph BT
    shared["@vue/shared<br/>共享工具"]

    reactivity["@vue/reactivity<br/>响应式核心"]
    shared --> reactivity

    compiler-core["@vue/compiler-core<br/>编译核心"]
    shared --> compiler-core

    compiler-dom["@vue/compiler-dom<br/>DOM 编译"]
    compiler-core --> compiler-dom

    compiler-sfc["@vue/compiler-sfc<br/>SFC 编译"]
    compiler-core --> compiler-sfc
    compiler-dom --> compiler-sfc

    compiler-ssr["@vue/compiler-ssr<br/>SSR 编译"]
    compiler-core --> compiler-ssr
    compiler-dom --> compiler-ssr

    compiler-vapor["@vue/compiler-vapor<br/>Vapor 编译 🆕"]
    compiler-core --> compiler-vapor

    runtime-core["@vue/runtime-core<br/>运行时核心"]
    reactivity --> runtime-core
    shared --> runtime-core

    runtime-dom["@vue/runtime-dom<br/>DOM 运行时"]
    runtime-core --> runtime-dom

    runtime-vapor["@vue/runtime-vapor<br/>Vapor 运行时 🆕"]
    runtime-core --> runtime-vapor
    reactivity --> runtime-vapor

    server-renderer["@vue/server-renderer<br/>SSR 渲染"]
    runtime-core --> server-renderer

    vue["vue<br/>完整入口"]
    runtime-dom --> vue
    compiler-dom --> vue
    compiler-sfc --> vue

    style compiler-vapor fill:#ff6b6b,stroke:#333,color:#fff
    style runtime-vapor fill:#ff6b6b,stroke:#333,color:#fff
    style reactivity fill:#ffd93d,stroke:#333

几个关键的依赖方向值得注意:

  1. 响应式不依赖运行时@vue/reactivity 只依赖 @vue/shared,不知道 DOM、组件或虚拟节点的存在。这使得它可以作为独立库使用。

  2. 编译器不依赖运行时@vue/compiler-* 系列只依赖 @vue/shared,不依赖任何运行时包。编译器是纯函数——输入模板字符串,输出代码字符串。

  3. 运行时依赖响应式@vue/runtime-core 依赖 @vue/reactivity,将响应式系统用于组件的状态管理和依赖驱动更新。

  4. 入口包组合一切vue 包将编译器和运行时组合在一起,提供开箱即用的完整框架。

💡 最佳实践

当你在库(library)项目中只需要响应式能力时,直接安装 @vue/reactivity 而非 vue。这可以将 bundle size 从 ~30KB(gzipped)降低到 ~5KB。Vue 的 monorepo 架构让这种按需使用成为可能。

@vue/shared:万物之基

每个大型项目都有一个"公共库"——放那些"哪里都用得上但不属于任何主业务"的工具函数。管理这个公共库是一门隐形的技艺:放得太少则工具函数分散在各个业务包里、代码重复;放得太多则变成"什么都往里塞"的垃圾桶,反而没人敢引用。Vue 的 @vue/shared 是这方面做得极好的一个范例——它小而精,几乎每个函数都是跨多个包共享的、有明确语义的通用工具。看看它,你会对"怎样写一个被多方复用的库"有更深的感觉。

@vue/shared 是整个架构的底座。它提供了一组与平台无关的工具函数:

// packages/shared/src/general.ts

// 类型判断
export const isArray = Array.isArray
export const isMap = (val: unknown): val is Map<any, any> =>
  toTypeString(val) === '[object Map]'
export const isSet = (val: unknown): val is Set<any> =>
  toTypeString(val) === '[object Set]'
export const isFunction = (val: unknown): val is Function =>
  typeof val === 'function'
export const isString = (val: unknown): val is string =>
  typeof val === 'string'
export const isSymbol = (val: unknown): val is symbol =>
  typeof val === 'symbol'
export const isObject = (val: unknown): val is Record<any, any> =>
  val !== null && typeof val === 'object'

// 高性能的 Set 查询 — 用闭包代替 Set.has()
export function makeMap(
  str: string,
  expectsLowerCase?: boolean
): (key: string) => boolean {
  const set = new Set(str.split(','))
  return expectsLowerCase
    ? val => set.has(val.toLowerCase())
    : val => set.has(val)
}

// HTML 标签检查(编译器和运行时共用)
export const isHTMLTag = /*#__PURE__*/ makeMap(
  'html,body,base,head,link,meta,style,title,...'
)

这些看似简单的工具函数有一个微妙但重要的设计细节:/*#__PURE__*/ 注解。这个注解告诉打包工具(Rollup/esbuild):"这个函数调用没有副作用,如果返回值没被使用,可以安全地移除。"这是 Vue 3 tree-shaking 友好设计的基石之一。

为什么这么一个注释这么重要?——因为 JavaScript 的 tree-shaking 受限于"函数调用可能有副作用"这个动态语言的本质。打包工具看到 makeMap('html,body,...') 时无法静态判断这个函数是不是在修改全局状态——万一它在内部注册了一个全局表呢?万一它依赖了外部 IO 呢?稳妥起见,打包工具默认不敢删除任何函数调用,即使调用结果没被用。这就导致一种很常见的现象:你 import 了一个大库里的一个小函数,打包结果却把整个库都塞进来了——因为库的顶层有函数调用、打包工具不敢删。

/*#__PURE__*/ 是开发者给打包工具的一纸"我保证没副作用"的承诺——有了这张保证书,打包工具就敢在函数返回值没被用时把整个调用删掉。Vue 3 源码里这个注释出现了几百次,几乎每一个顶层的辅助函数调用都标了。这种"对 tree-shaking 友好"的纪律让 Vue 3 的运行时构建从 Vue 2 的 ~25KB gzip 降到 ~14KB gzip——省下了接近一半的体积。这种"源码里看起来毫不起眼的小细节,在 bundle 尺度上产生十 KB 级影响",是大型开源项目里反复出现的现象。你写自己的库时如果也在意 bundle 体积,/*#__PURE__*/ 是第一个值得学习的技巧。

全量 packages 的行数与职责账本

前面的表只列了 Vue 团队对外主打的"12 个核心包"。但真要打开 vuejs/corepackages/ 目录数一数,你会发现还有几个"低调包"也在那里服务——它们不被多数教程提及、但确实是 Vue 源码的真实一员。把 12 个对外包和这几个"低调包"合并起来看,才算是完整的 monorepo 账本。下面这张表是截至 main 2026-04 快照的完整清单,行数以 src/**/*.ts 非空非注释行的近似量级给出——目的不是精确到一位数(行数每天都在变),而是让你对"每个包是胖是瘦"有一个数量级认识。

包名 角色定位 源码规模(粗量级) 依赖的其他包 对外独立使用价值
@vue/shared 跨包工具函数底座 ~1.5k 行 低(内部用)
@vue/reactivity 响应式内核(ref/reactive/effect/computed) ~4k 行 shared 极高(Nuxt Server/Vitest 在用)
@vue/reactivity-transform $ref / $computed 宏(已废弃但仍在仓库) ~1k 行 shared, compiler-core
@vue/compiler-core 平台无关模板编译器(Parse/Transform/Codegen) ~10k 行 shared 中(写自定义 compiler 时)
@vue/compiler-dom DOM 平台编译扩展(事件/v-html/scoped) ~1.5k 行 compiler-core, shared
@vue/compiler-sfc SFC 编译(<template>/<script setup>/<style> ~8k 行 compiler-core, compiler-dom, compiler-ssr, reactivity-transform (被 Vite plugin 调用)
@vue/compiler-ssr SSR 字符串拼接编译 ~1.5k 行 compiler-core, compiler-dom
@vue/compiler-vapor 🆕 Vapor IR 编译器 ~6k 行 compiler-core, shared
@vue/runtime-core 运行时内核(组件/调度/VDOM patch/生命周期) ~12k 行 reactivity, shared (写自定义 renderer 时)
@vue/runtime-dom DOM patch 实现(setAttribute/addEventListener) ~2k 行 runtime-core, reactivity, shared
@vue/runtime-test 用于测试的 renderer(无 DOM) ~0.8k 行 runtime-core, shared 中(单元测试框架)
@vue/runtime-vapor 🆕 Vapor 运行时(直接 DOM,无 VNode) ~4k 行 runtime-core, reactivity, shared
@vue/server-renderer SSR 字符串/流渲染 ~2.5k 行 runtime-core, compiler-ssr
vue 面向用户的完整入口(全家桶构建) ~0.5k 行(薄壳) 所有运行时 + 编译器 终端用户默认引用
vue-compat Vue 2 兼容层(createApp 兼容 2.x 选项) ~3k 行 runtime-core 低(仅迁移用)
@vue/dts-test .d.ts 类型断言测试(非运行时包) 极小 TS 类型 内部 QA
@vue/global.d.ts 全局类型补丁 极小 内部

三个你可能第一次听说的包值得特别说一下:

  1. @vue/runtime-test——它的存在是 Vue 坚持"runtime-core 与平台无关"这一承诺的物证。能为非 DOM 平台写一个 renderer 说明 runtime-core 的 platform abstraction 接口是真能落地的——这个包的测试是 Vue CI 的一部分,用于保证 runtime-core 的 renderer 接口不意外绑死到 DOM 上。Vue 3 能在 Weex、NativeScript 等非 DOM 环境里跑起来,靠的就是这条"可替换的 hostAPI"承诺,而 runtime-test 就是测试这个承诺的守门人。

  2. @vue/reactivity-transform——早期实验性的 $ref 宏语法包,让你可以写 let count = $ref(0); count++ 而不是 count.value++。后来 Vue 团队判断这个宏不够 ergonomic、类型支持难做好,在 3.3 版本标记了废弃。但为了不破坏已经用它的项目,包没有从仓库删除、只是停止推广。这是成熟开源项目处理"废弃特性"的标准姿势——不删除、不推广、但也不做新功能

  3. vue-compat——Vue 2 → Vue 3 的迁移桥包。它让你可以在 Vue 3 项目里继续用 Vue 2 的 Vue.extend()Vue.use()、option merge 策略等老 API。这个包的存在体现了 Vue 的"平滑迁移"哲学——大版本升级不意味着推翻一切。

与第 7 章编译器账本的串联

第 7 章会把"编译器"这个单词拆成四个具体的包——compiler-core/compiler-dom/compiler-sfc/compiler-ssr——并给出每个包的职责边界、入口函数、典型调用路径。本章的表格是"纵览",第 7 章的账本是"细查"。两份账本配合使用的姿势是:当你想知道"Vue 的编译器一共写了多少代码、每个包在干什么"时看本章的总表;当你要在编译器源码里追一个具体 bug(例如"为什么我的 v-model 生成的代码有问题")时翻到第 7 章,按"问题属于 DOM 特性还是 SFC 特性"定位到是 compiler-dom 还是 compiler-sfc 的事,然后直奔对应包的入口函数。

"不在源码树里但也属于 Vue 生态"的几个项目

作为补充,有些你在 vuejs/core 仓库里找不到、但属于 Vue 官方维护的独立仓库——vuejs/router(Vue Router)、vuejs/pinia(Pinia 状态管理)、vuejs/devtools-next(浏览器调试面板)、vuejs/language-tools(Vue 的 VS Code 插件和 tsc 替代方案 vue-tsc)。这些项目都是独立 repo、独立版本号、独立 release 节奏,但都建立在 vuejs/core 的响应式和运行时之上。本书 14-16 章会专门讲 Router 和 Pinia 的源码——你会看到"建立在 @vue/reactivity 之上"这个约束如何塑造了这两个库的 API 设计。

2.2 构建系统:从 Rollup 到 esbuild 的演进

框架的构建系统通常不被用户直接感知——用户在意的是运行时表现、不是 pnpm build 的速度。但构建系统的选择会反向影响框架能做到多好:构建慢则开发者迭代慢、bug 修复周期拉长;tree-shaking 不好则 bundle 臃肿、用户下载慢;source map 质量差则调试体验差、社区贡献门槛升高。所以即使你永远不会自己碰 Vue 的构建配置,理解它的构建系统演进依然对你"读懂 Vue 为什么是今天这样"有帮助。

Vue 3.0–3.4:Rollup 时代

构建工具的选型看似是纯工程决定,实际上往往折射出项目的阶段性价值取向。Vue 3.0 发布时选择 Rollup 而不是 Webpack,就很典型地体现了"我是一个库,不是一个应用"的定位——Rollup 从一开始就是为 library 设计的,输出干净、没有多余的 wrapper、tree-shaking 友好;Webpack 面向的是 application bundling(大型应用打包),对 library 场景偏重。这种"用对工具做对事"的判断看似琐碎,但在 Vue 3 早期发布后的几次生态兼容性危机里帮 Vue 省了大量后续工程。

Vue 3 最初使用 Rollup 作为构建工具。选择 Rollup 而非 Webpack 的原因很明确:Rollup 天生面向库(library)打包,支持输出 ESM、CJS、IIFE 等多种格式,且 tree-shaking 能力更强。

Vue 的构建配置相当复杂,需要为每个包生成多种格式的产物:

// 每个包可能输出的格式
interface BuildFormats {
  'esm-bundler': string   // 供 Webpack/Vite 消费的 ESM(保留 import)
  'esm-browser': string   // 可直接在 <script type="module"> 中使用
  'cjs': string           // CommonJS(Node.js)
  'global': string        // IIFE(通过 <script> 标签引入,挂载到 window.Vue)
}

Vue 3.5+:esbuild 加入

从 Vue 3.5 开始,开发模式的构建切换到 esbuild。esbuild 用 Go 编写,构建速度比 Rollup 快 10–100 倍。但 Vue 没有完全放弃 Rollup——生产构建仍然使用 Rollup,因为 Rollup 的插件生态更成熟,对产物的控制更精细。

场景 工具 原因
开发构建(pnpm dev esbuild 速度,毫秒级重建
生产构建(pnpm build Rollup 产物质量,精确控制
类型检查 tsc (TypeScript) 类型安全
类型声明生成 rollup-plugin-dts .d.ts 产物
// scripts/dev.js — 开发模式使用 esbuild
import esbuild from 'esbuild'

const ctx = await esbuild.context({
  entryPoints: [resolve(__dirname, `../packages/${target}/src/index.ts`)],
  bundle: true,
  external: ['vue', '@vue/*'],
  platform: 'browser',
  format: 'esm',
  outfile: resolve(__dirname, `../packages/${target}/dist/${target}.esm-browser.js`),
  sourcemap: true,
})

await ctx.watch() // 监听文件变化,毫秒级重建

🔥 深度洞察

Vue 的"双构建工具"策略揭示了一个重要的工程原则:开发体验和产物质量是两个不同的优化目标,不应该用同一个工具来妥协。 esbuild 的速度来自于它跳过了类型检查、不做复杂的代码分析、不支持某些高级的 Rollup 插件。这些"缺失"在开发阶段无关紧要(IDE 负责类型检查),但在生产构建中不可接受。用正确的工具做正确的事,而非一个工具做所有事。

构建产物的设计

Vue 的构建产物设计体现了"渐进式"哲学——不同场景的用户获得不同的产物:

vue/dist/
├── vue.esm-bundler.js        # Vite/Webpack 用户(推荐)
├── vue.esm-browser.js        # 直接 <script type="module">
├── vue.global.js             # 传统 <script> 标签
├── vue.runtime.esm-bundler.js # 只要运行时(预编译模板)
├── vue.runtime.global.js      # 只要运行时(全局构建)
├── vue.cjs.js                 # Node.js CommonJS
└── vue.d.ts                   # TypeScript 类型声明

其中 esm-bundlerruntime.esm-bundler 的区别至关重要:

  • 完整构建vue.esm-bundler.js):包含编译器,可以在运行时编译模板
  • 运行时构建vue.runtime.esm-bundler.js):不包含编译器,模板必须在构建时预编译
// 完整构建可以做这件事
import { createApp } from 'vue'
createApp({
  template: '<div>{{ message }}</div>',  // 运行时编译
  data() { return { message: 'Hello' } }
})

// 运行时构建不能,必须使用预编译的 render 函数或 .vue 文件
import { createApp, h } from 'vue'
createApp({
  render() { return h('div', this.message) }
})

当你使用 Vite 或 Webpack 搭配 vue-loader/@vitejs/plugin-vue 时,.vue 文件中的模板会在构建时被编译,所以 bundler 自动选择运行时构建,这就是为什么你的 bundle 中不包含 Vue 编译器——它的工作已经在 npm run build 时完成了。

这个"编译器只在构建时需要、运行时不需要"的特性是 Vue 架构里最值得细品的一个决定。它的收益有两层:第一层是 bundle 体积——运行时构建比完整构建小 14KB(gzipped),这是一次性收益。第二层是心智简化——因为编译器只在构建阶段用、运行时用不到,它的复杂度、依赖、慢速路径都不会被用户的浏览器承担。你可以放心地让编译器做很重的分析、很慢的优化、甚至做一些"编译一次慢一点但运行时快十倍"的交换——因为慢的部分被 CI 的构建机器消化了,用户拿到的只是优化后的快速代码。

这种"把成本推给构建机器、把收益留给用户浏览器"的思路在 Vapor Mode 里被发扬光大——Vapor 的编译器做了更重的静态分析,但用户只看到更快的运行时。理解这个分离,你就不会再被那些"Vue 启动时间长"的误解迷惑——生产环境的 Vue 运行时只有 14KB,启动比任何同类框架都快;所谓的"启动慢"都是开发模式下运行时 + 编译器 + HMR 一起跑造成的假象。开发环境下的速度和生产环境的速度是两件完全不同的事,在评价任何框架时都要分开看。

💡 最佳实践

如果你的项目中没有运行时模板编译(绝大多数项目都没有),确保你的打包工具解析到 vue.runtime.esm-bundler.js 而非完整构建。这可以节省约 14KB(gzipped)的 bundle size——那是整个编译器的体积。Vite 默认就是这样配置的。

2.3 核心三角:Compiler → Reactivity → Runtime

第 1 章的"核心三角"小节已经介绍过这三个子系统的宏观分工——本节要做的是从协作流程的视角再深入一层:不只是讲"每个子系统做什么",而是讲"它们之间是怎么配合的、接口在哪、数据如何流动"。这种"协作视角"是理解任何复杂系统的关键——知道每个部件做什么还不够,要知道部件怎么拼起来才构成整体能力。

Vue 3 的架构可以抽象为三个核心子系统的协作——编译器(Compiler)、响应式(Reactivity)和运行时(Runtime)。理解它们各自的职责边界和协作方式,是理解 Vue 全部源码的关键。

编译器:从模板到代码

编译器的输入是模板字符串,输出是 JavaScript 代码。这个过程分为三个阶段:

graph LR
    A["模板字符串<br/>&lt;div&gt;{{ msg }}&lt;/div&gt;"] --> B["Parse<br/>解析"]
    B --> C["AST<br/>抽象语法树"]
    C --> D["Transform<br/>转换 + 优化"]
    D --> E["优化后的 AST"]
    E --> F["Codegen<br/>代码生成"]
    F --> G["render 函数代码<br/>(字符串)"]

    style B fill:#4ecdc4,stroke:#333
    style D fill:#4ecdc4,stroke:#333
    style F fill:#4ecdc4,stroke:#333
  1. Parse(解析):将模板字符串解析为 AST(抽象语法树)。这是一个状态机驱动的过程——逐字符扫描模板,识别标签、属性、指令、插值表达式等。

  2. Transform(转换):对 AST 进行一系列转换和优化。包括:静态节点标记、PatchFlag 计算、Block Tree 构建、指令处理(v-if → 条件分支、v-for → 循环结构)等。

  3. Codegen(代码生成):将优化后的 AST 转化为 render 函数的 JavaScript 代码字符串。

// packages/compiler-core/src/compile.ts(简化)

export function baseCompile(
  source: string | RootNode,
  options: CompilerOptions = {}
): CodegenResult {
  // 1. Parse
  const ast = isString(source) ? baseParse(source, options) : source

  // 2. Transform
  transform(ast, {
    ...options,
    nodeTransforms: [
      ...getBaseTransformPreset(),    // 内置转换(v-if, v-for 等)
      ...(options.nodeTransforms || []) // 用户自定义转换
    ],
  })

  // 3. Codegen
  return generate(ast, options)
}

关键设计:编译器是平台无关的。@vue/compiler-core 不知道 DOM 的存在——它只知道如何解析模板语法、构建 AST、生成代码。DOM 特定的逻辑(如 HTML 标签验证、事件修饰符处理)由 @vue/compiler-dom 通过扩展点注入。

响应式:数据的因果传播引擎

响应式系统是 Vue 的数据引擎。它的核心职责是:当数据变化时,精确地找到所有依赖这个数据的计算和副作用,并触发它们的更新。

在 Vue 3.6 中,这个引擎的内核已经被 Alien Signals 完全重写。但无论内部实现如何变化,它对外提供的核心 API 保持稳定:

import { ref, reactive, computed, watch, effect } from '@vue/reactivity'

// ref — 包装基本类型为响应式
const count = ref(0)

// reactive — 包装对象为响应式
const state = reactive({ name: 'Vue', version: 3.6 })

// computed — 基于依赖自动计算
const doubled = computed(() => count.value * 2)

// watch — 监听变化并执行副作用
watch(count, (newVal, oldVal) => {
  console.log(`${oldVal}${newVal}`)
})

// effect — 底层副作用原语(computed 和 watch 基于它实现)
effect(() => {
  console.log(`count is ${count.value}`)
})

响应式系统的美妙之处在于:它是声明式的。你不需要手动调用 setStatedispatch——只需要修改数据,系统自动知道该更新什么。这种"自动"背后的机制是依赖追踪——当 effectcomputed 执行时,系统记录它们读取了哪些响应式数据;当这些数据被修改时,系统自动重新执行对应的 effect 或重算对应的 computed

运行时:从数据到 DOM

运行时是 Vue 的执行引擎,负责将编译器生成的代码与响应式系统连接起来,最终将数据的变化反映到 DOM 上。

运行时的核心职责包括:

  1. 组件实例管理:创建、挂载、更新、卸载组件实例
  2. 生命周期管理:在正确的时机触发 onMountedonUpdated 等钩子
  3. VNode 管理:(VDOM 模式)创建 VNode、执行 diff、应用 patch
  4. Vapor 执行:(Vapor 模式)执行编译器生成的直接 DOM 操作
  5. 调度器:batching 多个更新,在微任务中统一执行
// packages/runtime-core/src/renderer.ts(极度简化)

function patch(
  n1: VNode | null,  // 旧节点
  n2: VNode,         // 新节点
  container: Element
) {
  if (n1 === null) {
    // 挂载
    mountElement(n2, container)
  } else if (n1.type !== n2.type) {
    // 类型不同,替换
    unmount(n1)
    mountElement(n2, container)
  } else {
    // 类型相同,更新
    patchElement(n1, n2)
  }
}

三者的协作流程

让我们用一个具体例子串联三者的协作:

// App.vue
<template>
  <button @click="count++">{{ count }}</button>
</template>

<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

Step 1: 编译期(Compiler)

编译器将模板编译为 render 函数:

// 编译器输出(简化)
import { createElementVNode, toDisplayString, openBlock, createElementBlock } from 'vue'

function render(_ctx) {
  return (openBlock(), createElementBlock("button", {
    onClick: $event => (_ctx.count++)
  }, toDisplayString(_ctx.count), 9 /* TEXT, PROPS */))
}

Step 2: 挂载期(Runtime + Reactivity)

运行时创建组件实例,执行 setup() 建立响应式状态,然后将 render 函数包裹在一个 effect 中执行:

// 运行时内部(简化)
const instance = createComponentInstance(vnode)
const { setupState } = setupComponent(instance)  // 执行 setup(),得到 count ref

// 将 render 包裹在响应式 effect 中
effect(() => {
  const vnode = instance.render(setupState)       // 调用 render,触发 count.value 的读取
  patch(instance.subTree, vnode, container)        // 挂载/更新 DOM
  instance.subTree = vnode
})

render 函数执行时,count.value 被读取,触发依赖收集——响应式系统记录下"这个 effect 依赖了 count"。

Step 3: 更新期(Reactivity → Runtime)

用户点击按钮,count.value++ 触发响应式更新:

count.value++
  → Reactivity: 版本号递增,标记 effect 为 dirty
  → Scheduler: 将 effect 推入微任务队列
  → 微任务执行: effect 重新运行 → render() 生成新 VNode
  → Runtime: patch(旧 VNode, 新 VNode) → 更新按钮文本

整个过程是自动的——开发者只写了 count++,框架负责完成从数据到 DOM 的全部更新链路。

🔥 深度洞察

核心三角的设计体现了关注点分离(Separation of Concerns)的经典原则,但 Vue 的实现有一个微妙的特点:三者之间的耦合点是精确且最小化的。 编译器只知道运行时的 API 签名(如 createElementVNode),不知道它的实现;运行时只知道响应式系统的 API(如 effectref),不知道它的实现。这种"只知道接口,不知道实现"的设计,使得 Vue 3.6 能够在不改变编译器和运行时 API 的情况下,完全重写响应式系统的内核(Alien Signals)。同样,Vapor Mode 可以在不改变响应式系统的情况下,引入全新的编译器和运行时。

这种"窄接口"的价值在大型重构时才体现出来。想象一下如果编译器直接依赖 @vue/reactivity 的内部实现细节——比如它生成的代码里硬编码了 effect.deps.forEach(...) 这种内部字段访问。那么当响应式系统切到 Alien Signals、把 deps 从 Set 换成双向链表时,编译器生成的代码会瞬间全部失效——Vue 3.6 的升级就不可能做成"兼容"形态,只能做"不兼容的大版本"。

但 Vue 在设计时就把这条路堵死了:编译器只生成 effect(() => ...)trigger(...) 这类公开 API 调用,从不假设内部实现。响应式系统可以随便换内核——只要这些公开 API 的签名和语义不变,编译器生成的代码依然工作。API 窄 = 自由度高——这是所有 long-lived 软件项目的共识。Linux 内核的 syscall 接口几十年基本没变、POSIX 标准能让程序在各种 Unix 上跑——原理是一样的。Vue 团队把这种"系统级稳定性思维"带到了前端框架设计里,这是 Vue 能在 2014 到 2026 这十二年里持续演进而不破坏用户代码的根本原因。

2.4 从模板到像素:一次渲染的完整旅程

理解各个子系统之后,最有价值的事情是把它们按照真实运行顺序"串一遍"——从你在编辑器里写的 .vue 文件,一路追到浏览器在屏幕上画出的像素。这条链路涉及构建系统、编译器、模块加载、组件实例化、响应式绑定、VNode 生成、DOM 更新、浏览器渲染管线——每一步都是本书后续章节的某个主题。本节的目标不是细讲每一步(那是后面章节的事),而是让你在脑海里先形成这条完整链路的"骨架"——这样后面深入任何一个子话题时,你都能立刻知道"我现在在链路的哪一环"。

让我们追踪一次完整的渲染过程,从用户编写模板到浏览器在屏幕上绘制像素,每一步都标注对应的源码位置。

sequenceDiagram
    participant Dev as 开发者
    participant Compiler as 编译器(构建时)
    participant Runtime as 运行时
    participant Reactivity as 响应式系统
    participant Browser as 浏览器

    Dev->>Compiler: .vue 文件
    Note over Compiler: Parse → Transform → Codegen
    Compiler->>Runtime: render 函数 + setup 函数

    Dev->>Runtime: createApp(App).mount('#app')
    Runtime->>Runtime: createComponentInstance()
    Runtime->>Reactivity: setupComponent() → 执行 setup()
    Reactivity-->>Runtime: ref/reactive 状态

    Runtime->>Reactivity: new ReactiveEffect(render)
    Reactivity->>Runtime: effect.run() → 执行 render()
    Note over Runtime: render() 返回 VNode 树
    Runtime->>Runtime: patch(null, vnode, container)
    Runtime->>Browser: DOM API 调用(createElement, textContent 等)
    Browser->>Browser: Layout → Paint → Composite
    Note over Browser: 像素出现在屏幕上

阶段一:编译期(Build Time)

当你运行 npm run build 或 Vite 的开发服务器时,@vitejs/plugin-vue 调用 @vue/compiler-sfc 处理 .vue 文件:

// @vue/compiler-sfc 处理流程
//
// 1. 解析 SFC 结构(<template>、<script>、<style>)
// 2. 编译 <template> → render 函数
// 3. 编译 <script setup> → setup 函数
// 4. 编译 <style scoped> → 添加 data-v-xxxx 属性选择器
// 5. 组合输出为一个 JavaScript 模块

编译完成后,一个 .vue 文件变成了一个普通的 JavaScript 模块:

// App.vue 编译后的输出(简化)
import { ref } from 'vue'
import { createElementBlock, toDisplayString, openBlock } from 'vue'

const __sfc__ = {
  __name: 'App',
  setup() {
    const count = ref(0)
    return { count }
  },
  render(_ctx) {
    return (openBlock(), createElementBlock("button", {
      onClick: () => _ctx.count++
    }, toDisplayString(_ctx.count), 9))
  }
}

export default __sfc__

阶段二:创建应用(createApp

// packages/runtime-dom/src/index.ts
export const createApp = (...args) => {
  const app = ensureRenderer().createApp(...args)
  // ...
  return app
}

createApp 创建一个应用实例,返回一个具有 mountusecomponentdirective 等方法的对象。此时还没有任何 DOM 操作——应用只是被"声明"了。

阶段三:挂载(mount

app.mount('#app')

挂载过程启动组件的首次渲染:

  1. 创建根组件的 VNode
  2. 创建组件实例
  3. 执行 setup() 函数,建立响应式状态
  4. 创建渲染 effect,首次执行 render 函数
  5. render 函数返回 VNode 树
  6. patch 算法将 VNode 树转化为真实 DOM

阶段四:更新

当响应式数据变化时:

  1. 响应式系统检测到变化(版本号递增)
  2. 调度器将组件的更新 effect 推入微任务队列
  3. 微任务执行时,effect 重新运行 render 函数
  4. render 函数返回新的 VNode 树
  5. patch 算法对比新旧 VNode 树,找出差异
  6. 差异被应用到真实 DOM

💡 最佳实践

理解"编译期"和"运行时"的边界至关重要。如果你在排查性能问题时看到 createElementVNodeopenBlock 的调用栈,那是运行时的 VNode 创建——问题在渲染层。如果你看到 baseParsetransform,那是编译器——但这通常不会出现在运行时调用栈中,除非你使用了运行时模板编译(完整构建 + template 字符串选项)。

2.5 源码调试的三种姿势

本节讲的是"动手玩源码"的具体方法。如果你只是被动阅读别人写好的源码解析(包括这本书),最多只能吸收作者已经消化过的结论——你不会建立自己的理解。要想真正把 Vue 源码变成"自己的",必须亲手跑起来、亲手打断点、亲手修改代码验证假设。下面三种姿势按"上手难度"从低到高排列——每一种都值得花时间熟练。

读源码最怕"只看不跑"——看了半天以为自己懂了,实际上对执行顺序和数据流一头雾水。以下三种调试方式,由简到深,建议至少掌握第一种。

姿势一:在线调试(Vue SFC Playground)

Vue 官方提供了在线 SFC Playground(play.vuejs.org),可以直接在浏览器中编辑 Vue 组件并查看编译输出。

  1. 打开 https://play.vuejs.org
  2. 在左侧编辑 .vue 文件
  3. 点击右上角 "JS" 按钮查看编译后的 JavaScript
  4. 点击 "AST" 按钮查看模板的 AST 结构

这是最快的方式,适合快速验证编译器的行为——"这个模板会被编译成什么代码?"

我强烈推荐你在读本书的每一章时都把 Vue SFC Playground 开着——当书里提到"编译器会把这个模板转成 XXX 代码"时,你可以立刻在 Playground 里验证一下:"真的是这样吗?我换一个更复杂的模板试试?"这种"书本 + Playground 双屏读"的方式是我个人认为 Vue 源码学习最高效的姿势——看到一个论断立刻验证,比"读完书再动手"的学习曲线陡峭得多。任何一个技术主张"看过"和"亲手验证过"之间的记忆深度差别是好几个数量级的。

姿势二:本地构建 + Source Map

# 克隆 Vue 源码
git clone https://github.com/vuejs/core.git
cd core
git checkout v3.6.0

# 安装依赖
pnpm install

# 开发模式构建(带 source map)
pnpm dev

# 或者构建特定包
pnpm dev reactivity

开发构建会生成带 source map 的产物。在一个 Vite 项目中引用本地构建的 Vue:

// vite.config.ts
export default defineConfig({
  resolve: {
    alias: {
      'vue': '/path/to/vue/core/packages/vue/dist/vue.esm-bundler.js'
    }
  }
})

现在你可以在浏览器 DevTools 中直接对 Vue 源码(TypeScript 原始文件)打断点。

姿势三:单元测试调试

Vue 的测试覆盖率极高,几乎每个功能都有对应的测试用例。通过调试测试用例来理解源码的行为,往往比直接阅读源码更高效。

# 运行特定包的测试
pnpm test reactivity

# 运行特定测试文件
pnpm test reactivity -- --testPathPattern="ref"

# 调试模式(可在 IDE 中打断点)
node --inspect-brk node_modules/.bin/vitest run --testPathPattern="ref"

在 VS Code 中,可以配置调试启动项:

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Debug Vue Tests",
      "program": "${workspaceFolder}/node_modules/.bin/vitest",
      "args": ["run", "--testPathPattern", "${input:testPattern}"],
      "console": "integratedTerminal"
    }
  ],
  "inputs": [
    {
      "id": "testPattern",
      "type": "promptString",
      "description": "Test file pattern"
    }
  ]
}

🔥 深度洞察

Vue 的测试用例本身就是一份极好的"设计文档"。当你不确定某个 API 的边界行为时,找到对应的测试文件比翻文档更可靠。例如,packages/reactivity/__tests__/ref.spec.ts 详尽地覆盖了 ref 的每一种边界情况——嵌套 ref 的解包行为、与 reactive 的交互、类型推断等。测试用例是代码作者向未来读者的承诺:"这些行为是故意的,不是偶然的。"

2.6 Vapor 模式对架构的影响

第 1 章从"为什么"的角度介绍了 Vapor Mode,本节从"对架构意味着什么"的角度再切一刀。同一件事从不同视角看能看到不同的结构——这不是重复,而是多棱镜观察。

理解了传统架构之后,让我们简要预览 Vapor Mode 对全景图的影响。

Vapor Mode 引入了两个新包:@vue/compiler-vapor@vue/runtime-vapor。但它没有替换任何现有包——这意味着 VDOM 模式和 Vapor 模式可以共存。

传统模式:@vue/compiler-dom → render() → VNode → @vue/runtime-dom → DOM
Vapor 模式:@vue/compiler-vapor → template() + effect() → @vue/runtime-vapor → DOM

两种模式共享:

  • 相同的响应式系统@vue/reactivity
  • 相同的编译器前端@vue/compiler-core 的 Parse 阶段)
  • 相同的 SFC 编译流程@vue/compiler-sfc

不同的是:

  • Transform 阶段:Vapor 编译器有自己的转换逻辑,生成面向直接 DOM 操作的 IR(中间表示)
  • Codegen 阶段:Vapor 代码生成器输出 template() + effect() 而非 createElementVNode()
  • 运行时:Vapor 运行时不包含 VNode 和 patch 逻辑,只包含 DOM 操作辅助函数

这种架构设计的精妙之处在于:共享的部分最大化,独立的部分最小化。 编译器的前端(Parse)只需要写一次,因为模板语法对两种模式是相同的。响应式系统只需要一套,因为两种模式的数据追踪逻辑是相同的。只有"如何将模板翻译为代码"和"如何将代码应用到 DOM"这两个步骤需要为 Vapor 重新实现。

这种"共享最大、独立最小"的拆分艺术是大型软件工程里的一项核心能力。判断什么该共享、什么该独立的关键在于:那些你希望"两种模式永远保持一致"的部分就共享(比如模板语法,否则用户要学两套语法),那些你希望"两种模式可以自由演化"的部分就独立(比如运行时,否则 Vapor 被 VDOM 的历史包袱拖累)。这个判断很微妙——拆错了会导致长期的技术债。Vue 团队在 Vapor 设计上的这个拆分花了一年多时间反复讨论,最终的方案非常紧凑——相关代码只在 Transform 和 Codegen 两个阶段产生分叉,前端和后端都复用。这是 Vue 作为成熟项目的一种自信:不怕花时间在拆分设计上,因为错误的拆分后续会有长期代价。新手项目往往急着先写出来再说、拆分留给后续重构;成熟项目则相反——先把拆分想清楚再动手,动手后就不再回头改

2.7 本章小结

本章绘制了 Vue 3.6 源码的全景地图。关键要点:

  1. Monorepo 架构强制解耦:每个包的职责边界通过 package.json 的依赖声明显式化。@vue/reactivity 可以独立使用,不依赖任何 DOM 概念。

  2. 构建系统采用双工具策略:开发用 esbuild(快),生产用 Rollup(精确)。/*#__PURE__*/ 注解是 tree-shaking 的基石。

  3. 核心三角——Compiler、Reactivity、Runtime——通过最小化的接口协作。编译器只知道运行时的 API 签名,运行时只知道响应式系统的 API 签名,这使得内核的独立替换成为可能。

  4. 一次渲染的完整旅程:模板 → Parse → Transform → Codegen → render 函数 → 响应式 effect → VNode 树 → patch → DOM → 浏览器绘制。

  5. Vapor Mode 引入新的编译器和运行时包,但与现有架构共享响应式系统和编译器前端,实现了最大化的代码复用。

  6. 调试源码有三种姿势:在线 Playground(最快)、本地 Source Map(最灵活)、单元测试调试(最精确)。

下一章,我们将深入 Vue 架构中最核心的部分——响应式系统。从设计哲学入手,理解 Vue 为什么选择了"细粒度依赖追踪"这条路,以及这个选择如何影响了整个框架的性能特性。

本章的知识在后续章节中如何被反复使用

这一章是"地图章"——它本身不教你任何一个具体的技术细节,但它是后续 17 章的导航底座。让我把这张地图和后面的每一章的对应关系画清楚,这样你在后面阅读时遇到"这个在哪讲过?"的疑问时能迅速回到本章定位。

  • 第 3-6 章讲响应式原语(ref / reactive / computed / effect),对应本章"响应式层"的 @vue/reactivity 包。
  • 第 7-9 章讲模板编译器的 Parse → Transform → Codegen 全过程,对应本章"编译器层"的 @vue/compiler-core@vue/compiler-dom
  • 第 9 章专门讲 Vapor Mode,对应本章介绍的两个新包 @vue/compiler-vapor@vue/runtime-vapor
  • 第 10-13 章讲组件、渲染、生命周期、指令,对应本章"运行时层"的 @vue/runtime-core@vue/runtime-dom
  • 第 14-16 章讲 DI、Pinia、Vue Router——不在 Vue 核心包里,但都建立在核心三角的基础上。
  • 第 17 章讲 SSR,对应本章"服务端层"的 @vue/server-renderer
  • 第 18-19 章是性能和架构的应用章节。

延伸阅读

  • Vue 3 源码 vuejs/core 仓库的 CONTRIBUTING.md:Vue 团队对贡献者的介绍文档,包含了仓库结构说明和开发流程,是官方的"源码读者导引"。
  • pnpm.ioWorkspaces 章节:pnpm workspace 的官方文档,理解 Vue monorepo 工具链如何运作。
  • Evan You Maintaining A Large OSS Project(YouTube 2022):Vue 作者本人讲大型开源项目维护经验,包括为什么选择 monorepo、为什么选择 pnpm、构建流程的演进等。
  • Rollup 官方文档 Tree Shaking 章节:理解 /*#__PURE__*/ 的工作原理。
  • Vue SFC Playground(play.vuejs.org):本章推荐的"书本双屏伴侣",可以直接在浏览器中编辑模板并查看编译器输出。

思考题

  1. 概念理解:Vue 3 的 Monorepo 架构将代码拆分为 20+ 个包。请解释为什么 @vue/reactivity 不依赖 @vue/runtime-core,但 @vue/runtime-core 依赖 @vue/reactivity?这种单向依赖关系反映了什么设计原则?

  2. 工程实践:Vue 在开发模式使用 esbuild,生产模式使用 Rollup。如果你在设计一个大型开源库的构建系统,你会如何决定哪些场景用 esbuild、哪些用 Rollup?请列出至少 3 个决策因素。

  3. 深入思考:编译器和运行时之间存在"编译期契约"——编译器生成的代码必须调用运行时提供的特定 API。如果 Vue 团队想要修改 createElementVNode 的参数签名,需要同步修改哪些包?这种耦合是可以避免的吗?为什么?

  4. 横向对比:React 没有模板编译器——JSX 直接被 Babel 转换为 React.createElement 调用。Vue 选择模板 + 编译器的架构在编译期优化方面有什么优势?这种优势的代价是什么?

  5. 开放讨论:Vapor Mode 引入了 @vue/compiler-vapor@vue/runtime-vapor 两个新包,而不是在现有包中添加 Vapor 支持。请从软件架构的角度分析这个决策的利弊。