Vue 3 核心源码深度剖析系列(v3.5.44)-第 1 章 项目全景:monorepo 架构与工程化体系
本章是「Vue 3 核心源码深度剖析系列」的起点。我们不谈 API 用法,而是从仓库根目录出发,逐层剥开
vuejs/core@3.5.44的物理结构 、依赖拓扑 、构建流水线 、编译期开关 、类型声明产物 、测试矩阵 与发布工程。读完本章,你将能回答这样一类问题:
- 为什么
@vue/runtime-core里搜不到document,却能渲染出真实 DOM?- 为什么
pnpm build vue会产出 7 种不同后缀的dist文件,它们分别给谁用?__DEV__、__FEATURE_OPTIONS_API__这些"变量"在源码里从未被赋值,运行时却不报错?tsc明明能生成.d.ts,为什么 Vue 还要再跑一遍 Rollup +rollup-plugin-dts?- 一个
feat(compiler): ...的 commit,是如何被 CI 校验、被生成 CHANGELOG、最终被打包发布的?
本章所有结论均来自仓库一手源码,代码引用统一标注 文件路径:行号(行号对应 3.5.44 版本)。全文分 11 个大节,建议配合仓库实际文件对照阅读。
1.1 仓库全景:目录树与包的职责边界
1.1.1 顶层目录树
先建立"物理地图"。vuejs/core 是一个标准的 pnpm monorepo ,顶层结构如下(省略 node_modules、.git 等噪声目录):
text
core-3.5.44/
├── .github/ # CI/CD、issue 模板、commit 规范
│ ├── workflows/ # ci.yml / test.yml / release.yml / size-*.yml ...
│ ├── commit-convention.md # 提交信息规范(Angular 约定)
│ ├── contributing.md
│ └── renovate.json5 # 依赖自动升级配置
├── .git-blame-ignore-revs # 格式化等噪声提交的 SHA 清单
├── packages/ # ★ 11 个公开发布的核心包
│ ├── shared/ # 跨包基础工具(无任何 @vue 依赖)
│ ├── reactivity/ # 响应式系统(无 DOM、无 @vue 运行时依赖)
│ ├── runtime-core/ # 平台无关运行时(渲染器、组件、调度器)
│ ├── runtime-dom/ # 浏览器平台适配(DOM 节点操作、属性 patch)
│ ├── runtime-test/ # 测试用运行时(private)
│ ├── compiler-core/ # 平台无关编译器(模板 → AST → 代码)
│ ├── compiler-dom/ # 浏览器编译特化(DOM 指令、表达式)
│ ├── compiler-ssr/ # SSR 代码生成
│ ├── compiler-sfc/ # 单文件组件(SFC)编译器
│ ├── server-renderer/ # 服务端渲染运行时
│ ├── vue/ # ★ 面向用户的"全量包"入口
│ └── vue-compat/ # Vue 2 兼容构建(@vue/compat)
├── packages-private/ # 不发布的内部工具与测试包
│ ├── sfc-playground/ # SFC 在线演练场
│ ├── template-explorer/ # 模板编译产物可视化
│ ├── dts-test/ # .d.ts 类型测试(.test-d.ts)
│ ├── dts-built-test/ # 对"已构建的 dts"做类型测试
│ ├── vite-debug/ # Vite 调试 demo
│ ├── global.d.ts
│ └── tsconfig.json
├── scripts/ # 构建/发布/校验脚本(Node ESM)
├── docs/ # 本系列文档(非官方站点源码)
├── rollup.config.js # 生产构建配置(核心)
├── rollup.dts.config.js # 类型声明打包配置
├── vitest.config.ts # 测试矩阵配置
├── tsconfig.json # 开发/类型检查配置
├── tsconfig.build.json # dts 构建专用配置
├── eslint.config.js # 扁平化 ESLint 配置
├── package.json # 根 manifest(private + workspace 脚本)
└── pnpm-workspace.yaml # workspace 划分 + catalog + 策略
这张地图有三个关键信息值得注意:
packages/与packages-private/是两类完全不同的存在 。前者是发布到 npm 的包,后者永远不会被 publish。它们的区分不靠"命名巧合",而是靠package.json的private字段------详见 1.1.4。docs/目录不属于官方文档站 。Vue 官方文档在另一个仓库(vuejs/docs),本目录是我们这个剖析系列的产物。- 工程配置全部在顶层:Rollup、Vitest、TypeScript、ESLint 的配置都不在各自 package 里,而是集中管理。这与很多"每包各配一套"的 monorepo 不同,是 Vue 有意的取舍------详见 1.1.5。
1.1.2 11 个核心包的职责边界
Vue 3 的分包哲学是**"按抽象层级切"而非"按功能点切"**。下面这张表给出每个包的单一职责、对外暴露面与"依赖谁":
| 包名 | package 目录 | 单一职责 | npm 包名 | 平台 |
|---|---|---|---|---|
shared |
packages/shared |
类型判断、字符串工具、patchFlags/shapeFlags/slotFlags 常量、DOM 标签配置等无状态工具 |
@vue/shared |
通用 |
reactivity |
packages/reactivity |
reactive/ref/effect/computed/watch 的独立响应式系统,可单独使用 |
@vue/reactivity |
通用 |
runtime-core |
packages/runtime-core |
渲染器 createRenderer、VNode、组件实例、props/emits/slots、调度器、生命周期、内置组件(Suspense/KeepAlive/Teleport/BaseTransition) |
@vue/runtime-core |
平台无关 |
runtime-dom |
packages/runtime-dom |
用 createRenderer 注入 DOM 版 nodeOps/patchProp,产出 createApp/render,并实现 v-model、Transition、v-show 等浏览器专属能力 |
@vue/runtime-dom |
浏览器 |
runtime-test |
packages/runtime-test |
用内存节点树(TestElement)实现第二个平台适配层,专供单元测试 |
private | 测试 |
compiler-core |
packages/compiler-core |
平台无关编译流水线:baseParse → transform → generate,v-if/v-for/v-slot 等结构指令、静态提升 |
@vue/compiler-core |
通用 |
compiler-dom |
packages/compiler-dom |
在 core 之上补 DOM 语法:v-html/v-text/v-on/v-model 特殊处理、DOM 标签/属性白名单、表达式解析(@babel/parser) |
@vue/compiler-dom |
浏览器 |
compiler-ssr |
packages/compiler-ssr |
把 compiler-dom 的 AST 转成 SSR 字符串拼接代码(ssrRender),仅 cjs |
@vue/compiler-ssr |
Node |
compiler-sfc |
packages/compiler-sfc |
.vue 文件解析、<script setup> 宏、模板/样式编译、scoped CSS、CSS vars、PostCSS 管线 |
@vue/compiler-sfc |
Node |
server-renderer |
packages/server-renderer |
renderToString/renderToStream、ssrRender 执行、hydration 数据序列化 |
@vue/server-renderer |
Node |
vue |
packages/vue |
用户入口 :runtime + compiler 全量包,提供 compileToFunction 运行时编译 |
vue |
浏览器 |
vue-compat |
packages/vue-compat |
面向 Vue 2 迁移的兼容构建(@vue/compat),通过 configureCompat 分级降级 |
@vue/compat |
浏览器 |
注:
runtime-test虽然不是公开包,但它是与runtime-dom平级的平台适配层 ,理解它对理解createRenderer的抽象至关重要,因此纳入讨论。
1.1.3 从职责边界看设计动机
这 11 个包的划分不是拍脑袋的结果,而是一条**"依赖只能向下流动"的单向链**:
这张图直接对应各包 package.json 的 dependencies 字段。例如 packages/runtime-core/package.json:48-51:
json
"dependencies": {
"@vue/shared": "workspace:*",
"@vue/reactivity": "workspace:*"
}
而 packages/runtime-dom/package.json:51-56:
json
"dependencies": {
"@vue/shared": "workspace:*",
"@vue/runtime-core": "workspace:*",
"@vue/reactivity": "workspace:*",
"csstype": "^3.2.3"
}
观察这两组依赖,能读出几条硬约束:
reactivity只依赖shared。它不 importruntime-core,因此@vue/reactivity可以被任何框架(包括 React 生态、Node 服务端)独立引入。这也是@vue/reactivity能单独发布并被@vueuse等库使用的原因。runtime-core只依赖shared+reactivity,绝不依赖runtime-dom。这正是"平台无关"的物理保证------renderer.ts里只操作抽象的nodeOps/patchProp,真实 DOM 操作被隔离在runtime-dom。详见 1.2 的深入剖析。runtime-dom依赖csstype:因为patchProp需要给style提供类型约束;这是唯一的第三方运行时依赖。
1.1.4 packages-private:为什么不发布
packages-private/* 与 packages/* 的唯一判别字段是 package.json 中是否设置了 "private": true。举例:packages/runtime-test/package.json:2 与 packages/vue/package.json 对比------runtime-test 声明了 "private": true 且 "version": "0.0.0",而 vue 没有 private 字段、版本号为 3.5.44。
这个字段在构建与发布阶段被两处代码消费:
-
发布筛选 (
scripts/release.js:72-83):jsconst packages = fs .readdirSync(path.resolve(__dirname, '../packages')) .filter(p => { // ... const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) return !pkg.private })注意:发布逻辑只扫描
packages/,根本不看packages-private/;再加上!pkg.private过滤,runtime-test也不会被 publish。双保险。 -
全量构建跳过 (
scripts/build.js:166-170):js// if this is a full build (no specific targets), ignore private packages if ((isRelease || !targets.length) && pkg.private) { return }即
pnpm build(不指定 target)时会自动跳过 private 包;但如果你显式node scripts/build.js runtime-test,仍会构建它------这正是测试场景所需。
packages-private/ 内部又分四类用途不同的东西:
| 目录 | 用途 | 关键文件 |
|---|---|---|
sfc-playground/ |
在线 SFC 演练场(Vite 应用,构建后部署到 sfc.vuejs.org) |
vite.config.ts、src/vue-dev-proxy.ts |
template-explorer/ |
把模板编译成 AST/render 的可视化工具 | local.html、src/index.ts |
dts-test/ |
类型层测试(.test-d.ts),用 expectType/@ts-expect-error 断言类型 |
defineComponent.test-d.tsx 等 |
dts-built-test/ |
对打包后 的 dist/*.d.ts 做类型测试,验证产物可用性 |
src/index.ts |
dts-test 与 dts-built-test 的区别是本章 1.7、1.8 的关键伏笔:前者验证源码类型 ,后者验证构建产物类型。
1.1.5 为什么配置集中在顶层
与 Yarn/Lerna 时代"每个包一份 rollup/tsconfig"不同,Vue 3 把 rollup.config.js、tsconfig.json、eslint.config.js 全部放在仓库根。这样做有三个直接收益:
- 统一编译期开关注入 。
__DEV__等常量的替换表只需维护一份(rollup.config.js:170-218),避免各包口径不一致导致 tree-shaking 失效。 - 跨包类型引用可用 。
tsconfig.json:23-27的paths把@vue/*全部映射到packages/*/src,于是runtime-dom里可以直接import ... from '@vue/runtime-core'而无需先构建对方。 - 单一版本号真相 。
rollup.config.js:34从根package.json读取masterVersion,所有包共享同一版本,避免版本漂移。
1.2 包依赖图与分层:编译期 vs 运行时 vs 适配
1.2.1 三类包的划分标准
如果把 11 个包按"是否需要真实宿主环境"重新分类,会得到三组:
(A)编译期包(Compiler,纯字符串/数据变换)
compiler-core→compiler-dom→compiler-ssr→compiler-sfc
它们的共同点是输入模板字符串、输出代码字符串 ,不触碰 DOM。唯一的例外是 compiler-sfc 里对 Node API 的使用(读文件、PostCSS),但那是工具链层面而非"运行时平台"。
(B)运行时包(Runtime,处理真实节点树)
reactivity(无节点概念)runtime-core(操作抽象节点树)runtime-dom/runtime-test(操作具体节点树)
(C)适配/聚合包(Adapter / Aggregator)
server-renderer(把运行时适配到"字符串输出"这一特殊"宿主")vue(聚合 runtime + compiler,对用户暴露单一入口)vue-compat(在vue之上包装兼容层)shared(横切,被所有包依赖)
这个分类的关键洞察:runtime-core 属于 (B) 却不属于 runtime-dom 所在的具体平台,因为"节点树"在 core 眼里是抽象的。
1.2.2 runtime-core 为什么必须平台无关
这是 Vue 3 架构最核心的一条设计决策,值得展开。runtime-core 提供的是 createRenderer,它接收一个宿主环境配置对象(宿主 = Host):
ts
// 概念示意(等价于 packages/runtime-core/src/renderer.ts 中 createRenderer 的入参)
export function createRenderer<HostNode, HostElement>(
options: RendererOptions<HostNode, HostElement>,
): Renderer<HostNode, HostElement>
RendererOptions 是由若干"原子 DOM 操作"组成的接口,例如 createElement、insert、remove、setElementText、patchProp、nextSibling......runtime-core 的渲染流程只调用这些抽象方法,从不出现 document.createElement。
真实实现中,平台差异通过两个包分别注入:
runtime-dom(packages/runtime-dom/src/nodeOps.ts+patchProp.ts)实现浏览器版本;runtime-test(packages/runtime-test/src/nodeOps.ts)实现内存树版本。
两者都调用 core 的 createRenderer。以 runtime-test 为例(packages/runtime-test/src/index.ts:12-17):
ts
const { render: baseRender, createApp: baseCreateApp } = createRenderer(
extend({ patchProp }, nodeOps),
)
export const render = baseRender as RootRenderFunction<TestElement>
export const createApp = baseCreateApp as CreateAppFunction<TestElement>
runtime-test 只提供了 nodeOps 与 patchProp 两样东西,就"变出"了完整的 render 和 createApp------这证明平台无关性是真的,不是口号。
如果 runtime-core 直接依赖 runtime-dom,会发生什么?
- 循环依赖 :
runtime-dom需要 core 的createRenderer,core 又需要 dom 的nodeOps,形成环。 - 测试不能跑在内存里:单元测试将被迫依赖 jsdom,速度与可靠性都变差。
- SSR 无法复用 :
server-renderer依赖runtime-dom(因为它要复用运行时的组件/vnode 逻辑),若 core 绑死 DOM,SSR 就得重写一套运行时。 - 自定义渲染器消亡 :小程序、Canvas、NativeScript 等第三方渲染器正是基于
createRenderer构建,剥夺这层抽象等于砍掉整个生态。
因此 "runtime-core 不依赖任何具体平台" 不是风格偏好,而是保持多个宿主(浏览器/内存/服务端/第三方)共用同一套渲染算法的必要条件。
1.2.3 依赖方向的不变量(可校验)
把依赖图翻译成可机械校验的规则,共有 4 条:
shared依赖列表为空------它是 DAG 的源头。(packages/shared/package.json中确实没有dependencies字段)reactivity只允许依赖shared。runtime-core只允许依赖shared+reactivity(packages/runtime-core/package.json:48-51)。runtime-dom/runtime-test只允许依赖runtime-core+reactivity+shared(外加各平台第三方,如csstype)。
这 4 条一旦被打破,循环依赖检查(Rollup 的 CIRCULAR_DEPENDENCY 警告)会立刻报警。但注意 rollup.config.js:344-348 对循环依赖做了静默处理:
js
onwarn: (msg, warn) => {
if (msg.code !== 'CIRCULAR_DEPENDENCY') {
warn(msg)
}
},
这说明 Vue 允许包内 存在循环引用(例如 runtime-core 内部模块互相引用很常见),但在包间层面严格保持单向。
1.2.4 编译期包与运行时包的"两条腿"
一个易被忽视的事实:vue(全量包)同时依赖编译期的 compiler-dom/compiler-sfc 与运行时的 runtime-dom。为什么要有两条腿?
- 生产环境 (Vite/webpack +
@vitejs/plugin-vue):模板在构建时就被compiler-sfc/compiler-dom预编译为render函数,运行时完全不需要编译器 。此时vue会走dist/vue.runtime.esm-bundler.js(注意是.runtime.后缀),把编译器整块 tree-shake 掉,包体积骤减。 - 运行时编译 :当模板来自
template选项或innerHTML(如 CDN 直接<script>引入vue.global.js、或字符串模板),才需要compileToFunction(packages/vue/src/index.ts:30-102)。
packages/vue/src/index.ts:104-107 是这条腿的"注册点":
ts
registerRuntimeCompiler(compileToFunction)
export { compileToFunction as compile }
export * from '@vue/runtime-dom'
registerRuntimeCompiler 把编译器挂进 runtime,让 runtime-core 在处理 template 选项时能回调它。如果引入的是 .runtime.esm-bundler.js(不含 packages/vue/src/index.ts,只 re-export runtime-dom),编译器就是 undefined,Vue 会在开发环境警告"Component provided template option but runtime compilation is not supported"。这正是"两条腿"的开关。
1.2.5 与 Vue 2 依赖体系的对比
理解 Vue 3 为何如此分层,对比 Vue 2 最直观(Vue 2 发布时无 monorepo 分包):
| 维度 | Vue 2.x | Vue 3.5.x |
|---|---|---|
| 交付形态 | 单包 vue,内部一个 src/core |
12 包 monorepo,用户侧仍暴露单入口 vue |
| 渲染器抽象 | 无(patch 直接操作 DOM) |
createRenderer + 宿主配置,支持多平台 |
| 响应式 | Object.defineProperty,与组件强耦合 |
独立 @vue/reactivity,与组件解耦 |
| 编译器 | 单文件 compiler(含 parser + codegen) |
compiler-core/dom/ssr/sfc 四层 |
| 第三方复用 | 几乎不可能 | @vue/reactivity、@vue/runtime-core 可独立引用 |
| tree-shaking | 有限(依赖 UMD) | 完整(ESM + 编译期开关) |
一句话总结:Vue 2 的架构是"一个包、一个平台、一个渲染器";Vue 3 的架构是"多个包、多个平台、共享算法"。本章后续的构建、类型、测试体系,都是为支撑这个"多平台共享"目标服务的。
1.3 monorepo 工具链:pnpm workspace 与工程约束
1.3.1 pnpm-workspace.yaml 逐字段解读
整个 monorepo 的"地基"是 pnpm-workspace.yaml,全文仅 38 行,却定义了三件事:workspace 范围 、依赖版本目录(catalog) 、安装策略。
yaml
# core-3.5.44\pnpm-workspace.yaml:1-3
packages:
- 'packages/*'
- 'packages-private/*'
前 3 行划定 workspace 成员。两行 glob 分别把 packages/ 下的 11 个公开包和 packages-private/ 下的 5 个内部包纳入同一个 pnpm workspace。这样 link-workspace-packages 生效,包之间用 workspace:* 引用时会被软链接到源码目录,而非去 npm 拉取。
(1)catalog 协议:集中管理"版本真相"
yaml
# pnpm-workspace.yaml:5-13
catalog:
'@babel/parser': ^7.29.9
'@babel/types': ^7.29.8
'entities': '^7.0.1'
'estree-walker': ^2.0.2
'magic-string': ^0.30.21
'source-map-js': ^1.2.2
'vite': ^8.3.4
'@vitejs/plugin-vue': ^6.0.10
catalog: 是 pnpm 的"版本目录"特性。它的作用是:把同一个第三方依赖的版本号收敛到一处 ,各包只需写 "magic-string": "catalog:",pnpm 就会解析成 ^0.30.21。
对照实际使用------packages/compiler-sfc/package.json:45-53:
json
"dependencies": {
"@babel/parser": "catalog:",
"@vue/compiler-core": "workspace:*",
"@vue/compiler-dom": "workspace:*",
"@vue/compiler-ssr": "workspace:*",
"@vue/shared": "workspace:*",
"estree-walker": "catalog:",
"magic-string": "catalog:",
"postcss": "^8.5.29",
"source-map-js": "catalog:"
}
这里 @babel/parser、estree-walker、magic-string、source-map-js 都走 catalog:,只有 postcss 用了显式 semver(因为它是 compiler-sfc 专属依赖,无需共享)。
为什么用 catalog 而不是各包各写版本? 因为 compiler-core 与 vue-compat 都依赖 @babel/parser,若两处版本不一致,会安装两份(pnpm 的 content-addressable 存储虽去重,但仍可能产生语义漂移)。catalog 让"编译器系列"始终锁在同一份 AST 解析器版本上。根 package.json:65-66 也用了 catalog:
json
"@babel/parser": "catalog:",
"@babel/types": "catalog:",
(2)allowBuilds:白名单式构建脚本
yaml
# pnpm-workspace.yaml:15-21
allowBuilds:
'@parcel/watcher': true
'@swc/core': true
'esbuild': true
'puppeteer': true
'simple-git-hooks': true
'unrs-resolver': true
pnpm 默认不执行依赖的 postinstall 脚本(防止供应链攻击)。allowBuilds 显式放行 6 个必须编译原生代码/安装二进制的依赖:@swc/core(Rust 压缩器)、esbuild(Go 打包器)、puppeteer(下载 Chromium)、simple-git-hooks(安装 git hooks)等。如果哪天升级引入需要构建的新依赖而忘了加白名单,构建会静默跳过------这是一个典型的"构建慢半拍"踩坑点。
(3)其余安装策略
yaml
# pnpm-workspace.yaml:23-38
dedupePeers: true
peerDependencyRules:
allowedVersions:
'typescript-eslint>eslint': '^9.0.0'
'@typescript-eslint/eslint-plugin>eslint': '^9.0.0'
# ...
minimumReleaseAge: 1440
trustPolicy: no-downgrade
minimumReleaseAgeExclude:
# Renovate security update: vitest@4.1.11
- vitest@4.1.11
dedupePeers: true:让 peer 依赖尽量复用同一实例,减少重复安装。peerDependencyRules.allowedVersions:抑制"typescript-eslint 期望 eslint ^9 但仓库装了 eslint ^10"这类 peer 冲突告警。注意根package.json:86装的是eslint: ^10.12.0,而行内注释指向 eslint ^9 兼容窗口。minimumReleaseAge: 1440:单位是分钟,即新发布的包必须"冷却"满 24 小时才允许安装------这是抵御 npm 供应链攻击(恶意版本发布后立刻被大面积安装)的实用手段。trustPolicy: no-downgrade:禁止依赖降级。minimumReleaseAgeExclude:为安全补丁开例外口子(日志里明确标注是 Renovate 的安全更新)。
1.3.2 根 package.json:脚本编排与钩子
根 package.json(core-3.5.44\package.json)是整个工程的操作台。先看元信息:
json
// package.json:2-5
"private": true,
"version": "3.5.44",
"packageManager": "pnpm@12.11.2",
"type": "module",
四个字段各司其职:
"private": true:根包不可发布,防止误npm publish。"version": "3.5.44":全仓库的版本真相 ,被rollup.config.js:34读取用于生成 banner。"packageManager": "pnpm@12.11.2":由 Corepack 强制校验的包管理器版本。"type": "module":让所有.js脚本按 ESM 解析,因此scripts/*.js里能直接import。
(1)only-allow + preinstall:锁死包管理器
json
// package.json:45-46
"preinstall": "npx only-allow pnpm",
"postinstall": "simple-git-hooks"
preinstall 阶段运行 only-allow pnpm:若有人用 npm install 或 yarn install,脚本会直接报错退出。这保证了 lockfile 只有 pnpm-lock.yaml 一份。postinstall 则调用 simple-git-hooks 把 git 钩子写入 .git/hooks。
(2)simple-git-hooks:零依赖的钩子方案
json
// package.json:48-51
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged && pnpm check",
"commit-msg": "node scripts/verify-commit.js"
}
对比 husky,simple-git-hooks 无需额外目录、无需 prepare 脚本,直接把配置写进 package.json 即可。这里定义两个钩子:
pre-commit:先跑lint-staged(只对暂存文件做 lint/format),再跑pnpm check(全量tsc --incremental --noEmit)。这意味着提交前必过类型检查。commit-msg:调用scripts/verify-commit.js,强制 conventional commits 格式(见 1.10)。
(3)lint-staged:按文件类型分流
json
// package.json:52-60
"lint-staged": {
"*.{js,json}": [
"prettier --write"
],
"*.ts?(x)": [
"eslint --fix",
"prettier --parser=typescript --write"
]
}
规则很克制:.js/.json 只格式化;.ts/.tsx 先 eslint --fix(自动修掉一部分规则)再格式化。注意 TS 走的是 prettier --parser=typescript 而非直接 prettier,这是为了在 lint-staged 的临时文件场景下显式指定解析器。
(4)engines:Node 版本下限
json
// package.json:61-63
"engines": {
"node": ">=20.0.0"
}
>=20 不是随意选的:scripts/build.js、scripts/release.js 大量使用 node:util 的 parseArgs(Node 18.3+ 提供,但稳定可用性在 20 更佳)、node:fs 的 rmSync、原生 fetch(release.js:322)等。CI 中通过 .node-version 文件固定版本。
1.3.3 依赖版本的地板:devDependencies 一览
根 package.json:64-116 的 devDependencies 可归为五类:
| 类别 | 代表依赖 | 作用 |
|---|---|---|
| 打包 | rollup@^4.64.5、rollup-plugin-dts@^6.5.1、rollup-plugin-esbuild@^6.2.1、@rollup/plugin-* |
生产构建与 dts 打包 |
| 压缩/转译 | @swc/core、esbuild |
prod 压缩、dev 快速构建 |
| 测试 | vitest、@vitest/coverage-v8、jsdom、playwright、puppeteer、@vitest/browser-playwright |
多环境测试矩阵 |
| 语言/校验 | typescript@~6.0.3、typescript-eslint、eslint@^10、prettier@^3 |
类型与风格 |
| 发布/工具 | conventional-changelog-angular、semver、enquirer、simple-git-hooks、lint-staged、rimraf、picocolors、pretty-bytes |
发布流水线 |
注意 typescript 用了 ~6.0.3(波浪号,只允许补丁位浮动),说明 Vue 对 TS 版本相当敏感------因为 isolatedDeclarations 等新特性直接影响 dts 产物正确性(见 1.7)。
1.3.4 脚本编排:npm-run-all2 与并行/串行
根脚本用 npm-run-all2 的 run-s(串行)/run-p(并行)来组合子任务,例如:
json
// package.json:33-36
"dev-sfc": "run-s dev-sfc-prepare dev-sfc-run",
"dev-sfc-run": "run-p \"dev compiler-sfc -f esm-browser\" \"dev vue -if esm-bundler-runtime\" \"dev vue -ipf esm-browser-runtime\" \"dev server-renderer -if esm-bundler\" dev-sfc-serve",
dev-sfc-run 同时启动 5 个 esbuild watch 进程 + 1 个 Vite 服务器,用于本地调试 SFC 编辑器。这种"一个仓库、多条产物、并行 watch"的编排,是 monorepo 开发体验的典型形态。
再看 sfc-playground 的整链构建(package.json:39-44):
json
"build-sfc-playground": "run-s build-all-cjs build-runtime-esm build-browser-esm build-ssr-esm build-sfc-playground-self",
它串行构建 5 组产物,每一步都是对 scripts/build.js 的不同 target/format 组合调用。这说明同一个构建脚本通过参数组合覆盖了所有产物形态,而非为每种场景写一个脚本。
1.4 包入口矩阵:exports 字段与三种分发形态
1.4.1 三种分发形态(Dist Formats)
Vue 3 的每个包都会产出多种"分发形态",它们的划分依据是 "谁来打包/在哪运行" 。先给出全量对照(以 vue 的 buildOptions.formats 为准,packages/vue/package.json:72-83):
json
"buildOptions": {
"name": "Vue",
"formats": [
"esm-bundler",
"esm-bundler-runtime",
"cjs",
"global",
"global-runtime",
"esm-browser",
"esm-browser-runtime"
]
}
7 种格式的语义如下表(前三行在 rollup.config.js:57-88 有一一对应的 outputConfigs,可直接核对):
| format | 产物文件名 | 模块系统 | 目标环境 | 依赖处理 | 典型使用者 |
|---|---|---|---|---|---|
esm-bundler |
vue.esm-bundler.js |
ESM | 由 Vite/webpack 消费 | 依赖 external,编译期开关保留占位符 | @vitejs/plugin-vue 项目 |
esm-bundler-runtime |
vue.runtime.esm-bundler.js |
ESM | 同上(不含编译器) | 同上 | 同上(默认) |
esm-browser |
vue.esm-browser.js |
ESM | 浏览器直接 <script type=module> |
内联全部依赖,开关硬编码 | 免构建原型 |
esm-browser-runtime |
vue.runtime.esm-browser.js |
ESM | 同上(不含编译器) | 同上 | 同上 |
cjs |
vue.cjs.js / vue.cjs.prod.js |
CJS | Node | external 依赖 | SSR / 老工具链 |
global |
vue.global.js / vue.global.prod.js |
IIFE | 浏览器 <script>(挂 window.Vue) |
内联全部依赖 | CDN、无构建项目 |
global-runtime |
vue.runtime.global.js / .prod.js |
IIFE | 同上(不含编译器) | 同上 | 自带编译链的 CDN 项目 |
"runtime" 后缀表示只含运行时、不含编译器 ;.prod.js 后缀表示已用 SWC 压缩的生产版。
1.4.2 vue 的 exports 字段逐项精读
现代 Node 的 exports 字段决定了"不同 import 语法解析到哪个文件"。packages/vue/package.json:19-71 是最复杂的入口矩阵,值得逐块拆:
json
// packages/vue/package.json:20-35
".": {
"import": {
"types": "./dist/vue.d.mts",
"node": "./index.mjs",
"default": "./dist/vue.runtime.esm-bundler.js"
},
"require": {
"types": "./dist/vue.d.ts",
"node": {
"production": "./dist/vue.cjs.prod.js",
"development": "./dist/vue.cjs.js",
"default": "./index.js"
},
"default": "./index.js"
}
}
解析逻辑(Node/Vite 会按顺序匹配第一个命中的条件):
import分支 (被 ESMimport命中):types→./dist/vue.d.mts(ESM 专用类型文件,见 1.7 的copyMts)。node条件 →./index.mjs(Node 下走 ESM 代理)。default→./dist/vue.runtime.esm-bundler.js(默认给 bundler 用运行时版,即树摇掉编译器的版本)。
require分支 (被 CJSrequire命中):types→./dist/vue.d.ts。node.production→./dist/vue.cjs.prod.js(生产 CJS)。node.development→./dist/vue.cjs.js(开发 CJS,含警告)。default→./index.js(NODE_ENV运行时代理,见下)。
注意 node 条件里嵌套了 production/development:这是 Node 减少"运行时条件判断"的优化------在 NODE_ENV=production 时直接指向不含 dev 检查的产物。但旧工具链不认识 production 子条件,所以还有 default: "./index.js" 兜底。
兜底的 packages/vue/index.js:1-7 是一个极简的运行时代理:
js
'use strict'
if (process.env.NODE_ENV === 'production') {
module.exports = require('./dist/vue.cjs.prod.js')
} else {
module.exports = require('./dist/vue.cjs.js')
}
同样的模式出现在 packages/runtime-core/index.js 等每一个运行时包中------这就是为什么每个 runtime 包都有 index.js 与 dist/*.cjs.js / dist/*.cjs.prod.js。
1.4.3 子路径入口:vue/server-renderer、vue/compiler-sfc
vue 包同时是个"聚合入口",把常用子包以子路径导出(packages/vue/package.json:36-70):
json
"./server-renderer": {
"import": {
"types": "./server-renderer/index.d.mts",
"default": "./server-renderer/index.mjs"
},
"require": {
"types": "./server-renderer/index.d.ts",
"default": "./server-renderer/index.js"
}
},
"./compiler-sfc": {
"import": {
"types": "./compiler-sfc/index.d.mts",
"browser": "./compiler-sfc/index.browser.mjs",
"default": "./compiler-sfc/index.mjs"
},
"require": {
"types": "./compiler-sfc/index.d.ts",
"browser": "./compiler-sfc/index.browser.js",
"default": "./compiler-sfc/index.js"
}
}
compiler-sfc 子路径多了一个 browser 条件 :因为 SFC 编译器在浏览器(如 sfc-playground)里也要跑,但浏览器版不能含 fs/path/PostCSS 等 Node 能力,于是单独产出 index.browser.mjs。这背后是 rollup.config.js:242-248 对 isBrowserESMBuild 的 process.env/process.platform 替换(见 1.5)。
vue 包的 files 字段(packages/vue/package.json:10-18)列出发布时携带的目录:
json
"files": [
"index.js", "index.mjs", "dist",
"compiler-sfc", "server-renderer", "jsx-runtime", "jsx.d.ts"
]
即发布产物是"根目录几个代理/代理文件 + dist + 若干子包再导出目录"。
1.4.4 各包 exports 的两种范式
对比两个典型包的 exports,能看出两套范式。
范式 A:运行时包(runtime-dom / runtime-core / shared / reactivity / server-renderer)------统一"五条件"结构:
json
// packages/runtime-dom/package.json:13-26
".": {
"types": "./dist/runtime-dom.d.ts",
"node": {
"production": "./dist/runtime-dom.cjs.prod.js",
"development": "./dist/runtime-dom.cjs.js",
"default": "./index.js"
},
"module": "./dist/runtime-dom.esm-bundler.js",
"import": "./dist/runtime-dom.esm-bundler.js",
"require": "./index.js"
},
"./*": "./*"
顺序上:types 优先、node 次之、module(非标准但被 webpack 识别)、import、require。注意 module 与 import 都指向同一个 esm-bundler 文件。
范式 B:编译期包(compiler-sfc / compiler-ssr)------更简单,因其只服务 Node:
json
// packages/compiler-sfc/package.json:11-20
".": {
"types": "./dist/compiler-sfc.d.ts",
"node": "./dist/compiler-sfc.cjs.js",
"module": "./dist/compiler-sfc.esm-browser.js",
"import": "./dist/compiler-sfc.esm-browser.js",
"require": "./dist/compiler-sfc.cjs.js"
},
"./*": "./*"
compiler-sfc 的 module/import 指向的是 esm-browser 而非 esm-bundler(因为它没有 esm-bundler 产物,buildOptions.formats 只有 cjs 与 esm-browser)。compiler-ssr 更极端,只有 cjs 一种格式(packages/compiler-ssr/package.json:10-15),因为它只在服务端构建期运行。
两者都带 "./*": "./*"------允许 import '@vue/compiler-sfc/dist/...' 这类深路径访问。
1.4.5 传统字段(main/module/types/unpkg)为何还在
exports 是 ESM/Node 的新标准,但旧工具(webpack 4、老 Babel、unpkg CDN)不认它,所以要保留传统字段。以 packages/vue/package.json:5-9 为例:
json
"main": "index.js",
"module": "dist/vue.runtime.esm-bundler.js",
"types": "dist/vue.d.ts",
"unpkg": "dist/vue.global.js",
"jsdelivr": "dist/vue.global.js",
main:老 Node 的 CJS 入口 →index.js代理。module:rollup/webpack 认的 ESM 入口 → runtime esm-bundler。types:TS 的类型入口。unpkg/jsdelivr:CDN 的"默认文件",指向vue.global.js(可直接<script src="https://unpkg.com/vue">)。
reactivity 与 compiler-dom 也有 unpkg/jsdelivr(packages/reactivity/package.json:8-9),因为它们有 global 产物;而 runtime-core、shared、server-renderer 没有------因为它们不产出 global 包(buildOptions.formats 里不含 global)。
1.4.6 exports 顺序的陷阱
exports 条件匹配是顺序敏感 的。一个经典踩坑:把 "default" 写在 "import" 前面会导致 ESM 也命中 default,破坏 ESM 消费。Vue 在 compiler-sfc 的 "." 里把 node 放在 module 前、import 放在 require 前,就是因为 Node 环境下 node 条件应优先命中 CJS,而浏览器/打包器环境应命中 ESM。这看似细微,却直接决定 vite 里 import @vue/compiler-sfc 拿到的是 CJS 还是 ESM 版本。
1.5 构建体系:从 scripts/build.js 到 rollup.config.js
1.5.1 scripts/build.js:命令行参数解析
构建的入口是 scripts/build.js,它负责解析命令行参数、调度并发、调用 Rollup、汇总产物体积 。先看参数定义(scripts/build.js:34-68):
js
const { values, positionals: targets } = parseArgs({
allowPositionals: true,
options: {
formats: { type: 'string', short: 'f' },
devOnly: { type: 'boolean', short: 'd' },
prodOnly: { type: 'boolean', short: 'p' },
withTypes: { type: 'boolean', short: 't' },
sourceMap: { type: 'boolean', short: 's' },
release: { type: 'boolean' },
all: { type: 'boolean', short: 'a' },
size: { type: 'boolean' },
},
})
用 Node 内置的 parseArgs 定义 8 个选项。各选项语义:
| 选项 | 短标志 | 语义 | 底层如何透传 |
|---|---|---|---|
--formats |
-f |
指定要构建的格式(逗号分隔) | 传 FORMATS:<v> 环境变量 |
--devOnly |
-d |
只构建开发版(NODE_ENV=development) |
env 取 development |
--prodOnly |
-p |
只构建生产版(不产 dev 产物) | 传 PROD_ONLY:true |
--withTypes |
-t |
同时构建 .d.ts |
额外 pnpm run build-dts |
--sourceMap |
-s |
输出 sourcemap | 传 SOURCE_MAP:true |
--release |
无 | 标记发布构建,跳过 private 包 | isRelease 分支 |
--all |
-a |
模糊匹配时构建所有命中项 | fuzzyMatchTarget 第二参 |
--size |
无 | 把体积写入 temp/size/*.json |
writeSize |
位置参数 targets 是包名(支持模糊匹配),例如 pnpm build dom 会命中 compiler-dom、runtime-dom。
commit 注入:构建一开始就取当前 git 短哈希:
js
// scripts/build.js:30-32
const commit = spawnSync('git', ['rev-parse', '--short=7', 'HEAD'])
.stdout.toString()
.trim()
它最终会被塞进 __COMMIT__(见 1.6),用于识别"这个产物是哪个提交构建的"。
1.5.2 主流程与并发控制
js
// scripts/build.js:85-112
async function run() {
if (writeSize) fs.mkdirSync(sizeDir, { recursive: true })
const removeCache = scanEnums()
try {
const resolvedTargets = targets.length
? fuzzyMatchTarget(targets, buildAllMatching)
: allTargets
await buildAll(resolvedTargets)
await checkAllSizes(resolvedTargets)
if (buildTypes) {
await exec('pnpm', ['run', 'build-dts', ...], { stdio: 'inherit' })
}
} finally {
removeCache()
}
}
流程分五步:
scanEnums():预处理全仓库的 enum (见 1.5.6),返回一个清理缓存的闭包removeCache。- 解析 targets:显式给了 target 就用
fuzzyMatchTarget,否则用全部allTargets(来自scripts/utils.js)。 buildAll():按 CPU 核数并发跑 Rollup。checkAllSizes():打印产物体积(gzip/brotli)。- 若需要类型,调用
pnpm run build-dts。 finally里removeCache()删除temp/enum.json。
并发实现(scripts/build.js:131-151)是一个"信号量式"的手写并发池:
js
async function runParallel(maxConcurrency, source, iteratorFn) {
const ret = []
const executing = []
for (const item of source) {
const p = Promise.resolve().then(() => iteratorFn(item))
ret.push(p)
if (maxConcurrency <= source.length) {
const e = p.then(() => {
executing.splice(executing.indexOf(e), 1)
})
executing.push(e)
if (executing.length >= maxConcurrency) {
await Promise.race(executing)
}
}
}
return Promise.all(ret)
}
关键技巧是 await Promise.race(executing):每当在飞任务数达到 cpus().length,就等任意一个完成再继续排下一个。这避免了"一次性 spawn 几十个 Rollup 进程把机器打爆"的问题。
1.5.3 单个 target 的构建:环境变量透传
build(target)(scripts/build.js:160-199)决定每个包如何被构建:
js
const privatePackages = fs.readdirSync('packages-private')
async function build(target) {
const pkgBase = privatePackages.includes(target)
? `packages-private`
: `packages`
const pkgDir = path.resolve(`${pkgBase}/${target}`)
const pkg = JSON.parse(fs.readFileSync(`${pkgDir}/package.json`, 'utf-8'))
// if this is a full build (no specific targets), ignore private packages
if ((isRelease || !targets.length) && pkg.private) {
return
}
// if building a specific format, do not remove dist.
if (!formats && fs.existsSync(`${pkgDir}/dist`)) {
fs.rmSync(`${pkgDir}/dist`, { recursive: true })
}
const env =
(pkg.buildOptions && pkg.buildOptions.env) ||
(devOnly ? 'development' : 'production')
await exec('rollup', ['-c', '--environment', [
`COMMIT:${commit}`,
`NODE_ENV:${env}`,
`TARGET:${target}`,
formats ? `FORMATS:${formats}` : ``,
prodOnly ? `PROD_ONLY:true` : ``,
sourceMap ? `SOURCE_MAP:true` : ``,
].filter(Boolean).join(','),
], { stdio: 'inherit' })
}
逐点说明:
- private 判定 :
fs.readdirSync('packages-private')得到私有包名清单,决定去哪个目录找。 - 全量构建跳过 private :
(isRelease || !targets.length) && pkg.private时直接 return。 dist清理策略 :只有当没有指定--formats时才删dist。原因很实用------若你正在增量构建某个格式(如只重建esm-bundler),删掉整个dist会连带删掉其它已构建产物,导致后续引用失败。NODE_ENV决策 :优先读pkg.buildOptions.env(Vue 里目前没有包用),否则由devOnly决定development/production。- 透传方式 :所有信息通过
--environment KEY:VALUE,...传给 Rollup,Rollup 会把它塞进process.env,供rollup.config.js读取。这就是为什么rollup.config.js:27-29开头就断言TARGET必须存在。
1.5.4 体积账本:checkAllSizes / checkFileSize
构建完成后打印/记录体积(scripts/build.js:206-264)。先看跳过条件:
js
async function checkAllSizes(targets) {
if (devOnly || (formats && !formats.includes('global'))) {
return
}
console.log()
for (const target of targets) {
await checkSize(target)
}
console.log()
}
只有"构建了 global 产物"时才统计体积------因为 .prod.js 体积才是用户真正关心的。
checkSize 检查两个文件:<target>.global.prod.js 与 <target>.runtime.global.prod.js。
checkFileSize(scripts/build.js:235-264)给出三种度量:
js
const gzipped = gzipSync(file)
const brotli = brotliCompressSync(file)
console.log(
`${pico.gray(pico.bold(fileName))} min:${prettyBytes(file.length)} ` +
`/ gzip:${prettyBytes(gzipped.length)} / brotli:${prettyBytes(brotli.length)}`,
)
if (writeSize)
fs.writeFileSync(path.resolve(sizeDir, `${fileName}.json`),
JSON.stringify({ file: fileName, size: file.length, gzip: gzipped.length, brotli: brotli.length }), 'utf-8')
min 是 SWC 压缩后的原始字节数,gzip/brotli 是两种压缩算法后的字节数。--size 选项会把结果写成 JSON,供 scripts/size-report.js 生成 PR 体积对比表(见 1.9)。
实测思路:在本地 pnpm build vue -f global -p --size(对应脚本 size-global),即可得到 vue.global.prod.js 的三项指标并落地到 temp/size/vue.global.prod.js.json。Vue 官方对外宣传的 "约 34KB min / 12KB gzip" 就是这一类数字。
1.5.5 rollup.config.js 逐项解读
rollup.config.js 是全仓库最核心的构建文件(393 行),它的产出是一个 RollupOptions 数组 (export default packageConfigs,:116)。注意它是一次只构建一个 target ------TARGET 来自环境变量(:27-29),所以 build.js 要为每个包 spawn 一次 Rollup。
(1)格式与输出配置对象
js
// rollup.config.js:57-88
const outputConfigs = {
'esm-bundler': { file: resolve(`dist/${name}.esm-bundler.js`), format: 'es' },
'esm-browser': { file: resolve(`dist/${name}.esm-browser.js`), format: 'es' },
cjs: { file: resolve(`dist/${name}.cjs.js`), format: 'cjs' },
global: { file: resolve(`dist/${name}.global.js`), format: 'iife' },
'esm-bundler-runtime': { file: resolve(`dist/${name}.runtime.esm-bundler.js`), format: 'es' },
'esm-browser-runtime': { file: resolve(`dist/${name}.runtime.esm-browser.js`), format: 'es' },
'global-runtime': { file: resolve(`dist/${name}.runtime.global.js`), format: 'iife' },
}
Rollup 的 format 只有 es/cjs/iife 三种,而 Vue 的 format 名有 7 种------二者的映射就在这张表里。name 来自 packageOptions.filename || basename(:47),所以 vue-compat 通过 filename: "vue"(packages/vue-compat/package.json:29)让产物名与 vue 一致。
(2)prod 产物的二次生成
Rollup 配置先为每个 format 生成 dev 配置,再在 NODE_ENV=production 时追加 prod 配置:
js
// rollup.config.js:102-114
if (process.env.NODE_ENV === 'production') {
packageFormats.forEach(format => {
if (packageOptions.prod === false) return
if (format === 'cjs') {
packageConfigs.push(createProductionConfig(format))
}
if (/^(global|esm-browser)(-runtime)?/.test(format)) {
packageConfigs.push(createMinifiedConfig(format))
}
})
}
规则值得记忆:
packageOptions.prod === false的包(compiler-ssr、compiler-sfc)不产出 prod 文件------因为编译器只在构建期运行、不进入用户产物,压缩无意义。cjs走createProductionConfig(只换NODE_ENV,不额外压缩)。global/esm-browser(含 runtime 变体)走createMinifiedConfig(SWC 压缩)。
(3)createConfig 输出的关键字段
js
// rollup.config.js:319-352
return {
input: resolve(entryFile),
external: resolveExternal(),
plugins: [
json({ namedExports: false }),
alias({ entries }),
enumPlugin,
...resolveReplace(),
esbuild({ tsconfig: ..., sourceMap: output.sourcemap, minify: false,
target: isServerRenderer || isCJSBuild ? 'es2019' : 'es2016',
define: resolveDefine() }),
...resolveNodePlugins(),
...plugins,
],
output,
onwarn: (msg, warn) => {
if (msg.code !== 'CIRCULAR_DEPENDENCY') warn(msg)
},
treeshake: { moduleSideEffects: false },
}
逐个说明:
input:由entryFile决定(:159-168),runtime 格式走src/runtime.ts,其余走src/index.ts;compat 包的 ESM 构建走src/esm-index.ts/src/esm-runtime.ts(为同时提供 default + named export)。external:由resolveExternal()决定。json插件 :配合namedExports: false,把 JSON 作为默认导出(用于@vue/compiler-sfc内联的模板引擎元数据等)。alias:entries来自scripts/aliases.js,把@vue/*指向packages/*/src。enumPlugin:enum 内联插件(见下)。replace:见resolveReplace()。esbuild:转译 TS。注意minify: false------Rollup 阶段不压缩 ,压缩交给后面的 SWC 插件(仅 prod)。target对 server-renderer/CJS 用es2019,其余用es2016------因为es2016不支持 async/await(这也是 eslint 禁用 async/await 的原因,见 1.10)。onwarn:放行包内循环依赖警告。treeshake.moduleSideEffects: false:告诉 Rollup 假定所有模块无副作用,激进 tree-shake。这就是packages/*/package.json里"sideEffects": false的构建期对应物 (packages/runtime-dom/package.json:27)。
1.5.6 插件的体积账:inline-enums.js
enumPlugin 来自 scripts/inline-enums.js。Vue 早期用 const enum 遭到 #1228 的问题,后来改用普通 enum,但普通 enum 会被 esbuild 转成运行时对象,体积偏大。于是有了这套"预扫描 → 全局替换 → 声明改字面量"的方案。
scanEnums()(scripts/inline-enums.js:45-214)步骤:
git grep 'export enum'找出所有含导出的 enum 的文件。- 用
@babel/parser解析,收集每个 enum 成员的name/value,支持1 << 2(BinaryExpression)、一元表达式、成员引用等初始化形式,并生成反向映射(数字枚举才有)。 - 把结果写入
temp/enum.json:包含declarations(哪个文件哪个范围定义了什么)与defines(Enum.Member→ 字面量)。
inlineEnums()(scripts/inline-enums.js:219-287)返回 [plugin, defines]:
- plugin.transform :把声明处的
export enum X {...}用MagicString改写成export const X = {...}对象字面量(:250-273)。数字成员同时生成反向映射"0": "MemberName"。 - defines :并入 esbuild 的
define,于是源码里引用X.Member的地方会被直接替换为字面量,不再有属性查找。
体积收益:若某 enum 有 10 个成员,运行时对象形态约占数百字节,替换为字面量后调用点零成本。这与 rollup.config.js:220-222 的注释所说的"esbuild define is a bit strict and only allows literal json or identifiers"相呼应。
1.5.7 resolveDefine():编译期常量的替换表
这是 1.6 的前置。resolveDefine()(rollup.config.js:170-218)返回一个 Record<string,string>,最终交给 esbuild 的 define:
js
const replacements = {
__COMMIT__: `"${process.env.COMMIT}"`,
__VERSION__: `"${masterVersion}"`,
__TEST__: `false`,
__BROWSER__: String(isBrowserBuild),
__GLOBAL__: String(isGlobalBuild),
__ESM_BUNDLER__: String(isBundlerESMBuild),
__ESM_BROWSER__: String(isBrowserESMBuild),
__CJS__: String(isCJSBuild),
__SSR__: String(!isGlobalBuild),
__COMPAT__: String(isCompatBuild),
__FEATURE_SUSPENSE__: `true`,
__FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__` : `true`,
__FEATURE_PROD_DEVTOOLS__: isBundlerESMBuild ? `__VUE_PROD_DEVTOOLS__` : `false`,
__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__: isBundlerESMBuild ? `__VUE_PROD_HYDRATION_MISMATCH_DETAILS__` : `false`,
}
if (!isBundlerESMBuild) {
replacements.__DEV__ = String(!isProductionBuild)
}
三个设计要点:
isBundlerESMBuild时 feature flag 保留占位符 (如__VUE_OPTIONS_API__),把决定权交给下游打包器 (Vite/webpack 用DefinePlugin注入)。这样用户才能在构建时选择是否保留 Options API。- 非 bundler 构建时硬编码 (
true/false),因为 global/esm-browser 直接跑到浏览器,没有打包器来注入。 __DEV__只在非 bundler 时由构建决定,bundler 版另有处理(见下)。
随后还有一段"环境变量覆盖"逻辑(:210-216),允许这样调试:
bash
# 强制开启运行时编译
__RUNTIME_COMPILE__=true pnpm build runtime-core
1.5.8 resolveReplace():@rollup/plugin-replace 的三组替换
js
// rollup.config.js:222-255
function resolveReplace() {
const replacements = { ...enumDefines }
if (isProductionBuild && isBrowserBuild) {
Object.assign(replacements, {
'context.onError(': `/*@__PURE__*/ context.onError(`,
'emitError(': `/*@__PURE__*/ emitError(`,
'createCompilerError(': `/*@__PURE__*/ createCompilerError(`,
'createDOMCompilerError(': `/*@__PURE__*/ createDOMCompilerError(`,
})
}
if (isBundlerESMBuild) {
Object.assign(replacements, {
__DEV__: `!!(process.env.NODE_ENV !== 'production')`,
})
}
if (isBrowserESMBuild) {
Object.assign(replacements, {
'process.env': '({})',
'process.platform': '""',
'process.stdout': 'null',
})
}
if (Object.keys(replacements).length) {
return [replace({ values: replacements, preventAssignment: true })]
} else {
return []
}
}
三组替换各有用途:
/*@__PURE__*/注解 :在 production 浏览器构建中,给四个"错误构造"函数调用贴上 PURE 注解,告诉 Rollup 这些调用无副作用、可删。这样当__DEV__为 false 使if (__DEV__) createCompilerError(...)分支被消除后,函数体也能被 tree-shake。- bundler 的
__DEV__:替换成!!(process.env.NODE_ENV !== 'production'),让下游打包器按 NODE_ENV 决定 dev/prod 分支------这是 Vue 长期以来的标准做法。 - browser ESM 的
process屏蔽 :浏览器里没有process,而compiler-sfc的部分代码引用了它,于是把process.env→({})、process.platform→""、process.stdout→null。
注意 preventAssignment: true:防止把 process.env = x 这类赋值 也误替换(这是 @rollup/plugin-replace 的经典陷阱)。
1.5.9 resolveExternal():external 的三种口径
js
// rollup.config.js:257-283
function resolveExternal() {
const treeShakenDeps = ['source-map-js', '@babel/parser', 'estree-walker', 'entities/decode']
if (isGlobalBuild || isBrowserESMBuild || isCompatPackage) {
if (!packageOptions.enableNonBrowserBranches) {
return treeShakenDeps
}
} else {
return [
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
...['path', 'url', 'stream'],
...treeShakenDeps,
]
}
}
三档策略:
- 纯浏览器构建 (global / esm-browser,且未开启非浏览器分支):只把"会被 tree-shake 掉的 Node-only 依赖"列为 external(其实是为了抑制警告),其余依赖内联进产物。
- Node / bundler 构建 (cjs、esm-bundler):把所有
dependencies+peerDependencies+ Node 内置path/url/stream全部 external------因为这些环境有模块解析能力。 enableNonBrowserBranches的包 (如compiler-sfc):即使浏览器构建也 external 依赖(因为它内含的 consolidate 需要动态 require)。
1.5.10 resolveNodePlugins() 与 compiler-sfc 的特例
js
// rollup.config.js:285-317
function resolveNodePlugins() {
let cjsIgnores = []
if (pkg.name === '@vue/compiler-sfc') {
cjsIgnores = [
...Object.keys(consolidatePkg.devDependencies),
'vm', 'crypto', 'react-dom/server', 'teacup/lib/express',
'arc-templates/dist/es5', 'then-pug', 'then-jade',
]
}
const nodePlugins =
(format === 'cjs' && Object.keys(pkg.devDependencies || {}).length) ||
packageOptions.enableNonBrowserBranches
? [commonJS({ sourceMap: false, ignore: cjsIgnores }),
...(format === 'cjs' ? [] : [polyfillNode()]),
nodeResolve()]
: []
return nodePlugins
}
条件是"cjs 且有 devDependencies"或"开启了非浏览器分支"。compiler-sfc 内联了分叉版 @vue/consolidate,它会 require 一大堆模板引擎(pug、jade、svelte...),这些必须被 commonJS 的 ignore 列表忽略,否则会尝试解析不存在的模块。这正是 cjsIgnores 存在的理由。非 cjs 时用 polyfillNode() 补上 process/Buffer 等 Node 全局的浏览器 shim。
1.5.11 createMinifiedConfig():SWC 压缩插件
js
// rollup.config.js:362-391
function createMinifiedConfig(format) {
return createConfig(format, {
file: outputConfigs[format].file.replace(/\.js$/, '.prod.js'),
format: outputConfigs[format].format,
}, [{
name: 'swc-minify',
async renderChunk(contents, _, { format }) {
const { code } = await minifySwc(contents, {
module: format === 'es',
format: { comments: false },
compress: { ecma: 2016, pure_getters: true },
safari10: true,
mangle: true,
})
return { code: banner + code, map: null }
},
}])
}
用 @swc/core 的 minify(Rust 实现,比 Terser 快得多)在 renderChunk 阶段压缩。compress.ecma: 2016 与前面 esbuild 的 target: es2016 一致;pure_getters: true 允许把无副作用的 getter 访问当作纯表达式优化;safari10 处理 Safari 10 的已知 bug。
1.5.12 dev 构建:为什么用 esbuild 而非 Rollup
scripts/dev.js 用 esbuild 做开发期 watch 构建 ,注释说得明白(scripts/dev.js:3-5):
js
// Using esbuild for faster dev builds.
// We are still using Rollup for production builds because it generates
// smaller files and provides better tree-shaking.
即:开发用 esbuild(快),生产用 Rollup(小) 。dev.js 的 define 表(:141-159)比 Rollup 简化------例如 __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__ 直接为 true、__DEV__ 由 --prod 决定、__COMMIT__ 固定 "dev"。它还贴心地注入了 log-rebuild 插件(:117-123)在每次重建后打印产物路径。
体积账小结 :同一次 pnpm build vue -f global -p,Rollup 产出的 .prod.js 之所以比 esbuild 小,关键在于 Rollup 的 tree-shaking 更彻底(能跨模块消除未使用导出、识别 /*@__PURE__*/),而 esbuild 的 tree-shaking 相对保守。这就是"开发要快、生产要小"两个目标用两套工具的根本原因。
1.6 编译期开关:从 __DEV__ 到 __FEATURE_*
1.6.1 什么是"编译期开关"
Vue 源码里大量出现形如 if (__DEV__) { ... }、if (__FEATURE_OPTIONS_API__) { ... } 的代码。这些标识符从未在源码里被赋值 ,任何标准 JS 引擎都会在运行时报 ReferenceError。它们能正常工作,是因为构建期被替换成了字面量 (true/false/"..."),随后被死代码消除(DCE)与 tree-shaking 移除。
这套机制带来三类收益:
- 零运行时开销 :
if (false) {...}整块被删,if (true)的检查也被删,运行时无分支、无属性查找。 - 体积最小化:dev 分支(包含警告、代码帧、错误详情)在 prod 构建中完全消失,这是 Vue prod 包体积远小于 dev 包的根本原因。
- 功能可选化 :
__FEATURE_OPTIONS_API__让用户能在构建时决定"不要 Options API",从而把相关代码(resolveMergedOptions等)整块摇掉。
1.6.2 类型声明:packages/global.d.ts
TypeScript 需要知道这些标识符"存在"。声明在 packages/global.d.ts:1-17:
ts
// Global compile-time constants
declare var __DEV__: boolean
declare var __TEST__: boolean
declare var __BROWSER__: boolean
declare var __GLOBAL__: boolean
declare var __ESM_BUNDLER__: boolean
declare var __ESM_BROWSER__: boolean
declare var __CJS__: boolean
declare var __SSR__: boolean
declare var __VERSION__: string
declare var __COMPAT__: boolean
// Feature flags
declare var __FEATURE_OPTIONS_API__: boolean
declare var __FEATURE_PROD_DEVTOOLS__: boolean
declare var __FEATURE_SUSPENSE__: boolean
declare var __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__: boolean
这个文件通过 tsconfig.json:32 的 include: ["packages/global.d.ts", ...] 被纳入编译范围。注意它用的是 declare var(全局变量),而非模块导出------所以任何 .ts 文件无需 import 即可引用这些标识符。
packages/global.d.ts:19-38 还声明了 *.vue 与 estree-walker 的模块类型,以及一个刻意 @deprecated 的 String.prototype.substring(Vue 团队要求统一用 slice,以便多字节字符安全)。
1.6.3 每个开关的语义与典型用法
下表给出 14 个开关的精确语义与对应值(对照 rollup.config.js:170-218):
| 开关 | 类型 | 语义 | dev 常用值 | prod 常用值 |
|---|---|---|---|---|
__DEV__ |
boolean | 是否为开发构建 | true |
false |
__TEST__ |
boolean | 是否处于 Vue 内部测试 | 源码固定 false;vitest 设 true |
false |
__BROWSER__ |
boolean | 是否面向浏览器 | true |
true |
__GLOBAL__ |
boolean | 是否为 IIFE global 构建 | false |
true(global) |
__ESM_BUNDLER__ |
boolean | 是否为 esm-bundler 构建 | false |
true |
__ESM_BROWSER__ |
boolean | 是否为 esm-browser 构建 | false |
true |
__CJS__ |
boolean | 是否为 cjs 构建 | true |
false |
__SSR__ |
boolean | 是否需要 SSR 分支 | true |
true(非 global) |
__VERSION__ |
string | 版本号 | "3.5.44" |
"3.5.44" |
__COMMIT__ |
string | 构建时 commit 短哈希 | git hash | git hash |
__COMPAT__ |
boolean | 是否为兼容构建 | false |
true(compat) |
__FEATURE_OPTIONS_API__ |
boolean | 是否打包 Options API | true |
用户决定 |
__FEATURE_PROD_DEVTOOLS__ |
boolean | prod 是否支持 devtools | false |
用户决定 |
__FEATURE_SUSPENSE__ |
boolean | 是否打包 Suspense | true |
true |
__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__ |
boolean | prod 是否输出 hydration 失配详情 | false |
用户决定 |
注意几点:
__SSR__的判断是!isGlobalBuild(rollup.config.js:185)------即"非 global 构建都保留 SSR 分支"。这解释了一个常见困惑:为什么esm-bundler/cjs里能看到if (__SSR__)分支。__FEATURE_SUSPENSE__恒为true(:191)。Vue 3.5 后 Suspense 已是稳定特性,不再提供关闭选项,但开关保留着以兼容历史构建。__TEST__在构建中恒为false(:176),注释明确"only used during Vue's internal tests"。运行时测试用 vitest 的define把它设为true(vitest.config.ts:8)。__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__是较新的开关,用于在生产环境 选择性保留 hydration 失配详情(默认false以省体积)。
1.6.4 三类开关的替换路径不同
把上面的表按"谁负责替换"再分一次层,能看清设计的精妙:
第一类:构建时硬编码(非 bundler 构建) __VERSION__、__COMMIT__、__GLOBAL__、__ESM_BROWSER__、__CJS__、__SSR__、__BROWSER__ 等------它们由 createConfig 根据 format 直接算出。
第二类:交给下游打包器(仅 bundler 构建) feature flags:
js
// rollup.config.js:192-200
__FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__` : `true`,
__FEATURE_PROD_DEVTOOLS__: isBundlerESMBuild ? `__VUE_PROD_DEVTOOLS__` : `false`,
__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__: isBundlerESMBuild ? `__VUE_PROD_HYDRATION_MISMATCH_DETAILS__` : `false`,
在 esm-bundler 产物里,__FEATURE_OPTIONS_API__ 被替换成字符串 __VUE_OPTIONS_API__(一个未定义的占位符) ,等待 Vite/webpack 的 define 在用户项目中注入。这就是你在 Vite 项目 vite.config.ts 里写 define: { __VUE_OPTIONS_API__: false } 能生效的原因。
第三类:__DEV__ 的双重处理
js
// rollup.config.js:203-206(非 bundler)
if (!isBundlerESMBuild) {
replacements.__DEV__ = String(!isProductionBuild)
}
js
// rollup.config.js:234-239(bundler,走 @rollup/plugin-replace)
if (isBundlerESMBuild) {
Object.assign(replacements, {
__DEV__: `!!(process.env.NODE_ENV !== 'production')`,
})
}
为什么 bundler 版的 __DEV__ 要单独用 replace 而非 esbuild 的 define?因为 esbuild 的 define 要求值是字面量或标识符 ,而 !!(process.env.NODE_ENV !== 'production') 是一个表达式 。rollup.config.js:220-222 的注释解释了这一点------"esbuild define is a bit strict"。这也说明:replace 插件与 esbuild define 是互补的,前者处理表达式,后者处理常量。
1.6.5 未定义开关的运行时兜底:initFeatureFlags
如果用户用了 esm-bundler 构建却没在打包器里注入 feature flags,会怎样?Vue 有一个优雅的兜底(packages/runtime-core/src/featureFlags.ts:8-37):
ts
export function initFeatureFlags(): void {
const needWarn = []
if (typeof __FEATURE_OPTIONS_API__ !== 'boolean') {
__DEV__ && needWarn.push(`__VUE_OPTIONS_API__`)
getGlobalThis().__VUE_OPTIONS_API__ = true
}
if (typeof __FEATURE_PROD_DEVTOOLS__ !== 'boolean') {
__DEV__ && needWarn.push(`__VUE_PROD_DEVTOOLS__`)
getGlobalThis().__VUE_PROD_DEVTOOLS__ = false
}
if (typeof __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__ !== 'boolean') {
__DEV__ && needWarn.push(`__VUE_PROD_HYDRATION_MISMATCH_DETAILS__`)
getGlobalThis().__VUE_PROD_HYDRATION_MISMATCH_DETAILS__ = false
}
if (__DEV__ && needWarn.length) {
console.warn(`Feature flag... not explicitly defined. ...`)
}
}
关键机制:typeof __FEATURE_OPTIONS_API__ !== 'boolean'。在未注入的场景,打包器没有替换这个标识符,于是运行时它会作为全局变量 被 typeof 检查(typeof 对未声明变量不报错,返回 "undefined")。此时 Vue 把默认值挂到 globalThis 上,并在 dev 环境打印警告。
否则,如果用户注入了 __VUE_OPTIONS_API__: false,替换后 typeof false !== 'boolean' 为 false,跳过兜底。这个函数只在 baseCreateRenderer 创建渲染器时调用一次,保证"引入 runtime-core 不产生副作用"。
易错点 :很多人在 Vite 里看到这个警告,是因为 vue 的 esm-bundler 构建默认不注入这些 flag。解决方式是三条路------(a) 在打包器 define 里显式声明;(b) 使用 Vue 官方插件(@vitejs/plugin-vue 会处理一部分);(c) 忽略警告(行为上等价于默认值)。注意从某个版本起,@vitejs/plugin-vue/vue-loader 不再自动注入,因为 Vue 认为"让用户显式声明更符合 tree-shaking 语义"。
1.6.6 tree-shaking 收益的量化
以 __FEATURE_OPTIONS_API__ 为例,看它在源码里控制了什么(来自 grep 结果):
ts
// packages/runtime-core/src/componentPublicInstance.ts:416
$options: i => (__FEATURE_OPTIONS_API__ ? resolveMergedOptions(i) : i.type),
// packages/runtime-core/src/componentPublicInstance.ts:423
$watch: i => (__FEATURE_OPTIONS_API__ ? instanceWatch.bind(i) : NOOP),
还有 componentProps.ts:521-533、componentEmits.ts:244-255 等多处。当 __FEATURE_OPTIONS_API__ 被替换为 false:
$options直接退化为i.type(不合并 options);$watch退化为NOOP;resolveMergedOptions、instanceWatch、Options API 的 normalizer 等一大片代码因不再被引用而被 tree-shake 掉。
scripts/usage-size.js:35-42 用真实构建量化了这一收益:
js
{
name: 'createApp (CAPI only)',
imports: ['createApp'],
replace: { __VUE_OPTIONS_API__: 'false' },
},
{ name: 'createApp', imports: ['createApp'] },
它对比"只导入 createApp + 关闭 Options API"与"只导入 createApp + 开启 Options API"的产物体积。实测中,关闭 Options API 通常能省下数 KB(min)量级------这正是 Composition API 用户能获得更小包体积的原因。
同理,Suspense、KeepAlive、Transition 都不被导入时会被完全摇掉,这也是 scripts/usage-size.js 里 overall preset 与单项 preset 体积差异的来源。
1.6.7 开关的"语义冲突"与覆盖优先级
当同一标识符既出现在 esbuild define(resolveDefine)又出现在 @rollup/plugin-replace(resolveReplace)里时,谁生效?答案是replace 插件先生效 ------因为 resolveReplace() 返回的插件被放进了 plugins 数组(rollup.config.js:332)在 esbuild 插件(:333)之前 。对 __DEV__ 而言:
- 非 bundler:
resolveReplace不含__DEV__,由 esbuild define 处理。 - bundler:
resolveReplace覆盖__DEV__为表达式,esbuild define 里根本没有__DEV__(因为:203的if (!isBundlerESMBuild)排除了它)。
所以两者不会真正冲突,是精心分配的结果。
另一个易错点:__VERSION__ 被定义为 "3.5.44"(带引号的字符串字面量)。如果没有引号,esbuild 会把它当标识符解析,导致把源码里的 "3.5.44" 当成变量名而报错。同理 __COMMIT__ 也是 "${...}" 形式。
1.6.8 与 Vue 2 的编译期开关对比
Vue 2 只有 process.env.NODE_ENV 一个开关(靠 webpack DefinePlugin 替换),没有细粒度的 feature flag。Vue 3 引入 __FEATURE_* 的直接动机就是让 tree-shaking 粒度更细:
| 能力 | Vue 2 | Vue 3 |
|---|---|---|
| dev/prod 切换 | process.env.NODE_ENV |
__DEV__(进一步区分 bundler/browser) |
| Options API 可选 | 不可(总是打包) | __FEATURE_OPTIONS_API__ |
| prod devtools 可选 | 不可 | __FEATURE_PROD_DEVTOOLS__ |
| Suspense 可选 | 无此特性 | __FEATURE_SUSPENSE__ |
| compat 层可选 | 无 compat 概念 | __COMPAT__ |
| hydration 详情可选 | 无 | __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__ |
这张表也解释了为什么 Vue 3 的 prod 包能做得比 Vue 2 更小------不是算法更省,而是更多代码被框进了可摇掉的开关里。
1.7 类型声明构建:tsconfig.build.json + rollup.dts.config.js
1.7.1 为什么不能只用 tsc 直接输出 .d.ts
一个自然的疑问:TypeScript 自带 declaration: true 就能生成 .d.ts,为什么 Vue 还要"tsc 生成临时 dts → Rollup 打包 → 后处理"三段式(见根 package.json:9)?
原因有三,都是"发布级"要求:
.d.ts文件爆炸 。tsc会为每个源文件 生成一个.d.ts,packages/vue依赖的整棵子树可能有几十上百个.d.ts。npm 包若是这样"目录散落式"分发,用户node_modules会膨胀,且 import 解析成本高。internal与@private泄漏 。Vue 源码里有大量内部类型(如InternalRenderFunction),若不stripInternal或打包,会污染公开类型面。- 类型面需要"整理" 。
tsc生成的 dts 使用export {...}大声明块,VitePress/Volar 在defineComponent等场景会报"inferred type cannot be named without a reference"。Vue 需要把它改成 inline export(见 1.7.4)。
所以 Vue 的流程是:先用 tsc 做类型检查并吐出"每文件的 dts",再用 rollup-plugin-dts 把整棵类型树压成单文件,最后用自定义插件做 AST 级修补。
1.7.2 tsconfig.build.json:dts 专用配置
它继承主 tsconfig.json 并覆盖几个关键项(tsconfig.build.json:1-23):
json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["node"],
"declaration": true,
"emitDeclarationOnly": true,
"stripInternal": true,
"composite": false
},
"include": [
"packages/global.d.ts",
"packages/vue/src",
"packages/vue-compat/src",
"packages/compiler-core/src",
"packages/compiler-dom/src",
"packages/runtime-core/src",
"packages/runtime-dom/src",
"packages/reactivity/src",
"packages/shared/src",
"packages/compiler-sfc/src",
"packages/compiler-ssr/src",
"packages/server-renderer/src"
]
}
逐字段:
emitDeclarationOnly: true:只产.d.ts,不产.js(JS 由 Rollup 负责)。stripInternal: true:删除标注了/** @internal */的声明。这是控制公开类型面的关键开关。composite: false:关闭项目引用/增量构建的约束。主配置里composite: true(tsconfig.json:29)是为了增量类型检查(pnpm check),构建时不需要。types: ["node"]:构建时的全局类型只留 Node;测试用的vitest/globals、puppeteer不需要进入产物。include白名单 :显式列出 11 个包(不含runtime-test,因为它是 private 且不发布)。这就是"哪些包产出 dts"的权威清单------与packages/*/package.json的types字段一一对应。
关键细节:主 tsconfig.json:3 的 outDir: "temp"。因为 emitDeclarationOnly 会把每个 .d.ts 按 rootDir 结构输出到 temp/packages/<pkg>/src/*.d.ts。rollup.dts.config.js:8-13 正是从这里读取:
js
if (!existsSync('temp/packages')) {
console.warn('no temp dts files found. run `tsc -p tsconfig.build.json --noCheck` first.')
process.exit(1)
}
注意根 package.json:9 的命令带 --noCheck:
json
"build-dts": "tsc -p tsconfig.build.json --noCheck && rollup -c rollup.dts.config.js"
--noCheck 让 tsc 只做 emit、跳过类型检查 ,大幅提速。类型正确性由 pnpm check(tsc --incremental --noEmit)单独保证,职责分离。
1.7.3 tsconfig.json 的 isolatedDeclarations
主配置里有一个特别值得注意的选项(tsconfig.json:28):
json
"isolatedDeclarations": true,
它要求每个导出都必须有显式可推导的类型标注 (例如函数返回值必须写明,不能依赖推断)。这是为 dts 构建服务的:当 isolatedDeclarations 开启,即使单文件转译(如 esbuild/swc/babel 逐文件处理)也能正确生成声明,而不需要全局类型上下文。
这条约束对源码风格有实质影响,例如 packages/shared/src/general.ts 里到处是这种显式标注:
ts
// packages/shared/src/general.ts:34-37
export const hasOwn = (
val: object,
key: string | symbol,
): key is keyof typeof val => hasOwnProperty.call(val, key)
ts
// packages/shared/src/general.ts:125-128
export const capitalize: <T extends string>(str: T) => Capitalize<T> =
cacheStringFunction(<T extends string>(str: T) => {
return (str.charAt(0).toUpperCase() + str.slice(1)) as Capitalize<T>
})
这些写法(给箭头函数变量写完整类型签名)在普通项目里是"冗余"的,但在 Vue 里是 isolatedDeclarations 的硬要求。
1.7.4 rollup.dts.config.js:打包流程
该配置导出的是每个包一个 RollupOptions 的数组 (rollup.dts.config.js:15-43):
js
const packages = readdirSync('temp/packages')
const targets = process.env.TARGETS ? process.env.TARGETS.split(',') : null
const targetPackages = targets ? packages.filter(pkg => targets.includes(pkg)) : packages
export default targetPackages.map(pkg => ({
input: `./temp/packages/${pkg}/src/index.d.ts`,
output: {
file: `packages/${pkg}/dist/${pkg}.d.ts`,
format: 'es',
},
plugins: [dts(), patchTypes(pkg), ...(pkg === 'vue' ? [copyMts()] : [])],
onwarn(warning, warn) {
// during dts rollup, everything is externalized by default
if (warning.code === 'UNRESOLVED_IMPORT' && !warning.exporter?.startsWith('.')) {
return
}
warn(warning)
},
}))
要点:
- 输入 是
tsc产出的temp/packages/<pkg>/src/index.d.ts,输出 是packages/<pkg>/dist/<pkg>.d.ts。注意输出名用的是包目录名 (如runtime-core.d.ts),而非 npm 名(@vue/runtime-core)。 dts()是rollup-plugin-dts,它把整棵.d.ts依赖树合并成单文件。所有外部包默认被 external。patchTypes(pkg)是自定义后处理器(见下)。copyMts()只为vue包追加(见 1.7.6)。onwarn忽略对非相对路径(包名)的未解析导入------因为 dts 打包时这些类型引用会被保留为 import 语句,而不是试图解析。
1.7.5 patchTypes:AST 级的类型面整理
patchTypes(rollup.dts.config.js:56-186)是 Vue 自己写的 Rollup 插件,注释明确了它的两个目标(:47-53):
js
/**
* Patch the dts generated by rollup-plugin-dts
* 1. Convert all types to inline exports
* and remove them from the big export {} declaration
* otherwise it gets weird in vitepress `defineComponent` call with
* "the inferred type cannot be named without a reference"
* 2. Append custom augmentations (jsx, macros)
*/
它用 MagicString + @babel/parser 做三次 AST 遍历:
- pass 0 (
:90-100):收集所有导出的类型名,存入isExported。 - pass 1 (
:102-125):遍历VariableDeclaration/TSTypeAliasDeclaration/TSInterfaceDeclaration/TSDeclareFunction/TSEnumDeclaration/ClassDeclaration,把需要导出的声明就地加export前缀 (s.prependLeft(start, 'export ')),并记入shouldRemoveExport。名字以_开头的跳过(内部符号)。 - pass 2 (
:127-171):从大export {}声明块里移除已经变成 inline export 的条目,避免重复导出。
最后(:174-183)追加包专属类型:
js
const additionalTypeDir = `packages/${pkg}/types`
if (existsSync(additionalTypeDir)) {
code += '\n' + readdirSync(additionalTypeDir)
.map(file => readFileSync(`${additionalTypeDir}/${file}`, 'utf-8'))
.join('\n')
}
例如 packages/runtime-dom/types/jsx.d.ts(全局 JSX 类型增强)就是通过这一步被追加进 runtime-dom.d.ts 的。
pass 1 里还有一个防御性断言(:106-115):若遇到含多个 declarator 的 declare const 会直接抛错,说明该写法未被支持。这是一个"边界条件明确化"的典型做法。
1.7.6 copyMts:为什么需要 vue.d.mts
copyMts(rollup.dts.config.js:196-204):
js
function copyMts() {
return {
name: 'copy-vue-mts',
writeBundle(_, bundle) {
assert('code' in bundle['vue.d.ts'])
writeFileSync('packages/vue/dist/vue.d.mts', bundle['vue.d.ts'].code)
},
}
}
注释(:188-194)解释了原因:根据 TS 4.7 的"package.json exports/imports 与自引用"规则,要同时正确提供 Node ESM 与 CJS 的类型,必须有两个独立声明文件 。因此把 vue.d.ts 原样复制一份为 vue.d.mts。
这就与 1.4.2 的 exports 呼应了------import 分支的 types 指向 vue.d.mts,require 分支指向 vue.d.ts:
json
"import": { "types": "./dist/vue.d.mts", ... },
"require": { "types": "./dist/vue.d.ts", ... }
注意只有 vue 包做了这个复制。因为只有它是用户 import 的直接入口;内部包(如 @vue/runtime-core)用户一般不直接 import ESM 版,且其 exports 的 types 统一指 .d.ts。
1.7.7 dts 构建的校验闭环
类型声明构建正确与否,靠 packages-private/dts-built-test 验证。根脚本(package.json:22-23):
json
"test-dts": "run-s build-dts test-dts-only",
"test-dts-only": "tsc -p packages-private/dts-built-test/tsconfig.json && tsc -p ./packages-private/dts-test/tsconfig.test.json"
流程是:先构建 dts,再跑两个 tsc:
packages-private/dts-built-test/tsconfig.json:针对构建产物 做类型检查(验证.d.ts可用)。packages-private/dts-test/tsconfig.test.json:针对源码 做类型测试(.test-d.ts文件,用expectType/@ts-expect-error断言)。
这套"源码类型测试 + 产物类型测试"的双保险,是 Vue 类型质量长期稳定的秘诀------很多库只测源码类型,产物类型损坏却无法自知。
1.7.8 实战:本地复现 dts 构建
powershell
# 1) 只 emit 声明(跳过类型检查,快)
pnpm exec tsc -p tsconfig.build.json --noCheck
# 2) 查看中间产物结构
Get-ChildItem temp/packages/vue/src/*.d.ts
# 3) 打包 dts
pnpm exec rollup -c rollup.dts.config.js
# 4) 检查产物
Get-Content packages/vue/dist/vue.d.ts -TotalCount 30
Get-ChildItem packages/vue/dist/*.d.mts
# 5) 或一键
pnpm run build-dts
第 5 步就是 CI 中 lint-and-test-dts job 执行的内容(见 1.8、1.9)。一个常见踩坑:直接 tsc(不带 -p tsconfig.build.json)会走主配置,composite: true + include 里含 __tests__,导致输出结构与 rollup.dts.config.js 期望的 temp/packages/<pkg>/src/index.d.ts 不匹配,从而触发 no temp dts files found 的警告并退出。
1.8 测试体系:vitest.config.ts 的多 project 矩阵
1.8.1 全局 define 与别名
vitest.config.ts 顶部先注入测试期的编译期常量(:5-24):
ts
export default defineConfig({
define: {
__DEV__: true,
__TEST__: true,
__VERSION__: '"test"',
__BROWSER__: false,
__GLOBAL__: false,
__ESM_BUNDLER__: true,
__ESM_BROWSER__: false,
__CJS__: true,
__SSR__: true,
__FEATURE_OPTIONS_API__: true,
__FEATURE_SUSPENSE__: true,
__FEATURE_PROD_DEVTOOLS__: false,
__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
__COMPAT__: true,
},
resolve: {
alias: entries,
},
// ...
})
两点值得注意:
__TEST__: true------这是唯一与生产构建不同、且为"真"的 dev-only 开关。它让测试能走一些仅在测试中启用的分支(如runtime-core里的__TEST__断言)。alias: entries------直接复用scripts/aliases.js的entries(与 Rollup 同源),保证"源码级"路径映射一致。scripts/aliases.js:16-21定义:
js
const entries = {
vue: resolveEntryForPkg('vue'),
'vue/compiler-sfc': resolveEntryForPkg('compiler-sfc'),
'vue/server-renderer': resolveEntryForPkg('server-renderer'),
'@vue/compat': resolveEntryForPkg('vue-compat'),
}
随后自动为 packages/ 下所有非特殊目录补 @vue/<dir> 映射(:25-35)。这个"Rollup 与 Vitest 共享 alias"设计,意味着测试引用的 @vue/runtime-core 与构建引用的必然是同一份源码,杜绝了两套映射漂移。
1.8.2 顶层 test 配置
ts
// vitest.config.ts:25-49
test: {
globals: true,
pool: 'threads',
setupFiles: 'scripts/setup-vitest.ts',
sequence: { hooks: 'list' },
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['packages/*/src/**'],
exclude: [
'packages/vue-compat/**',
'packages/vue/src/dev.ts',
'packages/vue/src/runtime.ts',
'packages/runtime-core/src/profiling.ts',
'packages/runtime-core/src/featureFlags.ts',
'packages/runtime-core/src/customFormatter.ts',
'packages/runtime-core/src/hydrationStrategies.ts',
'packages/runtime-dom/src/components/Transition*',
],
},
// ...
}
逐项解读:
globals: true:让describe/it/expect无需 import(配合tsconfig.json:21的types: ["vitest/globals"])。pool: 'threads':默认用 worker 线程池,比forks轻量。setupFiles: scripts/setup-vitest.ts:设置 JSDOM 之外的公共前置逻辑(如全局__DEV__相关的 mock、requestAnimationFrame等)。sequence.hooks: 'list':按定义顺序执行 hooks(而非并行),避免"后置清理先于前置初始化"的竞态;对effectScope、nextTick类测试尤为重要。- coverage 排除清单 :注释给出了排除原因(
:37-48)------vue-compat是"entries that are not really used during tests";profiling.ts/featureFlags.ts/customFormatter.ts是"not testable during unit tests";Transition*与hydrationStrategies.ts是"tested via e2e so no coverage is collected"。这是一份"为什么某些文件不纳入覆盖率"的说明书。
1.8.3 五个 project:环境分层的艺术
projects(:51-118)是 Vitest 4 的多项目特性,等价于"多套 Vitest 配置并存在一个 run 里"。Vue 定义了 5 个:
(1)unit------主单元测试
ts
{
extends: true,
test: {
name: 'unit',
exclude: [
...configDefaults.exclude,
'**/e2e/**',
'**/{vue,vue-compat,runtime-dom}/**',
'packages/server-renderer/__tests__/ssrWatch.spec.ts',
'packages/server-renderer/__tests__/ssrRender.spec.ts',
],
},
}
关键在 exclude:排除 e2e、排除依赖 DOM 的 vue/vue-compat/runtime-dom(它们由 unit-jsdom 接管)、排除需要 GC 的两个 SSR 测试(由 unit-gc 接管)。这样 unit 跑在纯 Node 环境,速度最快。
(2)unit-gc------需要手动 GC 的 SSR 测试
ts
{
extends: true,
test: {
name: 'unit-gc',
pool: 'forks',
include: [
'packages/server-renderer/__tests__/ssrWatch.spec.ts',
'packages/server-renderer/__tests__/ssrRender.spec.ts',
],
execArgv: ['--expose-gc'],
},
}
为什么要单列?因为 execArgv: ['--expose-gc'] 需要把 --expose-gc 透传给 Node 进程,而线程池不支持 execArgv------所以这里必须用 pool: 'forks'(子进程池)。这两个测试验证的是"SSR 渲染时 effect 是否被正确释放、内存是否被回收",必须能手动触发 global.gc()。
(3)unit-jsdom------依赖 DOM 的单元测试
ts
{
extends: true,
test: {
name: 'unit-jsdom',
include: ['packages/{vue,vue-compat,runtime-dom}/**/*.{test,spec}.*'],
exclude: [...configDefaults.exclude, '**/e2e/**'],
environment: 'jsdom',
},
}
runtime-dom 的 patchProp、v-model、v-show、Transition(部分)都需要真实 DOM API,故用 jsdom 环境。
(4)e2e------jsdom 下的端到端
ts
{
extends: true,
test: {
name: 'e2e',
environment: 'jsdom',
isolate: true,
include: ['packages/vue/__tests__/e2e/*.spec.ts'],
exclude: [
'packages/vue/__tests__/e2e/Transition.spec.ts',
'packages/vue/__tests__/e2e/TransitionGroup.spec.ts',
],
},
}
这里的 "e2e" 并非"真浏览器",而是构建产物级别的集成测试 ------它加载打包后的 vue.global.js(见 package.json:21 的 test-e2e 先跑 node scripts/build.js vue -f global -d)。isolate: true 保证每个测试文件在独立环境运行,避免全局状态串扰。特别注意 Transition/TransitionGroup 被排除到 e2e-browser------因为 jsdom 对 CSS transition 事件的模拟不真实。
(5)e2e-browser------Playwright 真浏览器
ts
{
extends: true,
define: { __BROWSER__: true },
test: {
name: 'e2e-browser',
include: [
'packages/vue/__tests__/e2e/Transition.spec.ts',
'packages/vue/__tests__/e2e/TransitionGroup.spec.ts',
],
browser: {
enabled: true,
provider: playwright(),
headless: true,
instances: [{ browser: 'chromium' }],
},
},
}
这是唯一的真浏览器 project:用 @vitest/browser-playwright 的 playwright() provider,在 chromium 里跑。并且单独覆盖了 __BROWSER__: true ------因为它在浏览器里运行,这个开关必须为真。这印证了 1.6 的开关不是"全局一刀切",而是按 project 精细设置。
1.8.4 测试矩阵一览
| project | 环境 | 池 | 覆盖范围 | 特殊配置 |
|---|---|---|---|---|
unit |
Node | threads | 除 DOM/e2e 外的所有单测 | 最快 |
unit-gc |
Node | forks | SSR 内存回收测试 | --expose-gc |
unit-jsdom |
jsdom | threads | vue/vue-compat/runtime-dom |
DOM API |
e2e |
jsdom | threads | vue/__tests__/e2e/* |
isolate、依赖 global 构建 |
e2e-browser |
chromium | - | Transition/TransitionGroup | Playwright、__BROWSER__: true |
对应根脚本(package.json:19-24):
json
"test": "vitest",
"test-unit": "vitest --project unit*",
"test-e2e": "node scripts/build.js vue -f global -d && vitest --project e2e --project e2e-browser",
"test-coverage": "vitest run --project unit* --coverage",
注意 --project unit* 会同时命中 unit、unit-gc、unit-jsdom 三个(通配),也就是"所有单测"。而 test-e2e 必须先构建 vue -f global -d(development 版 global),否则 e2e 找不到 dist/vue.global.js。
1.8.5 覆盖率策略
用 @vitest/coverage-v8(V8 引擎的 native 覆盖率,比 Istanbul 快)。test-coverage 只跑 unit* 三个 project------因为 jsdom 与 e2e 的覆盖率口径不同,混在一起会失真。include: ['packages/*/src/**'] 表示只统计源码,不含测试文件本身。
1.8.6 test-dts:类型测试的两种形态
类型测试不在 Vitest 里跑,而是独立走 tsc(见 1.7.7):
packages-private/dts-test/:*.test-d.ts/*.test-d.tsx文件,用expectType/@ts-expect-error断言。例如defineComponent.test-d.tsx验证defineComponent的类型推断、h.test-d.ts验证h函数的类型。packages-private/dts-built-test/:对打包后的 dts 做类型检查,确保产物可用。
两者在 CI 中都由 lint-and-test-dts job 覆盖(pnpm run test-dts)。
1.8.7 tree-shaking 的 CI 校验
除了体积统计,Vue 还有一道"树摇正确性"的守门员------scripts/verify-treeshaking.js。CI 里在 e2e 后运行(test.yml:94-95):
js
// scripts/verify-treeshaking.js:5-48(节选)
exec('pnpm', ['build', 'vue', '-f', 'global-runtime']).then(() => {
const errors = []
const devBuild = fs.readFileSync('packages/vue/dist/vue.runtime.global.js', 'utf-8')
if (devBuild.includes('__spreadValues')) {
errors.push('dev build contains unexpected esbuild object spread helper. ...')
}
const prodBuild = fs.readFileSync('packages/vue/dist/vue.runtime.global.prod.js', 'utf-8')
if (prodBuild.includes('Vue warn')) {
errors.push('prod build contains unexpected warning-related code. ...')
}
if (prodBuild.includes('html,body,base') || ...) {
errors.push('prod build contains unexpected domTagConfig lists. ...')
}
// ...
})
它做了三项断言:
- dev 产物不含
__spreadValues:说明源码没用对象展开语法(与 1.10 的 eslint 规则呼应)。 - prod 产物不含
Vue warn:说明所有警告都被__DEV__守卫,没有泄漏到生产环境。 - prod 产物不含
domTagConfig列表 (html,body,base等):说明编译器专用的 DOM 标签判断没有被误打进运行时。
这是一套用字符串包含检测来守卫体积的巧妙机制------比逐帧 diff 更简单、更可靠。
1.8.8 常见踩坑与调试建议
unit里跑 DOM 测试会报错 :因为unit项目exclude了 DOM 包且未设jsdom环境。要跑 DOM 测试用pnpm test-unit的unit-jsdom项目。e2e报"找不到 vue.global.js" :忘记先node scripts/build.js vue -f global -d。test-e2e脚本已内置该步骤。unit-gc报--expose-gc未生效 :必须用pool: 'forks',threads 不支持execArgv。- 改完
scripts/aliases.js后 Vitest 与 Rollup 行为都要重测 :因为二者共享同一份entries,一处错则两处错。
1.9 发布流程:scripts/release.js 与 CI 工作流
1.9.1 发布的两条路径
Vue 3 的发布有两条路径,理解它们的区别是理解整个流程的关键:
- 本地交互式发布 :
pnpm release,运行scripts/release.js,一步步交互式确认版本、跑测试、生成 CHANGELOG、打 tag、推送。它不直接 publish (除非显式加--publish)。 - CI 自动发布 :推送
v*tag 后,.github/workflows/release.yml触发,在 CI 中执行pnpm release --publishOnly,真正 publish 到 npm。
scripts/release.js:256-263 明确提示了这一点:
js
if (!args.publish) {
console.log(
pico.yellow(
'\nRelease will be done via GitHub Actions.\n' +
'Check status at https://github.com/vuejs/core/actions/workflows/release.yml',
),
)
}
即:本地 release 只负责"改版本 + 生成 CHANGELOG + commit + 打 tag + push",真正的 publish 交给 CI。这样 npm token 不必出现在开发者本地,也更符合供应链安全规范。
1.9.2 参数与前置检查
scripts/release.js:27-62 定义了参数:
| 参数 | 语义 |
|---|---|
--preid |
预发布标识(alpha/beta/rc) |
--dry |
试运行,只打印命令不执行 |
--tag |
显式指定 npm dist-tag |
--skipBuild / --skipTests / --skipGit / --skipPrompts |
跳过对应步骤 |
--publish |
本地也执行 publish |
--publishOnly |
只发布(供 CI 使用,跳过版本变更交互) |
--registry |
指定 registry |
一开场先校验"本地 HEAD 是否与远端同步"(scripts/release.js:144-148):
js
if (!(await isInSyncWithRemote())) {
return
} else {
console.log(`${pico.green(`✓`)} commit is up-to-date with remote.\n`)
}
isInSyncWithRemote(:337-363)通过 GitHub API 拉取当前 branch 的 HEAD sha 与本地 git rev-parse HEAD 对比。若不一致则交互式确认是否继续。这避免了"在落后于远端的提交上发版"。
1.9.3 版本选择:semver 与 versionIncrements
核心逻辑(scripts/release.js:110-186):
js
const versionIncrements = [
'patch', 'minor', 'major',
...(preId ? ['prepatch', 'preminor', 'premajor', 'prerelease'] : []),
]
const inc = i => semver.inc(currentVersion, i, typeof preId === 'string' ? preId : undefined)
流程:
- 读当前版本
currentVersion(:24,来自根package.json)。 - 若存在
preid(如当前版本是3.0.0-alpha.1,semver.prerelease会提取出alpha),则额外提供预发布增量选项。 - 用
enquirer的select交互(:155-176):列出patch (3.5.45)、minor (3.6.0)、major (4.0.0)、custom等选项,或输入自定义版本。 semver.valid(targetVersion)校验(:184-186),非法直接抛错。
updateVersions(targetVersion, keepThePackageName)(:206-208)把新版本写回根 package.json 与所有 packages/(:377-398):
js
function updateVersions(version, getNewPackageName = keepThePackageName) {
updatePackage(path.resolve(__dirname, '..'), version, getNewPackageName)
packages.forEach(p => updatePackage(getPkgRoot(p), version, getNewPackageName))
}
function updatePackage(pkgRoot, version, getNewPackageName) {
const pkgPath = path.resolve(pkgRoot, 'package.json')
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
pkg.name = getNewPackageName(pkg.name)
pkg.version = version
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
}
注意 pkg.name = getNewPackageName(pkg.name) 这一行看似多余(默认 keepThePackageName 是恒等函数),但它保留了"发布预发布版本时改包名"的可能性------历史上 Vue 会用这种方式发布 vue@next 之类的并行包。
1.9.4 dist-tag 与预发布通道
publishPackage 里根据版本号推导 dist-tag(scripts/release.js:442-451):
js
let releaseTag = null
if (args.tag) {
releaseTag = args.tag
} else if (version.includes('alpha')) {
releaseTag = 'alpha'
} else if (version.includes('beta')) {
releaseTag = 'beta'
} else if (version.includes('rc')) {
releaseTag = 'rc'
}
于是:
3.5.0-alpha.1→ 发布到alphatag(用户npm i vue@alpha)。3.5.0-beta.1→betatag。3.5.0-rc.1→rctag。- 正式版 →
latest(无 tag 参数时 pnpm/npm 默认)。
有一个易混点:dist-tag 与语义化版本是两个正交的维度 。beta tag 可以指向任意版本,但它通常只在预发布时被设置。用户安装 vue@next(Vue 3 早期)或 vue@alpha 都是通过 dist-tag 实现的。
1.9.5 幂等发布与已有版本跳过
发布流程对"重复发布"做了处理(scripts/release.js:453-488):
js
if (!isDryRun && (await isPackagePublished(packageName, version))) {
const pkgVersion = `${packageName}@${version}`
console.log(pico.yellow(`Skipping already published: ${pkgVersion}`))
alreadyPublishedPackages.push(pkgVersion)
return
}
isPackagePublished(:491-513)用 npm view <pkg>@<version> version 探测;若报错匹配 E404|No match found|No matching version|notarget(:515-517)则视为未发布。这保证了即使发布中途失败、重跑时也不会因"版本已存在"而整体崩掉------这是幂等发布的关键。
1.9.6 发布顺序:vue 最后发
一个精妙的细节(scripts/release.js:85-90):
js
const sortPackagesForPublishing = packageNames => [
// Publish vue last so users cannot install the new entry package before
// the matching internal packages are available.
...packageNames.filter(p => p !== 'vue'),
...packageNames.filter(p => p === 'vue'),
]
注释点明了原因:先发内部包(@vue/runtime-core 等),最后发 vue 。若先发 vue,用户在其它内部包还没更新到同版本时安装 vue@最新,会因为 vue 的依赖指向了不存在的版本而失败。这是"发布顺序即一致性"的经典实践。
1.9.7 CHANGELOG 生成
package.json:30:
json
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s"
release.js:210-213 调用它:
js
step('\nGenerating changelog...')
await run(`pnpm`, ['run', 'changelog'])
它基于 conventional commits 生成 CHANGELOG:feat → "Features" 段、fix → "Bug Fixes" 段、带 BREAKING CHANGE 的 commit → "Breaking Changes"。这就把 1.10 的 commit 规范与 1.9 的发布流程串成了一条链:commit 格式正确 → CHANGELOG 自动生成 → 用户能看到清晰的变更说明。
生成后还有交互确认(:214-225)------让发布者人工检查 CHANGELOG 是否可读。
1.9.8 CI 工作流全景
.github/workflows/ 下 8 个 workflow,与本章各节一一对应:
| workflow | 触发 | 职责 |
|---|---|---|
ci.yml |
push 任意分支 / PR 到 main/minor | 汇总入口:调用 test.yml;另跑 continuous-release(pkg-pr-new 预览包) |
test.yml |
workflow_call |
单元测试(ubuntu + windows)、e2e、lint + dts |
release.yml |
push v* tag |
跑测试 → pnpm release --publishOnly → 创建 GitHub Release |
size-data.yml |
push/PR | 构建并上传体积数据 artifact |
size-report.yml |
size data 完成后 |
对比体积并评论到 PR |
autofix.yml |
- | 自动格式化修复 |
ecosystem-ci-trigger.yml |
- | 触发第三方生态 CI |
lock-closed-issues.yml |
- | 锁定已关闭 issue |
ci.yml 的巧妙条件 (ci.yml:22):
yaml
if: ${{ ! startsWith(github.event.head_commit.message, 'release:') && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository) }}
两点:跳过以 release: 开头的提交(那是发布机器人的工作,无需重复跑 CI),以及跳过同仓库分支的 PR 首轮(避免重复)。
test.yml 的四个 job (test.yml:8-128):
unit-test:ubuntu 上跑pnpm run test-unit。unit-test-windows:windows 上并行跑compiler与server-renderer单测------专门捕获路径分隔符、文件系统差异导致的平台 bug。e2e-test:缓存 puppeteer/playwright 二进制,跑pnpm run test-e2e,最后node scripts/verify-treeshaking.js。lint-and-test-dts:并行跑 eslint + prettier,再跑pnpm check(tsc)与pnpm run test-dts。
这些 job 与 1.7、1.8、1.10 完全对应------CI 就是工程规范的自动化执行。
release.yml 的关键点 (release.yml:12-57):
yaml
release:
if: github.repository == 'vuejs/core'
needs: [test]
permissions:
contents: write
id-token: write
environment: Release
steps:
# ...
- name: Build and publish
id: publish
run: |
pnpm release --publishOnly
needs: [test]:publish 前必须先过测试。permissions.id-token: write+ npm provenance:允许发布带来源证明的包,release.js:425-427会在 CI 环境自动加--provenance。environment: Release:使用 GitHub Environment 的部署保护,需人工审批才能发布。
1.9.9 体积报告工作流
size-data.yml 负责构建并生成体积数据,size-report.yml 负责对比并评论。核心脚本 scripts/size-report.js:
js
// scripts/size-report.js:43-75
async function renderFiles() {
const curr = filterFiles(await readdir(currDir))
const prev = existsSync(prevDir) ? filterFiles(await readdir(prevDir)) : []
const fileList = new Set([...curr, ...prev])
const rows = []
for (const file of fileList) {
const curr = await importJSON(currPath)
const prev = await importJSON(prevPath)
// ...
rows.push([fileName,
`${prettyBytes(curr.size)}${getDiff(curr.size, prev?.size)}`,
`${prettyBytes(curr.gzip)}${getDiff(curr.gzip, prev?.gzip)}`,
`${prettyBytes(curr.brotli)}${getDiff(curr.brotli, prev?.brotli)}`])
}
output += markdownTable([['File', ...sizeHeaders], ...rows])
}
getDiff(:124-130)把差值格式化为 (**+1.2 kB**),于是 PR 里会出现一张"本 PR 让哪个产物胖了多少"的表格。size-report.yml:80-88 用 actions-cool/maintain-one-comment-backup 把这张表以 <!-- VUE_CORE_SIZE --> 为锚点更新到 PR ------同一个 PR 只保留一条评论,反复更新而非刷屏。
1.9.10 实战:一次本地 release 的预期行为
powershell
# 需要 git 与远端同步;dry 模式只打印命令
pnpm release --dry
预期输出(节选,实际会因版本/环境而异):
text
✓ commit is up-to-date with remote.
Select release type
patch (3.5.45)
minor (3.6.0)
major (4.0.0)
custom
...(选定后)...
Releasing v3.5.45. Confirm?
Checking CI status for HEAD...
CI for this commit passed. Skip local tests?
Updating cross dependencies...
Generating changelog...
Changelog generated. Does it look good?
Updating lockfile...
Committing changes...
Pushing to GitHub...
Release will be done via GitHub Actions.
注意到两个"人机协作"点:确认版本、确认 CHANGELOG。这体现了 Vue 发布流程"自动化但保留人工把关"的哲学。
1.10 工程规范:ESLint、Prettier 与提交约定
1.10.1 eslint.config.js:扁平化配置
Vue 3.5.44 用 ESLint 的扁平配置 (flat config),导出的是一个配置数组 (eslint.config.js:16-184),而非旧式 .eslintrc。它按"文件匹配 + 规则覆盖"组织,先宽后窄。
(1)基础规则块 (:17-79)------对所有 JS/TS/TSX 生效:
js
{
files: ['**/*.js', '**/*.ts', '**/*.tsx'],
extends: [tseslint.configs.base],
plugins: { 'import-x': importX },
rules: {
'no-debugger': 'error',
'no-console': ['error', { allow: ['warn', 'error', 'info'] }],
'no-restricted-globals': ['error', ...DOMGlobals, ...NodeGlobals],
'no-restricted-syntax': ['error', banConstEnum, /* ... */],
'sort-imports': ['error', { ignoreDeclarationSort: true }],
'import-x/no-nodejs-modules': ['error', { allow: builtinModules.map(m => `node:${m}`) }],
'@typescript-eslint/ban-ts-comment': ['error', { minimumDescriptionLength: 0 }],
'@typescript-eslint/consistent-type-imports': ['error', { fixStyle: 'inline-type-imports', disallowTypeAnnotations: false }],
'@typescript-eslint/no-import-type-side-effects': 'error',
},
}
其中 no-restricted-syntax 是一组"体积守卫规则"(:29-55),每条都给出了具体原因:
js
const banConstEnum = {
selector: 'TSEnumDeclaration[const=true]',
message: 'Please use non-const enums. This project automatically inlines enums.',
}
// ...
{
selector: 'ObjectPattern > RestElement',
message: 'Our output target is ES2016, and object rest spread results in verbose helpers and should be avoided.',
},
{
selector: 'ObjectExpression > SpreadElement',
message: 'esbuild transpiles object spread into very verbose inline helpers.\nPlease use the `extend` helper from @vue/shared instead.',
},
{
selector: 'AwaitExpression',
message: 'Our output target is ES2016, so async/await syntax should be avoided.',
},
{
selector: 'ChainExpression',
message: 'Our output target is ES2016, and optional chaining results in verbose helpers and should be avoided.',
},
把这五条与前面章节串起来,会发现它们构成一个自洽的体系:
- 禁
const enum(banConstEnum)↔ 呼应scripts/inline-enums.js:Vue 用普通 enum + 构建期内联替代 const enum。 - 禁对象剩余/展开 (
ObjectPattern > RestElement、ObjectExpression > SpreadElement)↔ 呼应rollup.config.js的target: es2016(es2016 无对象展开)与scripts/verify-treeshaking.js对__spreadValues的检查------三者共同守护"不使用对象展开"。 - 禁 async/await (
AwaitExpression)↔ 呼应esbuild的target: es2016:es2016 环境下 async/await 需转译为体积巨大的 generator 状态机。 - 禁可选链 (
ChainExpression)↔ 同样是 es2016 目标下的体积考量。
这四条规则是"约束写法来换体积"的典范:如果团队不主动禁用这些语法,写出来的源码在 es2016 目标下会膨胀。
(2)分层覆盖 ::82-139 按包类型放宽或收紧规则:
js
// tests,无限制
{ files: ['**/__tests__/**', 'packages-private/dts-test/**'], rules: { 'no-restricted-globals': 'off', 'no-console': 'off', 'no-restricted-syntax': 'off' } },
// shared,任何环境可用
{ files: ['packages/shared/**', 'eslint.config.js'], rules: { 'no-restricted-globals': 'off' } },
// 面向 DOM 的包
{ files: ['packages/{vue,vue-compat,runtime-dom}/**'], rules: { 'no-restricted-globals': ['error', ...NodeGlobals] } },
// 面向 Node 的包
{ files: ['packages/{compiler-sfc,compiler-ssr,server-renderer}/**'], rules: { 'no-restricted-globals': ['error', ...DOMGlobals] } },
这四块把"平台边界"编码进了 lint 规则:DOM 包不能用 module/require;Node 包不能用 window/document 。如果有人在 runtime-core(通用包)里写了 document,会因为基础规则的 no-restricted-globals 报错------这正是 1.2 说的"平台无关性"的静态保证。
(3)compiler-sfc 的 Node 模块例外 (:167-173):
js
{
files: ['packages/compiler-sfc/src/**'],
rules: { 'import-x/no-nodejs-modules': ['error', { allow: builtinModules }] },
}
因为 compiler-sfc 必须用 fs/path/postcss 等,这里允许内置模块(而非仅 node: 前缀)。
(4)忽略清单 (:175-184):**/dist/、**/temp/、**/coverage/、explorations/、dts-build/packages------避免对产物做 lint。
1.10.2 Prettier
Prettier 用最简配置(仓库根 .prettierrc / 默认),通过 format、format-check 脚本驱动:
json
"format": "prettier --write --cache .",
"format-check": "prettier --check --cache ."
--cache 是关键------它把已格式化文件的哈希缓存到 .prettier-cache,第二次运行只处理变更文件。CI 中 format-check 与 eslint 并行跑(test.yml:117-122)。
值得一提的是 .git-blame-ignore-revs(全文仅 1 行):
text
# update prettier & eslint config (#9162)
bfe6b459d3a0ce6168611ee1ac7e6e789709df9d
它记录了一次"纯格式化提交"的 SHA。配置 git config blame.ignoreRevsFile .git-blame-ignore-revs 后,git blame 会跳过这些噪声提交,让"最后真正改逻辑的人"而非"最后格式化的人"出现在 blame 里。这是一个小而重要的工程细节。
1.10.3 verify-commit.js:commit 规范守卫
scripts/verify-commit.js 在 commit-msg 钩子里执行(package.json:50),读取 .git/COMMIT_EDITMSG 并做正则校验(scripts/verify-commit.js:6-27):
js
const msgPath = path.resolve('.git/COMMIT_EDITMSG')
const msg = readFileSync(msgPath, 'utf-8').trim()
const commitRE =
/^(revert: )?(feat|fix|docs|dx|style|refactor|perf|test|workflow|build|ci|chore|types|wip|release)(\(.+\))?: .{1,50}/
if (!commitRE.test(msg)) {
console.log()
console.error(` ${pico.white(pico.bgRed(' ERROR '))} ${pico.red(`invalid commit message format.`)}...`)
process.exit(1)
}
正则的语义拆解:
^(revert: )?:允许revert:前缀(回滚提交)。(feat|fix|docs|dx|style|refactor|perf|test|workflow|build|ci|chore|types|wip|release):允许的 type 枚举,共 15 个。(\(.+\))?:可选的 scope(如(compiler)、(v-model))。::冒号加空格。.{1,50}:subject 长度在 1~50 字符之间。
与 .github/commit-convention.md:10 的正则相比,代码实现多了 release 一个 type ------因为发布机器人会提交 release: v3.5.45,必须放行。
commit-convention.md:63-67 补充了语义规则:feat/fix/perf 会进入 CHANGELOG;BREAKING CHANGE 出现在 footer 时强制进入 CHANGELOG。
1.10.4 三条规范如何联动
把 1.10 的三件套串起来看,它们其实是一条自动化流水线:
text
开发者写代码
│
├─ pre-commit: lint-staged (eslint --fix + prettier) ← 只修暂存文件
│ + pnpm check (tsc --noEmit) ← 全量类型检查
│
├─ commit-msg: verify-commit.js 校验 conventional commits
│
└─ CI (lint-and-test-dts): eslint + prettier --check + tsc + test-dts
← 兜底,防止 --no-verify 绕过本地钩子
本地钩子负责"快反馈",CI 负责"不可绕过"。这正是为什么 pre-commit 只跑 lint-staged(增量)而非全量 lint------本地要快,全量交给 CI。
1.10.5 工程规范与"体积优先"价值观
回顾本章出现过的所有工程约束,会发现它们都指向同一个价值观------体积优先:
| 规范 | 位置 | 守卫的体积目标 |
|---|---|---|
| 禁对象展开 | eslint.config.js:38-43 |
es2016 不生成 __spreadValues |
| 禁 async/await | eslint.config.js:45-49 |
不生成 generator 状态机 |
| 禁可选链 | eslint.config.js:51-55 |
不生成链式判空 helper |
| 禁 const enum | eslint.config.js:10-14 |
用 inline enum 替代 |
| tree-shaking 校验 | scripts/verify-treeshaking.js |
prod 产物不含 dev 代码 |
| 体积报告 | size-report.yml |
PR 级别监控体积回归 |
对任何想"深度使用"或"二次构建" Vue 的工程团队来说,这张表就是可以直接抄的清单。
1.11 本章要点回顾
1.11.1 全景速查表
| 主题 | 一句话结论 | 关键文件 |
|---|---|---|
| 目录结构 | packages/ 发布、packages-private/ 不发布,两套配置全部集中在根 |
pnpm-workspace.yaml |
| 包数量与职责 | 11 个公开包 + runtime-test(private),按抽象层级切分 |
packages/*/package.json |
| 依赖方向 | shared 是源头;runtime-core 只依赖 shared+reactivity,不依赖平台 |
packages/runtime-core/package.json:48-51 |
| 平台适配 | runtime-dom/runtime-test 各自用 createRenderer 注入 nodeOps/patchProp |
packages/runtime-test/src/index.ts:12-17 |
| pnpm 策略 | catalog 收敛版本、allowBuilds 白名单、minimumReleaseAge 24h 冷却 | pnpm-workspace.yaml |
| 分发形态 | 7 种 format × (dev + prod) 覆盖 bundler/browser/node/CDN | rollup.config.js:57-88 |
| 入口矩阵 | exports 条件顺序敏感;module/import 多指 esm-bundler |
packages/vue/package.json:19-71 |
| 构建入口 | build.js 解析参数 → 透传 --environment → 并发跑 Rollup |
scripts/build.js:34-199 |
| 编译期开关 | dev/bundler/browser 三套替换路径,feature flag 交给下游打包器 | rollup.config.js:170-255 |
| 类型构建 | tsc --noCheck 出临时 dts → rollup-plugin-dts 打包 → AST 修补 |
rollup.dts.config.js |
| 测试矩阵 | 5 个 vitest project:unit/unit-gc/unit-jsdom/e2e/e2e-browser | vitest.config.ts:51-118 |
| 发布流程 | 本地打 tag、CI publish;vue 最后发;幂等跳过已发布 |
scripts/release.js |
| 工程规范 | conventional commits + 体积守卫 lint + blame 豁免 | scripts/verify-commit.js、eslint.config.js |
1.11.2 五条核心认知
-
分层是手段,复用是目的 。Vue 3 把渲染算法(
runtime-core)与平台实现(runtime-dom/runtime-test)分离,使得浏览器、内存测试、SSR、第三方渲染器共享同一套算法。判断一个包是否"放对位置",就看它的dependencies是否越过了抽象边界。 -
编译期开关是体积与功能的调节旋钮 。
__DEV__决定 dev/prod,__FEATURE_*决定功能取舍,__SSR__/__ESM_BUNDLER__等决定平台分支。理解了它们各自的替换路径,就理解了 Vue 包体积的底层账本。 -
构建工具的组合是"各司其职" 。esbuild 负责快速转译与 dev 构建,Rollup 负责精细 tree-shaking 与多形态产物,SWC 负责生产压缩,
rollup-plugin-dts负责类型打包。没有哪个工具能独揽全部,组合才是答案。 -
类型与测试是"双闭环"。类型上有"源码测试 + 产物测试",测试上有"五种环境 project + tree-shaking 校验"。任何一环缺失,都会让"看起来正常"的改动在生产环境翻车。
-
规范即自动化。commit 规范驱动 CHANGELOG,lint 规则守卫体积,CI 覆盖本地钩子,体积报告监控回归。Vue 的工程质量不是靠自觉,而是靠一层层可执行的自动化。
1.11.3 面向后续章节的伏笔
本章建立的地图,是后续所有章节的坐标参照:
- 第 02~04 章的
reactivity,其"无 DOM、无 runtime 依赖"的独立性能成立,正是因为 1.2 的reactivity → shared单依赖链。 - 第 05~10 章的编译器,其"core/dom/ssr/sfc"四层结构,对应 1.1.2 与 1.4.4 的编译期包范式。
- 第 11~17 章的 runtime,其"抽象节点 → 具体平台"的调用链,正是 1.2.2 的
createRenderer宿主注入模型。 - 第 18 章的
vue-compat与全链路,其双语构建(default + named export 的esm-index.ts)正是 1.5.5 提到的 compat 入口特例。
理解了"包怎么划分、产物怎么产出、开关怎么替换、怎么测、怎么发",再去看任何一个包的源码,都不会在"这段代码会不会被打进产物"这类问题上迷失。
1.11.4 练习题(面向资深工程师)
- 如果要新增一个第三方渲染器(如 Canvas),应该新增一个包还是复用
runtime-core?它需要实现RendererOptions里的哪些方法?(提示:对照runtime-test只提供了nodeOps+patchProp。) - 为什么
compiler-sfc的buildOptions里设了prod: false与enableNonBrowserBranches: true?这两个选项分别影响rollup.config.js的哪几段逻辑? - 假设你在 Vite 项目里把
__VUE_OPTIONS_API__设为false,请列出至少 5 处会因此被 tree-shake 的源码文件(提示:grep__FEATURE_OPTIONS_API__)。 vue包的exports.require.node里嵌套了production/development子条件,若去掉这两个子条件会有什么后果?为什么还要保留default?unit-gc为什么必须用pool: 'forks'?如果把它的两个测试合并进unit会发生什么?- 一次 release 中,若
@vue/compiler-core已发布成功而vue发布失败,重跑pnpm release --publishOnly会发生什么?请结合isPackagePublished与sortPackagesForPublishing说明。
本章小结 :我们从目录树出发,依次拆解了包的职责边界与依赖拓扑、pnpm 工具链的版本收敛策略、多形态入口矩阵、Rollup 构建流水线的每一段逻辑、编译期开关的替换路径与体积收益、dts 类型产物的打包与修补、Vitest 的五环境测试矩阵、release 的两条路径与 CI 工作流,以及 ESLint/Prettier/commit 三重工程规范。这些"骨架"构成了后续逐包剖析的坐标系。下一章开始,我们将深入
packages/reactivity,从effect.ts的依赖收集机制讲起------那是 Vue 3 一切响应式能力的源头。