第 1 章:宏观认知:core 仓库的工程化哲学
第 1 章:宏观认知:core 仓库的工程化哲学
在开始追踪任何一行响应式或虚拟 DOM 的实现之前,我们首先需要理解这些代码赖以生存的工程化母体。打开 Vue core 仓库,最先映入眼帘的并非框架核心逻辑,而是 package.json 与 pnpm-workspace.yaml 这类工程配置文件——它们不包含任何运行时功能,却决定了整个框架能否被正确构建、测试与发布。本章要回答的正是这个前置问题:core 仓库到底是什么。它并非 @vue/runtime-core 那个 npm 包,而是承载 runtime-core、reactivity、compiler-sfc 等十余个公开发布包,外加 sfc-playground、template-explorer 等私有实验包的工程化母体。理解这个母体的组织方式,是后续所有章节(构建、类型、发布、体积预算)的前提。本章将沿三条主线展开:workspace 的双目录结构、根级 TypeScript 与 Rollup 的统一约束,以及「源码仓库」与「发布产物」的解耦哲学。
一、双目录结构:packages 与 packages-private 的物理隔离
直觉模型
把 core 仓库想象成一栋研发大楼。packages/ 是正式产品线,生产出来的东西要贴上商标卖到市场上;packages-private/ 是内部试验室,里面的样品只用于调试和演示,绝不对外发货。两者共用同一套水电(依赖、构建工具),但门禁系统(发布流程)对它们区别对待。
若没有这层物理隔离,一个内部调试用的 playground 包很容易被误发布到 npm——这不是假设,而是 monorepo 的经典事故。
数据结构与内存布局
workspace 的边界由 pnpm-workspace.yaml 定义。它只有三行有效声明:
📎 pnpm-workspace.yaml:1-3
packages:
- 'packages/*'
- 'packages-private/*'这两条 glob 告诉 pnpm:packages/ 和 packages-private/ 下的每个子目录都是一个独立包。pnpm 会为它们建立符号链接,使 @vue/runtime-core 引用 @vue/reactivity 时直接指向本地源码目录,而非从 registry 下载。
紧接着的 catalog: 段是 pnpm 的依赖版本目录机制:
📎 pnpm-workspace.yaml:5-13
catalog:
'@babel/parser': ^7.29.8
'@babel/types': ^7.29.8
'entities': '^7.0.1'
'estree-walker': ^2.0.2
'magic-string': ^0.30.21
'source-map-js': ^1.2.1
'vite': ^8.3.0
'@vitejs/plugin-vue': ^6.0.9根 package.json 中对应写的是 "@babel/parser": "catalog:" 📎 package.json:65-65。catalog: 是一个占位符,pnpm 在安装时把它替换为 catalog 段中声明的版本。这样做的收益是:@babel/parser 的版本只在 pnpm-workspace.yaml 一处维护,所有引用它的包自动对齐,杜绝了「A 包用 7.28、B 包用 7.29」的版本漂移。
场景驱动 Walkthrough:一次 pnpm install 之后发生了什么
假设你在仓库根目录执行 pnpm install。代入这个场景,逐步追踪:
第一步:preinstall 门禁。 pnpm 在安装前会触发根 package.json 的 preinstall 脚本:
📎 package.json:45-45
"preinstall": "npx only-allow pnpm"〔设计推断与架构权衡〕
only-allow pnpm会检查当前包管理器是否为 pnpm,若不是则直接报错退出。这行脚本的存在意味着:用 npm 或 yarn 安装 core 仓库会失败。为什么必须锁死 pnpm? 因为 core 仓库依赖 pnpm 的 workspace 符号链接与 catalog 机制,npm 的 workspaces 不支持catalog:语法,yarn 的 PnP 模式又会改变模块解析路径,导致构建脚本中的createRequire行为不一致。
第二步:解析 workspace。 pnpm 读取 pnpm-workspace.yaml,扫描 packages/* 与 packages-private/*,为每个含 package.json 的目录建立包记录。
第三步:应用 catalog 替换。 根 package.json 中所有 catalog: 占位符被替换为 catalog 段的实际版本,随后统一安装。
第四步:postinstall 钩子。 安装完成后触发:
📎 package.json:46-46
"postinstall": "simple-git-hooks"simple-git-hooks 读取根 package.json 中的 simple-git-hooks 字段,把 Git 钩子写入 .git/hooks/:
📎 package.json:48-51
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged && pnpm check",
"commit-msg": "node scripts/verify-commit.js"
}pre-commit 钩子在每次提交前跑 lint-staged 与类型检查,commit-msg 钩子校验提交信息格式(Vue 使用 conventional commits)。注意 preinstall 与 postinstall 的对称性:前者守门(只允许 pnpm),后者布防(安装 Git 钩子)。
设计思考与踩坑
〔设计推断与架构权衡〕
为什么用两条 glob 而非一条packages*/? 显式列出两个目录,是为了让「公开」与「私有」的语义在配置层面就可见。任何新加入的开发者读到pnpm-workspace.yaml第一眼就知道仓库有两类包。若写成packages*/,这个语义就被隐藏了。
allowBuilds 与供应链安全。 注意这段配置:
📎 pnpm-workspace.yaml:15-21
allowBuilds:
'@parcel/watcher': true
'@swc/core': true
'esbuild': true
'puppeteer': true
'simple-git-hooks': true
'unrs-resolver': truepnpm 默认禁止依赖包执行安装脚本(postinstall),因为这是供应链攻击的常见入口。allowBuilds 是白名单:只有列出的包才被允许运行构建脚本。@swc/core、esbuild 需要下载平台相关的原生二进制,puppeteer 需要下载 Chromium,simple-git-hooks 需要写 Git 钩子——这些都是合法的构建期行为,因此被显式放行。
minimumReleaseAge: 1440 的深意。 这行配置要求新发布的依赖版本必须「满 24 小时」(1440 分钟)才允许被安装:
📎 pnpm-workspace.yaml:33-33
minimumReleaseAge: 1440〔设计推断与架构权衡〕
这是防御 npm 供应链投毒的冷却期机制。攻击者劫持某个包并发布恶意版本后,通常会在数小时内被发现并撤下。设置 24 小时冷却期,可以让 core 仓库避开这个窗口。而 minimumReleaseAgeExclude 则允许对特定安全补丁破例:
📎 pnpm-workspace.yaml:36-38
minimumReleaseAgeExclude:
# Renovate security update: vitest@4.1.11
- vitest@4.1.11注释明确说明这是 Renovate 触发的安全更新,需要立即生效,因此豁免冷却期。
---
二、根级 tsconfig:统一约束所有子包的类型边界
直觉模型
如果每个子包各自维护一份 tsconfig,就会出现「A 包用 strict: false、B 包用 strict: true」的裂缝。根级 tsconfig 是宪法:它规定所有子包共同遵守的类型规则,子包只能在此基础上追加,不能违背。
数据结构与内存布局
根 tsconfig.json 的 compilerOptions 是整个仓库类型系统的地基。挑出几个关键字段:
📎 tsconfig.json:5-29
"target": "es2016",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": true,
"isolatedModules": true,
"isolatedDeclarations": true,
"composite": true,
"paths": {
"@vue/compat": ["./packages/vue-compat/src"],
"@vue/*": ["./packages/*/src"],
"vue": ["./packages/vue/src"]
}逐条解读:
target: es2016:输出语法降级到 ES2016。这与 Rollup 配置中 esbuild 的target相呼应(isServerRenderer || isCJSBuild ? 'es2019' : 'es2016'📎rollup.config.js:337-337)。moduleResolution: bundler:采用打包器风格的模块解析,允许省略扩展名、支持exports字段。strict: true:开启全部严格检查,包括strictNullChecks、noImplicitAny等。noUnusedLocals: true:未使用的局部变量直接报错。这条规则配合 Tree-shaking 有实际意义——未使用的变量往往是死代码的信号。isolatedModules: true:要求每个文件可独立转译。这是 esbuild/swc 这类「逐文件转译、不做跨文件类型分析」工具的前提。isolatedDeclarations: true:要求所有导出必须显式标注类型。这条规则直接服务于.d.ts生成流水线——只有显式标注才能让tsc快速生成声明文件而不做完整类型推断。composite: true:开启项目引用(project references)所需的增量构建元数据。
paths 字段是 workspace 的类型层镜像:@vue/* 映射到 ./packages/*/src,让 TypeScript 在编译期直接解析到源码,而非 node_modules 中的符号链接。这与 pnpm 的运行时符号链接形成互补——运行时靠 pnpm,编译时靠 paths。
场景驱动 Walkthrough:一次 pnpm check 的类型检查
check 脚本是 tsc --incremental --noEmit 📎 package.json:15-15。代入这个场景:
第一步:读取 include 范围。 tsconfig 的 include 决定了哪些文件参与检查:
📎 tsconfig.json:31-39
"include": [
"packages/global.d.ts",
"packages/*/src",
"packages/*/__tests__",
"packages/vue/jsx-runtime",
"packages/runtime-dom/types/jsx.d.ts",
"scripts/*",
"rollup.*.js"
]注意 scripts/* 与 rollup.*.js 也在检查范围内。这意味着构建脚本本身也受类型约束——rollup.config.js 顶部的 // @ts-check 📎 rollup.config.js:1-1 配合 JSDoc 类型注解,让这个纯 JS 文件也能被 tsc 检查。
第二步:应用 exclude 排除。
📎 tsconfig.json:40-40
"exclude": ["packages-private/sfc-playground/src/vue-dev-proxy*"]〔设计推断与架构权衡〕
sfc-playground中的vue-dev-proxy文件被排除。为什么? 这类文件通常是运行时动态生成的代理代码,其类型形状不稳定,纳入检查会产生噪音。
第三步:增量检查。 --incremental 让 tsc 把上次检查结果缓存到 .tsbuildinfo,只重新检查变更的文件。--noEmit 表示只检查不输出——类型检查与产物生成是两条独立的流水线。
设计思考与踩坑
isolatedDeclarations 的代价与收益。 开启这条规则后,任何导出都必须显式标注返回类型,例如 export function foo(): number 而非 export function foo() { return 1 }。这增加了书写成本,但换来的是 .d.ts 生成速度的大幅提升——tsc 无需做跨文件推断即可产出声明文件。这与 build-dts 脚本 tsc -p tsconfig.build.json --noCheck 中的 --noCheck 标志形成呼应:既然类型已显式标注,生成声明文件时甚至可以跳过检查。
types 字段的全局注入。
📎 tsconfig.json:21-21
"types": ["vitest/globals", "puppeteer", "node"]这三个类型包被全局注入,意味着测试文件可以直接使用 describe、it、expect 而无需 import,e2e 测试可以直接使用 puppeteer 的类型。这是便利性与污染性的权衡——全局类型越多,命名冲突风险越大,但测试代码的书写体验越好。
---
三、Rollup 配置:从 buildOptions 到多格式产物的统一工厂
直觉模型
Rollup 配置是 core 仓库的总装车间。它不关心某个包具体做什么,只关心「这个包要产出哪些格式、每种格式的入口文件在哪、哪些依赖要外部化」。每个子包的 package.json 中的 buildOptions 字段是贴在包裹上的发货单,总装车间照着单子干活。
数据结构与内存布局
配置文件的入口处就确立了「按包构建」的模型:
📎 rollup.config.js:32-44
if (!process.env.TARGET) {
throw new Error('TARGET package must be specified via --environment flag.')
}
...
const privatePackages = fs.readdirSync('packages-private')
const pkgBase = privatePackages.includes(process.env.TARGET)
? `packages-private`
: `packages`
const packagesDir = path.resolve(__dirname, pkgBase)
const packageDir = path.resolve(packagesDir, process.env.TARGET)
...
const pkg = require(resolve(`package.json`))
const packageOptions = pkg.buildOptions || {}
const name = packageOptions.filename || path.basename(packageDir)关键设计:TARGET 环境变量指定要构建哪个包。配置通过 fs.readdirSync('packages-private') 判断该包属于公开目录还是私有目录,从而决定 pkgBase。这是一个运行时目录探测——不需要维护一份「哪些包是私有的」清单,目录结构本身就是真相。
buildOptions 是子包 package.json 中的自定义字段,packageOptions.filename 决定产物文件名前缀,packageOptions.formats 决定默认构建格式。
格式到产物的映射由 outputConfigs 定义:
📎 rollup.config.js:58-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' },
}七种格式,覆盖三类消费场景:esm-bundler 给 Vite/webpack 等打包器消费,esm-browser 给浏览器原生 ESM 消费,global 给 <script> 标签消费。带 -runtime 后缀的是「仅运行时」构建,只对主 vue 包开放。
场景驱动 Walkthrough:一次 pnpm build vue 的完整决策流
代入执行 node scripts/build.js vue 的场景。TARGET=vue,追踪 createConfig 内部的决策:
第一步:确定格式列表。
📎 rollup.config.js:91-92
const defaultFormats = ['esm-bundler', 'cjs']
const inlineFormats = process.env.FORMATS && process.env.FORMATS.split(',')
const packageFormats = inlineFormats || packageOptions.formats || defaultFormats
const packageConfigs = process.env.PROD_ONLY
? []
: packageFormats.map(format => createConfig(format, outputConfigs[format]))优先级:命令行 FORMATS > 子包 buildOptions.formats > 默认 ['esm-bundler', 'cjs']。PROD_ONLY 环境变量若为真,则跳过非生产构建,只保留后续追加的 .prod.js 配置。
第二步:计算构建标志位。 createConfig 内部根据格式字符串推导出一组布尔标志:
📎 rollup.config.js:131-142
const isProductionBuild = process.env.__DEV__ === 'false' || /\.prod\.js$/.test(output.file)
const isBundlerESMBuild = /esm-bundler/.test(format)
const isBrowserESMBuild = /esm-browser/.test(format)
const isServerRenderer = name === 'server-renderer'
const isCJSBuild = format === 'cjs'
const isGlobalBuild = /global/.test(format)
const isCompatPackage = pkg.name === '@vue/compat'
const isCompatBuild = !!packageOptions.compat
const isBrowserBuild =
(isGlobalBuild || isBrowserESMBuild || isBundlerESMBuild) &&
!packageOptions.enableNonBrowserBranches这些标志位是后续所有决策的单一真相源:入口文件选择、define 替换、external 判定、插件装配,全部依赖它们。
第三步:选择入口文件。
📎 rollup.config.js:159-168
let entryFile = /runtime$/.test(format) ? `src/runtime.ts` : `src/index.ts`
if (isCompatPackage && (isBrowserESMBuild || isBundlerESMBuild)) {
entryFile = /runtime$/.test(format)
? `src/esm-runtime.ts`
: `src/esm-index.ts`
}默认入口是 src/index.ts,仅运行时构建用 src/runtime.ts。compat 包(@vue/compat,即 Vue 2 兼容构建)需要同时提供 default 和 named 导出,这会让 Rollup 对非 ESM 目标报错,因此为 ESM 构建单独使用 esm-index.ts / esm-runtime.ts 入口。
第四步:生成 define 替换表。 resolveDefine 把源码中的 __DEV__、__BROWSER__ 等编译期常量替换为字面量:
📎 rollup.config.js:170-201
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`,
}这里有一个精妙的分层:feature flags 在 esm-bundler 构建中不硬编码,而是保留为 __VUE_OPTIONS_API__ 这样的标识符,交给最终用户的打包器去替换。这样用户可以通过 define: { __VUE_OPTIONS_API__: false } 关闭 Options API 支持,从而 Tree-shake 掉相关代码。而在 global/esm-browser 构建中,这些 flag 被硬编码为 true/false,因为浏览器直接消费的产物没有打包器介入。
第五步:允许环境变量覆盖。
📎 rollup.config.js:208-216
// allow inline overrides like
//__RUNTIME_COMPILE__=true pnpm build runtime-core
Object.keys(replacements).forEach(key => {
if (key in process.env) {
const value = process.env[key]
assert(typeof value === 'string')
replacements[key] = value
}
})任何 define 键都可以通过同名环境变量覆盖。注释给出的例子是 __RUNTIME_COMPILE__=true pnpm build runtime-core——用于调试特定编译分支。
第六步:装配插件链。
📎 rollup.config.js:324-342
plugins: [
json({ namedExports: false }),
alias({ entries }),
enumPlugin,
...resolveReplace(),
esbuild({
tsconfig: path.resolve(__dirname, 'tsconfig.json'),
sourceMap: output.sourcemap,
minify: false,
target: isServerRenderer || isCJSBuild ? 'es2019' : 'es2016',
define: resolveDefine(),
}),
...resolveNodePlugins(),
...plugins,
],插件顺序有讲究:json 先处理 JSON 导入,alias 把 @vue/* 映射到源码路径,enumPlugin 做枚举内联,replace 做字符串替换,esbuild 做 TS 转译。注意 esbuild 的 tsconfig 指向根 tsconfig——所有子包共用同一份类型配置,这正是第二节讨论的「宪法」在构建期的体现。
第七步:生产构建追加。 若 NODE_ENV=production:
📎 rollup.config.js:97-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))
}
})
}CJS 格式追加一个 .prod.js 版本(用 __DEV__=false 替换),global 与 esm-browser 格式追加一个压缩版本(用 swc 做 minify)。packageOptions.prod === false 的包可以退出这个机制。
整个决策流可以用下面的控制流图概括:
flowchart TD
start["node scripts/build.js vue"] --> check_target{"process.env.TARGET 存在?"}
check_target -->|否| throw_err["throw Error: TARGET must be specified"]
check_target -->|是| detect_dir{"TARGET 在 packages-private 中?"}
detect_dir -->|是| base_priv["pkgBase = packages-private"]
detect_dir -->|否| base_pub["pkgBase = packages"]
base_priv --> read_pkg["require(package.json) 读取 buildOptions"]
base_pub --> read_pkg
read_pkg --> resolve_formats{"FORMATS 环境变量?"}
resolve_formats -->|有| use_inline["使用命令行格式"]
resolve_formats -->|无| check_buildopts{"buildOptions.formats?"}
check_buildopts -->|有| use_pkg["使用包声明格式"]
check_buildopts -->|无| use_default["使用默认 esm-bundler,cjs"]
use_inline --> create_cfg["createConfig(format, output)"]
use_pkg --> create_cfg
use_default --> create_cfg
create_cfg --> check_output{"output 配置存在?"}
check_output -->|否| exit_err["console.log invalid format; process.exit(1)"]
check_output -->|是| pick_entry{"格式含 runtime?"}
pick_entry -->|是| entry_rt["entryFile = src/runtime.ts"]
pick_entry -->|否| entry_idx["entryFile = src/index.ts"]
entry_rt --> build_flags["计算 isBundlerESMBuild/isCJSBuild 等标志"]
entry_idx --> build_flags
build_flags --> prod_check{"NODE_ENV == production?"}
prod_check -->|是| add_prod["追加 .prod.js 与 minified 配置"]
prod_check -->|否| done["导出 packageConfigs"]
add_prod --> done设计思考与踩坑
external 的三分支策略。 resolveExternal 根据构建类型返回不同的外部化列表:
📎 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)把所有依赖内联,只把 treeShakenDeps 列为 external 以抑制警告——这些依赖在浏览器分支中不会被实际引用,会被 Tree-shaking 移除。Node/esm-bundler 构建则把所有 dependencies 和 peerDependencies 外部化,让消费方自己管理依赖版本。
onwarn 过滤循环依赖。
📎 rollup.config.js:344-348
onwarn: (msg, warn) => {
if (msg.code !== 'CIRCULAR_DEPENDENCY') {
warn(msg)
}
},循环依赖警告被静默。Vue 的 runtime-core 与 reactivity 之间存在合法的循环引用(响应式系统需要引用组件实例类型),这些循环在运行时是安全的,因此被过滤。
treeshake.moduleSideEffects: false 的激进假设。
📎 rollup.config.js:355-355
treeshake: {
moduleSideEffects: false,
},这告诉 Rollup:所有模块都没有副作用,可以放心移除未引用的导入。这是一个激进假设——如果某个模块在顶层执行了副作用代码(如注册全局变量),它可能被错误移除。Vue 源码通过约定保证所有模块都是纯的,因此可以开启这个优化。
swc-minify 的 pure_getters 陷阱。
📎 rollup.config.js:373-388
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 }
}pure_getters: true 告诉压缩器「属性访问没有副作用」,可以安全移除未使用的 getter 调用。这对 Vue 的响应式代码是危险的——obj.foo 可能触发 getter 并收集依赖。但这里只用于 global/esm-browser 的生产构建,且 Vue 源码中依赖收集通过显式函数调用(track())而非隐式 getter 副作用完成,因此是安全的。map: null 表示压缩后不生成 sourcemap——生产产物不需要调试映射。
---
设计思考:为什么源码仓库与发布产物必须解耦
回到本章的核心命题。core 仓库的工程化设计有一条贯穿始终的主线:源码仓库的职责是「生产」,发布产物的职责是「消费」,两者通过构建流水线解耦。
具体体现在三个层面:
第一,源码不直接发布。 package.json 的 private: true 📎 package.json:2-2 表明根包永不发布。每个子包的 package.json 中 main/module/exports 字段指向 dist/ 下的产物,而非 src/。用户安装 vue 时拿到的是构建后的 .js 与 .d.ts,源码留在仓库里。
第二,产物格式由消费场景决定。 七种格式不是随意罗列,而是对应七种真实的消费路径:Vite 用户拿 esm-bundler,CDN 用户拿 global,Node SSR 用户拿 cjs。格式的选择逻辑集中在 rollup.config.js 一处,子包只需在 buildOptions.formats 中声明需要哪些。
第三,类型与实现分离。 build-dts 脚本 tsc -p tsconfig.build.json --noCheck && rollup -c rollup.dts.config.js 📎 package.json:9-9 表明 .d.ts 生成是独立流水线。isolatedDeclarations: true 让声明文件生成可以跳过类型检查(--noCheck),因为类型已显式标注。
〔设计推断与架构权衡〕
这种解耦的深层动机是:源码的组织方式服务于开发者,产物的组织方式服务于消费者,两者的最优解不同。源码需要清晰的目录结构、完整的类型信息、可调试的 sourcemap;产物需要最小的体积、正确的模块格式、稳定的 API 表面。强行统一两者(例如直接发布 TS 源码)会同时损害两端的体验。
---
本章小结
本章从三个维度建立了对 core 仓库的宏观认知:
1. 双目录结构:packages/ 与 packages-private/ 的物理隔离,配合 pnpm workspace 的符号链接与 catalog 版本目录,实现了「公开包」与「私有包」的清晰边界。preinstall 门禁、allowBuilds 白名单、minimumReleaseAge 冷却期共同构成供应链安全防线。
2. 根级 tsconfig:作为所有子包的类型宪法,通过 paths 映射实现编译期的 workspace 解析,通过 isolatedDeclarations 与 composite 支撑增量构建与快速声明文件生成。
3. Rollup 统一工厂:以 TARGET 环境变量为入口,通过 buildOptions 读取子包元信息,通过一组布尔标志位驱动入口选择、define 替换、external 判定与插件装配,最终产出七种格式的产物。
核心哲学是源码仓库与发布产物的解耦:仓库负责生产,产物负责消费,构建流水线是两者之间的唯一桥梁。
---
章末过渡
本章回答了「core 仓库是什么」。但仓库的静态结构只是舞台,真正的戏剧发生在一次构建请求的执行过程中:scripts/build.js 如何解析命令行参数、如何调用 Rollup API、如何处理构建失败与并发。下一章将追踪一次构建请求从输入到产物的端到端旅程,把本章建立的静态认知转化为动态的执行视图。
本章思考与自测
Q1: 若把 pnpm-workspace.yaml 中的 minimumReleaseAge: 1440 改为 0,在依赖升级场景下会引入什么风险?为什么 minimumReleaseAgeExclude 的存在是必要的?
参考解析:
minimumReleaseAge: 1440 📎 pnpm-workspace.yaml:33-33 要求新发布的依赖版本必须满 24 小时才允许安装。若改为 0,则任何刚发布的版本都可立即被拉入。
风险场景:攻击者劫持某个传递依赖(例如 @babel/parser 的某个 patch 版本),发布含恶意 postinstall 脚本的版本。在 24 小时冷却期内,社区通常会发现问题并撤下该版本;若冷却期为 0,core 仓库的 CI 可能在攻击窗口内自动升级并执行恶意脚本。
minimumReleaseAgeExclude 📎 pnpm-workspace.yaml:36-38 的存在是因为冷却期机制会与安全补丁的紧迫性冲突。注释中的 vitest@4.1.11 是 Renovate 检测到的安全更新——这类更新需要立即生效,等待 24 小时反而延长了暴露窗口。因此需要一个显式的豁免清单,让安全更新绕过冷却期。这体现了「默认保守、例外显式」的安全设计原则。
Q2: rollup.config.js 中 resolveDefine 对 __FEATURE_OPTIONS_API__ 的处理是 isBundlerESMBuild ? '__VUE_OPTIONS_API__' : 'true'。如果错误地改成对所有格式都返回 'true',会对最终用户产生什么影响?
参考解析:
📎 rollup.config.js:192-194
__FEATURE_OPTIONS_API__: isBundlerESMBuild
? `__VUE_OPTIONS_API__`
: `true`,在 esm-bundler 构建中,__FEATURE_OPTIONS_API__ 被保留为标识符 __VUE_OPTIONS_API__,交给最终用户的打包器替换。用户可以在自己的构建配置中设置 define: { __VUE_OPTIONS_API__: false },从而让 Tree-shaking 移除所有 Options API 相关代码(data、methods、computed 等选项的处理逻辑),显著减小产物体积。
若改成对所有格式都返回 'true',则 esm-bundler 产物中 Options API 代码被硬编码保留,用户的 define 配置失效,无法 Tree-shake。对于一个只用 Composition API 的项目,这会白白增加数 KB 的产物体积。
这个设计的关键洞察是:esm-bundler 产物的最终形态由用户的打包器决定,因此 feature flag 必须延迟到用户构建期才解析。而 global/esm-browser 产物直接运行在浏览器中,没有打包器介入,因此必须硬编码。
Q3: rollup.config.js 的 resolveExternal 中,浏览器构建只返回 treeShakenDeps 作为 external,而 Node 构建返回所有 dependencies。假设某天有人给 runtime-core 添加了一个新的运行时依赖 foo-lib,但忘记更新 resolveExternal 的逻辑。在浏览器构建中会发生什么?
参考解析:
📎 rollup.config.js:257-283
浏览器构建(isGlobalBuild || isBrowserESMBuild)在 !packageOptions.enableNonBrowserBranches 时只返回 treeShakenDeps(source-map-js、@babel/parser、estree-walker、entities/decode)。这意味着 foo-lib 不在 external 列表中,
至此,我们已经从宏观层面看清了 core 仓库作为工程化母体的整体设计哲学:双目录 workspace 结构划定了公开包与私有实验包的边界,根级 TypeScript 与 Rollup 配置提供了统一约束,而源码仓库与发布产物的解耦则让多格式输出成为可能。这些认知为后续深入具体工程链路铺平了道路。下一章,我们将把视线从静态结构转向动态流程,以 node scripts/build.js vue 为起点,追踪一次完整构建请求从命令行参数解析、目标包定位、Rollup 配置生成到产物落盘的端到端旅程,看看 build.js 如何通过 parseArgs 解析 formats/devOnly/release 等标志位,如何动态 require 目标包的 package.json 并读取 buildOptions,最终驱动 rollup.config.js 产出 esm-bundler、cjs、global 等多格式产物。
读完了本章?为你自己的私有项目生成专属架构全景书
基于 Tauri 2 + Rust 本地原生引擎,100% 源码离线隐私安全,零代码上传云端。像阅读一本传世专著一样拆解你的复杂系统。
⚡ Tauri 2 · Rust 原生引擎 · 100% 离线私密安全 · 适配超百万行代码库
第 2 章:构建闭环:一次构建的端到端调用链
第 2 章:构建闭环:一次构建的端到端调用链
上一章我们厘清了 core 仓库作为工程化母体的定位,以及 pnpm workspace 与根级配置如何统一约束所有子包。现在,我们深入构建系统的核心,追踪一条命令如何驱动整个构建流程。node scripts/build.js vue 看似简单,却是所有产物——esm-bundler、cjs、global——的唯一入口。理解它如何将用户意图翻译成可执行的构建任务,是掌握 Vue 构建机制的关键一步。
Rollup 配置生成:从环境变量到多格式产物
build.js 通过 exec 启动 Rollup 后,控制权转移到 rollup.config.js。这个文件是构建系统的「大脑」——它读取环境变量,动态生成 Rollup 配置对象数组。
环境变量校验与包定位
📎 rollup.config.js:27-29
如果 TARGET 未设置,直接抛错。这是防御性编程:Rollup 配置可能被直接调用(如 rollup -c),此时没有 build.js 注入环境变量,必须快速失败。
📎 rollup.config.js:32-44
这里重复了 build.js 中的私有包判断逻辑——因为 rollup.config.js 是独立进程,无法共享 build.js 的内存状态。resolve 函数把相对路径解析为包目录下的绝对路径,pkg 是目标包的 package.json 内容,packageOptions 是其中的 buildOptions 字段,name 是产物文件名前缀(优先用 buildOptions.filename,否则用目录名)。
格式映射表:outputConfigs
📎 rollup.config.js:58-88
这张表定义了 7 种格式到输出配置的映射。关键观察:
esm-bundler、esm-browser、esm-bundler-runtime、esm-browser-runtime都是format: 'es',区别只在文件名。cjs是format: 'cjs'。global和global-runtime是format: 'iife'(立即执行函数表达式),适合<script>标签直接引入。runtime后缀的格式只对主vue包有意义——它们不包含编译器,体积更小。
格式选择:三层优先级
📎 rollup.config.js:91-92
格式选择遵循三层优先级:命令行 FORMATS 环境变量 > 包的 buildOptions.formats > 默认 ['esm-bundler', 'cjs']。PROD_ONLY 环境变量控制是否跳过基础配置——如果只构建生产版本,基础配置数组为空,后续只推入生产配置。
生产配置的追加逻辑
📎 rollup.config.js:97-114
当 NODE_ENV === 'production' 时,对每个格式:
- 如果
packageOptions.prod === false,跳过(该包不需要生产版本)。 - 如果是
cjs,追加createProductionConfig——生成.prod.js文件。 - 如果匹配
/^(global|esm-browser)(-runtime)?/,追加createMinifiedConfig——生成压缩版。
〔设计推断与架构权衡〕
为什么cjs用createProductionConfig而global/esm-browser用createMinifiedConfig?因为 CJS 是给 Node 用的,Node 环境不需要压缩(用户自己会处理),但需要区分 dev/prod 分支;而浏览器直接引入的产物必须压缩以减小体积。这个差异体现在两个工厂函数的实现上。
createConfig:配置生成的核心
createConfig 是最大的函数,它接收格式和输出配置,返回完整的 Rollup 配置对象。
📎 rollup.config.js:125-142
开头是一系列布尔标志位的计算:
isProductionBuild:通过__DEV__环境变量或文件名是否含.prod.js判断。isBundlerESMBuild、isBrowserESMBuild、isCJSBuild、isGlobalBuild:通过格式名正则匹配。isServerRenderer:包名是否为server-renderer。isCompatPackage、isCompatBuild:Vue 2 兼容构建相关。isBrowserBuild:全局构建或浏览器 ESM 构建,且未启用非浏览器分支。
这些标志位在后续的 resolveDefine、resolveReplace、resolveExternal 中被反复使用,是配置差异化的核心依据。
📎 rollup.config.js:144-157
输出配置的基础设置:banner 版权头、exports 模式(compat 包用 auto,其余用 named)、CJS 构建启用 esModule 互操作、sourcemap 由环境变量控制、externalLiveBindings: false 和 reexportProtoFromExternal: false 是 Rollup 4 的兼容性设置。全局构建额外设置 output.name,即挂载到 window 上的变量名。
入口文件选择
📎 rollup.config.js:159-168
默认入口是 src/index.ts,但 runtime 后缀的格式用 src/runtime.ts。compat 包的 ESM 构建需要同时导出 default 和 named,所以用单独的 esm-index.ts / esm-runtime.ts 入口。
宏定义:resolveDefine
📎 rollup.config.js:170-218
resolveDefine 返回一个替换表,把源码中的 __COMMIT__、__VERSION__、__BROWSER__ 等宏替换为字面量。这些宏在源码中用于条件编译——例如 if (__DEV__) { ... } 在生产构建中会被替换为 if (false) { ... },进而被 Tree-shaking 移除。
关键设计:__FEATURE_OPTIONS_API__、__FEATURE_PROD_DEVTOOLS__ 等特性开关在 esm-bundler 构建中保留为 __VUE_OPTIONS_API__ 这样的标识符,让最终用户可以通过打包器配置覆盖;而在其他构建中直接硬编码为 true 或 false。
📎 rollup.config.js:203-206
非 esm-bundler 构建硬编码 __DEV__,因为它们的 dev/prod 分支在构建时就已确定。
📎 rollup.config.js:210-216
最后一步允许环境变量覆盖任何宏定义,支持 __RUNTIME_COMPILE__=true pnpm build runtime-core 这样的内联覆盖。
替换插件:resolveReplace
📎 rollup.config.js:222-255
resolveReplace 在 resolveDefine 之外处理 esbuild 无法处理的替换:
- 合并
enumDefines(来自inlineEnums的枚举内联定义)。 - 生产浏览器构建中,给错误创建函数加
/*@__PURE__*/注解,帮助 Tree-shaking。 esm-bundler构建中,__DEV__替换为!!(process.env.NODE_ENV !== 'production'),让打包器决定。- 浏览器 ESM 构建中,把
process.env替换为空对象,避免浏览器报错。
外部依赖:resolveExternal
📎 rollup.config.js:257-283
这是上一章结尾思考题的核心。浏览器构建只返回 treeShakenDeps 作为 external——这些依赖虽然被 import,但在浏览器分支中不会被实际执行,列在这里只是为了抑制 Rollup 的警告。Node/ESM-bundler 构建则 externalize 所有 dependencies 和 peerDependencies,以及 path、url、stream 等 Node 内置模块。
最终配置对象
📎 rollup.config.js:319-352
返回的配置对象包含:
input:入口文件绝对路径。external:外部依赖列表。plugins:插件数组,顺序为 json → alias → enumPlugin → replace → esbuild → nodePlugins。output:输出配置。onwarn:过滤掉CIRCULAR_DEPENDENCY警告(Vue 源码中存在循环依赖,但运行时无害)。treeshake.moduleSideEffects: false:告诉 Rollup 所有模块都没有副作用,激进 Tree-shaking。
下图展示了从环境变量到最终配置的数据流:
flowchart LR
env["process.envTARGET, FORMATS, NODE_ENV"] --> pkg_load["require(package.json)"]
pkg_load --> pkg_opts["packageOptions= pkg.buildOptions"]
env --> fmt_sel["packageFormats= FORMATS || buildOptions.formats || default"]
fmt_sel --> cfg_map["outputConfigs[format]"]
pkg_opts --> create_cfg["createConfig(format, output)"]
cfg_map --> create_cfg
create_cfg --> define["resolveDefine()__DEV__, __BROWSER__ ..."]
create_cfg --> replace["resolveReplace()enumDefines, __DEV__"]
create_cfg --> external["resolveExternal()treeShakenDeps / deps"]
create_cfg --> node_plugins["resolveNodePlugins()commonJS, nodeResolve"]
define --> rollup_cfg["RollupOptions{ input, external, plugins, output }"]
replace --> rollup_cfg
external --> rollup_cfg
node_plugins --> rollup_cfg
rollup_cfg --> rollup_run["Rollup 执行构建"]
rollup_run --> dist["dist/*.js 产物落盘"]产物落盘与体积检查
exec 的进程管理
build.js 通过 exec 启动 Rollup 子进程:
📎 scripts/utils.js:64-114
exec 封装了 spawn,返回一个 Promise。关键设计:
stdio默认是['ignore', 'pipe', 'pipe']——stdin 忽略,stdout/stderr 管道捕获。shell: process.platform === 'win32'——Windows 上需要 shell 才能正确解析命令。- 通过
stderrChunks和stdoutChunks数组收集输出,在exit事件中拼接。 - 退出码为 0 时 resolve,否则 reject 并附带 stderr 内容。
〔设计推断与架构权衡〕
注意build.js调用exec时传了{ stdio: 'inherit' },这会覆盖默认的管道配置,让 Rollup 的输出直接透传到终端。这是构建工具的正确行为——用户需要实时看到构建进度。
体积检查:checkAllSizes
📎 scripts/build.js:206-215
体积检查有两个跳过条件:devOnly 为真,或指定了格式但不含 global。因为体积检查只针对全局构建产物——那是最终用户直接引入的文件,体积最敏感。
📎 scripts/build.js:222-228
checkSize 检查两个文件:${target}.global.prod.js 和 ${target}.runtime.global.prod.js(后者仅在未指定格式或指定了 global-runtime 时检查)。
📎 scripts/build.js:235-264
checkFileSize 读取文件,用 gzipSync 和 brotliCompressSync 计算压缩后大小,用 prettyBytes 格式化输出。如果 writeSize 为真,把结果写入 temp/size/${fileName}.json——这是 CI 中体积预算检查的数据来源。
类型声明构建
📎 scripts/build.js:94-108
如果 buildTypes 为真,调用 pnpm run build-dts,并通过 --environment TARGETS:... 传递目标列表。这确保只为实际构建的包生成类型声明。
设计思考与生产踩坑
为什么用 --environment 而不是直接传参? Rollup 的 --environment 是唯一能在配置文件中通过 process.env 读取的传参方式。直接传 --config 参数需要解析 process.argv,而 --environment 提供了结构化的键值对解析。
fuzzyMatchTarget 的正则陷阱。 target.match(partialTarget) 中 partialTarget 是用户输入。如果用户输入 runtime-core,- 在正则中是字面量,没问题;但如果输入 runtime.core,. 会匹配任意字符,可能匹配到意外目标。这是模糊匹配的固有风险,但 Vue 的包名不含正则特殊字符,实际不会触发。
并发构建的资源竞争。 runParallel 用 cpus().length 作为并发上限,但每个 Rollup 进程本身也会启动 worker。在 CI 的低核数容器中,这可能导致内存溢出。生产环境中如果遇到 OOM,可以通过 --max-old-space-size 或减少并发数缓解。
scanEnums 的缓存生命周期。 removeCache 在 finally 中调用,但如果 scanEnums 本身抛错,removeCache 不会被赋值,finally 中的调用会失败。实际上 scanEnums 返回的函数在 try 之前就已确定,所以这个风险不存在——但这是阅读时需要确认的时序细节。
resolveExternal 的遗漏风险。 上一章的思考题已经指出:如果给 runtime-core 添加新依赖但忘记更新 resolveExternal,浏览器构建会把该依赖打包进去(因为不在 external 列表中),导致体积膨胀。这是「白名单 external」策略的固有代价。
本章小结
一次 node scripts/build.js vue 的完整旅程:
1. parseArgs 解析命令行,commit 同步获取。
2. run() 调用 scanEnums 生成枚举缓存,解析目标(fuzzyMatchTarget 或 allTargets)。
3. buildAll 通过 runParallel 并发调度 build。
4. build 定位包目录、读取 package.json、过滤私有包、清理 dist、拼装 --environment 参数、调用 exec 启动 Rollup。
5. rollup.config.js 读取环境变量,通过 createConfig 生成配置数组,resolveDefine/resolveReplace/resolveExternal 分别处理宏、替换和外部依赖。
6. Rollup 执行构建,产物落盘到 dist/。
7. checkAllSizes 计算 gzip/brotli 体积,可选写入 temp/size/。
8. 如果 --withTypes,调用 build-dts 生成类型声明。
本章思考与自测
Q1: 在 build.js 的 build 函数中,if (!formats && fs.existsSync(...)) 这个条件决定了是否删除 dist 目录。如果去掉 !formats 这个条件(即无论是否指定格式都删除 dist),在 pnpm build-all-cjs 这样的脚本中会发生什么?
参考解析:
📎 scripts/build.js:172-175
pnpm build-all-cjs 对应 node scripts/build.js vue runtime compiler reactivity shared -af cjs(见 📎 package.json:40)。它指定了 -f cjs,所以 formats 为 'cjs',!formats 为假,当前逻辑不会删除 dist。
如果去掉 !formats,每次构建都会删除 dist。但 build-all-cjs 只构建 cjs 格式,删除后 dist 中只剩 cjs 产物,之前构建的 esm-bundler、global 等格式全部丢失。更严重的是,build-runtime-esm、build-browser-esm 等脚本会依次执行(见 📎 package.json:39 的 build-sfc-playground 脚本),每个脚本都会删除前一个脚本的产物,导致最终 dist 中只有最后一个脚本的格式。这会破坏 SFC Playground 的构建——它需要同时存在多种格式的产物。
Q2: runParallel 中 if (maxConcurrency <= source.length) 这个条件的作用是什么?如果去掉它,在构建单个包(targets.length === 1)时会发生什么?
参考解析:
📎 scripts/build.js:131-151
这个条件控制是否启用并发限流。当 maxConcurrency > source.length 时,不需要限流——所有任务可以同时启动。如果去掉这个条件,即使只有一个任务,也会创建 executing 数组并执行 await Promise.race(executing)。
对于单个任务,executing 中只有一个 Promise e,Promise.race 会等待它完成。这不会导致错误,但会引入不必要的 Promise 链和微任务调度开销。更重要的是,executing.splice(executing.indexOf(e), 1) 在单任务场景下仍然正确工作,所以功能上无差异,只是性能上的微小损失。
真正的风险在于:如果 maxConcurrency 为 0(理论上不可能,因为 cpus().length 至少为 1),executing.length >= 0 永远为真,Promise.race([]) 会永远挂起。但 cpus().length 保证了这个边界不会触发。
Q3: resolveExternal 中,浏览器构建返回 treeShakenDeps 作为 external,但这些依赖在浏览器分支中不会被实际执行。如果把它们从 external 列表中移除(即让 Rollup 尝试打包它们),会发生什么?
参考解析:
📎 rollup.config.js:257-283
treeShakenDeps 包含 source-map-js、@babel/parser、estree-walker、entities/decode。这些是 compiler-sfc 等包的依赖,在浏览器构建中通过 __BROWSER__ 宏被条件编译排除。
如果从 external 中移除,Rollup 会尝试解析并打包这些依赖。由于 treeshake.moduleSideEffects: false(📎 rollup.config.js:355-355),且这些依赖的导入语句位于 if (!__BROWSER__) 分支中,esbuild 的 define 会把 __BROWSER__ 替换为 true,导致分支被标记为死代码。Rollup 的 Tree-shaking 会移除这些导入,最终产物中不会包含这些依赖的代码。
但问题在于:Rollup 在 Tree-shaking 之前需要先解析模块。如果这些依赖没有安装(例如在精简的 CI 环境中),Rollup 会报「无法解析模块」的错误。把它们列为 external 是一种防御措施——即使依赖不存在,Rollup 也不会尝试解析,只是发出警告(而 onwarn 会过滤掉非循环依赖的警告)。
至此,我们完整走过了从命令解析到 Rollup 调用的构建旅程,揭示了并发调度、私有包过滤等核心机制。然而,生产构建只是故事的一半。下一章,我们将转向开发态链路,看 scripts/dev.js 如何与 SFC 预编译协作,实现毫秒级的开发反馈循环。
读完了本章?为你自己的私有项目生成专属架构全景书
基于 Tauri 2 + Rust 本地原生引擎,100% 源码离线隐私安全,零代码上传云端。像阅读一本传世专著一样拆解你的复杂系统。
⚡ Tauri 2 · Rust 原生引擎 · 100% 离线私密安全 · 适配超百万行代码库
第 3 章:动态构建链路:dev 脚本与 SFC 预编译协议
第 3 章:动态构建链路:dev 脚本与 SFC 预编译协议
上一章我们追踪了生产构建从参数解析到多格式产物落盘的完整链路,那条链路追求的是产物的完整与规范。而开发态的核心诉求只有一个:改一行代码,浏览器里立刻能看到效果。生产构建那套「解析参数 → 生成配置 → 全量打包 → 落盘」的链路,动辄数十秒,完全无法满足这个诉求。Vue core 仓库为此维护了一条独立的开发态链路:scripts/dev.js 用 esbuild 的 watch 模式做增量构建,scripts/pre-dev-sfc.js 在主构建前预先编译 SFC 编译器。本章拆解这两者的协作机制。
3.1 dev.js:用 esbuild 换速度的增量构建器
直觉模型
生产构建像「印刷厂正式排版付印」——质量优先,慢一点没关系;开发构建像「草稿纸上的铅笔速写」——不求精美,只求下笔即现。Vue 选择 esbuild 而非 Rollup 来画这张速写,原因写在文件开头的注释里:Rollup 产物更小、Tree-shaking 更好,但 esbuild 快得多。📎 scripts/dev.js:3-5
若没有这个脚本,开发者每次改动都得跑一遍完整生产构建,反馈循环从毫秒级退化到分钟级,热更新体验荡然无存。
参数解析与格式推导
脚本入口用 Node 内置的 parseArgs 解析三个选项:format(默认 global)、prod(默认 false)、inline(默认 false)。📎 scripts/dev.js:18-40 位置参数被收集为 targets,若为空则默认为 ['vue']。📎 scripts/dev.js:42-53
〔设计推断与架构权衡〕
这里有个容易忽略的细节:rawFormat与format是两次赋值。parseArgs的default: 'global'已经保证了rawFormat有值,但脚本仍写了const format = rawFormat || 'global'作为兜底。📎scripts/dev.js:42这是防御性写法,避免parseArgs行为变化或显式传入空字符串时下游format.startsWith抛错。
format 到 esbuild 输出格式的映射是三路分支:以 global 开头映射为 iife,等于 cjs 映射为 cjs,其余一律 esm。📎 scripts/dev.js:42-53 产物文件名后缀则由 -runtime 后缀单独处理:global-runtime 会变成 runtime.global,其余保持原样。📎 scripts/dev.js:42-53
目标包定位与输出路径
脚本先读取 packages-private 目录列表,用于判断目标包属于公开包还是私有包。📎 scripts/dev.js:56 对每个 target,决定包基路径是 packages 还是 packages-private,再 require 其 package.json 拿到 version 与 buildOptions。📎 scripts/dev.js:58-63
输出文件名有个特例:vue-compat 目标会被重命名为 vue,避免产物叫 vue-compat.global.js。📎 scripts/dev.js:64-69 最终路径形如 packages/vue/dist/vue.global.js,prod 为真时插入 prod. 段。
external 解析:避免把依赖打进产物
external 数组决定哪些模块不被打包。逻辑分两层:
第一层,当 inline 未开启且格式为 cjs 或含 esm-bundler 时,把 dependencies、peerDependencies 的键全部加入 external,并硬编码 path、url、stream 三个 Node 内置模块。📎 scripts/dev.js:76-88 注释明确说明这三个是为 @vue/compiler-sfc 和 server-renderer 准备的。
第二层,针对 compiler-sfc 目标,额外解析 @vue/consolidate 的 devDependencies,把它们以及 fs、vm、crypto 等一并 external。📎 scripts/dev.js:90-112 代码里还硬编码了 react-dom/server、teacup/lib/express、arc-templates/dist/es5、then-pug、then-jade 等模板引擎路径——这些是 consolidate 支持的模板引擎,属于可选依赖,不能强制安装。
〔设计推断与架构权衡〕
这段逻辑与rollup.config.js高度重复,源码注释也承认了这点(TODO this logic is largely duplicated from rollup.config.js)。之所以没有抽公共函数,是因为 dev 与 prod 的 external 策略存在细微差异(dev 更激进地 external 化以加速构建),强行统一反而增加耦合。
插件与 define 注入
插件数组默认只有一个 log-rebuild,在 onEnd 钩子里打印构建产物相对路径。📎 scripts/dev.js:115-124 这是开发者感知「改动已生效」的唯一反馈信号。
〔设计推断与架构权衡〕
第二个插件是条件性的:当格式不是cjs且包的buildOptions.enableNonBrowserBranches为真时,挂载polyfillNode()。📎scripts/dev.js:126-128这类包(如compiler-sfc)在浏览器构建中仍会走 Node 分支,需要 Node 内置模块的 polyfill 才能在浏览器环境跑通。
define 块是本章信息密度最高的部分。📎 scripts/dev.js:141-159 它把源码里所有 __XXX__ 宏替换为字面量:
__COMMIT__固定为"dev",__VERSION__取包版本;__DEV__由prod标志决定,__TEST__恒为false;__BROWSER__的推导最微妙:format !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranches。📎scripts/dev.js:146-148也就是说,只有「非 cjs 且包不支持非浏览器分支」才标记为浏览器环境;__SSR__为format !== 'global',即 global 构建不启用 SSR 分支;__COMPAT__由 target 是否为vue-compat决定;- 三个 feature flag(
__FEATURE_SUSPENSE__、__FEATURE_OPTIONS_API__、__FEATURE_PROD_DEVTOOLS__、__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__)在 dev 模式下全部写死。
这些宏与 vitest.config.ts 中的 define 块一一对应。📎 vitest.config.ts:6-21 测试环境把 __TEST__ 设为 true、__DEV__ 设为 true,与 dev 构建的差异正是「测试 vs 开发」两种运行态的区分点。
watch 模式启动
最后一步是 esbuild.context(...).then(ctx => ctx.watch())。📎 scripts/dev.js:130-161 context 创建构建上下文但不立即执行,watch() 才真正启动文件监听。此后 esbuild 内部维护依赖图,任何被依赖文件变化都会触发增量重建,重建完成回调 onEnd 打印日志。
flowchart TD
start["parseArgs 解析 format/prod/inline"] --> targets{"positionals 为空?"}
targets -->|是| def["targets = ['vue']"]
targets -->|否| use["targets = positionals"]
def --> loop["遍历每个 target"]
use --> loop
loop --> priv{"target 在 packages-private?"}
priv -->|是| pbase["pkgBase = packages-private"]
priv -->|否| pub["pkgBase = packages"]
pbase --> req["require package.json"]
pub --> req
req --> ext{"inline 开启?"}
ext -->|是| noext["external = []"]
ext -->|否| fmt{"format 是 cjs 或 esm-bundler?"}
fmt -->|是| deps["加入 dependencies/peerDependencies + path/url/stream"]
fmt -->|否| sfc{"target == compiler-sfc?"}
deps --> sfc
sfc -->|是| cons["加入 consolidate devDeps + fs/vm/crypto"]
sfc -->|否| noext
cons --> ctx["esbuild.context 创建上下文"]
noext --> ctx
ctx --> watch["ctx.watch() 启动监听"]
watch --> onend["onEnd 打印 built: 相对路径"]3.2 pre-dev-sfc.js:破解循环依赖的预编译哨兵
直觉模型
想象一个「鸡生蛋」困局:compiler-sfc 的源码里 import 了 compiler-core,而 compiler-core 在开发态又需要 compiler-sfc 来处理 .vue 文件。如果两者都靠 esbuild watch 实时编译,谁先编译谁就卡死。pre-dev-sfc.js 的角色就是「先孵出蛋,再养鸡」——在主构建启动前,确保这几个包的 CJS 产物已经存在。
检查清单与短路逻辑
脚本维护一个固定清单:compiler-sfc、compiler-core、compiler-dom、compiler-ssr、shared。📎 scripts/pre-dev-sfc.js:4-10 对每个包,检查 packages/${pkg}/dist/${pkg}.cjs.js 是否存在。📎 scripts/pre-dev-sfc.js:4-23
只要有一个缺失,allFilesPresent 置为 false 并立即 break,不再检查剩余包。📎 scripts/pre-dev-sfc.js:20-21 最后若 allFilesPresent 为假,process.exit(1) 以非零码退出。📎 scripts/pre-dev-sfc.js:25-27
退出码的语义
这个脚本本身不执行任何编译,它只做「存在性断言」。exit(1) 是给上层调用者(通常是 npm script 的 && 链或 CI 脚本)看的信号:产物不全,需要先跑一次完整构建。若全部存在则正常退出(退出码 0),主构建继续。
flowchart TD
start["遍历 packagesToCheck 清单"] --> check{"dist/pkg.cjs.js 存在?"}
check -->|是| next{"还有下一个包?"}
next -->|是| check
next -->|否| ok["allFilesPresent 保持 true"]
check -->|否| fail["allFilesPresent = false 并 break"]
ok --> exit0["正常退出 退出码 0"]
fail --> exit1["process.exit(1) 退出码 1"]3.3 aliases.js 与 vitest.config.ts:开发态链路的另一半
scripts/dev.js 解决的是「产物怎么快速生成」,但开发时还有另一条路径:跑测试。scripts/aliases.js 为 vitest 和 rollup 提供共享的路径别名。📎 scripts/aliases.js:7-7
别名生成逻辑
resolveEntryForPkg 把包名映射到 packages/${p}/src/index.ts。📎 scripts/aliases.js:7-7 基础 entries 硬编码了四个特殊映射:vue、vue/compiler-sfc、vue/server-renderer、@vue/compat。📎 scripts/aliases.js:16-21
随后遍历 packages 目录下所有子目录,跳过 vue 本身、跳过 nonSrcPackages(sfc-playground、template-explorer、dts-test)、跳过已存在的 key,且必须是目录,才加入 @vue/${dir} 映射。📎 scripts/aliases.js:23-35
〔设计推断与架构权衡〕
这套「硬编码特殊项 + 动态扫描通用项」的策略,是为了让新增包无需手动改别名文件——只要目录名符合规范,vitest 自动能解析。nonSrcPackages排除列表则是因为这三个包没有src/index.ts入口,强行映射会导致解析失败。
vitest 的 define 与别名消费
vitest.config.ts 直接 import entries 作为 resolve.alias。📎 vitest.config.ts:3📎 vitest.config.ts:22-24 其 define 块与 dev.js 的宏注入形成对照:测试环境 __DEV__: true、__TEST__: true、__BROWSER__: false、__CJS__: true。📎 vitest.config.ts:6-21
测试被拆成五个 project:unit、unit-gc、unit-jsdom、e2e、e2e-browser。📎 vitest.config.ts:51-118 其中 unit-gc 用 pool: 'forks' 并传 --expose-gc,专门跑需要手动触发 GC 的 SSR 测试。📎 vitest.config.ts:65-76 e2e-browser 则启用 playwright 的 chromium 实例,跑 Transition 相关测试。📎 vitest.config.ts:99-117
sequenceDiagram
participant Dev as 开发者
participant NPM as npm script
participant Pre as pre-dev-sfc.js
participant DevJS as dev.js
participant ESB as esbuild context
participant FS as 文件系统
Dev->>NPM: 启动开发
NPM->>Pre: 检查 SFC 产物
Pre->>FS: existsSync(dist/*.cjs.js)
alt 产物缺失
FS-->>Pre: false
Pre-->>NPM: exit(1)
NPM-->>Dev: 提示先跑完整构建
else 产物齐全
FS-->>Pre: true
Pre-->>NPM: exit(0)
NPM->>DevJS: 启动 dev.js
DevJS->>ESB: context(...).watch()
ESB->>FS: 监听源码变化
Dev->>FS: 修改 src/index.ts
FS-->>ESB: 文件变更事件
ESB->>ESB: 增量重建
ESB-->>Dev: onEnd 打印 built: 路径
end设计思考
为什么 dev 用 esbuild 而 prod 用 Rollup? 这不是技术选型的随意,而是两种场景的约束不同。开发态对产物大小不敏感,对反馈延迟极度敏感;生产态反之。esbuild 用 Go 编写、并行化程度高,冷启动和增量构建都快一个数量级,但它的 Tree-shaking 和代码分割能力弱于 Rollup。📎 scripts/dev.js:3-5 用两套工具分别服务两种场景,是工程上的务实取舍。
〔设计推断与架构权衡〕
pre-dev-sfc 为什么只检查不编译? 如果它自己触发编译,就又把循环依赖引回来了——它要编译compiler-sfc,而编译过程本身可能依赖compiler-sfc的产物。所以它只能做「断言」,把「缺产物」这个事实暴露给上层,由上层决定是跑完整构建还是报错退出。 这是一种「哨兵模式」:不解决问题,只报告问题。
external 列表的重复是技术债吗? dev.js 与 rollup.config.js 的 external 逻辑重复,源码注释也承认了。📎 scripts/dev.js:73 但两者的 external 集合并不完全一致——dev 为了速度会更激进地 external 化。强行抽公共函数需要引入参数化的差异开关,反而让两处逻辑都更难读。这是「重复优于错误抽象」的典型权衡。
本章小结
本章拆解了 Vue core 开发态链路的三块拼图:
1. scripts/dev.js:用 esbuild 的 context().watch() 实现增量构建,通过 parseArgs 解析格式与标志位,动态 require 目标包 package.json 定位输出路径,注入 __DEV__、__BROWSER__ 等宏控制条件编译,并用 log-rebuild 插件在每次重建后打印反馈。
2. scripts/pre-dev-sfc.js:在主构建前检查五个核心包的 CJS 产物是否存在,缺失则以退出码 1 短路,避免循环依赖导致的构建死锁。
3. scripts/aliases.js + vitest.config.ts:为测试链路提供共享路径别名,硬编码特殊项加动态扫描通用项,配合多 project 配置覆盖单元、GC、jsdom、e2e、浏览器 e2e 五种测试场景。
本章思考与自测
Q1: 若把 scripts/pre-dev-sfc.js 中的 break 去掉(即检查完所有包再决定退出),在什么场景下会导致开发者体验变差?为什么源码作者选择「发现第一个缺失就短路」?
参考解析:
📎 scripts/pre-dev-sfc.js:4-23
break 位于 if (!fs.existsSync(...)) 分支内,一旦发现某个包产物缺失就立即跳出循环。
若去掉 break,脚本会继续检查剩余包,最终 allFilesPresent 仍为 false,退出码仍是 1,功能上等价。但差异在于:
1. 性能:五个 existsSync 调用本身很快,但若清单扩展到几十个包,短路能省下大量无谓的 stat 系统调用。
2. 语义:短路表达的是「只要有一个缺失,整体就不完整」——这是一个布尔断言,不需要知道具体缺几个。继续检查不产生额外信息。
3. 开发者体验:实际上变差的是「报错信息」。当前脚本不打印哪个包缺失,开发者只看到退出码 1。若去掉 break 并加上日志,反而能告诉开发者「缺 compiler-core 和 shared」——但这需要额外代码。作者选择最简实现,把「缺哪个」的诊断留给上层构建脚本的报错。
所以 break 的核心动机是「断言语义 + 性能」,而非体验优化。
Q2: scripts/dev.js 中 __BROWSER__ 的推导是 format !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranches。假设某个包的 buildOptions.enableNonBrowserBranches 为 true,且开发者用 -f global 构建,此时 __BROWSER__ 为 false。这会导致什么后果?如果误改为 true 会怎样?
参考解析:
📎 scripts/dev.js:146-148
当 format = 'global' 且 enableNonBrowserBranches = true 时:
format !== 'cjs'为true!pkg.buildOptions?.enableNonBrowserBranches为false- 整体
__BROWSER__ = false
这意味着源码中所有 if (__BROWSER__) 分支被 esbuild 的 define 替换为 if (false),浏览器专属代码被 Tree-shaking 移除,非浏览器分支(Node 专属逻辑)被保留。
后果:global 构建产物本应跑在浏览器里,却包含了 Node 专属分支。若这些分支引用了 fs、path 等 Node 内置模块,浏览器加载时会报「模块未定义」。这正是为什么 enableNonBrowserBranches 为真的包(如 compiler-sfc)通常不用于 global 构建,或者需要 polyfillNode() 插件兜底。📎 scripts/dev.js:126-128
若误改为 true:__BROWSER__ = true,浏览器分支被保留,Node 分支被移除。对于 compiler-sfc 这类必须在 Node 环境跑 SFC 编译的包,会导致核心功能(读取文件、调用 Node API)被 Tree-shaking 掉,产物在 Node 里运行时报「函数未定义」。
Q3: scripts/aliases.js 中,动态扫描 packages 目录时跳过了 nonSrcPackages(sfc-playground、template-explorer、dts-test)。如果某个新包被加入 packages 目录但没有 src/index.ts,且未被加入 nonSrcPackages,会发生什么?vitest 运行时会在哪个环节报错?
参考解析:
📎 scripts/aliases.js:23-35
动态扫描逻辑是:对每个目录,若 dir !== 'vue'、不在 nonSrcPackages、key 未存在、且是目录,就加入 entries['@vue/${dir}'] = resolveEntryForPkg(dir)。
resolveEntryForPkg 返回的是 packages/${p}/src/index.ts 的路径。📎 scripts/aliases.js:7-7 注意它不检查文件是否存在,只是拼接路径。
后果:别名会被注册,但指向一个不存在的文件。vitest 在解析 import 时,若某个测试文件 import 了这个包,Vite 的 resolve 插件会尝试加载该路径,报「无法解析模块」或「文件不存在」。
报错环节:不是在 aliases.js 执行时(它只做字符串拼接),而是在 vitest 启动后、首次解析到该 import 时。若没有任何测试 import 这个包,则不会报错——别名只是躺在 entries 对象里。
规避方式:把这类无 src/index.ts 的包加入 nonSrcPackages,或者确保新包有标准入口。这也是为什么 nonSrcPackages 需要手动维护——它是「约定优于配置」的例外清单。
三者协作的边界很清晰:pre-dev-sfc 管「产物是否就绪」,dev.js 管「产物如何快速更新」,aliases 管「测试如何解析源码」。开发态链路解决了速度问题,但构建期还有另一类更隐蔽的优化——那些在代码被浏览器执行之前就完成的变换。下一章将进入编译期魔法,看枚举内联与 Tree-shaking 验证机制如何在构建期把 TypeScript enum 替换为字面量,并确保按需引入的承诺不被破坏。
读完了本章?为你自己的私有项目生成专属架构全景书
基于 Tauri 2 + Rust 本地原生引擎,100% 源码离线隐私安全,零代码上传云端。像阅读一本传世专著一样拆解你的复杂系统。
⚡ Tauri 2 · Rust 原生引擎 · 100% 离线私密安全 · 适配超百万行代码库
第 4 章:魔鬼在细节:Tree-shaking 语义与类型声明生成
第 4 章:魔鬼在细节:Tree-shaking 语义与类型声明生成
上一章我们看到开发态链路如何用文件监听与增量构建换取「改一行立即生效」的速度。但速度之外,Vue 还有一条更隐蔽的约束:发布产物的体积必须可控。这条约束的敌人之一,是 TypeScript 的 enum——它在运行时是一个真实存在的对象,会破坏 Tree-shaking。本章进入编译期,看 scripts/inline-enums.js 如何在代码被浏览器执行之前,把枚举「溶解」成字面量;再看 scripts/verify-treeshaking.js 如何在构建之后,用产物字符串反向验证「按需引入」的承诺没有被悄悄破坏。
4.1 枚举内联:把运行时对象溶解成字面量
直觉模型
想象你写了一份菜谱,里面反复出现「少许盐」。如果每次做菜都要翻到附录去查「少许 = 3 克」,既慢又占地方。枚举内联做的事,就是在印刷前把全书的「少许盐」直接替换成「3 克盐」,然后把附录那一页撕掉。对读者(运行时)而言,结果完全一样,但书更薄了。
若没有它,系统会面临什么灾难?TypeScript 的普通 enum 编译后会生成一个真实的对象字面量,并且带有双向映射(Enum[Enum.A] === 'A')。这个对象是有副作用的模块级声明,Rollup 无法证明它未被使用,于是只能保留——哪怕你只 import 了其中一个成员,整个枚举对象连同反向映射都会被塞进产物。📎 scripts/inline-enums.js:3-9 的注释说得很直白:他们曾用 const enum,但因 issue #1228 改用普通 enum,于是用这个脚本「手动找回 const enum 的零成本收益」。
数据结构与内存布局
脚本的核心是三个类型定义,理解它们就理解了整个数据流。📎 scripts/inline-enums.js:33-36
EnumMember:{ name, value },单个枚举成员的名字与求值后的字面量。EnumDeclaration:{ id, range: [start, end], members }。range是源码字节偏移,指向export enum X { ... }整段声明在文件中的起止位置——这是后续 MagicString 精确替换的锚点。EnumData:{ declarations, defines }。declarations按文件路径索引,记录该文件里所有枚举声明的替换范围;defines是一个扁平映射,键是 `${枚举名}.${成员名}形式的字符串,值是JSON.stringify` 后的字面量。
这里有个关键设计:defines 的键不含文件路径。📎 scripts/inline-enums.js:98-103 注释解释了原因——ErrorCodes 可以同时存在于 @vue/compiler-core 和 @vue/runtime-core,所以允许同名枚举跨文件存在;但同一个 ErrorCodes.__EXTEND_POINT__ 不允许在两个同名枚举里重复,否则 fullKey in defines 命中,直接抛 name conflict。这是一个「按成员名全局唯一」的约束,而非「按枚举名全局唯一」。
缓存落在 temp/enum.json。📎 scripts/inline-enums.js:33-36 为什么需要落盘?因为 scanEnums() 在构建入口只调用一次,而 Rollup 会为每个包、每种格式启动独立的进程。📎 scripts/inline-enums.js:39-41 注释点明:数据要跨并发的 Rollup 进程共享,所以必须序列化到磁盘,由各进程的 inlineEnums() 读回。
Step-by-Step:从 grep 到字面量替换
第一步:grep 出所有含 export enum 的文件。📎 scripts/inline-enums.js:51-61 用 spawnSync('git', ['grep', 'export enum']),输出形如 path:line:content,再按 : 切出第一段(文件路径),用 Set 去重。注意这里用的是 git grep 而非遍历文件系统——它天然只扫被 Git 跟踪的文件,自动排除 node_modules 与构建产物。
第二步:Babel 解析并收集枚举信息。📎 scripts/inline-enums.js:64-70 对每个文件用 @babel/parser 以 typescript 插件、sourceType: 'module' 解析成 AST,然后只遍历 ast.program.body 的顶层节点。📎 scripts/inline-enums.js:74-79 只认 ExportNamedDeclaration 且其 declaration.type === 'TSEnumDeclaration' 的节点——也就是说,非导出的 enum 不会被处理。
对每个枚举声明,脚本逐成员求值。成员求值分三条路径:
1. 字面量初始化:StringLiteral 或 NumericLiteral 直接取 init.value。📎 scripts/inline-enums.js:114-119
2. 二元表达式:如 1 << 2。递归 resolveValue 处理左右操作数,操作数可以是字面量,也可以是 MemberExpression(即引用前面已定义的枚举成员)。📎 scripts/inline-enums.js:121-151 关键在 MemberExpression 分支:它用 content.slice(node.start, node.end) 从原始源码文本里切出表达式字符串(如 ErrorCodes.FOO),再查 defines。若查不到就抛 unhandled enum initialization expression。📎 scripts/inline-enums.js:132-141 这解释了为什么 defines 必须是全局扁平映射——跨枚举引用时,被引用者可能来自另一个文件,但键只认 枚举名.成员名。
3. 一元表达式:如 -1,拼成 -1 字符串后用 evaluate 求值。📎 scripts/inline-enums.js:152-163
求值本身用的是 new Function('return ' + exp)()。📎 scripts/inline-enums.js:39-41 这是一个受控的 eval:输入来自源码里已解析的 AST 片段,不是任意用户输入,所以安全边界可控。
第三步:处理无初始化器的成员(自增语义)。📎 scripts/inline-enums.js:171-183 若成员没有 initializer:第一个成员默认 0;后续成员若 lastInitialized 是数字则 ++;若是字符串则抛 wrong enum initialization sequence——因为字符串枚举成员不允许隐式自增。这正是 TypeScript 枚举的语义。
第四步:写缓存并返回清理函数。📎 scripts/inline-enums.js:200-213 scanEnums() 返回一个闭包,调用即 rmSync 删除缓存文件。build.js 在 try/finally 里使用它。📎 scripts/build.js:81-112 这保证了即使构建中途抛错,缓存也会被清理,不会污染下一次构建。
第五步:Rollup transform 阶段替换。 inlineEnums() 读回缓存,构造一个 Rollup 插件。📎 scripts/inline-enums.js:219-234 在 transform(code, id) 中,若 id 命中 enumData.declarations,就用 MagicString 把 [start, end] 这段声明替换成对象字面量。📎 scripts/inline-enums.js:242-274
替换后的形态是 export const X = { ... }。注意它不是简单地删掉枚举,而是重写成对象字面量,并且对数字成员额外生成反向映射:JSON.stringify(value.toString()) + ': ' + JSON.stringify(name)。📎 scripts/inline-enums.js:257-270 注释引用了 TypeScript 官方文档的 reverse-mappings 规则:字符串枚举成员不生成反向映射,数字成员生成。这保证了替换后运行时行为与原 enum 完全一致。
而真正消除运行时开销的,是 defines 被交给 @rollup/plugin-replace。📎 rollup.config.js:222-223 所有对 X.Member 的引用在替换插件里被直接换成字面量,于是那个重写出来的对象字面量如果没人用,就能被 Tree-shaking 摇掉。
下面这张流程图刻画了从 grep 到替换的完整决策路径:
flowchart TD
grep["spawnSync git grep 'export enum'"] --> files["去重得到文件列表"]
files --> parse["@babel/parser 解析 AST"]
parse --> check{"顶层节点是ExportNamedDeclaration且 declaration 为 TSEnumDeclaration?"}
check -->|否| skip["跳过该节点"]
check -->|是| dup{"enumIds 已含该 id?"}
dup -->|是| err1["throw 不支持声明合并"]
dup -->|否| member["遍历 members 求值"]
member --> init{"有 initializer?"}
init -->|有| eval["字面量/二元/一元求值"]
init -->|无| auto["lastInitialized 自增或默认 0"]
eval --> conflict{"fullKey 已在 defines?"}
auto --> conflict
conflict -->|是| err2["throw name conflict"]
conflict -->|否| save["saveValue 写入 members 与 defines"]
save --> cache["writeFileSync temp/enum.json"]
cache --> transform["Rollup transform: MagicString 重写声明"]
transform --> replace["plugin-replace 用 defines 替换引用"]设计思考与踩坑
为什么用 MagicString 而不是重新生成整个文件? 因为 s.update(start, end, ...) 只替换枚举声明那一段,其余源码字节完全不动,s.generateMap() 还能生成精确的 sourcemap。📎 scripts/inline-enums.js:277-281 若用 Babel 重新打印整个 AST,会丢失原始格式、注释,且 sourcemap 质量下降。
range 为何是 node.start/node.end 而非 declaration.start?📎 scripts/inline-enums.js:189-193 断言的是 node.start(即 ExportNamedDeclaration 节点),替换范围覆盖 export enum X {...} 整段,包括 export 关键字。替换文本以 export const 开头,正好接续。
踩坑点:defines 的全局唯一性约束。 如果两个不同文件里各有一个 ErrorCodes,且都定义了 __EXTEND_POINT__,构建会直接失败。📎 scripts/inline-enums.js:101-103 这不是 bug,而是刻意设计——因为 defines 是全局替换表,无法区分文件来源。生产环境中新增枚举成员时,若名字与已有枚举成员冲突,会在这里炸出来。
踩坑点:new Function 的求值时机。 二元表达式求值发生在 scanEnums 阶段,此时 defines 里可能还没有被引用的成员(若引用顺序颠倒)。📎 scripts/inline-enums.js:136-140 会抛 unhandled enum initialization expression。这要求枚举成员的引用必须遵循「先定义后引用」的源码顺序。
4.2 Tree-shaking 验证:用产物字符串反向证明承诺
直觉模型
枚举内联是「事前优化」,但优化是否真的生效?如果某个 helper 因为写法不当被意外保留,体积会悄悄膨胀,而开发者毫无察觉。verify-treeshaking.js 就是那个「事后质检员」:它构建出产物,然后像验尸一样检查产物里不该出现的东西是否出现。若没有它,Vue 的按需引入承诺可能在某次重构后无声破裂,直到用户抱怨包变大才被发现。
数据结构与检查项
这个脚本没有复杂数据结构,核心是一个 errors 数组和三次 includes 检查。📎 scripts/verify-treeshaking.js:6-6 它先构建 global-runtime 格式,然后分别读取 dev 与 prod 产物。
三个检查项对应三类「Tree-shaking 失败」:
1. dev 产物含 __spreadValues。📎 scripts/verify-treeshaking.js:13-19 这是 esbuild 为 { ...obj } 对象展开语法生成的 helper。若它出现,说明运行时代码里用了对象展开,而 Vue 约定应改用 extend helper 以避免额外代码。
2. prod 产物含 Vue warn。📎 scripts/verify-treeshaking.js:26-31 说明有 warn() 调用没有被 __DEV__ 条件包裹,导致警告代码泄漏进生产包。
3. prod 产物含 DOM tag 配置列表。📎 scripts/verify-treeshaking.js:33-42 如 html,body,base、svg,animate,animateMotion、annotation,annotation-xml,maction。这些是 isHTMLTag() 等 helper 内部的数据,本应只存在于编译器、被运行时摇掉。若出现在运行时产物里,说明运行时路径误用了编译器专属 helper。
Step-by-Step:验证流程
📎 scripts/verify-treeshaking.js:5-5 先 exec('pnpm', ['build', 'vue', '-f', 'global-runtime']),只构建 vue 包的 global-runtime 格式——这是最小化的运行时产物,最适合暴露泄漏。构建完成后同步读取两个文件,逐个 includes 检查,命中就往 errors 里 push 一条带解释的消息。最后若 errors.length 非零,抛出聚合错误。📎 scripts/verify-treeshaking.js:44-48
flowchart TD
build["exec pnpm build vue -f global-runtime"] --> readDev["读取 vue.runtime.global.js"]
readDev --> c1{"dev 含 __spreadValues?"}
c1 -->|是| e1["push: 应改用 extend helper"]
c1 -->|否| readProd["读取 vue.runtime.global.prod.js"]
e1 --> readProd
readProd --> c2{"prod 含 'Vue warn'?"}
c2 -->|是| e2["push: warn 未被 __DEV__ 包裹"]
c2 -->|否| c3{"prod 含 DOM tag 配置?"}
e2 --> c3
c3 -->|是| e3["push: 编译器 helper 泄漏到运行时"]
c3 -->|否| done{"errors 为空?"}
e3 --> done
done -->|是| pass["验证通过"]
done -->|否| fail["throw 聚合错误"]设计思考与踩坑
〔设计推断与架构权衡〕
为什么用字符串 includes 而不是 AST 分析? 因为这是「哨兵检查」而非「精确分析」。它不追求完备性,只针对历史上真实发生过的三类回归设置低成本警报。字符串匹配零依赖、零解析开销,且对压缩后的产物同样有效——AST 分析在 minify 后反而更难做。
〔设计推断与架构权衡〕
为什么只验证global-runtime? 这个格式把所有依赖内联(external为空),是体积最敏感、最容易被误引入的产物。若它干净,其他格式通常也干净。同时它构建快,适合放进 CI 频繁跑。
〔设计推断与架构权衡〕
踩坑点:检查项是「黑名单」,会随代码演进失效。 若某天isHTMLTag的数据结构改了,html,body,base这个字符串不再出现,检查就形同虚设。 这要求维护者在改动相关 helper 时同步更新这里的哨兵字符串。这是黑名单式验证的固有代价。
4.3 与 Rollup 的协作:插件顺序与 define 注入
枚举内联不是孤立运行的,它嵌在 Rollup 的插件流水线里。理解它在流水线中的位置,才能理解为什么 defines 要交给 replace 而非 esbuild。
📎 rollup.config.js:47-50 在配置模块顶层就调用 inlineEnums(),解构出 [enumPlugin, enumDefines]。注意这是在每个 Rollup 进程启动时执行的,读的是 scanEnums 写好的缓存。
插件数组的顺序是:json → alias → enumPlugin → ...resolveReplace() → esbuild。📎 rollup.config.js:324-339 enumPlugin 排在 replace 之前,意味着枚举声明的重写先发生,然后 replace 才用 defines 去替换引用。而 esbuild 排在最后,负责 TS 转译。
为什么 defines 走 replace 而不走 esbuild 的 define?📎 rollup.config.js:220-221 注释给出答案:esbuild 的 define「有点严格,只允许字面量 JSON 或标识符」。而枚举成员名如 ErrorCodes.__EXTEND_POINT__ 是带点的成员表达式,esbuild 的 define 无法直接处理这种键。所以必须用 @rollup/plugin-replace,它支持任意字符串键的替换。📎 rollup.config.js:250-251 且设置了 preventAssignment: true,避免把赋值语句左侧也替换掉。
resolveReplace() 里 const replacements = { ...enumDefines } 是第一步。📎 rollup.config.js:222-223 之后才叠加生产环境的 /*@__PURE__*/ 标注、__DEV__ 等替换。这个顺序保证了枚举字面量替换始终生效。
设计思考
枚举内联的本质是「用构建期复杂度换运行时体积」。 它把 TypeScript 的类型系统语义(枚举求值、自增、反向映射)在构建期完整复现了一遍——scanEnums 里的求值逻辑几乎是 TS 编译器枚举求值的一个子集。📎 scripts/inline-enums.js:110-183 这带来维护成本:TS 若新增枚举语法(如更复杂的常量表达式),这里必须跟进,否则抛 unhandled 错误。但收益是明确的:运行时零枚举对象,Tree-shaking 得以彻底。
〔设计推断与架构权衡〕
验证脚本与内联脚本是一对「承诺与兑现」。 内联脚本承诺「枚举不占运行时体积」,验证脚本检查「其他代码也没偷偷占体积」。两者共同守护 Vue 的体积预算。 这种「优化 + 验证」的成对设计,是大型前端库工程化的典型模式:任何优化都需要一个自动化检查来防止回归。
跨进程缓存是并发构建的必需品。 scanEnums 单次执行、inlineEnums 多次读取的模式,📎 scripts/inline-enums.js:39-41 解决了「一次扫描、N 个进程消费」的问题。若没有缓存,每个 Rollup 进程都要重新 grep + 解析,浪费大量 IO 与 CPU。
本章小结
本章思考与自测
Q1: 若把 scanEnums 中 saveValue 里的 if (fullKey in defines) 冲突检查删掉,在什么场景下会导致构建产物出现错误?
参考解析:
defines 是全局扁平映射,键为 枚举名.成员名,不含文件路径。📎 scripts/inline-enums.js:98-103 删除冲突检查后,若两个不同文件各有一个同名枚举且定义了同名成员(如 @vue/compiler-core 与 @vue/runtime-core 都有 ErrorCodes.__EXTEND_POINT__),后写入者会覆盖先写入者。
后果:defines['ErrorCodes.__EXTEND_POINT__'] 只剩一个值,而 plugin-replace 在替换时无法区分文件来源,会把所有文件里的 ErrorCodes.__EXTEND_POINT__ 都替换成同一个值。📎 rollup.config.js:222-223 于是其中一个包的枚举成员值被静默篡改,运行时行为错误且极难排查——因为源码看起来完全正确。
这正是注释强调「允许同名枚举跨文件,但不允许同名成员」的原因。📎 scripts/inline-enums.js:98-100 冲突检查是防止全局替换表被污染的守门人。
Q2: 若把 rollup.config.js 中插件数组里 enumPlugin 与 ...resolveReplace() 的顺序对调,会发生什么?
参考解析:
当前顺序是 enumPlugin 在前、replace 在后。📎 rollup.config.js:331-332 Rollup 的 transform 钩子按插件数组顺序执行。
若对调,replace 会先运行,此时枚举声明还是原始的 export enum X { ... } 形态。replace 用 defines 去替换 X.Member 引用——但此时引用还在,替换能生效。问题出在 enumPlugin 随后运行时:它用 s.update(start, end, ...) 重写声明段。📎 scripts/inline-enums.js:250-273 但 replace 已经修改过 code,而 enumPlugin 拿到的 code 是 replace 的输出,其字节偏移已与 scanEnums 记录的 range(基于原始源码)不再对应。
后果:MagicString 会在错误的偏移处切割,产物语法错乱。这揭示了插件流水线的一个隐含契约:基于源码偏移的变换必须最先执行,后续变换才能安全地在其输出上继续。
Q3: verify-treeshaking.js 只检查三个字符串哨兵。若某次重构把 isHTMLTag 内部数据从 'html,body,base' 改成数组形式 ['html','body','base'],验证脚本会怎样?这暴露了什么设计缺陷?
参考解析:
验证脚本用 prodBuild.includes('html,body,base') 检查。📎 scripts/verify-treeshaking.js:33-37 若数据改成数组,压缩产物里不再出现逗号连接的字符串,includes 返回 false,检查静默通过——即使 isHTMLTag 真的泄漏进了运行时产物。
这暴露了黑名单式字符串验证的固有缺陷:哨兵字符串与源码实现耦合,实现一变,验证即失效。它无法检测「未知的泄漏」,只能检测「已知的、且字符串形态未变的泄漏」。
〔设计推断与架构权衡〕
改进方向: 可以改为检查更稳定的标识符(如函数名 isHTMLTag),或在源码层面用 lint 规则禁止运行时 import 编译器 helper,而非依赖产物字符串。但在当前成本约束下,字符串哨兵是「够用且廉价」的折中。
枚举内联解决了「构建期如何消除运行时开销」,验证脚本解决了「如何确认优化没被破坏」。但构建产物除了 JS,还有一类同样需要流水线加工的产物——类型声明文件。下一章将进入类型产物流水线,看 Vue 如何从源码 .d.ts 生成发布级类型包,以及 dts-test 如何用类型契约测试守住公开 API 的类型形状。
本章拆解了编译期的两个关键脚本。inline-enums.js 用 git grep 定位枚举、Babel 解析 AST、new Function 求值成员、MagicString 精确重写声明,最终通过 defines 全局替换表把枚举引用变成字面量,让枚举对象可被 Tree-shaking 摇掉。verify-treeshaking.js 则在构建后用字符串哨兵检查产物,确保三类已知的 Tree-shaking 泄漏不会回归。两者一个负责「优化」,一个负责「验证优化没被破坏」,共同守护 Vue 的体积承诺。接下来,我们将从编译期转向类型产物的生成链路,看 Vue 如何保证源码类型与发布类型严格一致。
读完了本章?为你自己的私有项目生成专属架构全景书
基于 Tauri 2 + Rust 本地原生引擎,100% 源码离线隐私安全,零代码上传云端。像阅读一本传世专著一样拆解你的复杂系统。
⚡ Tauri 2 · Rust 原生引擎 · 100% 离线私密安全 · 适配超百万行代码库
第 5 章:类型测试流水线:源码与类型契约的守门人
第 5 章:类型测试流水线:源码与类型契约的守门人
上一章我们拆解了 inline-enums.js 与 verify-treeshaking.js:一个负责把 enum 引用替换成字面量、让枚举对象能被摇掉,另一个负责在构建后用字符串哨兵确认三类已知泄漏没有回归。两者共同守护了 Vue 的运行时体积承诺。但构建产物不止 JS。当用户 import { ref } from 'vue' 时,编辑器弹出的类型提示、tsc 对用户代码的类型检查,全都依赖另一类产物——.d.ts 声明文件。JS 产物错了,运行时报错;类型产物错了,用户侧编译期就报错,或者更糟:类型静默漂移,用户代码能编译通过,但类型形状与真实运行时行为不符。本章追踪 Vue 如何把散落在各子包 src 里的源码类型,聚合成发布级的类型包,并用 dts-built-test 在真实构建产物上做类型冒烟测试。
5.1 两阶段类型流水线:tsc 出料,rollup 聚合
直觉模型
想象一条印刷流水线:第一阶段,每个子包各自把自己的手稿(.ts 源码)排版成单页校样(.d.ts);第二阶段,把几十张校样按目录顺序装订成一本书(发布级 .d.ts),并统一页眉页脚(导出声明)。
若没有这条流水线,Vue 就得手工维护一份发布类型文件,源码一改就得同步手改——这是类型漂移的温床。Vue 的做法是:类型产物完全由源码生成,绝不手写。
第一阶段:tsconfig.build.json 划定出料范围
tsconfig.build.json 是这条流水线的第一阶段配置。它继承根 tsconfig.json,只覆盖构建相关选项。
📎 tsconfig.build.json:3-9
关键选项逐个拆解:
declaration: true:让 tsc 为每个源文件生成对应.d.ts。emitDeclarationOnly: true:只出类型,不出 JS。JS 由 Rollup 负责,tsc 在这里纯粹是类型提取器。stripInternal: true:凡是标注@internal的声明一律从.d.ts中剔除。这是 Vue 控制公开 API 表面的第一道闸门——内部实现细节即使被export,只要打了@internal就不会泄漏到发布类型里。composite: false:关闭项目引用(project references)的增量构建模式。Vue 这里不需要跨包增量,关掉可避免.tsbuildinfo带来的额外状态。
include 列表则精确划定了哪些目录参与出料:
📎 tsconfig.build.json:10-23
注意这里只列了 12 个目录,而不是整个 packages/。packages-private/、packages/dts-test/、packages/sfc-playground/ 等都不在其中。这意味着:私有包和测试包的类型永远不会进入发布产物。这是一个物理隔离——不是靠约定,而是靠配置。
〔设计推断与架构权衡〕
为什么用白名单而非黑名单?因为 monorepo 里新增子包是常态。若用 exclude 黑名单,新增一个私有包时忘了加进 exclude,它的类型就会悄悄混进发布产物。白名单则相反:新增包默认不参与构建,必须显式加入,符合「安全默认值」原则。
执行 tsc -p tsconfig.build.json --noCheck 后,产物落在 temp/packages/<pkg>/src/*.d.ts。注意 --noCheck:跳过类型检查,只做 emit。类型检查由单独的 tsc --noEmit 负责,构建阶段不重复检查,节省时间。
第二阶段:rollup.dts.config.js 聚合
第二阶段由 rollup.dts.config.js 驱动。它的入口先做一次前置校验:
📎 rollup.dts.config.js:15-22
若 temp/packages 不存在,说明第一阶段没跑,脚本直接 process.exit(1) 并提示先跑 tsc。这是流水线的顺序契约:rollup 阶段强依赖 tsc 阶段的产物,缺一不可。
接着读取所有子包目录,并支持 TARGETS 环境变量做子集构建:
📎 rollup.dts.config.js:15-22
TARGETS 机制允许只重建某几个包的类型,在开发调试时能显著缩短反馈环。
核心是 targetPackages.map(...) 为每个包生成一份 Rollup 配置:
📎 rollup.dts.config.js:23-42
逐字段解读:
input: ./temp/packages/${pkg}/src/index.d.ts:入口是第一阶段产出的类型文件,而非源码.ts。output.file: packages/${pkg}/dist/${pkg}.d.ts:产物落到各包自己的dist目录,文件名与包名一致(如vue.d.ts)。format: 'es':类型文件统一用 ES module 格式。plugins: [dts(), patchTypes(pkg), ...(pkg === 'vue' ? [copyMts()] : [])]:三个插件,前两个对所有包生效,copyMts只对vue包生效。
onwarn 钩子值得单独说:
📎 rollup.dts.config.js:23-42
在 dts rollup 过程中,所有非相对路径的 import 默认被外部化(externalized)。这会导致 Rollup 报 UNRESOLVED_IMPORT 警告。但这是预期行为——类型文件里的 import { X } from 'some-pkg' 本来就该保留为外部引用,不该被打包进来。所以脚本对「非相对路径的未解析导入」直接 return 吞掉警告,只对相对路径的未解析导入放行给默认 warn。
〔设计推断与架构权衡〕
这里有个微妙之处:!warning.exporter?.startsWith('.')判断的是 exporter 是否以.开头。相对路径导入若未解析,说明第一阶段产物有缺失,是真问题,必须报警。这个区分让警告噪音降到最低,同时不放过真错误。
流水线全景
flowchart TD
src["packages/*/src/*.ts源码类型"] --> tsc{"tsc -p tsconfig.build.json--noCheck"}
tsc -->|"include 白名单命中"| temp["temp/packages/*/src/*.d.ts单包校样"]
tsc -->|"不在 include 列表"| skip["不产出私有包/测试包被隔离"]
temp --> check{"temp/packages 存在?"}
check -->|"否"| exit["process.exit(1)提示先跑 tsc"]
check -->|"是"| rollup["rollup-plugin-dts聚合为单文件"]
rollup --> patch["patchTypes(pkg)内联导出 + 追加 types/"]
patch --> vue{"pkg === 'vue'?"}
vue -->|"是"| mts["copyMts()写 vue.d.mts"]
vue -->|"否"| done["packages/pkg/dist/pkg.d.ts"]
mts --> done这张图锚定了两阶段的控制流:tsc 的白名单决定谁能进流水线,rollup 的 check 决定能否继续,patchTypes 是必经环节,copyMts 是 vue 包专属分支。
5.2 patchTypes:把聚合产物改写成发布级形状
直觉模型
rollup-plugin-dts 把几十个 .d.ts 合并成一个文件后,产出的形状是「先声明一堆类型,最后用一个巨大的 export { A, B, C, ... } 统一导出」。这对人类阅读不友好,对某些工具链(如 VitePress 的 defineComponent 调用)还会触发「推断类型无法在不引用的情况下命名」的报错。
patchTypes 就是这道后处理整形工序:把「集中导出」改成「就地内联导出」,再追加包专属的类型增强。
数据结构:两个 Set 与三趟遍历
patchTypes 返回一个 Rollup 插件,核心逻辑在 renderChunk 钩子里。它维护两个集合:
📎 rollup.dts.config.js:87-88
isExported:记录所有原本就被导出的类型名(来自export { ... }声明)。shouldRemoveExport:记录所有需要从大导出块中移除的类型名(因为已经被内联导出了)。
处理流程分三趟(pass 0 / pass 1 / pass 2),这是典型的「先收集、再改写、后清理」模式。
Step-by-Step Walkthrough
Pass 0:收集所有已导出类型名。
📎 rollup.dts.config.js:90-100
遍历 AST 顶层节点,凡是 ExportNamedDeclaration 且不带 source(即不是 export ... from '...' 的再导出),就把其 specifier 的 local name 加进 isExported。
Pass 1:为声明节点就地添加 export 前缀。
📎 rollup.dts.config.js:102-125
遍历顶层节点,对 VariableDeclaration、TSTypeAliasDeclaration、TSInterfaceDeclaration、TSDeclareFunction、TSEnumDeclaration、ClassDeclaration 六类声明调用 processDeclaration。
processDeclaration 的逻辑:
📎 rollup.dts.config.js:70-85
三步:
1. 无 id 直接返回(如匿名声明)。
2. 名字以 _ 开头则跳过——这是约定:下划线前缀的类型是内部辅助类型,不导出。
3. 把名字加进 shouldRemoveExport;若该名字在 isExported 中(即原本就被导出),就在声明起始位置 prependLeft 一个 export 字符串。
注意 VariableDeclaration 分支有个额外断言:
📎 rollup.dts.config.js:104-115
若一个 declare const 声明了多个 declarator(如 declare const a, b),直接抛错。因为 processDeclaration 只处理 declarations[0],多 declarator 会导致漏处理。这里选择快速失败而非静默错误,是防御性编程的体现。
Pass 2:从大导出块中移除已内联的类型。
📎 rollup.dts.config.js:127-171
遍历 ExportNamedDeclaration,对每个 specifier:
- 若其 local name 在
shouldRemoveExport中,且exported === local(排除export { Foo as Bar }的重命名情况),则移除该 specifier。 - 移除时用 MagicString 精确删除:若后面还有 specifier,删到下一个 specifier 的 start;若是最后一个,删到前一个的 end 或自身 start。
- 若整个导出块的所有 specifier 都被移除,则删除整个
ExportNamedDeclaration节点。
收尾:追加包专属类型。
📎 rollup.dts.config.js:172-183
code = s.toString() 拿到改写后的代码后,检查 packages/${pkg}/types 目录是否存在。若存在,读取目录下所有文件内容,用换行拼接后追加到代码末尾。
〔设计推断与架构权衡〕
这个 types/ 目录是手工维护的类型增强入口,用于放那些无法从源码自动生成的类型(如 JSX 全局增强、宏类型声明)。它和自动生成的类型在同一个文件里合并,但来源清晰分离——自动生成的在上,手工增强的在下。
为什么必须内联导出?
注释里给出了直接原因:
📎 rollup.dts.config.js:45-51
原文说:把所有类型改成内联导出、并从大导出块中移除,否则在 VitePress 的 defineComponent 调用中会报「the inferred type cannot be named without a reference」。
〔设计推断与架构权衡〕
这个报错的本质是:TypeScript 在生成类型时,若某个类型只能通过「引用另一个模块的导出」来命名,而该引用在消费端不可见,就会报错。集中导出块让类型名和声明位置分离,加剧了这个问题。内联导出让每个类型在声明处就可见,消除了这个间接层。
copyMts:为 Node ESM/CJS 双模提供类型
copyMts 插件只对 vue 包生效:
📎 rollup.dts.config.js:196-204
它在 writeBundle 钩子里,把 vue.d.ts 的内容原样写入 vue.d.mts。
注释解释了原因:
📎 rollup.dts.config.js:188-192
根据 TypeScript 4.7 的 package.json exports 规范,要为 Node ESM 和 CJS 同时正确提供类型,必须有两个独立的声明文件。所以构建时把 vue.d.ts 复制一份为 vue.d.mts。
〔设计推断与架构权衡〕
为什么是复制而非重新生成?因为 ESM 和 CJS 的类型形状完全一致,差异只在文件扩展名和package.json的exports映射。复制是最廉价的方案,避免重复跑一遍 rollup。
5.3 dts-built-test:在真实产物上做类型冒烟测试
直觉模型
前两节保证了类型产物能生成、形状正确。但「能生成」不等于「生成得对」。如果 patchTypes 的某趟遍历有 bug,把某个导出误删了,产物依然能生成,但用户 import 时会发现类型缺失。
dts-built-test 就是在真实构建产物上跑的类型冒烟测试:它不测源码类型,而是 import 已发布的 vue 包,验证关键类型形状没有回归。
数据结构:一个最小化的类型断言
整个测试包的核心只有一个文件:
📎 packages-private/dts-built-test/src/index.ts:3-6
逐行解读:
- L1:从
vue导入defineComponent。注意这里导入的是包名,不是相对路径——它消费的是packages/vue/dist/vue.d.ts这个真实产物。 - L3-6:定义一个组件
_CustomPropsNotErased,带空 props 和空 setup。 - L8:注释
// #8376,指向一个具体 issue。 - L9-12:导出
CustomPropsNotErased,类型是_CustomPropsNotErased与{ foo: string }的交叉类型。
这个测试验证的是:defineComponent 的返回类型在交叉 { foo: string } 后,foo 属性不会被擦除。
〔设计推断与架构权衡〕
issue #8376 的背景推测:defineComponent 的返回类型可能经过某种条件类型或映射类型处理,导致交叉类型中的额外属性被「擦除」。这个测试用最小复现锁定了这个行为,一旦回归就会在类型检查阶段报错。
包配置:workspace 依赖指向真实产物
📎 packages-private/dts-built-test/package.json:1-11
关键字段:
private: true:不发布到 npm。types: dist/index.d.ts:类型入口指向构建产物。dependencies里三个workspace:*依赖:@vue/shared、@vue/reactivity、vue。
〔设计推断与架构权衡〕
为什么依赖@vue/shared和@vue/reactivity?因为vue的类型可能引用这两个包的类型。在 workspace 模式下,pnpm 会把这些依赖符号链接到本地包,而本地包的types字段指向各自dist下的产物。这样整个测试链路消费的都是构建产物,而非源码。
测试如何运行
dts-built-test 本身没有测试脚本,它的 src/index.ts 就是测试用例。运行方式是:在 CI 中执行 tsc 对该包做类型检查。若类型形状回归,tsc 报错,CI 失败。
〔设计推断与架构权衡〕
这个设计的巧妙之处在于:它把「类型契约」编码成了可编译的代码。不需要额外的断言库,不需要运行时,tsc 本身就是测试运行器。类型对了就编译通过,类型错了就编译失败。
与 dts-test 的分工
注意本章的 dts-built-test 和下一章的 dts-test 是两回事:
dts-built-test(本章):消费构建产物,验证发布级类型形状。dts-test(下一章):消费源码类型,验证 API 表面契约。
〔设计推断与架构权衡〕
为什么需要两层?因为源码类型和产物类型可能不一致。patchTypes的 AST 改写、stripInternal的剔除、types/目录的追加,都可能在源码类型正确的前提下引入产物级 bug。dts-built-test专门守住这最后一公里。
类型流水线的完整时序
sequenceDiagram
participant CI as CI 脚本
participant TSC as tsc (tsconfig.build.json)
participant Rollup as rollup.dts.config.js
participant Patch as patchTypes(pkg)
participant Dist as packages/vue/dist
participant BuiltTest as dts-built-test
CI->>TSC: tsc -p tsconfig.build.json --noCheck
TSC->>TSC: include 白名单过滤
TSC-->>Rollup: temp/packages/*/src/*.d.ts
Rollup->>Rollup: existsSync('temp/packages') 校验
Rollup->>Rollup: rollup-plugin-dts 聚合
Rollup->>Patch: renderChunk(code, chunk)
Patch->>Patch: pass0 收集 isExported
Patch->>Patch: pass1 prependLeft('export ')
Patch->>Patch: pass2 移除大导出块 specifier
Patch->>Patch: 追加 packages/vue/types/*
Patch-->>Rollup: 改写后 code
Rollup->>Dist: 写 vue.d.ts
Rollup->>Dist: copyMts 写 vue.d.mts
CI->>BuiltTest: tsc 类型检查
BuiltTest->>Dist: import { defineComponent } from 'vue'
Dist-->>BuiltTest: 类型形状
BuiltTest-->>CI: 编译通过 / 报错这张时序图锚定了跨模块协作:CI 驱动 tsc 和 Rollup 两个阶段,patchTypes 的三趟遍历是核心加工,dts-built-test 在最后消费产物做验证。
设计思考、错误恢复与生产踩坑
为什么用 MagicString 而非字符串替换?
patchTypes 全程用 MagicString 做精确改写,而非 code.replace(...)。原因有二:
1. 位置精确:AST 节点自带 start/end 偏移,MagicString 按偏移操作,不会误伤同名标识符。
2. 保留 sourcemap:MagicString 能生成映射,让改写后的类型文件仍能追溯回源码。虽然类型文件的 sourcemap 用途有限,但保持一致性是良好实践。
快速失败 vs 静默容错
patchTypes 在多处使用 assert:
📎 rollup.dts.config.js:74-74
📎 rollup.dts.config.js:107-108
📎 rollup.dts.config.js:147-148
这些断言在遇到非预期 AST 形状时立即抛错。对比 onwarn 里对 UNRESOLVED_IMPORT 的静默吞掉——预期内的噪音吞掉,预期外的形状快速失败。这是构建脚本的正确姿态:宁可构建失败,也不要产出形状错误的类型文件。
生产踩坑:_ 前缀约定
processDeclaration 跳过 _ 开头的类型:
📎 rollup.dts.config.js:76-78
这意味着源码里任何以 _ 开头的导出类型,都不会被内联导出。若某个类型本应公开,却因命名以 _ 开头而被跳过,用户侧就会遇到「类型不存在」的报错。
〔设计推断与架构权衡〕
排查这类问题的思路:先看产物vue.d.ts里该类型是否还在大导出块中,再看源码里该类型名是否以_开头。这是命名约定与工具行为的隐式耦合,容易踩坑。
生产踩坑:多 declarator 断言
📎 rollup.dts.config.js:106-115
若某个 .d.ts 里出现 declare const a, b,构建直接抛错。这在手写类型里罕见,但若某个工具生成的类型文件用了这种形式,就会触发。错误信息里会打印出问题代码片段,便于定位。
本章小结
本章追踪了 Vue 类型产物的完整流水线:
1. 第一阶段(tsc):tsconfig.build.json 用 include 白名单精确划定出料范围,emitDeclarationOnly 只出类型,stripInternal 剔除内部声明。产物落在 temp/packages/。
2. 第二阶段(rollup):rollup.dts.config.js 用 rollup-plugin-dts 聚合各包类型,patchTypes 通过三趟 AST 遍历把集中导出改写成内联导出,并追加 types/ 目录的手工增强。copyMts 为 vue 包额外生成 .d.mts。
3. 验证阶段(dts-built-test):在真实构建产物上做类型冒烟测试,用可编译的代码锁定关键类型形状,防止类型漂移。
本章思考与自测
Q1: 若把 tsconfig.build.json 的 include 白名单改成 ["packages"](即包含整个 packages 目录),会发生什么?在什么场景下会导致发布类型污染?
参考解析:
include 从 12 个精确目录改成 ["packages"] 后,所有子包(包括 packages-private 之外的所有 packages/*)都会参与 tsc 出料。📎 tsconfig.build.json:10-23
后果链:
1. temp/packages/ 下会多出许多包的 .d.ts。
2. rollup.dts.config.js 的 readdirSync('temp/packages') 会读到这些多出来的包。📎 rollup.dts.config.js:15-22
3. targetPackages 默认等于所有包,于是会为每个包生成 packages/<pkg>/dist/<pkg>.d.ts。📎 rollup.dts.config.js:15-22
污染场景:若某个包本不该发布(如内部工具包),它的类型产物会出现在 dist 下。若该包的 package.json 没有 private: true,发布脚本可能把它一起发到 npm,导致内部类型泄漏。
这正是白名单设计的价值:新增包默认不参与,必须显式加入,符合安全默认值。
Q2: patchTypes 的 pass 1 中,processDeclaration 对 _ 开头的类型直接 return。若某个公开 API 的类型恰好以 _ 开头(如 _InternalType 被意外导出),用户侧会看到什么现象?如何排查?
参考解析:
processDeclaration 遇到 _ 开头直接返回,既不加入 shouldRemoveExport,也不 prepend export 。📎 rollup.dts.config.js:76-78
后果:
1. 该类型不会获得内联 export。
2. 它也不会从大导出块中被移除(因为不在 shouldRemoveExport 中)。
3. 所以它仍在大导出块里,理论上仍可被导入。
但问题在于:大导出块里的 export { _InternalType } 引用的是声明位置。若该声明因某种原因(如 stripInternal)被剔除,导出块就会引用一个不存在的名字,导致 tsc 报错。
排查思路:
1. 看产物 vue.d.ts 里该类型是否既不在声明处有 export,又在大导出块里被引用。
2. 看源码里该类型名是否以 _ 开头。
3. 若确认是命名问题,重命名去掉下划线前缀即可。
这暴露了命名约定与工具行为的隐式耦合:_ 前缀本意是「内部」,但工具把它当成了「不导出」,两者语义不完全一致。
Q3: dts-built-test 的 src/index.ts 用交叉类型 typeof _CustomPropsNotErased & { foo: string } 验证 foo 不被擦除。若把交叉类型改成 Omit<typeof _CustomPropsNotErased, never> & { foo: string },测试还能捕获 #8376 的回归吗?为什么?
参考解析:
Omit<T, never> 会创建一个新的映射类型,它会重新计算 T 的所有属性。若 #8376 的 bug 是「交叉类型中的额外属性被擦除」,那么:
- 原始写法
T & { foo: string }:直接交叉,foo是交叉类型的一部分,若defineComponent的返回类型处理逻辑擦除了交叉中的额外属性,foo会丢失。 Omit写法:Omit先对T做映射,再与{ foo: string }交叉。Omit的映射过程可能改变类型结构,使得 bug 的触发条件不再成立——即使 bug 存在,测试也可能通过。
📎 packages-private/dts-built-test/src/index.ts:9-12
所以测试用例的最小性很关键:它必须精确复现 bug 的触发路径。任何额外的类型变换(如 Omit、Pick)都可能掩盖 bug。这也是为什么测试里用最朴素的交叉类型,而非更「优雅」的写法。
〔设计推断与架构权衡〕
改进方向: 可以同时保留多种写法,覆盖不同的类型变换路径,提高回归捕获率。但会增加维护成本,需权衡。
类型流水线解决了「如何从源码生成发布级类型」,dts-built-test 解决了「如何验证产物类型形状」。但类型契约不止于「形状对不对」,还包括「API 表面是否符合预期」——哪些类型该导出、哪些不该、泛型约束是否精确。下一章将进入 dts-test,看 Vue 如何用类型契约测试守护公开 API 表面。
三者构成「生成 → 整形 → 验证」的闭环,保证源码类型与发布类型严格一致。然而,类型包本身正确,并不等于公开 API 的类型形状被锁定。下一章我们将深入 packages-private/dts-test,看 20 余个 .test-d.ts 文件如何用 expectType 等工具,把「类型即 API 契约」变成可回归的自动化测试。
读完了本章?为你自己的私有项目生成专属架构全景书
基于 Tauri 2 + Rust 本地原生引擎,100% 源码离线隐私安全,零代码上传云端。像阅读一本传世专著一样拆解你的复杂系统。
⚡ Tauri 2 · Rust 原生引擎 · 100% 离线私密安全 · 适配超百万行代码库
第 6 章:模块化与 Monorepo:packages 与 packages-private 的解耦设计
第 6 章:模块化与 Monorepo:packages 与 packages-private 的解耦设计
上一章我们追踪了类型声明的生成链路,看到 Vue 如何通过构建配置与冒烟测试保证「源码类型」与「发布类型」严格一致。但类型契约不止于「形状对不对」,更关键的是「API 表面是否符合预期」——哪些类型该导出、哪些不该、泛型约束是否精确。本章进入 packages-private/dts-test,看 Vue 如何用 20 余个 .test-d.ts 文件把「类型即 API 契约」落地为可回归的自动化测试。
类型契约测试的认知模型:把「说明书」变成「可执行的合同」
dts-test 目录里的文件有一个反直觉的特征:它们几乎不产生任何运行时行为。打开 defineComponent.test-d.tsx,你会看到大量 defineComponent({...}) 调用,但它们从不在测试运行时被真正执行——这些文件只被 tsc/vue-tsc 做类型检查,noEmit: true 保证不产出任何 JS。
📎 packages-private/dts-test/tsconfig.test.json:1-11
这份配置是整个契约体系的「运行环境」:noEmit 关闭产物输出,jsx: preserve 让 TSX 语法保留给类型系统解析,strict 打开全部严格检查,moduleResolution: bundler 匹配现代打包语义,lib 同时引入 esnext 与 dom。若没有这套配置,.test-d.tsx 里的 JSX 会被当作运行时 JSX 处理,类型断言就失去意义。
〔设计推断与架构权衡〕
把类型测试独立成一个packages-private子包而非塞进packages/vue的__tests__,动机有三:其一,类型测试的依赖是vue的发布级类型(vue/jsx、vue的.d.ts),而非源码内部模块,物理隔离能强制走公开入口;其二,tsc检查类型测试的耗时远高于运行时单测,独立目录便于 CI 单独调度;其三,.test-d.tsx文件不会被 Vitest 的运行时收集器误执行。
生活类比:普通单元测试像「把机器通电跑一遍看会不会冒烟」,而类型契约测试像「签合同前逐条核对条款」——不实际交易,只确认「甲方应付款项」写的是「人民币」而不是「美元」。合同条款错了,机器跑得再顺也没用。
utils.d.ts 提供了这套「合同核对」的全部工具:
📎 packages-private/dts-test/utils.d.ts:7-21
关键工具只有四个:expectType<T>(value: T) 断言 value 的类型恰好是 T;expectAssignable<T, T2 extends T> 断言 T2 可赋值给 T;IsUnion<T> 判断 T 是否为联合类型;IsAny<T> 判断 T 是否为 any。注意 L5 的 import 'vue/jsx'——它注册了全局 JSX 命名空间,让 TSX 里的 <MyComponent /> 能被类型系统识别为 JSX.Element。
📎 packages-private/dts-test/utils.d.ts:7-21
IsUnion 的实现值得细看:T extends any ? (U extends T ? false : true) : never 利用分布式条件类型,若 T 是联合类型,每个成员会独立求值,最终 extends false 判断是否所有分支都返回 false。这是类型层面的存在性证明——用来锁定「props.jjj 必须是联合类型而非被合并成单一签名」这类契约。
场景驱动 Walkthrough:defineComponent 的 props 类型推导全链路
defineComponent.test-d.tsx 有 2260 行,是契约体系的核心。我们代入一个具象场景:用户写下 defineComponent({ props: {...}, setup(props) {...} }),Vue 的类型系统需要从 props 运行时声明推导出 setup 里 props 参数的精确类型。这条链路是 Vue 类型系统最复杂的部分。
第一步:构造「期望类型」作为契约基准
测试文件先定义 ExpectedProps 接口,把每种 props 声明方式应该推导出的类型显式写死:
📎 packages-private/dts-test/defineComponent.test-d.tsx:21-53
这个接口是「合同条款」的书面版本。注意几个微妙的类型:a?: number | undefined(可选 props 带 undefined)、aa: number(有 default 所以非可选)、aaa: number | null(PropType<number | null> 显式声明)、aaaa: number | undefined(required: true as const 但类型含 undefined)。这些差异不是随意写的,每一种对应 props 声明里一个特定分支。
第二步:用各种声明方式「喂」给 defineComponent
📎 packages-private/dts-test/defineComponent.test-d.tsx:57-158
这段 props 对象是声明方式的穷举矩阵,覆盖了 Vue props 的所有写法:
a: Number—— 构造函数简写,推导为number | undefinedaa: { type: Number as PropType<number | undefined>, default: 1 }—— 有 default,推导为非可选numberaaaa: { type: Number, required: true as const }——as const防止true被拓宽为boolean,保留字面量类型b: { type: String, required: true as true }——required: true让属性非 voidbb: { default: 'hello' }—— 无type,仅靠 default 推导类型cc: Array as PropType<string[]>—— 显式类型转换l: [Date]—— 数组语法,推导为Date | undefinedll: [Date, Number]—— 多类型数组,推导为Date | number | undefinedlll: [String, Number]—— 同上
〔设计推断与架构权衡〕
required: true as const(L70)与required: true as true(L75)两种写法并存,是历史演进痕迹:早期用as true,后来发现as const更通用(能同时锁定对象里其他字面量),但旧写法保留以验证向后兼容。这是契约测试的典型价值——它同时锁定了「新写法可用」和「旧写法不回归」。
第三步:在 setup / render / this 三个位置断言
这是契约测试最精妙的设计:同一个 props 类型,必须在三个不同的消费位置都推导正确。
📎 packages-private/dts-test/defineComponent.test-d.tsx:160-217
setup(props) 里对每个 prop 做 expectType<ExpectedProps['x']>(props.x)。注意 L168-170 的特殊处理:
📎 packages-private/dts-test/defineComponent.test-d.tsx:168-170
// @ts-expect-error should included 'undefined' 配合 expectType<number>(props.aaaa)——故意写一个会报错的断言,用 @ts-expect-error 吞掉错误。这验证了 props.aaaa 的类型不是 number(否则这行不会报错,@ts-expect-error 反而会因「无错误可吞」而失败)。这是类型测试的「反向断言」技巧。
📎 packages-private/dts-test/defineComponent.test-d.tsx:204-205
// @ts-expect-error props should be readonly 配合 props.a = 1——验证 props 在 setup 里是只读的。若某次重构不小心让 props 变成可变,这行不再报错,@ts-expect-error 就会失败。
render() 里则通过 this.$props 和 this.x 两个路径断言:
📎 packages-private/dts-test/defineComponent.test-d.tsx:221-279
L252-276 验证「声明的 props 也要暴露在 this 上」,L278-279 验证 this.a = 1 报错(this 上的 props 也只读)。L281-287 验证 setup 返回值的解包:this.c 是 number(ref(1) 被解包)、this.d.e.value 是 string(嵌套 ref 保留 .value)、this.f.g 是 GT(reactive 里的 branded 类型不被解包)。
第四步:TSX 消费端的类型校验
类型契约的最后一环是「用户怎么用这个组件」。TSX 里 <MyComponent /> 的 props 校验是独立的类型路径:
📎 packages-private/dts-test/defineComponent.test-d.tsx:296-322
这里验证了 <MyComponent> 接受所有声明的 props,以及 class/style/key/ref/ref_for 这些内置属性。然后是反向校验:
📎 packages-private/dts-test/defineComponent.test-d.tsx:337-345
// @ts-expect-error missing required props 验证缺必填 props 报错;wrong prop types 验证类型不匹配报错;L342 验证 ggg="baz" 报错(ggg 只接受 'foo' | 'bar')。
整条链路可以用一张数据流图概括:
flowchart LR
A["props 声明对象L57-158"] --> B["defineComponent泛型推导"]
B --> C["ExtractPropTypes运行时声明 → 类型"]
C --> D["setup(props)L162-217"]
C --> E["render() this.$propsL221-279"]
C --> F["TSX 消费端L296-345"]
D --> G["expectType 断言契约锁定"]
E --> G
F --> G
G --> H{"全部通过?"}
H -->|是| I["类型契约成立"]
H -->|否| J["tsc 报错CI 阻断合并"]这张图的关键在于:同一个 props 声明,必须同时满足三个消费位置的类型期望。任何一处推导偏差都会让 tsc 报错。
边界与后门:__typeProps、__typeEmits 与条件类型契约
defineComponent 的类型推导有个根本限制:运行时 props 声明无法表达「条件类型」。比如「当 color='white' 时 appearance 必须是 'outline'」这种约束,运行时对象语法写不出来。Vue 为此提供了 __typeProps 等「类型后门」。
__typeProps:条件 props 的类型逃生舱
📎 packages-private/dts-test/defineComponent.test-d.tsx:1803-1836
ConditionalProps 是一个联合类型:要么 color 和 appearance 都可选,要么 color: 'white' 且 appearance: 'outline'。测试验证:
- L1823-1824:
<Comp color="white" />报错——单独给color: 'white'不满足任一分支 - L1825-1826:
<Comp color="white" appearance="normal" />报错——appearance必须是'outline' - L1827:
<Comp color="white" appearance="outline" />通过
〔设计推断与架构权衡〕
__typeProps 的设计动机是「让类型系统表达运行时无法表达的约束」。它不参与运行时 props 解析,纯类型层面的覆盖。代价是用户需要手动维护类型与运行时声明的一致性——这也是为什么它叫「backdoor」而非正式 API。
__typeEmits:两种 emits 语法的等价性
__typeEmits 支持两种语法,测试同时锁定两者:
📎 packages-private/dts-test/defineComponent.test-d.tsx:1838-1885
对象语法 { change: [id: number], update: [value: string] } 用命名元组表达参数。测试验证 this.$props.onChange?.(123) 通过、onChange?.('123') 报错。
📎 packages-private/dts-test/defineComponent.test-d.tsx:1887-1934
调用签名语法 { (e: 'change', id: number): void; (e: 'update', value: string): void } 用重载表达。两种语法的测试体几乎逐行相同——这是刻意的:契约要求两种写法产生完全等价的类型行为。
〔设计推断与架构权衡〕
为什么保留两种语法?对象语法更接近 defineEmits 的写法,调用签名语法更接近传统 TS 事件类型。Vue 需要同时支持,且保证行为一致。测试的「逐行镜像」结构是最强的等价性证明。
__typeRefs 与 __typeEl:跨组件引用与宿主节点类型
📎 packages-private/dts-test/defineComponent.test-d.tsx:1936-1952
__typeRefs 让父组件能精确知道子组件 ref 的类型。Parent 声明 __typeRefs: { child: ComponentInstance<typeof Child> },于是 refs.child.$refs.foo 能推导为 number。
📎 packages-private/dts-test/defineComponent.test-d.tsx:1963-1977
__typeEl 更微妙。L1963-1977 的测试注释点明了设计意图:自定义渲染器(TUI、canvas、native)的宿主节点不是 DOM Element,所以 TypeEl 不能被约束为 Element。测试用 CustomElement 接口验证 $el 能接受任意宿主类型。
〔设计推断与架构权衡〕
这是 Vue 3 支持自定义渲染器的类型层面保障。若TypeEl被硬约束为Element,@vue/runtime-test这类非 DOM 渲染器的用户就无法正确推导$el类型。契约测试在这里守护的是「渲染器无关性」。
泛型组件与运行时 props 的互斥约束
function syntax w/ runtime props 一节锁定了一条重要规则:泛型组件不能与对象运行时 props 共存。
📎 packages-private/dts-test/defineComponent.test-d.tsx:1501-1545
L1501 的注释 generics aren't supported with object runtime props 是契约声明。L1525-1535 验证泛型 setup + 对象 props 报错;L1538-1539 验证 <Comp3<string>> 报错。而数组 props 则允许泛型(L1464-1499)。
〔设计推断与架构权衡〕
这条约束的根因是类型推导顺序:对象 props 需要 ExtractPropTypes 先确定类型,而泛型需要在实例化时才能确定,两者冲突。数组 props 不参与类型提取,所以不冲突。契约测试把这条「类型系统限制」固化为可回归的断言。
设计思考、错误恢复与生产踩坑
@ts-expect-error 的双刃剑
@ts-expect-error 是类型契约测试的核心工具,但它有个致命陷阱:当它下面的代码不再报错时,@ts-expect-error 本身会报错。这看似是保护,实则要求测试作者精确控制「错误发生的位置」。
📎 packages-private/dts-test/defineComponent.test-d.tsx:1354-1362
看这段:// @ts-expect-error missing prop 被放在 <Comp msg={123} /> 的上一行,但整个表达式被包在 expectType<JSX.Element>(...) 里。若 @ts-expect-error 的位置偏移一行,或错误实际发生在 expectType 调用而非 JSX 上,测试就会失败。
〔设计推断与架构权衡〕
生产踩坑点:当 TypeScript 版本升级导致错误位置微调时,大量@ts-expect-error可能集体失效。Vue 的应对策略是把@ts-expect-error紧贴被断言代码,并在 CI 里锁定 TypeScript 版本。任何 TS 升级都需要重新验证全部类型测试。
IsAny 与 IsUnion:类型层面的「存在性证明」
📎 packages-private/dts-test/defineComponent.test-d.tsx:1991-1993
expectType<IsAny<typeof props.foo>>(false) 验证 props.foo 不是 any。这是反向契约:不仅要求类型正确,还要求类型「不能退化为 any」。any 是类型系统的黑洞,任何 any 都会让后续断言失去意义。
📎 packages-private/dts-test/defineComponent.test-d.tsx:195-196
expectType<IsUnion<typeof props.jjj>>(true) 验证 jjj 是联合类型。jjj 声明为 ((arg1: string) => string) | ((arg1: string, arg2: string) => string),若类型系统把它合并成单一签名,IsUnion 会返回 false,测试失败。
〔设计推断与架构权衡〕
这两个工具守护的是「类型的精确性」而非「类型的正确性」。一个退化为 any 或联合被合并的类型,在大多数使用场景下「看起来能用」,但会丢失 IDE 提示和编译期检查。契约测试必须锁定这种精确性。
声明顺序的隐式契约
📎 packages-private/dts-test/defineComponent.test-d.tsx:1784-1801
这段注释极其关键:code generated by tsc / vue-tsc, make sure this continues to work so we don't accidentally change the args order of DefineComponent。DefineComponent 有 13 个泛型参数,顺序是公开契约——vue-tsc 生成的组件类型依赖这个顺序。测试用 declare const MyButton: DefineComponent<...> 显式写出全部 13 个参数,锁定顺序。
〔设计推断与架构权衡〕
这是最容易被忽视的契约:泛型参数顺序不是「实现细节」,而是「生成代码的 ABI」。任何调整顺序的 PR 都会让vue-tsc生成的.d.ts与运行时类型不兼容。契约测试在这里扮演「ABI 兼容性守卫」。
跨文件契约:componentInstance.test-d.tsx 的补充
componentInstance.test-d.tsx 只有 154 行,但覆盖了 ComponentInstance 工具类型的所有输入形态:
📎 packages-private/dts-test/componentInstance.test-d.tsx:10-40
ComponentInstance<typeof CompSetup> 从 defineComponent 结果提取实例类型;ComponentInstance<typeof CompFunctional> 从函数式组件提取;ComponentInstance<typeof CompFunction> 从裸函数提取。三者都必须推导出 ComponentPublicInstance 基类。
📎 packages-private/dts-test/componentInstance.test-d.tsx:71-116
更极端的是「无 defineComponent 包裹的裸对象」:CompObjectSetup、CompObjectData、CompObjectNoProps 三种形态都要能被 ComponentInstance 正确提取。L113-114 尤其反直觉:CompObjectNoProps 没有 props 声明,但 compObjectNoProps.test 仍推导为 string | undefined——这是 ComponentPublicInstance 基类提供的兜底。
📎 packages-private/dts-test/componentInstance.test-d.tsx:143-147
L141 的 #12751 测试锁定了一个边界:__typeEmits 声明的 'update:visible' 事件,在实例上应暴露为 comp['onUpdate:visible'](带冒号的字符串键),且 $props 类型为 { 'onUpdate:visible'?: (value?: boolean) => any }。L152-153 验证 comp['$props']['$props'] 报错——防止类型递归自引用。
本章小结
dts-test 目录用 20 余个 .test-d.ts 文件,把「类型即 API 契约」落地为可回归的自动化测试。核心机制有三层:
1. 工具层:expectType、expectAssignable、IsUnion、IsAny 提供类型断言原语,@ts-expect-error 提供反向断言能力。
2. 契约层:ExpectedProps 接口把「应该推导出什么类型」显式写死,props 声明矩阵穷举所有写法,三个消费位置(setup/render/TSX)交叉验证。
3. 后门层:__typeProps、__typeEmits、__typeRefs、__typeEl 为运行时无法表达的类型约束提供逃生舱,同时锁定两种 emits 语法的等价性。
本章思考与自测
Q1: 若把 defineComponent.test-d.tsx L168-170 的 @ts-expect-error 删掉,只保留 expectType<number>(props.aaaa),会发生什么?为什么这个测试会「静默失效」?
参考解析:
props.aaaa 声明为 { type: Number as PropType<number | undefined>, required: true as const },其推导类型是 number | undefined(因为 PropType<number | undefined> 显式包含了 undefined)。
expectType<number>(props.aaaa) 要求 props.aaaa 恰好是 number。由于实际类型是 number | undefined,这行本身就会报错。@ts-expect-error 的作用是「预期这里会报错,吞掉它」。
若删掉 @ts-expect-error,这行会直接报错,测试失败——看起来是「更严格」了。但问题在于:如果某次重构让 props.aaaa 真的变成 number(bug 修复或行为变更),这行不再报错,而删掉 @ts-expect-error 后测试会通过——此时测试无法区分「类型正确」和「类型错误但恰好不报错」。
保留 @ts-expect-error 的写法是双向锁定:既要求「当前类型是 number | undefined」(通过 @ts-expect-error 吞掉 expectType<number> 的错误),又要求「类型不能是 number」(若变成 number,@ts-expect-error 会因无错误可吞而失败)。这是类型契约测试的核心技巧——用「预期报错」来锁定「类型必须包含某成分」。
📎 packages-private/dts-test/defineComponent.test-d.tsx:168-170
Q2: __typeProps 后门测试(L1803-1836)验证了条件联合类型的约束。若把 ConditionalProps 从联合类型改成 { color?: 'normal' | 'primary' | 'secondary' | 'white'; appearance?: 'normal' | 'outline' | 'text' }(即把所有选项拍平),测试会怎样失败?这说明了 __typeProps 的什么设计约束?
参考解析:
拍平后的类型允许任意 color 与 appearance 组合,包括 color: 'white' + appearance: 'normal'。但测试 L1825-1826 明确要求这个组合报错:
// @ts-expect-error
;若类型被拍平,这行不再报错,@ts-expect-error 因「无错误可吞」而失败。同时 L1823-1824 的 <Comp color="white" /> 也会从「报错」变成「通过」,同样让 @ts-expect-error 失败。
这说明 __typeProps 的设计约束是:它必须保留联合类型的「分支互斥」语义。__typeProps 不是简单的「类型覆盖」,而是「用类型系统表达运行时 props 无法表达的条件约束」。若实现时把 Props 做了 Prettify 或 Omit 之类的映射变换,可能破坏联合分支的判别性,导致约束失效。
〔设计推断与架构权衡〕
这也是为什么__typeProps的测试用例用最朴素的CommonProps & ConditionalProps交叉,而非更「优雅」的映射类型——任何额外的类型变换都可能掩盖 bug。
Q3: DefineComponent 的 13 个泛型参数顺序被 L1784-1801 显式锁定。若某次重构把第 9 个参数(VNodeProps & AllowedComponentProps & ComponentCustomProps)与第 10 个参数(Readonly<ExtractPropTypes<{}>>)交换,哪些下游会受影响?为什么契约测试必须锁定这个顺序?
参考解析:
DefineComponent 的泛型参数顺序是 vue-tsc 生成组件类型时的「ABI」。当用户在 <script setup> 里写 defineProps / defineEmits,vue-tsc 会生成类似 L1999-2116 的 CreateComponentPublicInstance<...> 类型,其中泛型参数的位置决定了每个类型参数的含义。
若交换第 9、10 个参数:
1. vue-tsc 生成的 .d.ts 会按旧顺序填充参数,但 DefineComponent 按新顺序解释——VNodeProps & AllowedComponentProps & ComponentCustomProps 会被当作 props 类型,Readonly<ExtractPropTypes<{}>> 会被当作 VNode 属性。结果是用户组件的 props 类型全部错位。
2. L1786-1800 的 declare const MyButton: DefineComponent<...> 会直接报错——因为 {} 与 VNodeProps & ... 不兼容。
3. L1999-2116 的 ErrorMessage 类型(模拟 vue-tsc 生成结果)也会报错。
契约测试锁定顺序的价值在于:它把「泛型参数顺序」从「实现细节」提升为「公开契约」。任何调整顺序的 PR 都会让 L1786-1800 立即失败,阻止不兼容变更进入发布。
📎 packages-private/dts-test/defineComponent.test-d.tsx:1784-1801
〔设计推断与架构权衡〕
这是类型契约测试最容易被低估的价值:它守护的不是「类型对不对」,而是「类型系统的接口稳定性」。泛型参数顺序、@ts-expect-error的位置、IsAny的返回值,都是「类型 ABI」的组成部分。
类型契约测试解决了「API 表面是否符合预期」。但类型只是 Vue 工程化的一半——另一半是「用户如何在浏览器里实时验证这些 API 的行为」。下一章将进入 SFC Playground,看 Vue 如何把编译器、运行时、类型系统打包进一个浏览器内的实时调试环境,让用户在改代码的瞬间看到编译产物与运行结果。
契约测试守护的不只是「类型对不对」,还包括「类型精不精确」(IsAny/IsUnion)、「泛型参数顺序稳不稳定」(DefineComponent 13 参数)、「渲染器无关性」(__typeEl 不约束为 Element)。这些约束一旦被打破,用户侧的 IDE 提示、vue-tsc 生成的类型都会漂移。而类型契约的稳定性,最终要服务于开发者日常的调试体验——下一章我们将走进 packages-private/sfc-playground,看一个纯前端 Playground 如何在浏览器内完成 SFC 编译与实时预览的闭环。
读完了本章?为你自己的私有项目生成专属架构全景书
基于 Tauri 2 + Rust 本地原生引擎,100% 源码离线隐私安全,零代码上传云端。像阅读一本传世专著一样拆解你的复杂系统。
⚡ Tauri 2 · Rust 原生引擎 · 100% 离线私密安全 · 适配超百万行代码库
第 7 章:核心响应式子系统:@vue/reactivity 的双向绑定与调度
第 7 章:核心响应式子系统:@vue/reactivity 的双向绑定与调度
上一章我们用 20 余个 .test-d.ts 文件把「类型即 API 契约」钉死在 CI 里。但类型契约只回答「API 表面长什么样」,它无法回答「这段 SFC 编译出来到底长什么样」「SSR 模式下渲染结果是否一致」。要回答后两个问题,Vue 团队需要一个能在浏览器里跑完整编译管线的沙箱——这就是 packages-private/sfc-playground。它和 packages/ 下的公开包有本质区别:package.json 里 "private": true 且 "version": "0.0.0" 📎 packages-private/sfc-playground/package.json:2-4,意味着它永不发布到 npm,只是官方调试工具。它的依赖里 vue 指向 workspace:* 📎 packages-private/sfc-playground/package.json:19,也就是本地源码构建产物,而非 npm 上的稳定版——这让 Playground 天然成为「当前 commit 的活体演示」。本章聚焦三个问题:入口如何初始化、Header 如何驱动状态切换、构建期常量如何注入。
一、入口的极简主义:main.ts 与 ReplStore 的初始化契约
直觉模型
main.ts 只有 9 行,像一个「开机自检脚本」:在 Vue 应用挂载之前,先往 window 上塞一个全局配置,告诉 Vue DevTools「默认选中哪个 app」。若没有这一步,DevTools 打开时会面对多个 app 实例(Playground 自身 + 用户 REPL 里运行的代码)而无法自动聚焦,调试体验会退化成手动切换。
数据结构与全局副作用
main.ts 的核心不是 createApp,而是对 window 的污染式写入:
📎 packages-private/sfc-playground/src/main.ts:4-7
// @ts-expect-error Custom window property
window.VUE_DEVTOOLS_CONFIG = {
defaultSelectedAppId: 'repl',
}这里有两个值得注意的工程细节:
〔设计推断与架构权衡〕
1.@ts-expect-error而非@ts-ignore:window的标准类型Window & typeof globalThis上并没有VUE_DEVTOOLS_CONFIG字段。用@ts-expect-error意味着「我知道这里会报错,且我要求它必须报错」——如果未来某个@types/*补上了这个字段,@ts-expect-error会因「未产生错误」而反向报错,从而提醒作者移除该注释。这与上一章类型契约测试的思路一脉相承:用类型系统守护意图,而非掩盖问题。
〔设计推断与架构权衡〕
2.defaultSelectedAppId: 'repl'的字符串约定:这个'repl'必须与@vue/repl内部创建 app 时使用的 id 完全一致。它是一个跨包的字面量契约,没有任何类型约束保护——一旦@vue/repl改了 id,Playground 的 DevTools 默认选中就会静默失效。
Step-by-Step:从 HTML 到挂载
执行流极短,但每一步都有隐含约束:
1. 浏览器加载 index.html,其中包含 <div id="app">(本材料未提供,但 mount('#app') 反推可知)。
2. 模块图解析:main.ts 顶部 import App from './App.vue' 📎 packages-private/sfc-playground/src/main.ts:2 触发 @vitejs/plugin-vue 的 SFC 编译。
〔设计推断与架构权衡〕
3. 关键顺序:window.VUE_DEVTOOLS_CONFIG必须在createApp(App).mount('#app')📎packages-private/sfc-playground/src/main.ts:9之前写入。因为 DevTools 的 hook 是在createApp内部注册的,晚于 mount 写入配置将无法影响首次选中。
4. mount('#app') 触发 App.vue 的 setup,进而创建 ReplStore(在 App.vue 中,本材料未含)。
flowchart TD
load["浏览器加载 index.html"] --> parse["解析 main.ts 模块图"]
parse --> sfc["@vitejs/plugin-vue 编译 App.vue"]
sfc --> setcfg["写入 window.VUE_DEVTOOLS_CONFIG"]
setcfg --> check{"VUE_DEVTOOLS_CONFIG 已设置?"}
check -->|是| mount["createApp(App).mount('#app')"]
check -->|否| devtools["DevTools 无法默认选中 repl"]
mount --> appsetup["App.vue setup 创建 ReplStore"]
appsetup --> ready["Playground 就绪"]
devtools --> mount设计思考与踩坑
main.ts 的极简是刻意的:把复杂度全部下沉到 App.vue 与 ReplStore。入口只承担「全局副作用注入 + 挂载」两件事,任何业务逻辑都不应出现在这里。这是 Playground 作为「调试工具」而非「产品」的取舍——它不需要 SSR 兼容、不需要多入口、不需要懒加载。
〔设计推断与架构权衡〕
生产踩坑点:window.VUE_DEVTOOLS_CONFIG 是全局单例。如果 Playground 被嵌入到另一个也使用 DevTools 的页面(如 iframe 场景),后写入者会覆盖前者。由于 Playground 通常独立部署,这个风险被接受。
---
二、Header.vue:computed 派生状态与 emit 单向数据流
直觉模型
Header.vue 是 Playground 的「控制面板」——版本选择、PROD/DEV 切换、SSR 开关、主题切换、分享、下载。它本身不持有任何业务状态,所有状态都来自 props.store 与布尔 props,所有变更都通过 emit 上报给父组件。若没有这种「哑组件 + 事件冒泡」的约束,Header 会变成状态散落的重灾区,版本切换与 SSR 切换的副作用将无法集中管理。
数据结构与字段剖析
Header 的 props 定义是理解其职责的钥匙:
📎 packages-private/sfc-playground/src/Header.vue:13-19
const props = defineProps()五个 props 分成两类:
store: ReplStore:唯一的状态容器引用,来自@vue/repl。Header 通过它读取store.loading、store.vueVersion、store.typescriptVersion,并直接写入store.vueVersion。- 四个布尔/字面量 props:
prod、ssr、autoSave、theme。它们是受控状态,Header 只读不写,变更必须emit。
对应的 emit 列表 📎 packages-private/sfc-playground/src/Header.vue:20-28:
const emit = defineEmits([
'toggle-theme',
'toggle-ssr',
'toggle-prod',
'toggle-autosave',
'reload-page',
])注意 toggle-theme 虽然由 toggleDark() 内部 emit,但 toggle-ssr/toggle-prod/toggle-autosave 是模板里直接 $emit 的 📎 packages-private/sfc-playground/src/Header.vue:102-118。这种混用是 Vue 3 <script setup> 的常见风格:需要副作用时用函数 emit,纯转发时用模板 $emit。
Step-by-Step:版本显示与切换
代入场景:用户打开 Playground,Header 需要显示当前 Vue 版本。
步骤 1:computed 派生显示文本
📎 packages-private/sfc-playground/src/Header.vue:30-37
const vueVersion = computed(() => {
if (store.loading) {
return 'loading...'
}
return store.vueVersion || `@${__COMMIT__}`
})这里有三层优先级:loading 态 → 'loading...';用户显式选了版本 → store.vueVersion;否则 → @${__COMMIT__}(当前 commit 短哈希)。__COMMIT__ 是构建期注入的常量,下一节详述。
步骤 2:VersionSelect 双向绑定
📎 packages-private/sfc-playground/src/Header.vue:88-88
注意这里没有用 v-model,而是显式拆成 :model-value + @update:model-value。原因在于 vueVersion 是 computed(只读),不能直接双向绑定;必须通过 setVueVersion 这个 setter 函数写入 store.vueVersion:
📎 packages-private/sfc-playground/src/Header.vue:39-41
async function setVueVersion(v: string) {
store.vueVersion = v
}
function resetVueVersion() {
store.vueVersion = null
}〔设计推断与架构权衡〕
setVueVersion声明为async但内部无await——这是历史遗留还是有意为之? 推测是为了与VersionSelect的异步加载语义对齐(切换版本会触发远程加载),保持接口一致。
步骤 3:TypeScript 版本的对比
📎 packages-private/sfc-playground/src/Header.vue:76-80
TypeScript 版本用了 v-model,因为 store.typescriptVersion 是可写的普通属性,不需要 computed 包装。同一个组件在同一个模板里用两种绑定方式,正是「受控 vs 非受控」的直观体现。
主题切换:副作用与 emit 的组合
📎 packages-private/sfc-playground/src/Header.vue:58-66
function toggleDark() {
const cls = document.documentElement.classList
cls.toggle('dark')
localStorage.setItem(
'vue-sfc-playground-prefer-dark',
String(cls.contains('dark')),
)
emit('toggle-theme', cls.contains('dark'))
}这个函数做了三件事:操作 DOM class、持久化到 localStorage、emit 通知父组件。注意它没有直接改 props.theme——因为 props 只读,父组件收到 toggle-theme 后才会更新 theme,进而驱动模板里的 :title 文案 📎 packages-private/sfc-playground/src/Header.vue:123。
〔设计推断与架构权衡〕
这里有一个微妙的设计:DOM class 操作与 Vue 响应式状态是两条独立路径。document.documentElement.classList.toggle('dark')直接改 DOM,而themeprop 通过 Vue 更新。如果两者不同步(例如父组件拒绝更新),UI 会出现「class 已切换但 title 文案未变」的不一致。 实际中父组件总是接受 emit,所以问题不显现。
隐藏逻辑:copyLink 的 metaKey 分支
📎 packages-private/sfc-playground/src/Header.vue:47-56
async function copyLink(e: MouseEvent) {
if (e.metaKey) {
resetVueVersion()
// hidden logic for going to local debug from play.vuejs.org
window.location.href = 'http://localhost:5173/' + window.location.hash
return
}
await navigator.clipboard.writeText(location.href)
alert('Sharable URL has been copied to clipboard.')
}这是一个开发者后门:在 play.vuejs.org 上按住 Cmd 点击分享按钮,会跳转到 localhost:5173(本地 dev server),并把当前 URL hash 带过去。hash 里编码了完整的 REPL 状态(源码、版本、选项),因此本地调试能复现线上问题。注释 // hidden logic for going to local debug from play.vuejs.org 📎 packages-private/sfc-playground/src/Header.vue:47-56 明确标注了这是有意隐藏的功能。
〔设计推断与架构权衡〕
resetVueVersion()在跳转前被调用,把store.vueVersion置为null,确保本地调试用的是当前 commit 而非线上选定的版本。
flowchart TD
click["用户点击 Share 按钮"] --> meta{"e.metaKey 按下?"}
meta -->|是| reset["resetVueVersion() 置 null"]
reset --> jump["跳转 localhost:5173 + hash"]
jump --> local["本地 dev server 复现"]
meta -->|否| copy["navigator.clipboard.writeText(location.href)"]
copy --> check{"写入成功?"}
check -->|是| alert["alert 提示已复制"]
check -->|否| fail["静默失败 (无 catch)"]设计思考与踩坑
〔设计推断与架构权衡〕
踩坑 1:navigator.clipboard的权限与安全上下文。copyLink没有 try/catch 📎packages-private/sfc-playground/src/Header.vue:47-56。在非 HTTPS 或用户拒绝剪贴板权限时,writeText会 reject,导致未捕获的 Promise rejection。Playground 部署在 HTTPS 上,风险被接受,但这是典型的「生产环境陷阱」。
〔设计推断与架构权衡〕
踩坑 2:toggleDark的 localStorage key 硬编码。'vue-sfc-playground-prefer-dark'是字符串字面量,没有常量抽取。如果未来要改 key,需要全局搜索。
踩坑 3:currentCommit 与 vueVersion 的比较。模板里 :class="{ active: vueVersion === \@${currentCommit}\ }" 📎 packages-private/sfc-playground/src/Header.vue:88-88 用字符串拼接比较。如果 __COMMIT__ 注入失败(变成 undefined),这里会变成 '@undefined',永远不匹配。构建期常量注入的可靠性直接决定了 UI 正确性——这正是下一节的主题。
---
三、构建期常量注入:__COMMIT__ 与 copyVuePlugin 的双重职责
直觉模型
vite.config.ts 是 Playground 的「装配车间」:它在构建时执行 git rev-parse 拿到 commit 哈希,通过 define 把它变成全局常量 __COMMIT__;同时通过自定义插件把 packages/vue/dist/ 下的 ESM 浏览器产物复制到 Playground 的产物目录。若没有这一步,Playground 就无法在浏览器里加载「当前 commit 的 Vue 运行时」——它只能依赖 npm 上的稳定版,失去「活体演示」的意义。
数据结构与构建期常量
📎 packages-private/sfc-playground/vite.config.ts:7-9
const commit = spawnSync('git', ['rev-parse', '--short=7', 'HEAD'])
.stdout.toString()
.trim()spawnSync 同步执行 git 命令,--short=7 取 7 位短哈希。同步执行是刻意的:配置文件在模块加载期就需要 commit 的值,异步会打乱 Vite 的配置解析时序。
📎 packages-private/sfc-playground/vite.config.ts:23-26
define: {
__COMMIT__: JSON.stringify(commit),
__VUE_PROD_DEVTOOLS__: JSON.stringify(true),
},define 是 Vite 的文本替换机制:源码里所有 __COMMIT__ 会被替换成 JSON.stringify(commit) 的结果(即带引号的字符串字面量)。JSON.stringify 是必需的——如果直接写 commit,替换后会变成裸标识符 abc1234,被当作变量名而非字符串。
〔设计推断与架构权衡〕
__VUE_PROD_DEVTOOLS__: true 是另一个关键常量:它让 Vue 的生产构建也保留 DevTools 支持。默认情况下生产构建会剥离 DevTools hook 以减小体积,但 Playground 需要调试用户代码,所以强制开启。
Step-by-Step:copyVuePlugin 的产物搬运
📎 packages-private/sfc-playground/vite.config.ts:32-63
function copyVuePlugin(): Plugin {
return {
name: 'copy-vue',
generateBundle() {
const copyFile = (file: string) => {
const filePath = path.resolve(
import.meta.dirname,
'../../packages',
file,
)
const basename = path.basename(file)
if (!fs.existsSync(filePath)) {
throw new Error(
`${basename} not built. ` +
`Run "nr build vue -f esm-browser" first.`,
)
}
this.emitFile({
type: 'asset',
fileName: basename,
source: fs.readFileSync(filePath, 'utf-8'),
})
}
copyFile(`vue/dist/vue.esm-browser.js`)
copyFile(`vue/dist/vue.esm-browser.prod.js`)
copyFile(`vue/dist/vue.runtime.esm-browser.js`)
copyFile(`vue/dist/vue.runtime.esm-browser.prod.js`)
copyFile(`server-renderer/dist/server-renderer.esm-browser.js`)
},
}
}关键点逐一解析:
1. generateBundle 钩子:在 Rollup 生成 bundle 之后、写入磁盘之前执行。此时可以 emitFile 往产物里塞额外文件。
2. import.meta.dirname:Node 20.11+ 提供的 ESM 版 __dirname。路径 ../../packages 从 packages-private/sfc-playground/ 上溯到仓库根,再进入 packages/。
3. 存在性检查 + 明确报错:如果 vue.esm-browser.js 不存在,抛出带修复指令的错误 Run "nr build vue -f esm-browser" first.。这是开发者体验的典范——错误信息直接告诉你怎么修。
4. 五个产物:vue 的完整版/运行时版 × dev/prod,加上 server-renderer。这五个文件正是 Playground 在浏览器里动态 import 的候选集,对应 Header 里的版本切换与 SSR 开关。
〔设计推断与架构权衡〕
为什么是这五个? 完整版(含编译器)用于「运行时编译」场景;运行时版用于「预编译」场景;dev/prod 对应 Header 的 PROD/DEV 切换;server-renderer 对应 SSR 开关。这五个文件构成了 Playground 的「Vue 运行时矩阵」。
版本切换的完整数据流
把 Header 的 setVueVersion 与 copyVuePlugin 的产物连起来看:
flowchart LR
user["用户选择版本"] --> setver["setVueVersion(v)"]
setver --> store["store.vueVersion = v"]
store --> repl["@vue/repl 内部"]
repl --> fetch{"版本来源?"}
fetch -->|"@commit"| local["加载本地 vue.esm-browser.js"]
fetch -->|"3.4.0"| cdn["从 CDN 加载"]
local --> compile["浏览器内编译 SFC"]
cdn --> compile
compile --> preview["实时预览"]注意 @${__COMMIT__} 这个特殊值:它对应 copyVuePlugin 复制的本地产物,而非 CDN。这就是为什么 Playground 必须把 Vue 的浏览器构建产物复制进来——「This Commit」选项需要本地文件。
设计思考与踩坑
〔设计推断与架构权衡〕
踩坑 1:spawnSync的失败处理。如果当前目录不是 git 仓库(例如从 tarball 解压),spawnSync会返回非零退出码,stdout为空,commit变成空字符串。此时__COMMIT__被替换成"",Header 里@${currentCommit}变成'@'。没有显式错误处理。
〔设计推断与架构权衡〕
踩坑 2:optimizeDeps.exclude: ['@vue/repl']📎packages-private/sfc-playground/vite.config.ts:27-29。Vite 默认会预打包依赖以加速冷启动,但@vue/repl被排除。原因是@vue/repl内部使用了动态 import 与 worker,预打包会破坏这些机制。 这是 Vite 生态里常见的「预打包与动态加载冲突」问题。
〔设计推断与架构权衡〕
踩坑 3:script.fs配置 📎packages-private/sfc-playground/vite.config.ts:13-19。@vitejs/plugin-vue的script.fs选项允许 SFC 的<script>块通过fs读取文件。这里传入fs.existsSync与fs.readFileSync,是为了支持 SFC 里的import语句解析(例如import x from './foo'需要检查文件是否存在)。这是 Playground 能在浏览器里模拟完整模块解析的关键——它把 Node 的 fs 能力注入到编译器的解析阶段。
---
设计思考:Playground 的架构取舍
把三个小节串起来看,Playground 的架构遵循一条清晰的原则:把「状态」与「副作用」分离,把「构建期」与「运行期」分离。
main.ts只做全局副作用注入,不碰业务状态。Header.vue是纯展示组件,状态通过 props 流入、通过 emit 流出。vite.config.ts把「当前 commit」这个构建期信息固化为常量,运行期只读。
〔设计推断与架构权衡〕
这种分离带来一个直接好处:Playground 可以被嵌入到任何 Vue 应用里(例如文档站的内嵌示例),只要提供 store 与四个布尔 props 即可。
代价是状态分散:store 在 @vue/repl 里,布尔状态在父组件里,DOM class 在 document.documentElement 上,localStorage 里还有一份。四处状态需要手动同步,任何一处不同步都会导致 UI 不一致。
〔设计推断与架构权衡〕
另一个取舍是放弃 SSR 兼容。main.ts直接访问window,Header.vue的toggleDark直接访问document。Playground 是纯 CSR 应用,不需要考虑服务端渲染。
---
本章小结
本章剖析了 packages-private/sfc-playground 的三个核心文件:
1. main.ts:9 行入口,核心是 window.VUE_DEVTOOLS_CONFIG 的注入顺序——必须在 mount 之前。
2. Header.vue:通过 computed 派生 vueVersion,通过 emit 上报所有状态变更。copyLink 的 metaKey 分支是隐藏的本地调试后门。
3. vite.config.ts:spawnSync 拿 commit 哈希,define 注入 __COMMIT__,copyVuePlugin 把五个 Vue 浏览器产物搬运到 Playground 产物目录。
贯穿三者的主线是构建期常量与运行期状态的边界:__COMMIT__ 是只读的构建期事实,store.vueVersion 是可变的运行期选择,Header 的 vueVersion computed 把两者统一成一个显示字符串。
本章思考与自测
Q1: 如果把 main.ts 中 window.VUE_DEVTOOLS_CONFIG 的赋值移到 createApp(App).mount('#app') 之后,会发生什么?为什么?
参考解析:window.VUE_DEVTOOLS_CONFIG 是 Vue DevTools 在 createApp 内部注册 hook 时读取的配置 📎 packages-private/sfc-playground/src/main.ts:4-9。createApp 会立即注册 __VUE_DEVTOOLS_GLOBAL_HOOK__,此时 DevTools 会读取 defaultSelectedAppId 来决定默认选中哪个 app。如果赋值晚于 mount,DevTools 已经完成了首次 app 选择,配置将不会生效,用户需要手动在 DevTools 里切换到 repl app。更隐蔽的是:由于 @vue/repl 内部也会创建 app,晚赋值可能导致 DevTools 默认选中 Playground 自身而非用户 REPL,调试用户代码时需要手动切换。这体现了「全局副作用注入顺序」在调试工具中的重要性。
Q2: Header.vue 的 toggleDark() 同时操作了 DOM class、localStorage 和 emit,但没有直接修改 props.theme。如果父组件收到 toggle-theme 事件后拒绝更新 theme prop,会出现什么 UI 不一致?如何从源码层面定位?
参考解析:toggleDark() 在 📎 packages-private/sfc-playground/src/Header.vue:58-66 直接调用 document.documentElement.classList.toggle('dark'),这会立即改变 DOM 上的 dark class,触发 CSS 变量切换(见 📎 packages-private/sfc-playground/src/Header.vue:186-186 的 .dark nav 规则)。但模板里的 :title 文案 📎 packages-private/sfc-playground/src/Header.vue:123 依赖 props.theme,如果父组件不更新,title 会停留在旧值。定位方法:在浏览器 DevTools 里检查 <html> 的 class 与按钮的 title 属性是否矛盾。根因是「DOM 副作用」与「Vue 响应式状态」走了两条独立路径,没有单一数据源。
Q3: copyVuePlugin 在 generateBundle 里对每个文件做 fs.existsSync 检查,缺失时抛出带修复指令的错误。如果去掉这个检查,直接 fs.readFileSync,在 CI 环境(未先构建 vue)下会发生什么?错误信息会如何误导开发者?
参考解析:去掉检查后,fs.readFileSync 会抛出 ENOENT: no such file or directory, open '.../packages/vue/dist/vue.esm-browser.js' 📎 packages-private/sfc-playground/vite.config.ts:32-63。这个错误只告诉开发者「文件不存在」,但不会告诉开发者「需要先运行 nr build vue -f esm-browser」。在 CI 环境下,开发者可能误以为是路径配置错误、权限问题或 git 子模块未初始化,浪费大量时间排查。原代码的 throw new Error(\${basename} not built. Run "nr build vue -f esm-browser" first.\) 把「症状」与「修复动作」绑定在一起,是开发者体验设计的关键细节。这也解释了为什么 Playground 的构建脚本必须与 Vue 核心构建脚本有明确的依赖顺序。
---
下一章将进入 packages-private/template-explorer,看 Vue 如何把编译器的中间产物(AST、转换结果、代码生成)可视化,让开发者能逐步观察模板到渲染函数的每一步变换。与 Playground 的「端到端黑盒」不同,Template Explorer 是「白盒探针」。
至此,我们看清了 SFC Playground 如何把编译管线搬进浏览器:入口初始化、Header 状态切换与构建期常量注入共同构成了一个可实时调试的沙箱。但 Playground 的视角始终是「整段 SFC 的编译与运行」,它并不直接回答「编译器对某个模板表达式究竟做了什么变换」。下一章将走进 Template Explorer,看它如何把 @vue/compiler-dom 与 @vue/compiler-ssr 的编译结果逐行摊开,用 SourceMapConsumer 建立源码与产物的映射,从而把编译器的内部行为变成可观察、可反推的探针。
读完了本章?为你自己的私有项目生成专属架构全景书
基于 Tauri 2 + Rust 本地原生引擎,100% 源码离线隐私安全,零代码上传云端。像阅读一本传世专著一样拆解你的复杂系统。
⚡ Tauri 2 · Rust 原生引擎 · 100% 离线私密安全 · 适配超百万行代码库
第 8 章:运行时核心:@vue/runtime-core 的虚拟 DOM 与组件生命周期
第 8 章:运行时核心:@vue/runtime-core 的虚拟 DOM 与组件生命周期
上一章我们看到 SFC Playground 如何把「输入 SFC → 浏览器内编译 → 实时预览」整条链路封装成一个黑盒:开发者看到的是最终渲染结果,却看不到编译器在中间做了什么。当模板里写了一个自定义指令、或者把 hoistStatic 打开后产物突然多出一堆 _hoisted_1 变量时,Playground 无法回答「编译器为什么这么生成」。Template Explorer 的定位恰恰相反:它把 @vue/compiler-dom 与 @vue/compiler-ssr 的编译产物、AST、错误标记、以及源码到产物的位置映射全部摊开。它的核心不是「运行」,而是「观察」。本章围绕三个文件展开:index.ts 负责编译调用与 SourceMap 双向映射,options.ts 用 reactive 管理数十个 CompilerOptions 并驱动 UI,theme.ts 定制 Monaco 编辑器主题。
一、编译调用与 SourceMap 双向映射:index.ts
直觉模型
Template Explorer 的 index.ts 像一台「双向翻译机」:左边输入模板,右边输出渲染函数。但它比翻译机多一个能力——当你把光标放在左边某一行,右边会高亮对应的产物;反过来把光标放在右边,左边会高亮对应的模板。若没有 SourceMap 映射,这个工具就退化成两个并排的文本框,开发者只能靠肉眼比对,无法建立「模板第几行 → 产物第几行」的因果链。
数据结构与内存布局
index.ts 里没有复杂的 Struct,但有几个关键的模块级状态变量,它们决定了整个工具的行为:
lastSuccessfulCode 与 lastSuccessfulMap 是编译结果的缓存 📎 packages-private/template-explorer/src/index.ts:74-75。前者是字符串,后者是 SourceMapConsumer | undefined。注意 lastSuccessfulMap 初始为 undefined,只有在编译成功且 map 存在时才会被赋值 📎 packages-private/template-explorer/src/index.ts:99-100。这个 undefined 状态是后续所有光标映射逻辑的守卫条件——如果编译失败,映射功能自动静默失效,而不是抛出异常。
PersistedState 接口定义了持久化到 localStorage 与 URL hash 的状态形状 📎 packages-private/template-explorer/src/index.ts:26-30:src(模板源码)、ssr(是否 SSR 模式)、options(编译器选项)。这里有一个关键设计:options 的类型是完整的 CompilerOptions,但实际持久化时只保存「与默认值不同的项」,这个裁剪逻辑在 reCompile 里完成。
sharedEditorOptions 是两个编辑器共享的构造选项 📎 packages-private/template-explorer/src/index.ts:26-30:fontSize: 14、scrollBeyondLastLine: false、renderWhitespace: 'selection'、minimap.enabled: false。关闭 minimap 是因为模板和产物通常只有几十行,minimap 反而占用横向空间。
Step-by-Step Walkthrough
场景:用户打开页面,输入 <div>{{ msg }}</div>,然后移动光标。
第一步:初始化与状态恢复。 window.init 是全局入口 📎 packages-private/template-explorer/src/index.ts:41。它首先注册并激活自定义主题 📎 packages-private/template-explorer/src/index.ts:44-45,然后尝试从 URL hash 或 localStorage 恢复状态 📎 packages-private/template-explorer/src/index.ts:49-56。注意这里的解码顺序:先 atob 再 escape,然后 decodeURIComponent。如果 hash 解析失败,会 fallback 到 localStorage.getItem('state'),再 fallback 到 {}。如果整个 JSON.parse 失败,会清空 localStorage 并打印警告 📎 packages-private/template-explorer/src/index.ts:57-64。
恢复状态后,有一个容易被忽略的细节:delete persistedState.options?.nodeTransforms 📎 packages-private/template-explorer/src/index.ts:69。注释解释了原因——函数无法被序列化,所以持久化时 nodeTransforms 会丢失,恢复时如果残留一个空对象会导致编译器行为异常。这是「持久化不可序列化字段」的经典陷阱。
第二步:编译核心 compileCode。 这是整个工具的心脏 📎 packages-private/template-explorer/src/index.ts:76-106。它首先 console.clear(),然后根据 ssrMode.value 选择 ssrCompile 或 compile 📎 packages-private/template-explorer/src/index.ts:80。注意 compileFn 的调用参数:展开 compilerOptions,强制 filename: 'ExampleTemplate.vue'、sourceMap: true,并注入 onError 回调收集错误 📎 packages-private/template-explorer/src/index.ts:82-89。
这里有一个设计决策:filename 被硬编码为 'ExampleTemplate.vue'。这个值在后续的 generatedPositionFor 调用中必须精确匹配 📎 packages-private/template-explorer/src/index.ts:189,否则 SourceMap 查询会返回空结果。这是一个隐式的契约——两处字符串必须一致,但没有任何类型系统保证。
编译完成后,错误被转换为 Monaco 的 marker 格式并设置到编辑器上 📎 packages-private/template-explorer/src/index.ts:91-95。formatError 把 CompilerError 的 loc 转换为 Monaco 的 startLineNumber/startColumn/endLineNumber/endColumn 📎 packages-private/template-explorer/src/index.ts:108-119。注意 errors.filter(e => e.loc)——只有带位置信息的错误才会被标记,没有 loc 的错误(如全局配置错误)只会在控制台输出。
第三步:SourceMap 的建立。 编译成功后,lastSuccessfulMap = new SourceMapConsumer(map!) 📎 packages-private/template-explorer/src/index.ts:99,紧接着调用 computeColumnSpans() 📎 packages-private/template-explorer/src/index.ts:100。computeColumnSpans 是 source-map-js 的一个关键 API:它预计算每个映射段的列跨度,使得 generatedPositionFor 返回的 lastColumn 字段可用。没有这一步,反向映射只能定位到起始列,无法高亮整个 token 范围。
第四步:双向光标映射。 当用户在源码编辑器移动光标时,触发 editor.onDidChangeCursorPosition 📎 packages-private/template-explorer/src/index.ts:184。回调经过 100ms debounce 后,调用 lastSuccessfulMap.generatedPositionFor({ source: 'ExampleTemplate.vue', line, column: column - 1 }) 📎 packages-private/template-explorer/src/index.ts:188-192。注意 column - 1:Monaco 的列号从 1 开始,而 SourceMap 的列号从 0 开始。返回的 pos 如果有 line 和 column,就在输出编辑器上创建一个装饰器高亮对应范围 📎 packages-private/template-explorer/src/index.ts:194-206,并滚动到该位置 📎 packages-private/template-explorer/src/index.ts:207-210。
反向映射在 output.onDidChangeCursorPosition 中 📎 packages-private/template-explorer/src/index.ts:223。它调用 originalPositionFor 📎 packages-private/template-explorer/src/index.ts:227-230,但多了一个守卫:忽略 pos.line === 1 && pos.column === 0 的「mock location」📎 packages-private/template-explorer/src/index.ts:231-237。这个守卫非常关键——编译器生成的某些代码(如 import 语句或 helper 函数)没有对应的模板位置,SourceMap 会返回 { line: 1, column: 0 } 作为占位。如果不忽略,光标放在这些行上会错误地高亮模板第一行。
第五步:状态持久化。 reCompile 不仅触发编译,还负责把当前状态写入 localStorage 和 URL hash 📎 packages-private/template-explorer/src/index.ts:121-146。持久化时有一个裁剪逻辑:遍历 compilerOptions,只保存「非对象且不等于默认值」的项 📎 packages-private/template-explorer/src/index.ts:125-133。这解释了为什么 bindingMetadata 这种对象类型的选项不会被持久化——它太复杂,且默认值已经足够演示。
flowchart TD
init["window.init()"] --> restore{"hash 或 localStorage 有状态?"}
restore -->|是| parse["JSON.parse 成功?"]
restore -->|否| useDefault["使用默认模板"]
parse -->|成功| delNodeTrans["delete nodeTransforms"]
parse -->|失败| clearLS["localStorage.clear() + 警告"]
delNodeTrans --> createEditor["monaco.editor.create(source)"]
clearLS --> createEditor
useDefault --> createEditor
createEditor --> initOpt["initOptions()"]
initOpt --> watch["watchEffect(reCompile)"]
watch --> compileCode["compileCode(source)"]
compileCode --> chooseFn{"ssrMode.value?"}
chooseFn -->|true| ssr["ssrCompile(source, opts)"]
chooseFn -->|false| dom["compile(source, opts)"]
ssr --> hasMap{"map 存在?"}
dom --> hasMap
hasMap -->|是| newSMC["new SourceMapConsumer(map)"]
hasMap -->|否| skipMap["lastSuccessfulMap 保持 undefined"]
newSMC --> computeSpan["computeColumnSpans()"]
computeSpan --> setOutput["output.setValue(code)"]
skipMap --> setOutput
compileCode -->|抛异常| catchErr["lastSuccessfulCode = ERROR 注释"]
catchErr --> setOutput设计思考与生产踩坑
为什么用 source-map-js 而不是 source-map? source-map 是 Mozilla 的原版库,体积大且依赖 WASM(新版本)。source-map-js 是纯 JS 实现,体积小,适合浏览器环境。Template Explorer 作为纯前端工具,选择 source-map-js 是合理的 📎 packages-private/template-explorer/package.json:15。
debounce 的延迟选择。 源码编辑器的 debounce 默认 300ms 📎 packages-private/template-explorer/src/index.ts:271,而光标移动的 debounce 是 100ms 📎 packages-private/template-explorer/src/index.ts:215。这个差异是有意的:编译是重操作,300ms 避免频繁触发;光标移动是轻操作,100ms 保证响应感。但 100ms 仍然可能导致快速移动光标时的高亮闪烁——这是可接受的权衡。
window.init 的全局挂载。 注意 window.init 和 window.monaco 都挂在全局 📎 packages-private/template-explorer/src/index.ts:19-23。这是因为 Monaco 编辑器通过 CDN 的 loader.js 异步加载,加载完成后调用 window.init。这种「全局回调」模式是 Monaco 在非模块化环境下的标准用法,但与现代 ESM 构建方式格格不入。
---
二、reactive 驱动的选项面板:options.ts
直觉模型
options.ts 像一个「控制台面板」:上面有十几个开关和单选按钮,每个都对应编译器的一个行为。拨动任何一个开关,右边的编译产物立刻变化。若没有这个模块,开发者只能改源码里的 compile 调用参数再重新编译,无法实时对比不同选项的效果。
数据结构与内存布局
options.ts 的核心是三个导出:
ssrMode 是一个 ref(false) 📎 packages-private/template-explorer/src/options.ts:5。它独立于 compilerOptions,因为 SSR 模式切换的是编译函数本身(compile vs ssrCompile),而不是编译选项。
defaultOptions 是一个完整的 CompilerOptions 对象 📎 packages-private/template-explorer/src/options.ts:5-27。它定义了所有选项的默认值,包括 mode: 'module'、prefixIdentifiers: false、hoistStatic: false、cacheHandlers: false、scopeId: null、inline: false、ssrCssVars: '{ color }'、compatConfig: { MODE: 3 }、whitespace: 'condense',以及一个包含 7 个绑定类型的 bindingMetadata 📎 packages-private/template-explorer/src/options.ts:18-26。
compilerOptions 是 reactive(Object.assign({}, defaultOptions)) 📎 packages-private/template-explorer/src/options.ts:29-31。注意这里用了 Object.assign({}, ...) 做浅拷贝——如果直接 reactive(defaultOptions),修改 compilerOptions 会污染 defaultOptions,导致 reCompile 里的「与默认值比较」逻辑失效。
Step-by-Step Walkthrough
场景:用户点击「hoistStatic」复选框。
第一步:UI 渲染。 App 组件的 setup 返回一个渲染函数 📎 packages-private/template-explorer/src/options.ts:33-35。这个渲染函数读取 ssrMode.value、compilerOptions.mode、compilerOptions.prefixIdentifiers 等响应式状态 📎 packages-private/template-explorer/src/options.ts:36-39,因此当这些状态变化时,整个 UI 会重新渲染。
第二步:复选框的 checked 绑定。 hoistStatic 复选框的 checked 属性是 compilerOptions.hoistStatic && !isSSR 📎 packages-private/template-explorer/src/options.ts:150。这里有一个逻辑:SSR 模式下 hoistStatic 被强制显示为未选中,因为 SSR 编译不支持静态提升。同时 disabled: isSSR 📎 packages-private/template-explorer/src/options.ts:151 确保用户无法在 SSR 模式下切换它。
第三步:onChange 处理。 当用户点击复选框时,onChange 触发 📎 packages-private/template-explorer/src/options.ts:152-156,直接把 e.target.checked 赋给 compilerOptions.hoistStatic。由于 compilerOptions 是 reactive 的,这个赋值会触发依赖追踪,进而触发 watchEffect(reCompile) 📎 packages-private/template-explorer/src/index.ts:266,最终重新编译。
第四步:选项间的联动。 注意 cacheHandlers 的 checked 是 usePrefix && compilerOptions.cacheHandlers && !isSSR 📎 packages-private/template-explorer/src/options.ts:166,disabled 是 !usePrefix || isSSR 📎 packages-private/template-explorer/src/options.ts:167。这意味着 cacheHandlers 依赖 prefixIdentifiers 或 mode === 'module'。这种联动关系在 UI 上表现为:当 prefixIdentifiers 未开启且模式为 function 时,cacheHandlers 复选框是禁用的。
scopeId 的联动更复杂:disabled: !isModule 📎 packages-private/template-explorer/src/options.ts:182,checked: isModule && compilerOptions.scopeId 📎 packages-private/template-explorer/src/options.ts:183。只有 module 模式下才能设置 scopeId,且 onChange 时如果 isModule 为 false,会强制设为 null 📎 packages-private/template-explorer/src/options.ts:184-189。
第五步:挂载。 initOptions 调用 createApp(App).mount(document.getElementById('header')!) 📎 packages-private/template-explorer/src/options.ts:232-234。注意这里用的是 vue 包的 createApp,而不是 @vue/runtime-dom——因为 options.ts 是应用层代码,可以直接依赖完整的 vue 包。
flowchart LR
subgraph reactive_state["reactive 状态层"]
ssrMode["ssrMode: Ref"]
compilerOptions["compilerOptions: reactive(CompilerOptions)"]
end
subgraph ui_layer["UI 渲染层 (options.ts)"]
modeRadio["mode 单选"]
wsRadio["whitespace 单选"]
ssrCheck["SSR 复选框"]
prefixCheck["prefixIdentifiers 复选框"]
hoistCheck["hoistStatic 复选框"]
cacheCheck["cacheHandlers 复选框"]
scopeCheck["scopeId 复选框"]
inlineCheck["inline 复选框"]
compatCheck["compatConfig 复选框"]
end
subgraph compile_layer["编译层 (index.ts)"]
watchEffect["watchEffect(reCompile)"]
compileCode["compileCode()"]
end
ssrMode -->|"checked/disabled"| ssrCheck
ssrMode -->|"isSSR 守卫"| hoistCheck
ssrMode -->|"isSSR 守卫"| cacheCheck
compilerOptions -->|"mode"| modeRadio
compilerOptions -->|"whitespace"| wsRadio
compilerOptions -->|"prefixIdentifiers"| prefixCheck
compilerOptions -->|"hoistStatic"| hoistCheck
compilerOptions -->|"cacheHandlers"| cacheCheck
compilerOptions -->|"scopeId"| scopeCheck
compilerOptions -->|"inline"| inlineCheck
compilerOptions -->|"compatConfig.MODE"| compatCheck
modeRadio -->|"onChange 赋值"| compilerOptions
wsRadio -->|"onChange 赋值"| compilerOptions
ssrCheck -->|"onChange 赋值"| ssrMode
prefixCheck -->|"onChange 赋值"| compilerOptions
hoistCheck -->|"onChange 赋值"| compilerOptions
cacheCheck -->|"onChange 赋值"| compilerOptions
scopeCheck -->|"onChange 赋值"| compilerOptions
inlineCheck -->|"onChange 赋值"| compilerOptions
compatCheck -->|"onChange 赋值"| compilerOptions
compilerOptions -->|"依赖追踪"| watchEffect
ssrMode -->|"依赖追踪"| watchEffect
watchEffect --> compileCode设计思考与生产踩坑
为什么用 reactive 而不是 ref? compilerOptions 是一个包含十几个字段的对象,用 reactive 可以直接 compilerOptions.hoistStatic = true,而不需要 compilerOptions.value.hoistStatic = true。这在 UI 代码中更简洁。但 reactive 的代价是解构会丢失响应性——源码中没有任何解构,全部通过 compilerOptions.xxx 访问,这是正确的用法。
bindingMetadata 的默认值设计。 默认值包含 7 个绑定 📎 packages-private/template-explorer/src/options.ts:18-26,覆盖了 SETUP_CONST、SETUP_REF、SETUP_LET、SETUP_MAYBE_REF、PROPS 五种类型。这是为了让开发者打开 prefixIdentifiers 后能立刻看到不同绑定类型对产物中 $setup 访问方式的影响。如果没有这个默认值,prefixIdentifiers 的效果会非常单调。
compatConfig 的嵌套响应性。 compilerOptions.compatConfig!.MODE = 2 📎 packages-private/template-explorer/src/options.ts:216-220 这种嵌套赋值在 reactive 下是响应式的,因为 reactive 会递归代理嵌套对象。但注意 compatConfig 的类型是 CompatConfig | undefined,所以用了 ! 断言。如果默认值里没有 compatConfig,这里会运行时崩溃。
ssrMode 与 compilerOptions 的职责分离。 ssrMode 是 ref,compilerOptions 是 reactive。为什么不把 ssr 放进 compilerOptions?因为 ssr 不是 CompilerOptions 的字段——它决定用哪个编译函数,而不是传给编译函数的参数。这种「控制流状态」与「配置状态」的分离是清晰的设计。
---
三、Monaco 主题定制:theme.ts
直觉模型
theme.ts 像给编辑器「换一套皮肤」:它定义了每种语法 token 的颜色和字体样式。若没有这个模块,Monaco 会使用默认的 vs-dark 主题,虽然能用,但 Vue 模板中的 HTML 标签、表达式、指令会缺乏视觉区分,开发者难以快速定位关键部分。
数据结构与内存布局
theme.ts 导出一个符合 Monaco IStandaloneThemeData 接口的对象 📎 packages-private/template-explorer/src/theme.ts:1-244。它有三个顶层字段:
base: 'vs-dark' 指定基础主题 📎 packages-private/template-explorer/src/theme.ts:2,inherit: true 表示继承基础主题的规则 📎 packages-private/template-explorer/src/theme.ts:3。这意味着只需要定义差异部分,未定义的 token 会 fallback 到 vs-dark。
rules 是一个数组,每个元素包含 token(Monaco 的 token 名称)和 foreground/background/fontStyle 📎 packages-private/template-explorer/src/theme.ts:4-235。这个数组有 50 多个条目,覆盖了 number、comment、keyword、string、variable、entity.name.tag 等 token 类型。
colors 定义了编辑器 UI 的颜色 📎 packages-private/template-explorer/src/theme.ts:236-243:editor.foreground、editor.background、editor.selectionBackground、editor.lineHighlightBackground、editorCursor.foreground、editorWhitespace.foreground。
Step-by-Step Walkthrough
场景:页面加载时注册主题。
第一步:定义主题。 monaco.editor.defineTheme('my-theme', theme) 📎 packages-private/template-explorer/src/index.ts:44。这个调用把 theme.ts 的导出对象注册到 Monaco 的主题注册表中,键名为 'my-theme'。
第二步:激活主题。 monaco.editor.setTheme('my-theme') 📎 packages-private/template-explorer/src/index.ts:45。这行代码必须在 defineTheme 之后调用,否则会抛出「主题未定义」错误。
第三步:token 匹配。 当 Monaco 渲染模板代码时,它会用 HTML 语言服务对代码进行 tokenize,然后按 token 名称查找 rules 中的规则。例如 <div> 中的 div 会被标记为 entity.name.tag,匹配到 foreground: 'cc6666' 📎 packages-private/template-explorer/src/theme.ts:41-44,显示为红色。
设计思考与生产踩坑
为什么用 inherit: true? 如果不继承,需要定义所有 token 的颜色,包括那些模板中不出现的(如 markup.heading、meta.diff)。继承让主题文件只需要关注模板和 JS 产物中实际出现的 token。
token 名称的层级匹配。 Monaco 的 token 匹配是前缀匹配的:entity.name.tag 会匹配 entity.name.tag.html、entity.name.tag.css 等。源码中同时定义了 entity.name.tag 📎 packages-private/template-explorer/src/theme.ts:41-44 和 entity.name.tag.css 📎 packages-private/template-explorer/src/theme.ts:169-172,后者会覆盖前者的 CSS 特定场景。
colors 与 rules 的分工。 rules 控制代码文本的颜色,colors 控制编辑器 UI(背景、光标、选中行)的颜色。两者独立,但需要视觉协调。源码中的 editor.background: '#1D1F21' 与 base: 'vs-dark' 的默认背景接近,这是为了保持视觉一致性。
---
设计思考:可视化探针的工程取舍
Template Explorer 与 SFC Playground 的核心差异在于「观察粒度」。Playground 观察的是「整段 SFC 编译后能否运行」,Template Explorer 观察的是「单个模板表达式被编译成什么」。这种差异决定了两个工具的技术选型:
SourceMapConsumer 的引入是必然的。 没有它,开发者只能靠肉眼比对源码和产物,无法建立精确的「第几行 → 第几行」映射。但 SourceMapConsumer 的 API 是异步的(新版本返回 Promise),源码中使用的是同步版本 source-map-js,这是为了简化调用逻辑。
reactive 管理选项是 Vue 生态的自然选择。 如果用原生 DOM 事件手动管理十几个选项的状态同步,代码量会翻倍。reactive 的依赖追踪让「选项变化 → 重新编译」这条链路自动化,watchEffect(reCompile) 一行代码就完成了订阅。
Monaco 的全局加载模式是历史包袱。 window.monaco 和 window.init 的全局挂载方式源于 Monaco 的 AMD 加载器设计。在现代 ESM 构建中,这显得格格不入,但 Monaco 的体积(约 5MB)使得按需加载仍然是必要的。
---
本章小结
Template Explorer 是一个「白盒探针」:它不运行编译产物,只展示编译过程。index.ts 通过 compileCode 调用 @vue/compiler-dom 或 @vue/compiler-ssr,用 SourceMapConsumer 建立源码与产物的双向映射,通过 Monaco 的装饰器 API 实现光标联动高亮。options.ts 用 reactive 管理 CompilerOptions,通过 watchEffect 驱动重新编译,选项间的联动关系(如 SSR 禁用 hoistStatic)在 UI 层显式编码。theme.ts 定制 Monaco 主题,让模板和产物的语法 token 有清晰的视觉区分。
这个工具的核心价值在于「用工具反推编译器行为」:当你不确定 hoistStatic 对某个模板做了什么,打开 Template Explorer,切换选项,观察产物变化。这比阅读编译器源码更直观,比猜测更可靠。
本章思考与自测
Q1: 如果将 index.ts 中 originalPositionFor 的 mock location 守卫(pos.line === 1 && pos.column === 0)删除,在什么场景下会导致错误高亮?为什么编译器会生成 { line: 1, column: 0 } 这样的映射?
参考解析:守卫位于 📎 packages-private/template-explorer/src/index.ts:231-237。编译器在生成产物时会插入一些没有模板对应位置的代码,例如 import { createElementVNode as _createElementVNode } from 'vue' 这样的 helper 导入语句,或者 export function render(_ctx, _cache) { ... } 这样的函数签名。这些代码在 SourceMap 中没有原始位置,source-map-js 会返回 { line: 1, column: 0 } 作为占位。如果删除守卫,当用户把光标放在这些行上时,originalPositionFor 返回 { line: 1, column: 0 },代码会认为这是一个有效位置,进而在源码编辑器第一行第一列创建高亮装饰器。结果是:用户点击产物的 import 行,源码编辑器的第一行被错误高亮,产生误导。这个守卫的本质是「区分真实映射与占位映射」,而 { line: 1, column: 0 } 是 source-map-js 约定的「无映射」哨兵值。
Q2: reCompile 中持久化选项时,条件 typeof val !== 'object' && val !== defaultOptions[key] 会跳过所有对象类型的选项。如果 bindingMetadata 被用户修改(例如通过控制台),刷新页面后这个修改会丢失。这是 bug 还是有意设计?如果要在持久化中支持 bindingMetadata,需要解决什么问题?
参考解析:条件位于 📎 packages-private/template-explorer/src/index.ts:129。这是有意设计,原因有三:第一,bindingMetadata 的值是 BindingTypes 枚举,序列化后是数字,反序列化时无法区分「用户显式设置为 0」和「默认值」;第二,compatConfig 是嵌套对象,val !== defaultOptions[key] 比较的是引用,永远为 true,会导致所有对象选项都被持久化;第三,nodeTransforms 包含函数,无法序列化,源码中已经通过 delete persistedState.options?.nodeTransforms 处理 📎 packages-private/template-explorer/src/index.ts:69。如果要支持 bindingMetadata,需要实现深比较(而非引用比较),并且需要处理枚举值的序列化/反序列化。更根本的问题是:bindingMetadata 在 UI 上没有编辑入口,用户只能通过控制台修改,这种修改本身就不应该被持久化。
Q3: options.ts 中 compilerOptions 用 reactive(Object.assign({}, defaultOptions)) 创建。如果将 Object.assign({}, defaultOptions) 改为直接 reactive(defaultOptions),在用户切换选项后刷新页面,会发生什么?为什么?
参考解析:Object.assign({}, defaultOptions) 是浅拷贝,位于 📎 packages-private/template-explorer/src/options.ts:29-31。如果改为 reactive(defaultOptions),compilerOptions 和 defaultOptions 会指向同一个对象。当用户切换 hoistStatic 为 true 时,compilerOptions.hoistStatic 变为 true,同时 defaultOptions.hoistStatic 也变为 true。然后 reCompile 中的持久化逻辑 📎 packages-private/template-explorer/src/index.ts:129 会比较 val !== defaultOptions[key],此时 val 和 defaultOptions[key] 都是 true,条件为 false,该选项不会被保存到 localStorage。刷新页面后,defaultOptions 被重新初始化为 hoistStatic: false,用户的修改丢失。更严重的是,defaultOptions 被污染后,后续所有「与默认值比较」的逻辑都会失效,导致持久化功能完全崩溃。这个 bug 的隐蔽性在于:单次会话内一切正常,只有刷新后才能发现。
---
下一章将进入 scripts/release.js,看 Vue 如何用一个交互式状态机编排版本号更新、构建、测试、Git 提交、打 tag 与 npm publish 的全流程。与 Template Explorer 的「观察」不同,release.js 是「执行」——它需要在多个步骤间维护状态,处理失败回滚,并在交互式确认与自动化之间取得平衡。
通过 Template Explorer,我们掌握了如何将编译器内部状态——AST、编译产物、SourceMap——转化为可交互的可视化探针,从而把「编译器为什么这么生成」从猜测变成观察。这种对内部状态的精确控制与编排,同样体现在 Vue 的发布流程中:下一章将深入 scripts/release.js,看一个 500 余行的状态机如何用 parseArgs 解析十余个标志位、通过 enquirer 交互确认版本号,并按顺序触发构建、测试、Git 提交、打 tag 与 npm publish,揭示一次正式发版背后完整的状态流转与失败回滚策略。
读完了本章?为你自己的私有项目生成专属架构全景书
基于 Tauri 2 + Rust 本地原生引擎,100% 源码离线隐私安全,零代码上传云端。像阅读一本传世专著一样拆解你的复杂系统。
⚡ Tauri 2 · Rust 原生引擎 · 100% 离线私密安全 · 适配超百万行代码库
第 9 章:编译器核心:@vue/compiler-core 的 AST 转换与代码生成
第 9 章:编译器核心:@vue/compiler-core 的 AST 转换与代码生成
上一章我们借助 template-explorer 反推编译器行为,掌握了用工具观察内部机制的方法论。现在,我们把视线从编译时转向发布时——这是每个开源项目最危险的时刻:它同时触碰版本号、构建产物、Git 历史与 npm registry 四个不可逆的外部系统。一次错误的 npm publish 无法撤回,一次错误的 tag 推送会污染所有下游用户的依赖解析。Vue core 用一个 537 行的 scripts/release.js 来驯服这种危险——它既不是纯粹的自动化脚本,也不是纯粹的手动清单,而是一个交互式状态机:在关键节点停下来问人,在可预测的节点全自动执行,并在任何一步失败时把版本号回滚到起点。本章将拆解这个编排器的三个核心机制:参数解析与状态初始化、交互式版本决策与 CI 门禁、以及发布顺序与失败回滚。
参数解析与全局状态初始化
直觉模型
把 release.js 想象成一台老式洗衣机的控制面板:旋钮(parseArgs)决定用哪种模式,指示灯(全局变量)记录当前处于哪个阶段,而「取消」按钮(错误处理)必须能把机器恢复到进水前的状态。若没有这套初始化逻辑,脚本就会在「用户到底想发什么版本」这个问题上失控——要么发错版本号,要么在 CI 里卡死等待一个永远不会到来的键盘输入。
标志位与全局状态的内存布局
〔设计推断与架构权衡〕
脚本启动后的第一件事是把命令行参数解析成一个结构化对象。这里用的是 Node 内置的parseArgs,而非yargs或commander—— 这是为了消除第三方依赖,因为发布脚本本身必须在任何环境下都能跑起来,哪怕node_modules装了一半。
📎 scripts/release.js:27-62 定义了 10 个选项,可分为四类:
- 版本语义类:
preid(预发布标识符,如alpha/beta/rc)、tag(npm dist-tag) - 跳过类:
skipBuild、skipTests、skipGit、skipPrompts——这四个布尔开关构成了「自动化程度」的调节旋钮 - 执行模式类:
dry(空跑)、publish(是否在本地直接发布)、publishOnly(只发布不更新版本) - 目标类:
registry(自定义 registry 地址)
注意 publish 的默认值是 false 📎 scripts/release.js:51-54,而其他布尔项没有默认值(即 undefined)。这个不对称是刻意的:publish 的语义是「是否在本地执行 npm publish」,默认不发布,把发布动作交给 GitHub Actions;而 skipXxx 默认 undefined 意味着「未指定」,后续逻辑会区分「用户显式传了 --skipTests」和「用户没传」。
解析完成后,脚本把参数摊平到一组模块级变量上 📎 scripts/release.js:64-66:
const preId = args.preid || semver.prerelease(currentVersion)?.[0]
const isDryRun = args.dry
let skipTests = args.skipTests
const skipBuild = args.skipBuild
const skipPrompts = args.skipPrompts
const skipGit = args.skipGit这里有两处值得玩味的设计。第一,preId 的取值优先级是「命令行显式指定 > 从当前版本号推断」📎 scripts/release.js:64-66。如果当前 package.json 的版本是 3.5.0-beta.1,那么 semver.prerelease 会返回 ['beta', 1],取 [0] 得到 'beta'。这意味着在 beta 分支上连续发版时,不需要每次都敲 --preid beta。第二,skipTests 用 let 声明而其他用 const 📎 scripts/release.js:64-66,因为它在 runTestsIfNeeded 中会被 CI 结果动态改写——这是一个「延迟决策」的状态位。
紧接着是包发现逻辑 📎 scripts/release.js:68-83:读取 packages/ 目录,过滤掉非目录项、没有 package.json 的项,以及 private: true 的包。注意这里读的是 packages/ 而非 packages-private/——后者是内部调试包,永不发布。
发布顺序的排序算法
📎 scripts/release.js:85-85 定义了一个看似简单却至关重要的函数:
const sortPackagesForPublishing = (packageNames) => [
...packageNames.filter(p => p !== 'vue'),
...packageNames.filter(p => p === 'vue'),
]它把 vue 这个入口包排到最后。注释 📎 scripts/release.js:85-85 解释了原因:如果先发布 vue,用户在 @vue/runtime-core 等内部包还没上线时就能安装到新版 vue,npm 会因找不到匹配的内部依赖而报错。这是「发布原子性」在 npm 生态下的妥协方案——npm 没有跨包事务,只能靠顺序来逼近原子性。
版本增量候选集的动态构造
📎 scripts/release.js:111-116 构造了交互式菜单的候选项:
const versionIncrements = [
'patch', 'minor', 'major',
...(preId ? ['prepatch', 'preminor', 'premajor', 'prerelease'] : []),
]这是一个条件展开:只有在 preId 存在时(即当前处于预发布通道,或用户显式指定了 --preid),才把预发布相关的增量类型加入菜单。若当前是稳定版 3.5.43 且未指定 preid,菜单就只有 patch/minor/major 三项——避免用户误操作把稳定版变成 3.5.44-0 这种半吊子预发布版本。
inc 函数 📎 scripts/release.js:120-120 封装了 semver.inc,把 preId 作为第三个参数传入。这里有个类型防御:typeof preId === 'string' ? preId : undefined——因为 preId 可能是 string | undefined,而 semver.inc 期望 string | undefined,这个三元表达式是为了满足 TS 的类型收窄。
执行原语:run 与 dryRun 的双轨制
📎 scripts/release.js:122-123 是整章最精妙的设计之一:
const run = async (bin, args, opts = {}) =>
exec(bin, args, { stdio: 'inherit', ...opts })
const dryRun = async (bin, args, opts = {}) =>
console.log(pico.blue(`[dryrun] ${bin} ${args.join(' ')}`), opts)
const runIfNotDry = isDryRun ? dryRun : runrun 把子进程的 stdio 设为 inherit,让构建/测试的输出直接透传到终端——这对长时间运行的构建至关重要,用户能看到实时进度。dryRun 则只打印命令不执行。runIfNotDry 是一个「策略选择」:在模块加载时就把函数指针绑定到 dryRun 或 run,后续所有调用点无需再判断 isDryRun。
〔设计推断与架构权衡〕
这种「在初始化时决定策略」的模式比「在每个调用点判断」更不易出错:如果某个调用点忘了判断isDryRun,在 dry run 模式下就会真的执行副作用。而runIfNotDry把判断集中到一处,消除了这类遗漏的可能。
flowchart TD
start["node scripts/release.js"] --> parse["parseArgs 解析 10 个选项"]
parse --> preid{"args.preid 存在?"}
preid -->|是| use_arg["preId = args.preid"]
preid -->|否| infer["preId = semver.prerelease(currentVersion)[0]"]
use_arg --> scan["扫描 packages/ 目录"]
infer --> scan
scan --> filter{"是目录 且 有 package.json 且 非 private?"}
filter -->|否| skip_pkg["排除该包"]
filter -->|是| keep_pkg["加入 packages 列表"]
skip_pkg --> build_menu
keep_pkg --> build_menu
build_menu{"preId 存在?"} -->|是| full["versionIncrements = patch/minor/major + 4 个 pre*"]
build_menu -->|否| stable["versionIncrements = patch/minor/major"]
full --> dispatch{"args.publishOnly?"}
stable --> dispatch
dispatch -->|是| publish_only["fnToRun = publishOnly"]
dispatch -->|否| main_fn["fnToRun = main"]---
交互式版本决策与 CI 门禁
直觉模型
这一阶段像机场安检:先核对你的登机牌(本地 commit 是否与远端同步),再确认你要去哪(版本号),最后检查你是否已通过安检(CI 是否通过)。任何一环不通过,整个流程就中止。若没有这道门禁,一个未推送的本地 commit 可能被打上 tag 并发布,导致 npm 上的版本对应的源码在 GitHub 上根本不存在——这是最难以排查的发布事故。
同步检查与版本选择
main 函数的第一件事是 isInSyncWithRemote() 📎 scripts/release.js:141-141。这个函数 📎 scripts/release.js:337-363 的逻辑是:取当前分支名,请求 GitHub API 获取该分支的最新 commit SHA,与本地 git rev-parse HEAD 比对。若不一致,弹出一个红色警告的确认框 📎 scripts/release.js:348-355,让用户决定是否继续。若 API 请求失败(网络问题、无 token),则直接返回 false 并终止 📎 scripts/release.js:365-367。
〔设计推断与架构权衡〕
这里的设计哲学是「失败即中止」:网络异常时宁可不让发布,也不冒险在状态未知的情况下继续。因为发布是不可逆的,而重跑一次脚本的成本很低。
版本号的确定分两条路径。若用户在命令行传了位置参数(如 node scripts/release.js 3.6.0),targetVersion 直接取该值 📎 scripts/release.js:141-141。否则进入交互式菜单 📎 scripts/release.js:152-176:先让用户选增量类型,若选 custom 则再弹一个输入框让用户手填版本号。
注意 📎 scripts/release.js:174 这一行:
targetVersion = release.match(/\((.*)\)/)?.[1] ?? ''菜单项的格式是 patch (3.5.44),这行正则从括号里提取出实际版本号。如果用户选了 custom,走的是另一条分支 📎 scripts/release.js:164-172。
随后有一个「二次解析」逻辑 📎 scripts/release.js:178-182:如果 targetVersion 恰好是 patch/minor 这类增量关键字(用户可能直接传 node release.js minor),就调用 inc 把它转成具体版本号。最后用 semver.valid 校验 📎 scripts/release.js:184-186,非法版本号直接抛错。
CI 门禁:runTestsIfNeeded 的三态逻辑
这是全章最复杂的控制流。📎 scripts/release.js:281-317 的 runTestsIfNeeded 实际上是一个三态决策机:
状态一:用户显式传了 --skipTests。skipTests 初始为 true,直接跳过整个函数体,打印 "Tests skipped." 📎 scripts/release.js:314-316。
状态二:未跳过,且 CI 已通过。脚本调用 getCIResult() 📎 scripts/release.js:319-335,它请求 GitHub Actions API,检查是否存在名为 ci 且 conclusion === 'success' 的 workflow run 📎 scripts/release.js:319-335。若通过,则询问用户「CI 已通过,是否跳过本地测试?」📎 scripts/release.js:288-295。若用户开了 --skipPrompts,则自动跳过本地测试 📎 scripts/release.js:296-298。
状态三:未跳过,且 CI 未通过。若开了 --skipPrompts,直接抛错 📎 scripts/release.js:299-304:
throw new Error(
'CI for the latest commit has not passed yet. ' +
'Only run the release workflow after the CI has passed.',
)若没开 --skipPrompts,则 skipTests 保持 undefined,落到最后的本地测试分支 📎 scripts/release.js:307-313,执行 pnpm run test --run。
这里有个微妙的细节 📎 scripts/release.js:285:
skipTests ||= isCIPassed||= 是逻辑或赋值:只有当 skipTests 为假值(undefined 或 false)时才赋值为 isCIPassed。这意味着如果用户显式传了 --skipTests(true),这行不会改变它;如果用户没传(undefined),则把它设为 CI 结果。但紧接着 📎 scripts/release.js:287-298 又会在 CI 通过时重新赋值——所以 ||= 这行的实际作用只是「若 CI 未通过,把 skipTests 设为 false」,从而让后续的 if (!skipTests) 分支执行本地测试。
〔设计推断与架构权衡〕
这个逻辑绕了一圈,本质是想表达:「CI 通过 → 可以跳过本地测试(但问一下用户);CI 未通过 → 必须跑本地测试(除非用户明确要求跳过)」。用 ||= 加后续覆盖的写法虽然紧凑,但可读性不高,是典型的「状态位被多处修改」的代码味道。
sequenceDiagram
participant Dev as 开发者
participant Main as main()
participant Git as git CLI
participant GH as GitHub API
participant Pnpm as pnpm
Dev->>Main: node scripts/release.js
Main->>Git: getBranch() / getSha()
Git-->>Main: branch, sha
Main->>GH: fetch commits/{branch}
GH-->>Main: remote sha
alt sha 不一致
Main->>Dev: prompt 确认继续?
Dev-->>Main: yes/no
end
Main->>Dev: prompt 选择版本增量
Dev-->>Main: "patch (3.5.44)"
Main->>Main: semver.valid 校验
Main->>GH: getCIResult() 查询 workflow_runs
GH-->>Main: workflow_runs[]
alt CI 通过
Main->>Dev: prompt 跳过本地测试?
Dev-->>Main: yes
else CI 未通过
Main->>Pnpm: run test --run
Pnpm-->>Main: exit code
end
Main->>Main: updateVersions(targetVersion)版本号写入:updateVersions 的遍历
📎 scripts/release.js:377-384 的 updateVersions 做两件事:更新根 package.json,再遍历所有子包调用 updatePackage。updatePackage 📎 scripts/release.js:391-398 读取 JSON、改写 name 和 version、用 JSON.stringify(pkg, null, 2) + '\n' 写回——注意末尾的 \n,这是为了保持文件以换行结尾,避免 git diff 显示 "No newline at end of file"。
getNewPackageName 参数默认是 keepThePackageName 📎 scripts/release.js:105,即不改包名。这个参数的存在是为了支持「发布到自定义 registry 时重命名包」的场景——虽然当前调用点都传默认值,但接口预留了扩展性。
---
发布顺序、幂等性与失败回滚
直觉模型
这一阶段像多米诺骨牌:updateVersions 推倒第一张牌(改版本号),后续的 changelog、lockfile、commit、tag、publish 依次倒下。如果中途某张牌卡住,必须有一套机制把已经倒下的牌扶起来——否则仓库会停留在「版本号已改但没发布」的半吊子状态。
幂等发布:isPackagePublished 与错误兜底
〔设计推断与架构权衡〕
publishPackage📎scripts/release.js:439-489是发布的核心。它首先确定 dist-tag 📎scripts/release.js:442-451:优先用--tag参数,否则根据版本号中的alpha/beta/rc关键字推断。注意这里用的是version.includes('alpha')而非semver.prerelease—— 因为版本号可能形如3.5.0-alpha.1,includes足够简单且不会误判。
发布前有一道幂等性检查 📎 scripts/release.js:453-458:
if (!isDryRun && (await isPackagePublished(packageName, version))) {
console.log(pico.yellow(`Skipping already published: ${pkgVersion}`))
alreadyPublishedPackages.push(pkgVersion)
return
}isPackagePublished 📎 scripts/release.js:491-513 执行 npm view <pkg>@<version> version,若成功返回 true,若报 E404 类错误返回 false。这个检查的意义在于:发布流程可能因网络中断而重跑,重跑时已发布的包不应再次发布(npm 会拒绝重复版本)。
但检查本身也可能失败——比如 npm view 因网络超时抛了非 E404 错误。此时 isPackagePublished 会把错误向上抛 📎 scripts/release.js:507-510,导致整个发布中止。这是「宁可中止也不冒险」的又一体现。
即使检查通过,pnpm publish 本身仍可能因竞态(另一个 CI 刚发布了同版本)而失败。所以 publishPackage 在 catch 块里做了二次兜底 📎 scripts/release.js:480-488:
} catch (e) {
if (e.message?.match(/previously published/)) {
console.log(pico.red(`Skipping already published: ${pkgVersion}`))
alreadyPublishedPackages.push(pkgVersion)
} else {
throw e
}
}只有匹配到 previously published 才吞掉错误,其他错误一律重抛。这是「精确容错」:只对已知的、可安全忽略的错误做降级处理。
发布标志位的动态拼装
📎 scripts/release.js:412-432 根据运行环境拼装 pnpm publish 的附加标志:
const additionalPublishFlags = []
if (isDryRun) additionalPublishFlags.push('--dry-run')
if (isDryRun || skipGit || process.env.CI)
additionalPublishFlags.push('--no-git-checks')
if (process.env.CI && !args.registry)
additionalPublishFlags.push('--provenance')--no-git-checks 在三种情况下启用:dry run、跳过 git、或在 CI 中。原因是 pnpm publish 默认会检查工作区是否干净、当前分支是否是发布分支等,而在 CI 中这些检查会误报。
--provenance 只在 CI 且未指定自定义 registry 时启用 📎 scripts/release.js:425-427。provenance 是 npm 的供应链安全特性,它把构建产物的来源信息(哪个 commit、哪个 workflow)签名后附在包上。但自定义 registry(如内部私有 registry)通常不支持 provenance,所以加了 !args.registry 的条件。
失败回滚:versionUpdated 标志位
回到 main 的末尾 📎 scripts/release.js:528-537:
fnToRun().catch(err => {
if (versionUpdated) {
updateVersions(currentVersion)
}
console.error(err)
process.exit(1)
})versionUpdated 是一个模块级布尔量,初始为 false 📎 scripts/release.js:24-27,在 updateVersions 调用成功后立即置为 true 📎 scripts/release.js:208。若后续任何步骤(changelog 生成、lockfile 更新、git commit、publish)抛错,catch 块会检查这个标志位,若为 true 则把版本号回滚到 currentVersion。
〔设计推断与架构权衡〕
这个回滚是「尽力而为」的:它只回滚package.json中的版本号,不回滚 changelog 文件、不回滚 lockfile、不回滚已经执行的 git commit。如果错误发生在 git commit 之后,仓库里会留下一个「版本号已回滚但 commit 已存在」的中间状态。这是设计上的取舍——完整的回滚需要git reset,而那会破坏用户可能已经做的其他改动。所以脚本选择只回滚最关键的版本号,让用户手动处理其余部分。
注意 publishOnly 路径 📎 scripts/release.js:519-526 不设置 versionUpdated,因为它的语义是「只发布,不改版本」——即使失败也无需回滚。但它在 targetVersion 存在时会调用 updateVersions 📎 scripts/release.js:519-526,此时若失败,版本号不会被回滚。这是一个潜在的边界问题,见章末思考题。
flowchart TD
upd["updateVersions(targetVersion)"] --> flag["versionUpdated = true"]
flag --> changelog["pnpm run changelog"]
changelog --> lock["pnpm install --prefer-offline"]
lock --> gitdiff{"git diff 有输出?"}
gitdiff -->|是| commit["git add -A && git commit"]
gitdiff -->|否| nochange["No changes to commit"]
commit --> pub{"args.publish?"}
nochange --> pub
pub -->|是| build["buildPackages()"]
pub -->|否| push
build --> publish["publishPackages()"]
publish --> push["git tag && git push"]
push --> done["完成"]
changelog -.->|抛错| rollback["catch: updateVersions(currentVersion)"]
lock -.->|抛错| rollback
commit -.->|抛错| rollback
publish -.->|抛错| rollback
rollback --> exit["process.exit(1)"]发布顺序与 vue 包的特殊处理
publishPackages 📎 scripts/release.js:412-432 遍历 sortPackagesForPublishing(packages) 的结果,逐个调用 publishPackage。由于排序把 vue 放最后 📎 scripts/release.js:85-85,整个发布序列保证了内部包先上线。
publishPackage 内部用 cwd: getPkgRoot(pkgName) 📎 scripts/release.js:475 把工作目录切到子包目录,这样 pnpm publish 发布的是子包而非根包。注释 📎 scripts/release.js:462-463 特别提醒「不要改成 npm publish」——因为 pnpm publish 能正确处理 workspace:* 依赖协议,把它转换成实际版本号,而 npm publish 会原样保留 workspace:* 导致安装失败。
---
设计思考
为什么用 parseArgs 而非 yargs? 发布脚本是「最后一道防线」,它必须在任何环境下可执行。第三方 CLI 库若因依赖树损坏而加载失败,整个发布流程就瘫痪了。Node 内置的 parseArgs 虽然功能简陋(不支持子命令、不支持自动 help),但零依赖、零风险。
为什么把 publish 默认设为 false? 因为 Vue 的正式发布走 GitHub Actions(见 📎 scripts/release.js:256-263 的提示信息),本地脚本只负责改版本号、生成 changelog、打 tag、推送。真正的 npm publish 在 CI 中执行,这样能利用 CI 的 provenance 签名和受控环境。--publish 标志是给维护者在紧急情况下本地发布用的逃生通道。
为什么回滚只回滚版本号? 因为完整回滚需要理解「哪些改动是脚本做的、哪些是用户做的」,而这在 git 层面无法区分。脚本选择只回滚它最确定自己改过的东西——package.json 的版本号——其余交给用户判断。
---
本章小结
scripts/release.js 用 537 行代码实现了一个「交互式状态机」,其核心设计可归纳为三点:
1. 参数即策略:10 个标志位在模块加载时被解析并摊平到全局变量,runIfNotDry 在初始化时绑定策略,避免调用点遗漏判断。
2. 门禁前置:同步检查、版本校验、CI 门禁都在任何副作用发生前完成,确保「要么全做,要么不做」。
3. 精确容错:isPackagePublished 预检 + previously published 错误兜底构成双重幂等保护;versionUpdated 标志位实现最小化回滚。
这套机制与上一章的 Template Explorer 形成有趣对照:Template Explorer 是「观察」——把编译器内部状态可视化;release.js 是「执行」——把发布流程的每一步状态显式化。两者都体现了同一个工程哲学:把隐式状态变成显式状态,把不可控的副作用变成可控的步骤。
本章思考与自测
Q1: 若把 📎 scripts/release.js:285 的 skipTests ||= isCIPassed 改为 skipTests = isCIPassed,在用户显式传了 --skipTests 且 CI 未通过时会发生什么?为什么?
参考解析:原逻辑中,用户传 --skipTests 时 skipTests 初始为 true 📎 scripts/release.js:64-66,||= 不会改变它,因此 runTestsIfNeeded 在 📎 scripts/release.js:282 的 if (!skipTests) 判断为假,直接跳到 📎 scripts/release.js:314-316 打印 "Tests skipped."。若改为 skipTests = isCIPassed,则 skipTests 被强制设为 false(CI 未通过),随后 📎 scripts/release.js:287 的 if (isCIPassed) 为假,落到 📎 scripts/release.js:299 的 else if (skipPrompts)——若未开 --skipPrompts,则 skipTests 保持 false,最终在 📎 scripts/release.js:307-313 执行本地测试。这违背了用户「显式跳过测试」的意图,在 CI 环境(--skipPrompts)下更会直接抛错 📎 scripts/release.js:300-303,导致发布中止。||= 的存在正是为了尊重用户的显式选择。
Q2: publishOnly 路径 📎 scripts/release.js:519-526 在 targetVersion 存在时会调用 updateVersions,但它不设置 versionUpdated。若此时 buildPackages 或 publishPackages 抛错,会发生什么?这个设计是否合理?
参考解析:publishOnly 调用 updateVersions(targetVersion) 📎 scripts/release.js:519-526 修改了所有 package.json 的版本号,但没有设置 versionUpdated = true。当后续 buildPackages 📎 scripts/release.js:519-526 或 publishPackages 📎 scripts/release.js:519-526 抛错时,fnToRun().catch 📎 scripts/release.js:528-537 检查 versionUpdated 为 false,不会回滚版本号。结果是仓库停留在「版本号已改但发布失败」的状态。这个设计在 publishOnly 的原始语义(只发布、不改版本)下是合理的——因为 targetVersion 通常不传,updateVersions 不执行。但当用户传了 targetVersion 时,这个路径就存在回滚漏洞。修复方式是在 📎 scripts/release.js:519-526 后加 versionUpdated = true,或让 publishOnly 复用 main 的回滚逻辑。
Q3: isPackagePublished 📎 scripts/release.js:491-513 用 npm view 检查包是否已发布。若网络超时导致 npm view 抛出非 E404 错误,会发生什么?这个行为在 CI 重跑场景下是否安全?
参考解析:isPackagePublished 在 catch 块中 📎 scripts/release.js:507-510 调用 isPackageNotFoundError 判断错误类型。该函数 📎 scripts/release.js:515-515 只匹配 /E404|No match found|No matching version|notarget/i。网络超时错误的 message 不含这些关键字,因此 isPackageNotFoundError 返回 false,isPackagePublished 把错误重抛 📎 scripts/release.js:507-510。这个错误向上传播到 publishPackage 📎 scripts/release.js:453,导致整个发布中止。在 CI 重跑场景下,这会导致「明明包已发布,却因网络抖动而中止」——但这是安全的失败方向:中止比误判「未发布」而重复发布要好。重复发布会触发 npm 的 previously published 错误,被 📎 scripts/release.js:491-492 兜底,但会浪费一次网络往返。所以「网络错误即中止」是保守但正确的选择。
---
下一章将进入 .github/workflows/,看 release.js 推送 tag 之后,GitHub Actions 如何接管后续的构建与发布,以及 CI 门禁的完整实现。
至此,我们看清了 release.js 如何用状态机与交互式编排把不可逆的发布风险降到最低。但发布脚本本身只是执行者,真正决定何时触发、以何种条件放行的,是更上层的自动化守门人。下一章将剖析 .github/workflows 目录下的 CI/CD 体系:ci.yml 如何在 PR 阶段执行 lint/typecheck/test 三重门禁、release.yml 如何在 tag 推送时触发发布、size-report.yml 与 size-data.yml 如何追踪包体积回归、autofix.yml 如何自动修复格式问题。你将理解 Vue 如何用 GitHub Actions 把工程规范固化为不可绕过的流水线。
读完了本章?为你自己的私有项目生成专属架构全景书
基于 Tauri 2 + Rust 本地原生引擎,100% 源码离线隐私安全,零代码上传云端。像阅读一本传世专著一样拆解你的复杂系统。
⚡ Tauri 2 · Rust 原生引擎 · 100% 离线私密安全 · 适配超百万行代码库
第 10 章:SFC 单文件组件编译:@vue/compiler-sfc 的解析与代码块分割
第 10 章:SFC 单文件组件编译:@vue/compiler-sfc 的解析与代码块分割
上一章我们看到 scripts/release.js 如何用交互式状态机把一次发版的每一步串起来。但那个脚本有一个前提:它必须被某个人或某个系统主动调用。在 Vue core 仓库里,这个主动调用者不是维护者的本地终端,而是 GitHub Actions。release.js 是执行者,workflows 是决策者——它决定什么事件触发什么任务、什么条件下放行、什么条件下阻断。本章聚焦 .github/workflows/ 目录下的四个文件:ci.yml(PR 门禁与持续预发布)、release.yml(tag 触发的正式发布)、size-report.yml(体积回归报告)、autofix.yml(格式自动修复)。理解它们的核心不是记住 YAML 语法,而是看清 Vue 团队如何把工程规范翻译成不可绕过的流水线约束。
一、ci.yml:三重门禁与持续预发布
直觉模型
把 ci.yml 想象成机场安检口。每个 PR 都要过这道闸:lint 检查你的行李有没有违禁品,typecheck 确认你的证件真实有效,test 验证你没有携带危险品。但安检口不止一个——Vue 还在这里挂了一条「持续预发布」通道,把每个 PR 的构建产物直接发布到 pkg-pr-new,让贡献者能在真实 npm 安装场景下验证自己的改动。
若没有这道闸,任何一次合并都可能把格式错误、类型漏洞或行为回归带进 main 分支,而 main 分支是后续所有 release 的源头。
触发条件与并发控制
ci.yml 的触发配置值得逐行拆解。
📎 .github/workflows/ci.yml:2-11
on:
push:
branches:
- '**'
tags:
- '!**'
pull_request:
branches:
- main
- minor这里有两个关键设计。第一,push 事件监听所有分支('**'),但用 tags: ['!**'] 显式排除所有 tag 推送。为什么要排除 tag?因为 tag 推送由 release.yml 单独处理,如果 ci.yml 也响应 tag,会导致发布流程和 CI 流程重复触发,浪费 runner 资源甚至产生竞态。第二,pull_request 只监听 main 和 minor 两个分支——这是 Vue 的双分支策略:main 承载稳定版,minor 承载预发布版。
📎 .github/workflows/ci.yml:22-22
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}并发控制是这里最精妙的一笔。group 的表达式用 github.event.pull_request.number || github.ref 做 fallback:PR 事件用 PR 编号做分组键,push 事件用 ref(分支名)做分组键。这意味着同一个 PR 的多次推送会落在同一个并发组里。而 cancel-in-progress 只在 PR 事件时为 true——当你连续推送三次提交时,前两次的 CI 会被自动取消,只保留最新一次。
〔设计推断与架构权衡〕
这个设计的动机很明确:PR 阶段开发者频繁推送,旧提交的 CI 结果已经无意义,取消它们能节省大量 runner 时间。但 push 到 main 分支时不能取消——因为 main 上的每次 push 都可能是发布前的最后一次验证,取消会导致验证缺口。
三重门禁的入口:test job 的条件判断
📎 .github/workflows/ci.yml:22-22
jobs:
test:
if: ${{ ! startsWith(github.event.head_commit.message, 'release:') && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository) }}
uses: ./.github/workflows/test.yml这个 if 条件包含两个逻辑与(&&)的分支,每个都值得展开。
第一个条件 ! startsWith(github.event.head_commit.message, 'release:'):如果提交信息以 release: 开头,跳过测试。这正是上一章 release.js 推送的提交信息格式——release.js 在本地已经跑过完整测试,CI 不需要重复验证。这是一个「信任上游」的优化。
〔设计推断与架构权衡〕
第二个条件(github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository):push 事件总是跑测试;PR 事件则要求 PR 来自 fork(head.repo.full_name != github.repository)。为什么 fork 的 PR 才跑? 因为同仓库分支的 PR 通常由核心团队成员创建,他们的分支推送已经触发过 push 事件的 CI。而 fork 的 PR 不会触发 push 事件(fork 的 push 不会通知上游仓库),所以必须在 PR 事件里补跑。
注意 uses: ./.github/workflows/test.yml——这是一个 reusable workflow 调用。test.yml 是独立的 workflow 文件,被 ci.yml 和 release.yml 共享。这种复用避免了在多个 workflow 里重复定义 lint/typecheck/test 的步骤。
持续预发布:pkg-pr-new 的角色
📎 .github/workflows/ci.yml:25-51
continuous-release:
if: github.repository == 'vuejs/core'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# ... 安装 pnpm、Node.js、依赖 ...
- name: Build
run: pnpm build --withTypes
- name: Release
run: pnpx pkg-pr-new publish --compact --pnpm './packages/*' --packageManager=pnpm,npm,yarncontinuous-release job 只在 vuejs/core 主仓库运行(if: github.repository == 'vuejs/core'),fork 上不执行。它做三件事:构建(pnpm build --withTypes,带类型声明)、然后用 pkg-pr-new 把 ./packages/* 下的所有包发布到一个临时的 npm registry。
〔设计推断与架构权衡〕
这个机制的价值在于:贡献者可以在自己的项目里直接 npm install 这个 PR 的构建产物,验证改动是否真的解决了问题。这比「看 CI 绿了」更有说服力,因为它验证的是真实的包消费场景。
注意所有 action 都锁定了 commit SHA(如 actions/checkout@3d3c42e5...),而不是用 @v4 这样的浮动 tag。这是供应链安全的硬性要求——防止 action 仓库被入侵后恶意代码自动流入。
ci.yml 控制流图
flowchart TD
trigger{"事件类型?"}
trigger -->|"push 到任意分支"| push_check{"提交信息以 release: 开头?"}
trigger -->|"PR 到 main/minor"| pr_check{"PR 来自 fork?"}
push_check -->|"是"| skip_test["跳过 test job"]
push_check -->|"否"| run_test["调用 test.yml"]
pr_check -->|"是"| run_test
pr_check -->|"否"| skip_test
run_test --> test_result{"test.yml 通过?"}
test_result -->|"否"| block["PR 被阻断"]
test_result -->|"是"| cont_release{"仓库是 vuejs/core?"}
cont_release -->|"是"| build["pnpm build --withTypes"]
cont_release -->|"否"| end_node["结束"]
build --> publish["pkg-pr-new publish"]
publish --> end_node---
二、release.yml:tag 推送后的发布编排
直觉模型
如果说 ci.yml 是安检口,release.yml 就是发射台。当 release.js 在本地完成版本号更新、提交、打 tag 并推送后,tag 推送事件点燃了 release.yml 的引擎。它先跑一遍完整测试(再次确认),然后在受保护的 Release 环境中执行 pnpm release --publishOnly,最后创建 GitHub Release。
若没有它,release.js 推送的 tag 就只是一个 Git 引用,npm 上不会有新版本,GitHub 上不会有 Release 页面。
触发条件:只认 tag
📎 .github/workflows/release.yml:3-6
on:
push:
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10只监听 v* 格式的 tag 推送。这与 ci.yml 的 tags: ['!**'] 形成互补——两者严格互斥,不会同时触发。
发布 job 的守卫条件
📎 .github/workflows/release.yml:8-21
jobs:
test:
uses: ./.github/workflows/test.yml
release:
if: github.repository == 'vuejs/core'
needs: [test]
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
environment: Release这里有三层守卫,每一层都不可省略。
第一层 if: github.repository == 'vuejs/core':防止 fork 上误触发发布。如果有人 fork 了仓库并推送了一个 v1.0.0 tag,这个条件会阻止发布流程运行。
第二层 needs: [test]:release job 依赖 test job。test job 调用 test.yml,如果测试失败,release job 根本不会启动。这是「发布前必须通过测试」的硬约束。
〔设计推断与架构权衡〕
第三层 environment: Release:这是一个 GitHub Environment,可以配置部署保护规则(如需要特定人员审批)。 这意味着即使 tag 推送触发了 workflow,发布步骤也可能需要人工审批才能执行——这是对不可逆操作的最后一道防线。
权限方面,contents: write 用于创建 GitHub Release,id-token: write 用于 npm 的 provenance 认证(OIDC token)。注意这里没有 packages: write,因为 Vue 发布到 npm 而非 GitHub Packages。
发布步骤的完整链路
📎 .github/workflows/release.yml:37-46
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Update npm
run: npm i -g npm@latest
- name: Build and publish
id: publish
run: |
pnpm release --publishOnly〔设计推断与架构权衡〕
三个步骤各有讲究。--frozen-lockfile确保 CI 环境严格按 lockfile 安装,不会因为依赖版本漂移导致构建产物与本地不一致。npm i -g npm@latest是为了获取最新的 npm CLI—— 因为 provenance 和 OIDC 认证依赖较新版本的 npm,旧版本可能不支持这些特性。
pnpm release --publishOnly 是上一章 release.js 的入口。--publishOnly 标志告诉 release.js:跳过交互式版本号选择、跳过 Git 提交和打 tag(因为 tag 已经存在),只执行构建和 npm publish。
创建 GitHub Release
📎 .github/workflows/release.yml:48-57
- name: Create GitHub release
id: release_tag
uses: yyx990803/release-tag@8cccf7c5aa332d71d222df46677f70f77a8d2dc0 # v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
body: |
For stable releases, please refer to [CHANGELOG.md](...) for details.
For pre-releases, please refer to [CHANGELOG.md](...) of the `minor` branch.〔设计推断与架构权衡〕
这里用的是 Vue 作者尤雨溪自己维护的release-tagaction。tag_name: ${{ github.ref }}直接使用触发事件的 ref(即refs/tags/v3.x.x)。Release body 不写具体变更内容,而是指向 CHANGELOG.md—— 因为 Vue 的 changelog 由 conventional-changelog 自动生成,手动维护 Release body 会与 changelog 产生不一致。
release.yml 时序图
sequenceDiagram
participant Dev as "开发者本地"
participant GH as "GitHub"
participant Test as "test.yml"
participant Rel as "release job"
participant NPM as "npm registry"
Dev->>GH: "git push origin v3.x.x"
GH->>Test: "触发 test.yml"
Test-->>GH: "测试通过"
GH->>Rel: "needs: [test] 满足"
Rel->>Rel: "environment: Release 审批"
Rel->>Rel: "pnpm install --frozen-lockfile"
Rel->>Rel: "pnpm release --publishOnly"
Rel->>NPM: "npm publish (OIDC provenance)"
NPM-->>Rel: "发布成功"
Rel->>GH: "release-tag 创建 Release"---
三、size-report.yml 与 autofix.yml:体积追踪与格式自愈
size-report.yml:跨 workflow 的体积回归报告
size-report.yml 的触发方式很特殊——它不是由 push 或 PR 直接触发,而是由另一个 workflow 的完成事件触发。
📎 .github/workflows/size-report.yml:3-7
on:
workflow_run:
workflows: ['size data']
types:
- completedworkflow_run 事件监听名为 size data 的 workflow 完成。这是一个两阶段设计:size-data.yml(本章未提供源码)负责在 PR 上构建并测量体积,把结果作为 artifact 上传;size-report.yml 在 size data 完成后,下载 artifact,生成报告,并评论到 PR 上。
📎 .github/workflows/size-report.yml:20-23
if: >
github.repository == 'vuejs/core' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'三重守卫:主仓库、PR 事件、上游 workflow 成功。如果 size data 失败了,报告 job 不会运行——因为没有数据可报告。
数据流转过程如下:
📎 .github/workflows/size-report.yml:41-46
- name: Download Size Data
uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
with:
name: size-data
run_id: ${{ github.event.workflow_run.id }}
path: temp/size从上游 workflow run 下载 size-data artifact 到 temp/size。然后并行读取 PR 编号和 base 分支:
📎 .github/workflows/size-report.yml:48-59
- parallel:
- name: Read PR Number
id: pr-number
uses: juliangruber/read-file-action@271ff311a4947af354c6abcd696a306553b9ec18 # v1.1.8
with:
path: temp/size/number.txt
- name: Read base branch
id: pr-base
uses: juliangruber/read-file-action@271ff311a4947af354c6abcd696a306553b9ec18 # v1.1.8
with:
path: temp/size/base.txtparallel 是 GitHub Actions 的语法糖,让两个无依赖的步骤同时执行。number.txt 和 base.txt 是 size-data.yml 在测量时写入的元数据文件。
接着下载 base 分支的历史体积数据用于对比:
📎 .github/workflows/size-report.yml:61-69
- name: Download Previous Size Data
uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
with:
branch: ${{ steps.pr-base.outputs.content }}
workflow: size-data.yml
event: push
name: size-data
path: temp/size-prev
if_no_artifact_found: warn注意 if_no_artifact_found: warn——如果 base 分支还没有历史数据(比如新分支),不会失败,只是警告。这保证了首次运行时报告仍能生成,只是没有对比基线。
最后生成报告并评论:
📎 .github/workflows/size-report.yml:71-89
- name: Prepare report
run: node scripts/size-report.js > size-report.md
- name: Read Size Report
id: size-report
uses: juliangruber/read-file-action@271ff311a4947af354c6abcd696a306553b9ec18 # v1.1.8
with:
path: ./size-report.md
- name: Create Comment
uses: actions-cool/maintain-one-comment-backup@fbbc22ad1809c1bcf46f19b58397b6254773588c # backup for v3.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ steps.pr-number.outputs.content }}
body: |
${{ steps.size-report.outputs.content }}
body-include: ''scripts/size-report.js 读取 temp/size 和 temp/size-prev 下的数据,生成 Markdown 报告。maintain-one-comment-backup action 用 body-include: '<!-- VUE_CORE_SIZE -->' 作为标记,确保同一个 PR 上只保留一条体积报告评论(更新而非追加)。注意 L81 的注释说明原 action 仓库被 GitHub 屏蔽,所以用了备份仓库并锁定 commit。
autofix.yml:格式问题的自动修复
autofix.yml 解决一个很实际的问题:贡献者提交的代码格式不符合 prettier/eslint 规范,CI 报错,贡献者需要手动跑 pnpm lint --fix 再提交。这个 workflow 把这一步自动化了。
📎 .github/workflows/autofix.yml:3-8
on:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}触发所有 PR,并发控制与 ci.yml 类似——同一个 PR 的新推送会取消旧的 autofix 运行。
📎 .github/workflows/autofix.yml:35-41
- name: Run eslint
run: pnpm run lint --fix
- name: Run prettier
run: pnpm run format
- uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8先跑 eslint 的 --fix,再跑 prettier 格式化,最后 autofix-ci/action 把修改后的文件直接提交回 PR 分支。注意 pnpm run format 本身就是格式化命令(不需要 --fix 标志,因为 format 脚本内部就是 prettier --write)。
〔设计推断与架构权衡〕
这个机制的关键在于 autofix-ci/action 会以 PR 作者的身份提交修复,而不是以 bot 身份。这样贡献者不需要额外操作,格式修复就自动出现在他们的 PR 里。但这也意味着如果贡献者的分支有保护规则(不允许 bot 推送),autofix 会失败——这是需要贡献者手动处理的边界情况。
size-report 数据流图
flowchart LR
subgraph "size-data.yml (上游)"
build_pr["构建 PR 分支"] --> measure["测量体积"]
measure --> artifact_pr["artifact: size-data\n(number.txt, base.txt, 体积数据)"]
end
subgraph "size-report.yml (下游)"
artifact_pr -->|"workflow_run 触发"| download["下载 size-data"]
download --> read_meta["读取 number.txt / base.txt"]
read_meta --> download_prev["下载 base 分支历史数据\n(if_no_artifact_found: warn)"]
download_prev --> gen_report["node scripts/size-report.js"]
gen_report --> comment["评论到 PR\n(标记: VUE_CORE_SIZE)"]
end---
设计思考:把规范固化为流水线
回顾这四个 workflow,可以看到几条贯穿始终的设计原则。
第一,权限最小化。 ci.yml 和 autofix.yml 都声明 permissions: contents: read,只有 release.yml 需要 contents: write 和 id-token: write。size-report.yml 需要 pull-requests: write 和 issues: write 来发评论。每个 workflow 只拿它真正需要的权限。
第二,供应链安全。 所有第三方 action 都锁定到 commit SHA,而非浮动 tag。size-report.yml L81 的注释更是直接说明原 action 仓库被屏蔽后切换到备份仓库并锁定 commit——这是对供应链攻击的实战防御。
第三,职责分离与复用。 test.yml 被 ci.yml 和 release.yml 共享,避免测试逻辑重复。size-data.yml 和 size-report.yml 分离,让测量和报告各自独立演进。
第四,失败方向的选择。 size-report.yml 的 if_no_artifact_found: warn 选择「警告而非失败」,因为缺少历史数据不应该阻断 PR。而 release.yml 的 needs: [test] 选择「测试失败即阻断发布」,因为发布是不可逆操作。
第五,并发控制的差异化。 PR 事件取消旧运行(cancel-in-progress: true),push 事件不取消(cancel-in-progress: false)。这个差异反映了两种事件的语义:PR 的旧提交已无意义,push 的每次提交都可能是最终状态。
---
本章小结
本章剖析了 Vue core 仓库的四个核心 workflow:
ci.yml:PR 门禁 + 持续预发布。通过if条件区分 push/PR 和 fork/同仓库,用concurrency取消过时的 PR 运行,用pkg-pr-new发布可安装的预发布包。release.yml:tag 触发的正式发布。三层守卫(仓库检查、needs test、environment 审批)确保只有通过测试且经审批的 tag 才能发布到 npm。size-report.yml:跨 workflow 的体积回归报告。通过workflow_run事件监听上游size data完成,下载 artifact 并对比 base 分支数据,以评论形式反馈到 PR。autofix.yml:格式自动修复。在 PR 上运行 eslint --fix 和 prettier,通过autofix-ci/action把修复直接提交回 PR 分支。
这四个 workflow 共同构成了一道「不可绕过的流水线」:代码规范由 autofix 自动修复,类型和测试由 ci.yml 强制检查,体积回归由 size-report 追踪,发布由 release.yml 在多重守卫下执行。
本章思考与自测
Q1: 如果将 ci.yml 中 cancel-in-progress 的值改为恒为 true(即去掉 github.event_name == 'pull_request' 的条件),在什么场景下会导致问题?
参考解析:cancel-in-progress 恒为 true 意味着 push 到 main 分支时,新的 push 会取消正在运行的旧 CI。考虑这个场景:main 分支上连续合并了两个 PR,第一个 PR 的 CI 正在运行(包含完整的 lint/typecheck/test),第二个 PR 的合并触发了新的 CI 运行。如果 cancel-in-progress 为 true,第一个 PR 的 CI 会被取消——但第一个 PR 的代码已经在 main 上了,它的 CI 结果对于判断 main 分支的健康状态至关重要。取消它意味着 main 分支上有一段代码从未被完整验证过。而 📎 .github/workflows/ci.yml:22-22 的条件 github.event_name == 'pull_request' 正是为了避免这个问题:只有 PR 事件才取消旧运行,push 事件永远不取消。
Q2: release.yml 中 release job 的 if: github.repository == 'vuejs/core' 和 environment: Release 分别防御什么场景?如果去掉其中一个会怎样?
参考解析:if: github.repository == 'vuejs/core' 📎 .github/workflows/release.yml:14 防御的是 fork 场景。如果有人 fork 了 vuejs/core 并推送一个 v3.99.0 tag,没有这个条件,workflow 会在 fork 仓库中运行 pnpm release --publishOnly。虽然 fork 仓库没有 npm token 无法真正发布,但会浪费 runner 资源并可能产生误导性的失败通知。environment: Release 📎 .github/workflows/release.yml:21 防御的是「tag 推送后自动发布」的风险——它允许配置人工审批,确保即使 tag 被推送,发布也需要维护者确认。如果去掉 if 条件,fork 会浪费资源;如果去掉 environment,任何有 tag 推送权限的人都能触发发布,没有最后的人工确认环节。两者是不同层次的防御,不能互相替代。
Q3: size-report.yml 中 if_no_artifact_found: warn 的选择与 release.yml 中 needs: [test] 的选择,分别体现了怎样的失败方向设计哲学?如果互换这两个策略会发生什么?
参考解析:if_no_artifact_found: warn 📎 .github/workflows/size-report.yml:69 选择「缺少历史数据时警告而非失败」,因为体积报告是辅助信息,不是阻断条件。如果改为 fail,那么新分支或首次运行的 PR 会因为找不到 base 数据而失败,这显然不合理。needs: [test] 📎 .github/workflows/release.yml:15 选择「测试失败即阻断发布」,因为发布是不可逆操作,必须确保代码质量。如果互换——size-report 在缺少数据时失败,release 在测试失败时仍然发布——前者会导致大量误报阻断正常 PR,后者会导致未经测试的代码进入 npm。这体现了「辅助信息宽松、不可逆操作严格」的失败方向设计原则。
---
下一章将深入体积预算机制的核心:scripts/size-report.js 如何解析体积数据、如何计算增量、如何格式化输出,以及 usage-size 的度量哲学——为什么 Vue 选择测量「实际使用体积」而非「完整包体积」。
从 PR 门禁到 tag 发布,四个 workflow 文件共同构成了一条不可绕过的自动化守门链。但流水线能阻断合并,前提是它掌握可量化的判断依据。下一章将聚焦 Vue 对包体积这一核心指标的工程化治理:scripts/size-report.js 如何计算各产物 gzip 后大小并与基线对比,scripts/usage-size.js 如何模拟真实用户引入场景估算实际开销,以及 CI 如何在体积超标时阻断合并。
读完了本章?为你自己的私有项目生成专属架构全景书
基于 Tauri 2 + Rust 本地原生引擎,100% 源码离线隐私安全,零代码上传云端。像阅读一本传世专著一样拆解你的复杂系统。
⚡ Tauri 2 · Rust 原生引擎 · 100% 离线私密安全 · 适配超百万行代码库
第 11 章:平台特定运行时:@vue/runtime-dom 的 DOM 操作与事件绑定
第 11 章:平台特定运行时:@vue/runtime-dom 的 DOM 操作与事件绑定
上一章我们看到,Vue 用 GitHub Actions 把 lint、类型检查、测试和体积追踪固化成不可绕过的流水线,其中 size-report.yml 与 size-data.yml 负责在每次改动后留下体积数据。但流水线只负责执行,真正回答「大了多少、大在哪里」的,是本章要拆解的两个脚本。体积预算的核心矛盾在于:包体积是一个只能感知、难以精确归因的指标。用户抱怨「Vue 太大了」时,维护者需要回答三个问题——大了多少?大在哪里?这次改动是否让它更大?scripts/size-report.js 负责对比,scripts/usage-size.js 负责归因,二者共同构成体积预算的度量哲学。
11.1 size-report:把体积差异变成可读的 Markdown 表格
直觉模型
想象你是一个物流公司的质检员。每个包裹(构建产物)出库前都要称重,而你的工作不是称重本身,而是把「今天的重量」和「昨天的重量」并排放在一张表上,用加粗的 +2.3 kB 标出哪些包裹变重了。若没有这张对比表,维护者只能看到一堆孤立的数字,无法判断某次 PR 是否引入了体积回归。
size-report.js 就是这个质检员。它不产生体积数据(那是 usage-size.js 和构建脚本的事),它只消费两个目录下的 JSON 文件,生成一份 Markdown 报告。
数据结构与目录约定
脚本的核心约定藏在两个常量里。当前数据目录是 temp/size,历史基线目录是 temp/size-prev。
📎 scripts/size-report.js:23-24
这两个目录的命名不是随意的:temp/size 由 size-data.yml 工作流在每次运行时生成并上传为 artifact 📎 .github/workflows/size-data.yml:53-57,而 temp/size-prev 则由 size-report.yml 在拉取基线 artifact 后解压得到。目录名本身就是数据流的契约。
脚本定义了三个类型别名,它们精确刻画了 JSON 文件的结构:
📎 scripts/size-report.js:8-21
SizeResult 有三个数值字段:size(未压缩)、gzip、brotli。BundleResult 在此基础上加了 file 字段用于显示文件名。UsageResult 则是一个 Record,键是 preset 名称,值是 SizeResult & { name: string }——注意这里多了一个 name 字段,因为 JSON 对象的键在 Object.values 之后会丢失,必须把名字冗余存进值里。
Step-by-Step Walkthrough
主流程极简,只有两步加一次输出:
📎 scripts/size-report.js:23-38
run() 先调用 renderFiles() 渲染产物文件表格,再调用 renderUsages() 渲染使用场景表格,最后把累积在模块级变量 output 中的字符串一次性写到 stdout 📎 scripts/size-report.js:25。这种「累积字符串再一次性输出」的模式避免了多次 process.stdout.write 的拼接开销,也让输出顺序完全可控。
第一步:收集文件列表并求并集。
📎 scripts/size-report.js:44-49
filterFiles 过滤掉两类文件:以 _ 开头的(如 _usages.json)和以 .txt 结尾的(如 number.txt、base.txt)。这两类文件是元数据,不是体积数据。然后取当前目录和历史目录文件名的并集 fileList——用 Set 去重。为什么要取并集?因为一个文件可能只存在于历史目录(本次构建删除了该产物),也可能只存在于当前目录(本次构建新增了产物)。两种情况都需要在报告中体现。
第二步:逐文件对比。
📎 scripts/size-report.js:43-75
对并集中的每个文件,分别从两个目录尝试导入 JSON。importJSON 的实现是「文件不存在返回 undefined」:
📎 scripts/size-report.js:112-115
这里用了动态 import() 配合 with: { type: 'json' } 导入断言,而不是 fs.readFileSync + JSON.parse。前者由 Node 的模块加载器处理,后者需要手动处理编码和解析错误。选择 import() 的代价是它返回 Promise,所以整个 renderFiles 是 async 的。
关键分支在 if (!curr):如果当前目录没有这个文件,说明该产物已被删除,用 Markdown 的删除线语法 ~~fileName~~ 标记 📎 scripts/size-report.js:60-61。否则正常渲染一行,每个数值后面拼接 getDiff 的结果。
第三步:计算差异。
📎 scripts/size-report.js:124-130
getDiff 有三个提前返回点:prev === undefined 时返回空串(没有基线,无法比较);diff === 0 时返回空串(无变化,不显示噪音);否则返回加粗的带符号差值。注意 prettyBytes(diff) 对负数也能正确处理,会输出 -1.2 kB 这样的形式,而 sign 变量只在正数时补 +。
第四步:渲染 usage 表格。
📎 scripts/size-report.js:80-103
renderUsages 与 renderFiles 的结构差异值得注意:它直接导入 _usages.json,因为 usage 数据固定存在这一个文件里。Object.values(curr) 把 Record 转成数组后,通过 prev?.[usage.name] 用名字查找历史数据——这正是 name 字段冗余存储的原因。.filter(usage => !!usage) 这一行实际上是冗余的,因为 map 总是返回数组元素,不会产生 falsy 值。
最后用 markdown-table 库把二维数组渲染成 Markdown 表格 📎 scripts/size-report.js:72-74。
flowchart TD
start["run()"] --> rf["renderFiles()"]
rf --> read_curr["readdir(temp/size)"]
rf --> read_prev{"existsSync(temp/size-prev)?"}
read_prev -->|是| read_prev_dir["readdir(temp/size-prev)"]
read_prev -->|否| empty_prev["prev = []"]
read_curr --> union["fileList = Set(curr ∪ prev)"]
read_prev_dir --> union
empty_prev --> union
union --> loop{"遍历 fileList"}
loop -->|每个 file| import_c["importJSON(currPath)"]
loop -->|每个 file| import_p["importJSON(prevPath)"]
import_c --> check_curr{"curr 存在?"}
check_curr -->|否| deleted["push(~~fileName~~)"]
check_curr -->|是| render_row["push(fileName, size+diff, gzip+diff, brotli+diff)"]
deleted --> loop
render_row --> loop
loop -->|遍历结束| ru["renderUsages()"]
ru --> import_u["importJSON(_usages.json)"]
import_u --> table["markdownTable 渲染"]
table --> out["process.stdout.write(output)"]设计思考与踩坑
〔设计推断与架构权衡〕
为什么用import()而非readFileSync? 动态import()对 JSON 的导入断言是 Node 20+ 的标准做法,它天然处理了 ESM 环境下的 JSON 加载。代价是无法在同步上下文中使用,且每次导入都会被模块缓存——但在这个一次性脚本中,缓存不是问题。
filterFiles 的 file[0] !== '_' 判断。 这个判断假设文件名非空。如果 readdir 返回空字符串(理论上不可能),file[0] 是 undefined,undefined !== '_' 为 true,不会误过滤。这是防御性编程的边界。
删除产物的处理。 当某个产物被删除时,报告用删除线标记而非直接移除。这是有意的设计:维护者需要看到「这个文件消失了」,而不是让它静默地从表格中消失。若直接过滤掉,读者会误以为该产物从未存在过。
11.2 usage-size:模拟真实用户的引入场景
直觉模型
size-report 告诉你「完整包有多大」,但这回答不了用户真正关心的问题:「我只用 createApp,实际要下载多少代码?」完整包体积包含了大量你可能永远用不到的代码(如 defineCustomElement、Transition、KeepAlive)。usage-size.js 的角色就是扮演一个「典型用户」:写一个只 import 特定 API 的虚拟入口文件,用 Rollup 打包,看最终产物有多大。
这就像餐厅不告诉你「厨房里所有食材总重 50 公斤」,而是告诉你「点一份宫保鸡丁,实际用到的食材是 300 克」。
数据结构:Preset 数组
脚本的核心数据结构是 presets 数组,每个元素描述一个使用场景:
📎 scripts/usage-size.js:27-55
Preset 类型有三个字段:name(显示名)、imports(从 Vue 导入的 API 列表)、可选的 replace(额外的编译期替换)。五个 preset 覆盖了从最小到最大的使用场景:
createApp (CAPI only):只导入createApp,并把__VUE_OPTIONS_API__替换为'false',模拟纯组合式 API 用户 📎scripts/usage-size.js:35-40createApp:只导入createApp,保留 Options API 📎scripts/usage-size.js:35-40createSSRApp:SSR 场景 📎scripts/usage-size.js:35-40defineCustomElement:Web Components 场景 📎scripts/usage-size.js:35-40overall:导入六个核心 API,模拟「全功能」用户 📎scripts/usage-size.js:44-54
入口文件固定为 runtime-only 的 esm-bundler 产物:
📎 scripts/usage-size.js:24-28
选择 vue.runtime.esm-bundler.js 而非完整版 vue.esm-bundler.js,是因为运行时版本不含模板编译器,更接近现代构建工具用户的实际情况——他们用 SFC 预编译模板,不需要运行时编译器。
Step-by-Step Walkthrough
第一步:并行生成所有 preset 的 bundle。
📎 scripts/usage-size.js:62-69
main() 为每个 preset 创建 generateBundle 的 Promise,用 Promise.all 并行执行。这里并行是安全的,因为每个 generateBundle 调用独立的 rollup(),互不共享状态。
第二步:构造虚拟入口。
📎 scripts/usage-size.js:94-96
这是整个脚本最精巧的部分。它不写临时文件到磁盘,而是构造一个虚拟模块 ID virtual:entry,内容是一个 re-export 语句:export { createApp } from '/absolute/path/to/vue.runtime.esm-bundler.js'。注意 entry 是绝对路径,因为 Rollup 需要能解析它。
第三步:配置 Rollup 插件链。
📎 scripts/usage-size.js:98-121
插件数组的顺序至关重要:
1. 自定义 usage-size-plugin:resolveId 拦截 virtual:entry 返回自身,load 返回虚拟内容 📎 scripts/usage-size.js:101-110。这是 Rollup 虚拟模块的标准模式。
2. nodeResolve():解析 vue.runtime.esm-bundler.js 内部的 import 📎 scripts/usage-size.js:111。
3. replace:注入编译期常量 📎 scripts/usage-size.js:112-119。
replace 插件的配置揭示了 esm-bundler 产物的核心机制:它保留了 __VUE_OPTIONS_API__、__VUE_PROD_DEVTOOLS__ 等运行时标志,由使用者的构建工具替换。这里脚本替用户做了替换:
process.env.NODE_ENV→"production":走生产分支__VUE_PROD_DEVTOOLS__→'false':关闭 devtools 支持__VUE_PROD_HYDRATION_MISMATCH_DETAILS__→'false':关闭 hydration 详细报错__VUE_OPTIONS_API__→'true':默认保留 Options API
然后展开 ...preset.replace,让 preset 可以覆盖默认值。createApp (CAPI only) preset 正是用这个机制把 __VUE_OPTIONS_API__ 改成 'false' 📎 scripts/usage-size.js:35-40。
preventAssignment: true 防止替换 obj.process.env.NODE_ENV = x 这类赋值语句 📎 scripts/usage-size.js:117。
第四步:生成、压缩、度量。
📎 scripts/usage-size.js:123-134
result.generate({}) 产出代码,取 output[0].code。然后用 SWC 压缩:
📎 scripts/usage-size.js:125-130
module: true 表示输入是 ESM,toplevel: true 允许压缩顶层作用域变量名。压缩后分别计算三个指标:minified.length(字节长度)、gzipSync(minified).length、brotliCompressSync(minified).length。
注意这里用的是 node:zlib 的同步 API,而非异步版本。在一次性脚本中,同步 API 更简洁,且压缩本身是 CPU 密集操作,异步不会带来并行收益。
第五步:输出与持久化。
📎 scripts/usage-size.js:62-86
结果先以人类可读格式打印到控制台,用 pico 着色 📎 scripts/usage-size.js:62-86。然后写入 temp/size/_usages.json,用 Object.fromEntries 把数组转回 Record,键是 preset 名 📎 scripts/usage-size.js:81-85。
--write 标志控制是否额外写出每个 preset 的未压缩 bundle 到磁盘 📎 scripts/usage-size.js:136-138,用于调试。
flowchart LR
subgraph preset_loop["presets 并行遍历"]
p1["Preset: createApp"]
p2["Preset: overall"]
end
p1 --> virtual["virtual:entry\n'export { createApp } from ...'"]
p2 --> virtual
virtual --> rollup["rollup({ input: virtual:entry })"]
rollup --> resolve["nodeResolve()\n解析 vue.runtime.esm-bundler.js"]
resolve --> replace["replace()\n__VUE_OPTIONS_API__ 等"]
replace --> gen["result.generate()\noutput[0].code"]
gen --> minify["swc.minify(module, toplevel)"]
minify --> metrics["size / gzipSync / brotliCompressSync"]
metrics --> json["_usages.json"]设计思考与踩坑
〔设计推断与架构权衡〕
为什么用虚拟模块而非临时文件? 临时文件需要处理路径、清理、并发写入冲突。虚拟模块把入口内容保留在内存中,Rollup 的resolveId/load钩子天然支持这种模式。代价是必须精确匹配 ID,任何拼写错误都会导致 Rollup 报「无法解析入口」。
replace 的 preventAssignment 陷阱。 如果不设 preventAssignment: true,replace 插件会对 process.env.NODE_ENV = 'x' 这样的赋值语句也做替换,产生 "production" = 'x' 的语法错误。Vue 源码中确实存在对 process.env.NODE_ENV 的赋值(在测试工具中),所以这个选项是必需的。
__VUE_OPTIONS_API__ 的默认值选择。 脚本把默认值设为 'true' 📎 scripts/usage-size.js:116,而非 'false'。这是保守选择:如果用户不配置,Vue 会保留 Options API 支持。createApp (CAPI only) preset 显式覆盖为 'false',展示关闭后的体积收益。这个对比本身就是给用户的文档:告诉用户「关掉 Options API 能省多少」。
并行 Promise.all 的失败语义。 如果任何一个 preset 的打包失败,Promise.all 会立即 reject,其他正在进行的打包不会被取消(Rollup 没有提供取消机制)。在 CI 中这意味着一次失败会浪费其他 preset 的计算,但脚本本身会以非零退出码结束,CI 能正确捕获。
11.3 从数据到门禁:CI 如何消费这些报告
数据流全景
理解这两个脚本,必须把它们放回 CI 流水线中。size-data.yml 在 push 到 main/minor 或 PR 时运行 pnpm run size 📎 .github/workflows/size-data.yml:45,产生 temp/size 目录,然后上传为 artifact 📎 .github/workflows/size-data.yml:53-57。
对于 PR,它还会额外写入两个元数据文件:
📎 .github/workflows/size-data.yml:47-51
number.txt 存 PR 编号,base.txt 存目标分支名。这两个文件正是 size-report.js 中 filterFiles 要过滤掉的 .txt 文件 📎 scripts/size-report.js:44-45。它们的存在是为了让下游的 size-report.yml 知道「该和哪个基线对比」。
基线的获取与对比
size-report.yml(上一章已详述)的工作流是:下载当前 PR 的 size-data artifact,下载目标分支的基线 artifact,把基线解压到 temp/size-prev,然后运行 size-report.js 生成 Markdown 报告并评论到 PR。
这里有一个关键的设计约束:size-report.js 本身不负责获取基线,它假设 temp/size-prev 已经存在。如果不存在,existsSync(prevDir) 返回 false,prev 为空数组 📎 scripts/size-report.js:48,所有 diff 都为空串。这是优雅降级:没有基线时报告仍然生成,只是不显示差异。
体积门禁的判定逻辑
〔设计推断与架构权衡〕
需要澄清一个常见误解:size-report.js本身不做门禁判定。它只生成报告,不返回退出码,不设置阈值。真正的门禁发生在size-report.yml工作流层面——它可能包含一个步骤,解析报告中的 diff 值,如果超过阈值则让 job 失败。
这种「度量与判定分离」的设计有深刻理由:度量脚本应该保持纯粹,只负责产生事实;判定逻辑应该在工作流层面,因为阈值可能随版本、分支、发布阶段而变化。把阈值硬编码进 size-report.js 会让它难以复用。
设计思考
为什么体积预算需要两套度量? 完整包体积和 usage 体积回答不同问题。完整包体积是「上限」——它告诉你最坏情况下用户要下载多少。usage 体积是「典型值」——它告诉你大多数用户实际下载多少。两者结合才能给出完整的体积画像。如果只有完整包体积,维护者会倾向于过度优化冷门 API;如果只有 usage 体积,可能忽略某些边缘场景的体积爆炸。
gzip 与 brotli 双指标的意义。 现代 CDN 普遍支持 brotli,但并非所有场景都启用。同时报告两者,让维护者能评估「在只支持 gzip 的环境下体积如何」。brotli 通常比 gzip 小 15-20%,这个差距本身就是有价值的信息。
数据格式的稳定性契约。 size-report.js 和 usage-size.js 通过 JSON 文件解耦。usage-size.js 写 _usages.json,size-report.js 读它。这个契约的字段名(name、size、gzip、brotli)是隐式的,没有 schema 校验。如果 usage-size.js 改了字段名而忘记同步 size-report.js,报告会静默显示错误数据。这是当前设计的脆弱点。
本章小结
本章思考与自测
Q1: size-report.js 的 filterFiles 过滤掉以 _ 开头的文件。如果 usage-size.js 把输出文件从 _usages.json 改名为 usages.json,会发生什么?
参考解析:filterFiles 的过滤条件是 file[0] !== '_' && !file.endsWith('.txt') 📎 scripts/size-report.js:44-45。如果文件改名为 usages.json,它不再以 _ 开头,会被 filterFiles 保留,进入 fileList 并集。然后 renderFiles 会尝试把它当作 bundle 文件处理:importJSON 能成功导入(它是合法 JSON),但它的结构是 Record<string, UsageResult> 而非 BundleResult,所以 curr?.file 是 undefined,fileName 为空串,curr.size 也是 undefined,prettyBytes(undefined) 会抛错或输出异常。这会导致报告生成失败。这个问题的根源是 filterFiles 用文件名前缀作为「元数据 vs 数据」的区分依据,而非用目录结构或显式清单。更健壮的做法是把 usage 数据放在子目录中,或维护一个显式的元数据文件列表。
Q2: usage-size.js 中 Promise.all(tasks) 并行执行所有 preset 的打包。如果某个 preset 的 replace 配置遗漏了 __VUE_OPTIONS_API__,会发生什么?为什么默认值设为 'true' 而非 'false'?
参考解析:replace 插件的配置中,__VUE_OPTIONS_API__: 'true' 是默认值,然后展开 ...preset.replace 允许覆盖 📎 scripts/usage-size.js:116-118。如果某个 preset 遗漏了配置,它会使用默认值 'true',即保留 Options API 支持,体积会偏大。默认值设为 'true' 是保守选择:它反映「用户不配置时的实际行为」。Vue 的 esm-bundler 产物中,__VUE_OPTIONS_API__ 的默认行为就是保留 Options API(除非用户显式关闭)。如果把默认值设为 'false',所有未显式配置的 preset 都会显示偏小的体积,误导用户以为「不配置就能省体积」。createApp (CAPI only) preset 显式设为 'false' 📎 scripts/usage-size.js:35-40,正是为了展示「显式关闭后的收益」,与默认值形成对比。
Q3: size-report.js 的 importJSON 使用动态 import() 而非 fs.readFileSync。如果 temp/size-prev 目录中的某个 JSON 文件损坏(非法 JSON),两种实现的行为有何不同?
参考解析:动态 import() 在解析非法 JSON 时会抛出 SyntaxError,且这个错误无法被 importJSON 内部的 existsSync 检查捕获——existsSync 只检查文件是否存在,不检查内容合法性 📎 scripts/size-report.js:112-115。错误会向上传播到 renderFiles,导致整个报告生成失败。如果用 fs.readFileSync + JSON.parse,同样会抛错,但可以在 importJSON 内部用 try-catch 包裹,返回 undefined 实现优雅降级。当前实现选择让错误传播,隐含假设是「artifact 中的 JSON 一定是合法的」——这个假设在 CI 环境中通常成立,因为文件是由 usage-size.js 和构建脚本生成的。但在本地调试时,如果手动修改了 JSON 文件导致损坏,报告会直接崩溃而非跳过该文件。这是一个「信任数据源」的设计选择。
---
体积预算机制解决了「度量什么」和「如何对比」的问题,但它依赖一个前提:构建产物本身是可复现的。下一章将进入最小调试沙盒:vite-debug 如何用最少的配置启动一个可交互的 Vue 开发环境,以及它如何与本地构建产物联动,形成从源码修改到运行时验证的闭环。
至此,体积预算的度量闭环已经清晰:size-report.js 用目录对比回答「大了多少」,usage-size.js 用虚拟模块模拟真实引入场景回答「大在哪里」,而门禁判定则留给工作流层。这套机制让体积回归从模糊的抱怨变成可追溯的数据。但数据只能告诉你问题存在,要真正定位和修复,还需要一个能快速复现问题的最小环境。下一章将进入 packages-private/vite-debug,看 Vue 如何用 Vite + SFC 搭建一个极简调试沙盒,把「在真实源码上做最小复现」变成可操作的日常实践。
读完了本章?为你自己的私有项目生成专属架构全景书
基于 Tauri 2 + Rust 本地原生引擎,100% 源码离线隐私安全,零代码上传云端。像阅读一本传世专著一样拆解你的复杂系统。
⚡ Tauri 2 · Rust 原生引擎 · 100% 离线私密安全 · 适配超百万行代码库
第 12 章:开发调试与生态工具链:sfc-playground 与 template-explorer 的工程化支撑
第 12 章:开发调试与生态工具链:sfc-playground 与 template-explorer 的工程化支撑
上一章我们完成了体积预算的度量闭环:size-report.js 回答「大了多少」,usage-size.js 回答「大在哪里」,工作流层负责门禁判定。但这套机制有一个隐含前提——构建产物本身是可复现的。当你发现某个包体积异常膨胀,或者某个运行时行为与预期不符时,你需要一个能快速加载本地源码、修改后立即看到效果的最小环境。packages-private/vite-debug 就是这个环境。它只有四个文件、总计不到 40 行代码,却构成了 Vue core 仓库中「在真实源码上做最小复现」的日常实践入口。本章将逐文件拆解这个沙盒的构造逻辑,并解释它为什么被放在 packages-private 而非 packages 目录下。
一、沙盒的骨架:main.ts 与 App.vue 的最小挂载链路
直觉模型
如果把整个 Vue 运行时比作一台发动机,那么 vite-debug 就是一台「裸机测试台」——没有外壳、没有仪表盘,只有最少的接线让发动机转起来。它的价值不在于功能完整,而在于排除一切干扰变量:当你怀疑某个 bug 出在响应式系统或渲染器内部时,你不会希望调试环境本身的复杂度成为噪音源。
数据结构与文件布局
先看 main.ts 的全部内容:
📎 packages-private/vite-debug/main.ts:4-4
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.mount('#app')这六行代码是 Vue 应用启动的标准范式,但每一行在调试场景下都有精确的工程含义:
- L1 的
import { createApp } from 'vue'中,'vue'这个模块标识符最终解析到什么,完全由vite.config.ts和package.json的依赖声明决定。这是整个沙盒最关键的一环——我们稍后会看到它如何被指向本地源码。 - L2 的
import App from './App.vue'触发了@vitejs/plugin-vue的 SFC 编译管线:Vite 在 dev server 启动时注册了这个插件,当浏览器请求App.vue时,插件将其拆解为<script>、<template>、<style>三个虚拟模块分别编译。 - L4 的
createApp(App)创建应用实例,此时 Vue 内部会初始化app._context、app._instance等核心字段,但尚未触发任何渲染。 - L6 的
app.mount('#app')是真正的启动开关:它会查找 DOM 中 id 为app的容器元素,创建根组件实例,触发首次渲染。
注意这里没有 index.html 的引用——Vite 的约定是项目根目录下的 index.html 作为入口 HTML,其中包含 <div id="app"></div> 和 <script type="module" src="/main.ts"></script>。这个文件虽然不在本章的 keyFiles 中,但它是 app.mount('#app') 能成功的前提。
场景驱动的 Walkthrough:一次点击的完整链路
现在看 App.vue,它是这个沙盒的「实验载体」:
📎 packages-private/vite-debug/App.vue:4-8
import { ref } from 'vue'
const count = ref(0)
{{ count }}
button {
color: red;
}
代入一个具象场景:当用户在浏览器中点击按钮时,发生了什么?
第一步:SFC 编译期(dev server 启动时)
@vitejs/plugin-vue 将 App.vue 编译为三个部分:
<script setup>块被编译为组件的setup()函数,ref(0)调用返回一个RefImpl对象,其.value初始为0。<template>块被编译为渲染函数,{{ count }}被转换为_toDisplayString(count.value),@click="count++"被转换为onClick: $event => (count.value++)。<style>块被编译为 CSS 模块,通过<style>标签注入 DOM。
第二步:首次渲染(app.mount 调用时)
createApp(App) 返回的 app 实例在 mount('#app') 时,会创建根组件的 ComponentInternalInstance,执行 setup() 得到 count 的 RefImpl,然后调用渲染函数生成 VNode 树。渲染函数中读取 count.value 会触发 track 收集依赖——当前活跃的渲染副作用(ReactiveEffect)被记录到 count 的 dep 中。
第三步:点击事件(用户交互时)
浏览器触发 click 事件,Vue 的事件处理器执行 count.value++。这是一个 setter 操作,触发 trigger:遍历 count.dep 中收集的副作用,调度重新渲染。由于是同步更新且不在批量队列中,渲染副作用被立即执行,重新调用渲染函数,生成新的 VNode,与旧 VNode 进行 diff,发现文本内容从 0 变为 1,更新真实 DOM 的 textContent。
整个链路可以用下面的数据流图表示:
flowchart LR
subgraph compile["编译期 (Vite Dev Server)"]
sfc["App.vue"] -->|"@vitejs/plugin-vue"| script["setup() 函数"]
sfc -->|"@vitejs/plugin-vue"| render["渲染函数"]
sfc -->|"@vitejs/plugin-vue"| style["CSS 模块"]
end
subgraph runtime["运行时 (浏览器)"]
script -->|"ref(0)"| refimpl["RefImpl { value: 0 }"]
render -->|"读取 count.value"| track["track() 收集依赖"]
click["用户点击"] -->|"count.value++"| trigger["trigger() 触发更新"]
trigger -->|"调度渲染副作用"| rerender["重新执行渲染函数"]
rerender -->|"diff + patch"| dom["更新真实 DOM"]
end
track -.->|"dep 记录 ReactiveEffect"| trigger这张图的关键在于:编译期产物和运行时行为之间的耦合点只有两个——ref(0) 返回的 RefImpl 对象,以及渲染函数中对 count.value 的读写。这意味着如果你想调试响应式系统的某个分支(比如 trigger 中的调度逻辑),你只需要在这个 App.vue 中构造对应的读写模式即可。
设计思考:为什么是 ref 而不是 reactive?
〔设计推断与架构权衡〕
选择ref(0)而非reactive({ count: 0 })作为默认示例,隐含了一个调试优先的考量:ref的.value访问路径更短,在调试器中展开RefImpl对象时能直接看到_value、dep、__v_isRef等内部字段,而reactive返回的 Proxy 对象在控制台中展开会触发 getter,可能干扰对原始状态的观察。对于「最小复现」场景,减少一层 Proxy 间接层意味着更少的变量。
---
二、别名解析:vite.config.ts 与 package.json 如何把 'vue' 指向本地源码
直觉模型
vite.config.ts 只有六行,但它是整个沙盒的「路由中枢」——决定了 import { createApp } from 'vue' 中的 'vue' 最终加载的是 npm 上的发布版本,还是仓库中正在开发的源码。如果没有正确的别名配置,你在 App.vue 中修改的代码可能根本没有触发你正在调试的那份 Vue 源码,调试就变成了「对着错误的靶子开枪」。
数据结构与解析链路
先看 vite.config.ts:
📎 packages-private/vite-debug/vite.config.ts:4-6
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
})这里没有显式的 resolve.alias 配置。那么 'vue' 是如何被解析到本地源码的?答案在 package.json 中:
📎 packages-private/vite-debug/package.json:1-15
{
"name": "vite-debug",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
},
"devDependencies": {
"@vitejs/plugin-vue": "catalog:",
"vite": "catalog:",
"vue": "workspace:*"
}
}关键在 L13:"vue": "workspace:*"。这是 pnpm workspace 协议的声明,表示 vite-debug 依赖的是 monorepo 中名为 vue 的本地包,而非 npm registry 上的版本。pnpm 会在 node_modules/vue 创建符号链接,指向 packages/vue(Vue 的主包目录)。
但这还不够——packages/vue 的 package.json 中 main/module/exports 字段通常指向构建产物(如 dist/vue.runtime.esm-bundler.js),而不是 src/ 下的源码。如果你修改了 packages/runtime-core/src/renderer.ts,但没有重新构建,Vite 加载的仍然是旧的 dist 文件。
〔设计推断与架构权衡〕
这就是为什么 Vue core 仓库的packages/vue/package.json中通常会配置"development"条件导出或类似的源码入口映射——在 dev 模式下,Vite 的resolve.conditions会优先匹配development条件,从而加载src/index.ts而非dist。这个机制使得vite-debug无需显式配置 alias,就能在修改源码后通过 HMR 立即看到效果。
场景驱动的 Walkthrough:一次 import 'vue' 的解析过程
代入场景:当 Vite dev server 收到浏览器对 main.ts 的请求,遇到 import { createApp } from 'vue' 时,解析链路是怎样的?
flowchart TD
req["浏览器请求 /main.ts"] --> parse["Vite 解析 import 'vue'"]
parse --> resolve{"resolve 条件匹配"}
resolve -->|"development 条件命中"| src_entry["packages/vue/src/index.ts"]
resolve -->|"仅 production 条件"| dist_entry["packages/vue/dist/vue.runtime.esm-bundler.js"]
src_entry -->|"源码模块图"| hmr["HMR 监听 src/ 变更"]
dist_entry -->|"预构建产物"| no_hmr["无源码级 HMR"]
hmr -->|"修改 renderer.ts"| reload["浏览器热更新"]
no_hmr -->|"修改 renderer.ts"| stale["仍加载旧产物"]
reload --> verify["验证行为变更"]
stale --> rebuild["需手动重新构建"]
rebuild --> verify这个流程图揭示了一个关键分支:如果 development 条件没有正确配置,修改源码后浏览器不会热更新,你会陷入「改了代码但行为没变」的困惑。排查方法是在浏览器 DevTools 的 Network 面板中查看 vue 模块的实际加载路径——如果看到 dist/ 路径,说明源码入口映射未生效。
设计思考:为什么不在 vite.config.ts 中显式写 alias?
〔设计推断与架构权衡〕
一个自然的疑问是:为什么不直接在vite.config.ts中写resolve: { alias: { vue: '../../packages/vue/src/index.ts' } }?这样做虽然直观,但有两个问题:
1. 破坏子路径导入:Vue 的公开 API 包含 vue/server-renderer、vue/compiler-sfc 等子路径。如果只 alias 了 'vue' 本身,子路径导入仍然会走 dist,导致部分模块来自源码、部分来自产物,行为不一致。
2. 绕过条件导出机制:Vue 的 package.json 中 exports 字段已经定义了完整的条件导出映射(development/production/browser/node 等),alias 会覆盖这套机制,使得调试环境与真实用户环境的解析行为产生偏差。
因此,vite-debug 选择「信任 workspace 协议 + 条件导出」的组合,让解析链路尽可能接近真实使用场景。这也解释了为什么 package.json 中 "vue": "workspace:*" 是必需的——它是触发 pnpm 符号链接、进而让 Vite 能通过 node_modules/vue 找到 packages/vue 的前提。
生产踩坑:catalog: 协议与版本漂移
注意 package.json 中 L11-L12 使用了 "catalog:" 协议:
"@vitejs/plugin-vue": "catalog:",
"vite": "catalog:",这是 pnpm 的 catalog 特性,表示版本号由 pnpm-workspace.yaml 中的 catalog 字段统一管理。它的作用是避免 monorepo 中多个包引用同一依赖时出现版本漂移。
〔设计推断与架构权衡〕
在调试场景下,这带来一个隐蔽的陷阱:如果你在vite-debug中遇到一个疑似 Vite 或 plugin-vue 的 bug,想临时升级版本验证,直接修改package.json中的catalog:是无效的——你需要修改pnpm-workspace.yaml中的 catalog 定义,这会影响所有使用该 catalog 的包。正确的做法是临时改为显式版本号(如"vite": "5.0.0"),验证完毕后再改回catalog:。
---
三、packages-private 的隔离设计:为什么调试沙盒不对外发布
直觉模型
packages-private 目录就像公司的「内部试验室」——里面的样品不对外销售,只用于测试和演示。它与 packages 目录物理隔离,避免调试代码被误发布到 npm。
隔离机制的三层保障
第一层:目录隔离
packages-private/vite-debug 不在 packages/ 下,而 pnpm-workspace.yaml 通常会将 packages/* 和 packages-private/* 都声明为 workspace 成员,但发布脚本(如 scripts/release.js)只会遍历 packages/ 下的包。
第二层:private: true
📎 packages-private/vite-debug/package.json:3
"private": true,这一行是 npm/pnpm 的硬性约束:标记为 private 的包永远无法被 npm publish 发布,即使手动执行也会被拒绝。这是防止误发布的最后一道防线。
第三层:无 version 字段
注意 package.json 中没有 version 字段。npm 规范要求可发布的包必须有 version,缺少该字段的包在 npm publish 时会报错。这是「双重保险」——即使 private 被误删,缺少 version 仍会阻止发布。
设计思考:调试沙盒与 Playground 的分工
Vue core 仓库中已经有一个功能完整的 SFC Playground(第 7 章讨论过),为什么还需要 vite-debug?
〔设计推断与架构权衡〕
两者的定位截然不同:
| 维度 | SFC Playground | vite-debug |
|---|---|---|
| 运行环境 | 浏览器内(编译也在浏览器) | Node.js + 浏览器 |
| 源码加载 | 通过 CDN 或预构建产物 | 直接加载本地源码 |
| 调试能力 | 受限于浏览器沙盒 | 可用 Node.js 调试器、断点 |
| 修改源码 | 不支持 | 支持 HMR |
| 适用场景 | 验证编译输出、分享复现 | 调试运行时内部行为 |
vite-debug 的核心价值在于它运行在真实的 Node.js 环境中,你可以用 node --inspect 附加调试器,在 packages/reactivity/src/effect.ts 中打断点,观察 ReactiveEffect 的创建和调度过程。这是 Playground 无法提供的。
生产踩坑:HMR 边界与状态丢失
〔设计推断与架构权衡〕
使用vite-debug调试时,一个常见的困惑是:修改App.vue中的count初始值后,浏览器中的计数没有重置。这是因为 Vite 的 HMR 对<script setup>块的处理是保留组件状态、只替换渲染函数。如果你需要完全重置状态,需要手动刷新页面,或者在App.vue中添加import.meta.hot?.invalidate()强制整页刷新。
另一个陷阱是:当你修改 packages/runtime-core/src/ 下的源码时,HMR 的传播链路可能不会自动触发——因为 vite-debug 的 HMR 边界定义在 App.vue 层面,而 packages/ 下的源码变更需要通过 Vite 的模块图传播。如果发现修改源码后浏览器无反应,检查 Vite 终端输出是否有 hmr update 日志;如果没有,可能需要重启 dev server。
---
本章小结
packages-private/vite-debug 用四个文件、不到 40 行代码,构建了一个完整的调试闭环:
1. main.ts 提供最小挂载链路:createApp(App).mount('#app'),排除一切非必要初始化逻辑。
2. App.vue 作为实验载体:ref + 模板插值 + 事件处理,覆盖响应式系统的主路径。
3. vite.config.ts + package.json 通过 workspace:* 协议和条件导出,将 'vue' 解析到本地源码,实现「改源码即生效」。
4. packages-private + private: true + 无 version 三层隔离,确保调试代码不会被误发布。
这个沙盒的工程哲学是:调试环境本身的复杂度应该趋近于零,把所有的复杂度留给被调试的源码。当你在 packages/reactivity 中遇到一个难以复现的 bug 时,vite-debug 提供了一个可以随意修改、立即验证的实验台。
本章思考与自测
Q1: 如果将 package.json 中的 "vue": "workspace:*" 改为 "vue": "^3.4.0",在 vite-debug 中修改 packages/reactivity/src/ref.ts 后,浏览器中的行为会发生什么变化?为什么?
参考解析:改为 "^3.4.0" 后,pnpm 会从 npm registry 下载 Vue 3.4.x 的发布版本,而非链接到本地 packages/vue 📎 packages-private/vite-debug/package.json:13。此时 import { createApp } from 'vue' 解析到的是 node_modules/.pnpm/vue@3.4.x/node_modules/vue/dist/vue.runtime.esm-bundler.js,即预构建产物。修改 packages/reactivity/src/ref.ts 不会触发任何 HMR,因为 Vite 的模块图中根本不包含这个文件。浏览器中运行的仍然是 npm 版本的 ref 实现。这个实验反向验证了 workspace:* 是源码级调试的必要条件。
Q2: App.vue 中 <style> 块没有加 scoped,如果在这个沙盒中同时挂载两个组件实例,样式会发生什么?这与 vite-debug 的调试目标有何关系?
参考解析:没有 scoped 时,button { color: red } 是全局样式 📎 packages-private/vite-debug/App.vue:4-8,会作用于页面中所有 <button> 元素。如果挂载两个组件实例,两个实例的按钮都会变红。这与调试目标的关系在于:vite-debug 的定位是「最小复现」,而非「样式隔离验证」。省略 scoped 减少了编译期注入 data-v-xxx 属性的变量,使得调试器中的 DOM 结构更干净。如果你需要调试 scoped 样式的编译逻辑,应该显式添加 scoped 并观察 @vitejs/plugin-vue 生成的属性注入代码。
Q3: 假设你在 packages/runtime-core/src/renderer.ts 的 patch 函数中加了一行 console.log,但浏览器控制台没有输出。请列出至少三种可能的原因,并说明如何逐一排查。
参考解析:
原因一:源码入口未生效。'vue' 解析到了 dist 产物而非 src。排查:在 DevTools Network 面板查看 vue 模块的加载路径,如果是 dist/ 开头,说明条件导出未命中 development 条件 📎 packages-private/vite-debug/package.json:13。
原因二:HMR 未传播。Vite 的模块图没有将 packages/runtime-core/src/renderer.ts 的变更传播到 vite-debug。排查:查看 Vite 终端是否有 hmr update 日志;如果没有,重启 dev server。
原因三:patch 函数未被调用。如果当前页面没有触发任何 DOM 更新(比如没有点击按钮),patch 可能只在首次挂载时执行一次,而首次挂载发生在你添加 console.log 之前。排查:刷新页面,或在 App.vue 中添加一个触发更新的操作。
原因四(补充):构建缓存。Vite 的依赖预构建缓存(node_modules/.vite)可能仍然使用旧版本。排查:删除 node_modules/.vite 后重启。
---
体积预算告诉你「问题存在」,vite-debug 让你「亲手复现问题」。但当你试图把这个沙盒模式推广到整个 monorepo 时,会遇到一系列边界条件:workspace 协议在 CI 环境下的解析差异、catalog: 版本锁定的升级困境、packages-private 与 packages 之间的依赖方向约束……下一章将进入架构权衡与避坑指南,系统梳理 monorepo 工程化在真实项目中暴露的边界条件。
至此,我们完成了从体积度量到最小复现的工程闭环:vite-debug 用极简的四个文件,把「在真实源码上快速验证」变成了日常可用的实践。但当你真正开始复刻这套体系时,会发现更多隐藏的权衡——为什么 packages-private 必须与 packages 物理隔离?为什么枚举内联必须在 Rollup 之前完成?下一章将汇总前十二章暴露的关键决策点与生产踩坑记录,为你提供一份完整的避坑清单与决策依据。
读完了本章?为你自己的私有项目生成专属架构全景书
基于 Tauri 2 + Rust 本地原生引擎,100% 源码离线隐私安全,零代码上传云端。像阅读一本传世专著一样拆解你的复杂系统。
⚡ Tauri 2 · Rust 原生引擎 · 100% 离线私密安全 · 适配超百万行代码库
第 13 章:性能优化与打包权衡:Tree-shaking、Feature Flags 与 Rollup 插件设计
第 13 章:性能优化与打包权衡:Tree-shaking、Feature Flags 与 Rollup 插件设计
上一章我们以 packages-private/vite-debug 为切口,掌握了在真实源码上做最小复现的调试范式。当这种内部调试包越来越多,一个现实问题便浮出水面:它们与对外发布的正式包共处同一 workspace,如何确保发布流程不会误伤?本章将深入 monorepo 工程化的边界条件,从 packages 与 packages-private 的双目录契约出发,剖析架构权衡背后的防御性设计,并给出可落地的避坑指南。
13.2 时序铁律:枚举内联必须先于 Rollup 执行
直觉模型
枚举内联就像「在装箱前把零件上的标签换成数字」。如果装箱工人(Rollup)已经开始打包,你再去改标签,箱子里的零件和标签就对不上了。build.js 用 scanEnums() / removeCache() 这对函数把内联严格夹在 Rollup 之前。
数据结构与生命周期
inline-enums.js 导出的 scanEnums() 返回一个 removeCache 闭包,它扫描源码中的 enum 定义,生成临时文件供 Rollup 消费 📎 scripts/build.js:30-34。build.js 的 run() 用 try/finally 保证缓存清理 📎 scripts/build.js:81-112:
const removeCache = scanEnums()
try {
// ... buildAll / checkAllSizes / build-dts
} finally {
removeCache()
}rollup.config.js 在模块顶层调用 inlineEnums() 拿到 [enumPlugin, enumDefines] 📎 rollup.config.js:47-50,其中 enumPlugin 插入 plugins 数组 📎 rollup.config.js:331-331,enumDefines 并入 replace 插件的替换表 📎 rollup.config.js:222-223。
Step-by-Step:一次构建中枚举的完整生命周期
1. build.js 的 run() 首先调用 scanEnums(),扫描所有包的 enum 定义并写入临时缓存,返回 removeCache 📎 scripts/build.js:87-87。
2. buildAll 并发启动多个 Rollup 进程 📎 scripts/build.js:119-121。
3. 每个 Rollup 进程在配置加载阶段执行 inlineEnums(),读取上一步生成的缓存,得到 enumPlugin 与 enumDefines 📎 rollup.config.js:47-50。
4. enumPlugin 在 transform 阶段把源码中的 enum 引用替换为字面量;enumDefines 作为 replace 的补充,处理跨模块的常量替换 📎 rollup.config.js:222-223。
5. 构建结束,finally 块调用 removeCache() 清理临时文件 📎 scripts/build.js:119-121。
flowchart LR
src["源码 enum 定义"] --> scan["scanEnums()scripts/inline-enums.js"]
scan --> cache["临时缓存文件"]
cache --> inline["inlineEnums()rollup.config.js"]
inline --> plugin["enumPlugintransform 阶段替换"]
inline --> defines["enumDefinesreplace 替换表"]
plugin --> bundle["Rollup 产物字面量已内联"]
defines --> bundle
bundle --> cleanup["removeCache()finally 块"]设计思考与踩坑
〔设计推断与架构权衡〕
为什么不用 Rollup 插件在 transform 阶段现扫现用?因为枚举内联需要跨包全局视图:runtime-core引用的 enum 可能定义在shared中,单个 Rollup 进程只看到自己包的源码树,无法完成跨包替换。scanEnums()在构建前建立全局缓存,正是为了解决这个可见性问题。
生产踩坑点:removeCache() 放在 finally 中,意味着即使构建中途抛错也会清理。但如果你在调试时手动中断进程(Ctrl+C),finally 可能不执行,残留的缓存文件会导致下次构建读到过期枚举。排查方法:检查 temp/ 目录下是否有残留的 enum 缓存文件,手动删除后重试。
---
13.3 发布编排器:release.js 的 skip 标志位矩阵
直觉模型
release.js 像婚礼总导演,skipBuild / skipTests / skipGit / skipPrompts 四个开关就是「跳过彩排」「跳过宣誓」「跳过拍照」「跳过确认」的按钮。每个按钮的存在都对应一种真实场景:CI 环境需要 skipPrompts,本地调试需要 skipGit,紧急热修需要 skipTests。
标志位的数据结构与默认值
四个 skip 标志在 parseArgs 中声明 📎 scripts/release.js:39-50,随后解构为局部变量 📎 scripts/release.js:64-66:
let skipTests = args.skipTests
const skipBuild = args.skipBuild
const skipPrompts = args.skipPrompts
const skipGit = args.skipGit注意 skipTests 用 let 声明,因为它在 runTestsIfNeeded() 中会被动态改写 📎 scripts/release.js:281-317。
Step-by-Step:一次 release 的完整决策流
main() 的执行顺序 📎 scripts/release.js:143-279:
1. 远程同步检查:isInSyncWithRemote() 比对本地 HEAD 与远程分支 SHA,不一致时弹确认框 📎 scripts/release.js:337-363。
2. 版本选择:无位置参数时弹出 versionIncrements 选择菜单 📎 scripts/release.js:152-176。
3. 测试决策:runTestsIfNeeded() 是 skip 逻辑最密集的地方 📎 scripts/release.js:281-317。
4. 版本更新:updateVersions() 遍历所有包改写 package.json 📎 scripts/release.js:377-398。
5. Changelog 生成:调用 pnpm run changelog 📎 scripts/release.js:211-212。
6. Git 提交:skipGit 为真时整段跳过 📎 scripts/release.js:231-240。
7. 发布:仅当 args.publish 为真时执行 buildPackages() + publishPackages() 📎 scripts/release.js:243-246。
runTestsIfNeeded() 的分支逻辑值得单独展开:
flowchart TD
entry["runTestsIfNeeded()"] --> skipFlag{"skipTests?"}
skipFlag -->|是| done["Tests skipped"]
skipFlag -->|否| ci["getCIResult()"]
ci --> ciPass{"CI passed?"}
ciPass -->|是| promptMode{"skipPrompts?"}
promptMode -->|是| setSkip["skipTests = true"]
promptMode -->|否| ask["prompt: Skip local tests?"]
ask --> setSkip2["skipTests = promptSkipTests"]
ciPass -->|否| noPrompt{"skipPrompts?"}
noPrompt -->|是| throwErr["throw ErrorCI not passed"]
noPrompt -->|否| runLocal["run('pnpm', ['run','test','--run'])"]
setSkip --> done
setSkip2 --> done
runLocal --> done设计思考与踩坑
〔设计推断与架构权衡〕
skipTests用let而非const的设计,是为了支持「CI 已通过则自动跳过本地测试」的优化路径。这在 CI 发布场景下节省了大量时间——GitHub Actions 的release.yml已经跑过完整测试,本地再跑一遍纯属浪费。
发布顺序的隐藏契约:sortPackagesForPublishing 把 vue 排到最后 📎 scripts/release.js:85-85,注释明确说明「用户不能在内部包可用之前安装新的入口包」。如果你修改了这个排序,用户 npm install vue@next 时可能拉到依赖尚未发布的版本,导致 ERR_MODULE_NOT_FOUND。
幂等性保护:publishPackage 在发布前调用 isPackagePublished 检查 registry 📎 scripts/release.js:453-458,发布失败时捕获 previously published 错误并降级为跳过 📎 scripts/release.js:480-488。这让 release 脚本可以安全重试——网络中断后重新执行不会因为「包已存在」而整体失败。
失败回滚:fnToRun().catch() 在 versionUpdated 为真时调用 updateVersions(currentVersion) 回滚版本号 📎 scripts/release.js:528-537。但注意:这只回滚 package.json 中的版本字段,不会回滚已经 git commit 的提交。如果你在 skipGit 为假的情况下发布失败,需要手动 git reset。
---
设计思考:三个权衡的共性模式
回顾本章三个核心权衡,它们共享同一个设计哲学:把「容易忘记的运行时检查」转化为「不可能绕过的结构性约束」。
packages-private物理隔离:不依赖脚本作者记得检查private字段,而是让扫描范围天然排除。- 枚举内联前置:不依赖 Rollup 插件在 transform 时「碰巧」能看到跨包 enum,而是构建前建立全局缓存。
release.js的 skip 矩阵:不依赖发布者记得「CI 已过就不用本地跑测试」,而是让脚本自动查询 CI 状态并改写skipTests。
〔设计推断与架构权衡〕
这种模式的代价是脚本复杂度上升:build.js需要维护privatePackages列表,rollup.config.js需要重复目录探测逻辑,release.js需要处理四个 skip 标志的交叉组合。但对于 Vue 这种每周多次发布的仓库,结构性约束带来的可靠性收益远超复杂度成本。
---
本章小结
本章从源码出发,拆解了 Vue core 工程化体系的三个关键边界条件:
1. packages-private 与 packages 的物理隔离由 workspace glob、build.js 目录探测、release.js 过滤三处共同保证 📎 pnpm-workspace.yaml:1-3📎 scripts/build.js:153-170📎 scripts/release.js:68-83。
2. 枚举内联的时序约束由 scanEnums() / removeCache() 的 try/finally 结构强制保证,Rollup 配置在模块顶层消费缓存 📎 scripts/build.js:81-112📎 rollup.config.js:47-50。
3. release.js 的 skip 标志位矩阵服务于 CI 发布、本地调试、紧急热修三种场景,skipTests 的动态改写和发布顺序排序是两个最容易被忽略的隐藏契约 📎 scripts/release.js:281-317📎 scripts/release.js:85-85。
本章思考与自测
Q1: 如果把 build.js 中 build(target) 函数里的 privatePackages.includes(target) 判断去掉,统一用 packages 作为 pkgBase,在什么场景下会出问题?
参考解析:build.js:160-164 的目录探测是私有包能被构建的唯一入口。去掉后,nr build vite-debug 会在 packages/vite-debug 下查找 package.json,而该目录不存在,fs.readFileSync 直接抛 ENOENT。更隐蔽的问题是:如果未来有人在 packages/ 下创建了同名目录,构建会静默使用错误目录的配置,产物路径和 buildOptions 全部错位。此外,rollup.config.js:37-42 有独立的目录探测逻辑,两处必须同步修改,否则会出现「build.js 找到了包但 Rollup 找不到」的不一致状态。
Q2: release.js 的 runTestsIfNeeded() 中,skipTests ||= isCIPassed 这行代码(release.js:285)在 skipPrompts 为真且 CI 未通过时会走哪条分支?如果去掉 else if (skipPrompts) 分支的 throw,会有什么后果?
参考解析:当 skipPrompts 为真且 CI 未通过时,skipTests ||= isCIPassed 中 isCIPassed 为 false,skipTests 保持原值(通常为 false)。随后进入 else if (skipPrompts) 分支,抛出 Error(release.js:299-304)。如果去掉这个 throw,代码会继续执行到 if (!skipTests) 分支,在无交互环境下运行 pnpm run test --run。这在 CI 中可能导致测试因环境差异而失败,或者更糟——测试通过但 CI 实际未通过(比如 CI 跑的是不同的测试子集),发布出未经完整验证的版本。
Q3: rollup.config.js:55 的 inlineEnums() 在模块顶层调用,而 build.js:87 的 scanEnums() 在 run() 函数内调用。如果交换这两者的执行时机(即让 inlineEnums() 在 Rollup 的 buildStart 钩子中调用),会破坏什么?
参考解析:scanEnums() 必须在所有 Rollup 进程启动之前完成,因为它需要扫描所有包的源码来建立全局 enum 缓存。inlineEnums() 在 rollup.config.js 模块顶层调用,此时 Rollup 尚未开始任何构建,缓存已经就绪。如果改为在 buildStart 中调用,每个 Rollup 进程会独立扫描——但 buildAll 是并发执行的(build.js:119-121),多个进程同时扫描同一批文件会产生竞态:进程 A 可能读到进程 B 尚未写完的缓存文件,导致 enum 替换不完整。更严重的是,scanEnums() 返回的 removeCache 闭包依赖扫描时的文件句柄状态,并发场景下清理时机无法协调。
双目录契约、构建脚本的归属判定、发布脚本的二次过滤——这些机制共同划定了 monorepo 工程化的安全边界。但边界并非一成不变:随着构建工具从 Rollup 向 Rolldown 迁移、类型测试与运行时测试走向融合,现有的权衡策略也将面临新的挑战。下一章,我们将基于 3.0 至 3.4 的变更轨迹,展望下一代工程化体系的演进方向。
读完了本章?为你自己的私有项目生成专属架构全景书
基于 Tauri 2 + Rust 本地原生引擎,100% 源码离线隐私安全,零代码上传云端。像阅读一本传世专著一样拆解你的复杂系统。
⚡ Tauri 2 · Rust 原生引擎 · 100% 离线私密安全 · 适配超百万行代码库
第 14 章:架构演进与未来展望:Vue 3 源码的设计权衡与避坑指南
第 14 章:架构演进与未来展望:Vue 3 源码的设计权衡与避坑指南
上一章我们梳理了 Vue core 工程化体系的「安全边界」——双目录契约、构建脚本归属判定、发布脚本二次过滤,这些机制并非一次性设计,而是在 3.0 到 3.4 的迭代中被反复打磨出来的。本章换一个视角:不再看「现在长什么样」,而是看「它是怎么长成现在这样的」,并据此推断下一代工程化体系会往哪里走。本章的源码材料是 changelogs/CHANGELOG-3.3.md、changelogs/CHANGELOG-3.4.md 以及仓库根部的 package.json。变更日志看起来只是「修了什么 bug」的流水账,但它是工程化体系最真实的体检报告:每一次 build: 前缀的提交、每一次 types: 前缀的改动、每一次依赖版本的回退,都在暴露当前架构的应力点。我们要做的,是从这些应力点里读出演进方向。把变更日志当作「工程化体系的观测窗口」而非「功能清单」,是本章的核心方法论。功能变更告诉我们 Vue 能做什么,而构建、类型、CI 相关的变更告诉我们 Vue 的工程化体系「在哪里疼」。
一、构建工具链的应力点:从 Rollup 到 Rolldown 的迁移势能
直觉模型
把构建工具链想象成一条装配流水线:Rollup 是主装配台,esbuild 负责快速切割(转译 TS),terser 负责最后打包压缩。当产品(Vue 运行时)越来越复杂,装配台上的工序越来越多,主装配台本身就成了瓶颈。Rolldown 的定位,就是用 Rust 重写的主装配台——它要替换的不是 esbuild,而是 Rollup 本身。
若没有这层演进压力,系统面临的「灾难」不是崩溃,而是构建时间随包数量线性膨胀:每加一个子包,就要多起一个 Rollup 进程,多扫描一遍 enum 缓存,多跑一轮 dts 生成。
数据结构与依赖布局
先看当前工具链的静态快照。package.json 的 devDependencies 是一份精确的「装配台清单」:
📎 package.json:103-106
"rollup": "^4.63.3",
"rollup-plugin-dts": "^6.5.1",
"rollup-plugin-esbuild": "^6.2.1",
"rollup-plugin-polyfill-node": "^0.13.0",这里能读出三个关键事实。第一,Rollup 主版本是 ^4.63.3,处于 Rollup 4.x 的成熟期。第二,rollup-plugin-esbuild 承担 TS 转译,意味着 Rollup 本身不解析 TS,只处理 esbuild 吐出的 JS。第三,rollup-plugin-dts 独立负责 .d.ts 打包,这正是上一章讨论的 dts-built-test 独立性的物质基础。
再看构建脚本的入口编排:
📎 package.json:8-9
"build": "node scripts/build.js",
"build-dts": "tsc -p tsconfig.build.json --noCheck && rollup -c rollup.dts.config.js",build-dts 是「两段式」的:先 tsc --noCheck 生成原始声明文件(--noCheck 跳过类型检查,只做 emit),再用 rollup -c rollup.dts.config.js 把散落的 .d.ts 打包成单文件。这个设计本身就是对 Rollup 能力的依赖——rollup-plugin-dts 需要 Rollup 的模块图来追踪类型依赖。
场景驱动:一次 build: 提交暴露了什么
变更日志里 build: 前缀的条目,是构建工具链应力点的直接证据。我们挑三条来看。
第一条,3.4.32 的 minify 配置对齐:
📎 changelogs/CHANGELOG-3.4.md:84
* **build:** use consistent minify options from previous terser config ([789675f](https://github.com/vuejs/core/commit/789675f65d2b72cf979ba6a29bd323f716154a4b))这条提交的动机是「从 terser 迁移到 esbuild minify 后,压缩选项不一致」。它揭示了一个迁移中的中间态:Vue 曾用 terser 做压缩,后来改用 esbuild(devDependencies 里的 esbuild: ^0.28.2 印证了这点),但压缩选项没有完全对齐,导致产物体积或行为出现偏差。这正是「换装配台零件」时的典型代价。
第二条,3.4.38 的 entities 版本回退:
📎 changelogs/CHANGELOG-3.4.md:6
* **build:** revert entities to 4.5 to avoid runtime resolution errors ([f349af7](https://github.com/vuejs/core/commit/f349af7b65b9f8605d8b7bafcc06c25ab1f2daf0)), closes [#11603](https://github.com/vuejs/core/issues/11603)entities 是 HTML 实体解码库,被 compiler-dom 依赖。回退到 4.5 是因为新版本在运行时解析上出问题。这条提交说明:构建工具链的依赖升级不是孤立的,一个间接依赖的版本跳动会穿透到运行时行为。
第三条,3.4.29 的 server-renderer cjs 构建污染:
📎 changelogs/CHANGELOG-3.4.md:155
* **build:** fix accidental inclusion of runtime-core in server-renderer cjs build ([11cc12b](https://github.com/vuejs/core/commit/11cc12b915edfe0e4d3175e57464f73bc2c1cb04)), closes [#11137](https://github.com/vuejs/core/issues/11137)这是最典型的一类构建 bug:CJS 格式下,server-renderer 意外把 runtime-core 打进了自己的产物。原因通常是 Rollup 的 external 判定在 CJS 格式下失效——ESM 能靠 import 语句静态识别外部依赖,CJS 的 require 动态性更强,容易漏判。这条提交直接指向了 Rollup 配置中 external 逻辑的脆弱性。
迁移势能的 Mermaid 刻画
下面这张图刻画了当前构建流水线的控制流,并标出了 Rolldown 迁移会触及的节点:
flowchart TD
start["node scripts/build.js"] --> scan["scanEnums() 全局扫描"]
scan --> cache_ok{"enum 缓存就绪?"}
cache_ok -->|否| err_enum["抛出错误 / 中断构建"]
cache_ok -->|是| build_all["buildAll() 并发启动"]
build_all --> rollup_proc["每个包一个 Rollup 进程"]
rollup_proc --> inline["inlineEnums() 顶层调用"]
inline --> esbuild_plugin["rollup-plugin-esbuild 转译 TS"]
esbuild_plugin --> external_check{"external 判定"}
external_check -->|ESM 格式| ext_ok["静态 import 识别成功"]
external_check -->|CJS 格式| ext_risk["require 动态性导致漏判"]
ext_risk --> pollution["runtime-core 被打进 server-renderer"]
ext_ok --> output["产物输出"]
pollution --> output
output --> dts["build-dts 两段式生成"]
dts --> tsc_emit["tsc --noCheck 生成原始 d.ts"]
tsc_emit --> rollup_dts["rollup-plugin-dts 打包"]
rollup_dts --> done["构建完成"]〔设计推断与架构权衡〕
Rolldown 的迁移价值在于:它把「每个包一个进程」的并发模型换成「单进程内并行」的模型,scanEnums()的全局扫描和inlineEnums()的替换可以在同一个 Rust 运行时内协调,上一章讨论的「并发扫描竞态」问题会从根上消失。但迁移的阻力也在这里——rollup-plugin-esbuild、rollup-plugin-dts这些插件生态需要 Rolldown 提供兼容层,而external判定逻辑需要重写。
设计思考与踩坑
为什么迁移不会一蹴而就? 看 package.json 的 engines 字段:
📎 package.json:61-63
"engines": {
"node": ">=20.0.0"
},Node 20 是硬性下限。Rolldown 作为 Rust 原生模块,需要对应的 N-API 绑定和预编译二进制分发。一旦引入,pnpm install 的耗时、跨平台(Windows/macOS/Linux)的二进制兼容性、CI 缓存策略都要重新设计。这不是「换个依赖」那么简单,而是整条安装-构建-缓存链路的重新校准。
生产踩坑点:build-dts 的 tsc --noCheck 是个双刃剑。跳过类型检查让 emit 变快,但意味着 .d.ts 生成阶段不会发现类型错误——类型错误只能靠 pnpm check(tsc --incremental --noEmit)和 test-dts 兜底。如果 Rolldown 迁移后想合并这两步,必须确保类型检查不会拖慢构建,否则就违背了 --noCheck 的初衷。
---
二、类型测试与运行时测试的融合趋势
直觉模型
把类型测试和运行时测试想象成两道独立的质检关卡:一道检查「说明书(.d.ts)写得对不对」,一道检查「机器(运行时)转得对不对」。两道关卡各自有独立的工位、独立的工具、独立的报告。融合趋势的意思是:能不能让同一份测试用例同时验证说明书和机器?
若没有融合,系统面临的灾难是类型与运行时行为漂移:.d.ts 说 ref() 返回 Ref<T>,但运行时实际返回的对象形状变了,类型测试通过、运行时测试也通过,但两者组合起来是错的。
数据结构:测试脚本的编排布局
package.json 的 scripts 里,测试相关的条目清晰地分成两组:
📎 package.json:19-24
"test": "vitest",
"test-unit": "vitest --project unit*",
"test-e2e": "node scripts/build.js vue -f global -d && vitest --project e2e --project e2e-browser",
"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",
"test-coverage": "vitest run --project unit* --coverage",这里的关键结构是 test-dts 的 run-s build-dts test-dts-only——它是串行的:先构建 .d.ts,再跑类型测试。而 test-dts-only 内部又是两个独立的 tsc 进程:一个跑 dts-built-test(验证构建产物),一个跑 dts-test(验证源码类型)。
注意 test-unit 用的是 vitest --project unit*,test-e2e 用的是 vitest --project e2e --project e2e-browser。这说明 Vitest 的 --project 机制已经把测试按「单元/端到端/浏览器」分成了不同的 project。融合的物理基础已经存在:Vitest 的 project 机制允许在同一个 runner 里跑不同类型的测试。
场景驱动:一次 types: 提交的完整路径
变更日志里 types: 前缀的条目密度极高,这是类型系统复杂度的直接体现。我们追踪一条典型的类型修复。
3.4.37 的 ref 类型回退:
📎 changelogs/CHANGELOG-3.4.md:23-24
* Revert "fix(types/ref): allow getter and setter types to be unrelated ([#11442](https://github.com/vuejs/core/issues/11442))" ([b1abac0](https://github.com/vuejs/core/commit/b1abac06cdb198bd72f8e614b1f68b92e1c78339))
* Revert "fix(types/ref): correct type inference for nested refs ([#11536](https://github.com/vuejs/core/issues/11536))" ([3a56315](https://github.com/vuejs/core/commit/3a56315f94bc0e11cfbb288b65482ea8fc3a39b4))两条连续的 Revert,回退了两个类型修复。注意 3.4.35 里这两个修复刚被合入:
📎 changelogs/CHANGELOG-3.4.md:55
* **types/ref:** allow getter and setter types to be unrelated ([#11442](https://github.com/vuejs/core/issues/11442)) ([e0b2975](https://github.com/vuejs/core/commit/e0b2975ef65ae6a0be0aa0a0df43fb887c665251))📎 changelogs/CHANGELOG-3.4.md:30
* **types/ref:** correct type inference for nested refs ([#11536](https://github.com/vuejs/core/issues/11536)) ([536f623](https://github.com/vuejs/core/commit/536f62332c455ba82ef2979ba634b831f91928ba)), closes [#11532](https://github.com/vuejs/core/issues/11532) [#11537](https://github.com/vuejs/core/issues/11537)从 3.4.35 合入到 3.4.37 回退,中间只隔了一个补丁版本。这个「合入-回退」的快速循环,暴露了类型测试的一个根本困境:类型测试能验证「类型签名符合预期」,但验证不了「这个类型签名在真实代码里是否好用」。allow getter and setter types to be unrelated 在类型测试里可能完全通过,但实际使用时会让 ref 的类型推断变得过于宽松,破坏下游代码的类型安全。
类型测试融合的 Mermaid 刻画
下面这张图刻画了当前类型测试与运行时测试的分离结构,以及融合后的目标形态:
flowchart LR
subgraph current["当前:分离的两条链路"]
src["packages/*/src/*.ts"] --> tsc_build["tsc -p tsconfig.build.json --noCheck"]
tsc_build --> raw_dts["散落的 .d.ts"]
raw_dts --> rollup_dts["rollup -c rollup.dts.config.js"]
rollup_dts --> built_dts["打包后的 .d.ts"]
built_dts --> dts_built_test["dts-built-test/tsconfig.json"]
src --> dts_test["dts-test/tsconfig.test.json"]
src --> vitest_unit["vitest --project unit*"]
dts_built_test --> report_a["类型报告"]
dts_test --> report_a
vitest_unit --> report_b["运行时报告"]
end
subgraph future["融合目标:单一 runner"]
src2["源码"] --> vitest_all["vitest --project unit --project dts"]
vitest_all --> unified["统一报告 + 类型断言"]
end
current -.演进.-> future〔设计推断与架构权衡〕
融合的技术路径大概率是:把dts-built-test和dts-test的tsc调用封装成 Vitest 的自定义 project,让类型断言以expectTypeOf的形式内联在测试文件里。这样一次vitest调用就能同时跑运行时断言和类型断言,报告统一。但阻力在于:tsc的类型检查是「全量」的,而 Vitest 的测试是「按文件」的,两者的增量策略不兼容。
设计思考与踩坑
为什么 dts-built-test 必须独立于 dts-test? 上一章已经讨论过,这里从演进视角补充:dts-built-test 验证的是构建产物(rollup-plugin-dts 打包后的 .d.ts),dts-test 验证的是源码类型。如果融合时把两者合并,就会丢失「构建产物是否与源码类型一致」这个关键检查点。3.4.38 的这条提交正好印证了构建产物类型的重要性:
📎 changelogs/CHANGELOG-3.4.md:9
* **types:** add fallback stub for DOM types when DOM lib is absent ([#11598](https://github.com/vuejs/core/issues/11598)) ([4db0085](https://github.com/vuejs/core/commit/4db0085de316e1b773f474597915f9071d6ae6c6))「当 DOM lib 缺失时提供 fallback stub」——这是构建产物层面的类型兼容性修复,只有在 dts-built-test 这种「消费打包后 .d.ts」的场景下才能被发现。
生产踩坑点:类型测试的「合入-回退」循环说明,类型签名的变更需要真实下游项目的验证,而不仅仅是类型断言。Vue 的类型测试跑在 packages-private/dts-test 里,用的是仓库内部的测试用例,覆盖不了所有下游用法。融合趋势如果只关注「把两个 runner 合并」,而不解决「如何引入真实下游反馈」,就只是形式上的融合。
---
三、CI 缓存的细粒度优化方向
直觉模型
把 CI 缓存想象成一个仓库的「备料区」:每次构建都要从备料区取原料(依赖、构建产物、类型缓存)。如果备料区只有一个大箱子,取任何一样东西都要翻遍整个箱子,那缓存命中率再高也快不起来。细粒度优化的意思是:把大箱子拆成按用途分类的小格子。
若没有细粒度缓存,系统面临的灾难是缓存失效的级联放大:改一行源码,导致整个 node_modules 缓存失效,CI 重新安装所有依赖,构建时间从 2 分钟变成 10 分钟。
数据结构:可缓存物的分类
从 package.json 里能识别出几类可缓存的「物料」:
第一类,依赖安装产物。packageManager 字段锁定了 pnpm 版本:
📎 package.json:4
"packageManager": "pnpm@12.4.2",pnpm 的 node_modules 是符号链接结构,缓存的是 pnpm 的 content-addressable store,而不是扁平的 node_modules。这意味着缓存键应该基于 pnpm-lock.yaml 的哈希,而不是 package.json。
第二类,构建产物。clean 脚本揭示了产物的物理位置:
📎 package.json:10
"clean": "rimraf --glob packages/*/dist temp .eslintcache",packages/*/dist、temp、.eslintcache——这三类产物可以独立缓存。dist 是构建输出,temp 是临时文件(如 bench.json),.eslintcache 是 lint 缓存。
第三类,类型检查缓存。check 脚本用了 --incremental:
📎 package.json:15
"check": "tsc --incremental --noEmit",--incremental 会生成 .tsbuildinfo 文件,这是类型检查的增量缓存。CI 里如果缓存了这个文件,tsc 的二次运行会快很多。
场景驱动:一次 PR 的 CI 执行流
代入一个典型场景:开发者修改了 packages/reactivity/src/ref.ts,提交 PR。CI 需要跑哪些步骤,哪些能命中缓存?
从 scripts 里能推断出 CI 的执行序列(simple-git-hooks 的 pre-commit 是本地钩子,CI 会跑更完整的序列):
📎 package.json:48-51
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged && pnpm check",
"commit-msg": "node scripts/verify-commit.js"
},本地 pre-commit 跑 lint-staged 和 check。CI 上则会跑 lint、check、test-unit、test-dts、size 等。每一步的缓存策略不同:
lint:缓存.eslintcache,键基于源码文件哈希。check:缓存.tsbuildinfo,键基于tsconfig和源码哈希。test-unit:Vitest 有自己的缓存,但通常 CI 上不缓存测试结果,只缓存依赖。test-dts:依赖build-dts的产物,缓存键基于packages/*/dist的哈希。size:依赖构建产物,缓存键同上。
CI 缓存优化的 Mermaid 刻画
flowchart TD
pr["PR 提交"] --> checkout["checkout 代码"]
checkout --> cache_deps{"pnpm store 缓存命中?"}
cache_deps -->|是| install_fast["pnpm install --offline"]
cache_deps -->|否| install_slow["pnpm install 全量下载"]
install_fast --> lint_step["pnpm lint"]
install_slow --> lint_step
lint_step --> cache_eslint{".eslintcache 命中?"}
cache_eslint -->|是| lint_inc["增量 lint"]
cache_eslint -->|否| lint_full["全量 lint"]
lint_inc --> check_step["pnpm check"]
lint_full --> check_step
check_step --> cache_tsbuild{".tsbuildinfo 命中?"}
cache_tsbuild -->|是| check_inc["增量类型检查"]
cache_tsbuild -->|否| check_full["全量类型检查"]
check_inc --> test_unit["pnpm test-unit"]
check_full --> test_unit
test_unit --> build_dts["pnpm build-dts"]
build_dts --> cache_dist{"packages/*/dist 命中?"}
cache_dist -->|是| dts_cached["复用 dts 产物"]
cache_dist -->|否| dts_rebuild["重新生成 dts"]
dts_cached --> test_dts["pnpm test-dts-only"]
dts_rebuild --> test_dts
test_dts --> size_check["pnpm size"]
size_check --> done["CI 通过"]〔设计推断与架构权衡〕
细粒度缓存的核心矛盾是缓存键的粒度:键太粗(比如只基于 commit hash),命中率低;键太细(比如基于每个文件的哈希),计算键的开销就抵消了缓存收益。Vue 这类 monorepo 的合理策略是「按包分片」:每个packages/*子包独立缓存dist,reactivity的改动不会让compiler-core的dist缓存失效。
设计思考与踩坑
为什么 size 脚本要拆成多个子命令? 看这三条:
📎 package.json:11-14
"size": "run-s \"size-*\" && node scripts/usage-size.js",
"size-global": "node scripts/build.js vue runtime-dom -f global -p --size",
"size-esm-runtime": "node scripts/build.js vue -f esm-bundler-runtime",
"size-esm": "node scripts/build.js runtime-dom runtime-core reactivity shared -f esm-bundler",size 用 run-s "size-*" 串行跑所有 size- 前缀的子命令。这种「前缀聚合」模式让每个体积维度(global、esm-runtime、esm)可以独立缓存和独立失败。如果合并成一个大命令,任何一个维度超标都会让整个 size 失败,无法定位是哪个维度的问题。
生产踩坑点:CI 缓存最容易踩的坑是缓存污染——缓存了错误的产物,导致后续构建基于脏数据。clean 脚本的存在就是为了应对这种情况:
📎 package.json:10
"clean": "rimraf --glob packages/*/dist temp .eslintcache",注意它清理的是 packages/*/dist,而不是 packages-private/*/dist。这意味着 packages-private 的产物不在常规清理范围内——如果 CI 缓存了 packages-private 的产物,而 clean 不清理它,就可能出现「缓存了旧版本 playground 产物」的问题。细粒度缓存设计时必须把 packages-private 单独处理。
---
设计思考:工程化体系作为产品的生命周期
把三节的线索串起来,能看到一条清晰的主线:Vue 的工程化体系正在从「能用」走向「好用」,从「手工编排」走向「声明式配置」。
构建工具链的迁移(Rollup → Rolldown)是「性能驱动」的演进:当包数量增长到一定程度,进程级并发的开销超过了收益,必须换成更轻量的并发模型。
类型测试的融合是「一致性驱动」的演进:当类型签名的变更频率超过运行时行为的变更频率,分离的两套测试就成了负担,必须让它们共享同一份用例。
CI 缓存的细粒度化是「成本驱动」的演进:当 CI 分钟数成为瓶颈,粗粒度缓存的浪费就不可接受,必须按用途分片。
〔设计推断与架构权衡〕
这三条演进线的共同约束是向后兼容。Vue 的发布策略(从变更日志的 BREAKING CHANGES 段落可见)允许在 minor 版本做「type-only breaking change」,但不允许运行时 breaking change。这意味着工程化体系的演进必须保证:无论内部工具链怎么换,产物的公开 API 和运行时行为不能变。这是所有演进决策的硬边界。
---
本章小结
本章从变更日志和 package.json 出发,梳理了 Vue core 工程化体系的三条演进线:
1. 构建工具链:Rollup 4.x + esbuild + rollup-plugin-dts 的当前组合,其应力点体现在 build: 前缀的提交里(minify 配置对齐、entities 版本回退、CJS external 漏判)。Rolldown 迁移的势能来自「单进程并行」对「多进程并发」的替代,阻力来自插件生态和跨平台二进制分发。
2. 类型测试融合:test-dts 的 run-s build-dts test-dts-only 串行结构,以及 dts-built-test 与 dts-test 的双 tsc 进程,是当前分离形态的物理证据。融合的技术路径是借助 Vitest 的 --project 机制,阻力是 tsc 全量检查与 Vitest 按文件测试的增量策略不兼容。
3. CI 缓存细粒度化:packageManager 锁定 pnpm、clean 清理三类产物、check 用 --incremental、size 用前缀聚合——这些都是可缓存物的分类依据。核心矛盾是缓存键的粒度,合理策略是「按包分片」。
最重要的认知转变是:工程化体系本身就是一个产品,它有自己的用户(贡献者)、自己的性能指标(构建时间、CI 分钟数)、自己的兼容性约束(产物 API 不变)。它需要持续迭代,而不是一次性设计。
本章思考与自测
Q1: package.json:9 的 build-dts 用了 tsc -p tsconfig.build.json --noCheck。如果去掉 --noCheck,在 Rolldown 迁移后会带来什么连锁反应?
参考解析:--noCheck 的作用是跳过类型检查、只做 emit。去掉它后,tsc 会在生成 .d.ts 之前做全量类型检查。在当前 Rollup 架构下,这只是让 build-dts 变慢;但在 Rolldown 迁移后,问题会放大:Rolldown 的核心卖点是「单进程并行构建」,如果 build-dts 阶段引入一个全量 tsc 检查,它就成了整条流水线的串行瓶颈——所有包的构建都要等这个检查完成。更严重的是,tsc 的类型检查是单线程的,无法利用 Rolldown 的并行能力。正确的做法是保持 --noCheck,把类型检查交给独立的 pnpm check(package.json:15)和 test-dts(package.json:22),让构建和检查解耦。
Q2: 变更日志 3.4.37 连续回退了两个 types/ref 修复(CHANGELOG-3.4.md:23-24),而这两个修复在 3.4.35 刚合入(CHANGELOG-3.4.md:30,55)。如果类型测试与运行时测试已经融合,这个「合入-回退」循环能否被避免?为什么?
参考解析:不能完全避免,但能缩短循环。融合后的类型测试仍然只能验证「类型签名符合断言」,而 allow getter and setter types to be unrelated 这类修复的问题在于「类型签名过于宽松,破坏下游代码的类型安全」——这是下游用法的问题,不是签名本身的问题。融合能缩短循环的地方在于:如果类型断言和运行时断言写在同一个测试文件里,开发者能更快发现「类型签名变了但运行时行为没变」的不一致。但要真正避免回退,需要引入真实下游项目的类型检查(比如把 packages-private/dts-test 扩展成「模拟下游用法」的测试集),这超出了单纯「融合 runner」的范畴。
Q3: package.json:10 的 clean 脚本清理 packages/*/dist,但不清理 packages-private/*/dist。如果 CI 采用「按包分片」的细粒度缓存策略,这个不对称会带来什么生产陷阱?
参考解析:陷阱在于「缓存了 packages-private 的旧产物」。packages-private 包含 sfc-playground、template-explorer 等调试工具,它们的构建产物(如 packages-private/sfc-playground/dist)如果被 CI 缓存,而 clean 不清理它们,就会出现:源码更新了,但 CI 复用了旧的 playground 产物,导致 build-sfc-playground(package.json:39)的验证结果失真。更隐蔽的是,dev-sfc-prepare(package.json:34)会检查 packages-private 的产物是否存在,如果缓存了旧产物,它会跳过重新构建,让开发者以为环境是新的。细粒度缓存设计时,必须为 packages-private 单独定义缓存键,或者干脆不缓存它的产物——因为它是调试工具,重建成本低,缓存收益小。
通过变更日志的观测窗口,我们识别出了当前工程化体系的应力点,并据此推断了下一代体系可能的演进方向。这些方向并非空中楼阁,而是从真实的生产踩坑与权衡中生长出来的。至此,全书对 Vue 工程化体系的剖析告一段落,但工程化的探索永无止境——下一章将作为末章,把视角从 Vue 本身拉远,探讨这些经验如何迁移到更广泛的工程化场景中。
读完了本章?为你自己的私有项目生成专属架构全景书
基于 Tauri 2 + Rust 本地原生引擎,100% 源码离线隐私安全,零代码上传云端。像阅读一本传世专著一样拆解你的复杂系统。
⚡ Tauri 2 · Rust 原生引擎 · 100% 离线私密安全 · 适配超百万行代码库
读懂任何复杂项目,你真正需要的是一本专著
本书由 AiReadCode 扫描官方开源仓库全自动编撰,结合真实不可变 Commit 节点与 FACT 药丸行号溯源,提供纯静态、零服务依赖的极致双栏交互式在线阅读体验。