CHAPTER 01

第 1 章:宏觀認知:core 倉庫的工程化設計哲學

Upstream: vuejs/core · Commit @4ab865a8 · 閱讀進度:第 1 章 / 共 14 章

在開始追蹤任何一行響應式或虛擬 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

yaml
packages:
  - 'packages/*'
  - 'packages-private/*'

這兩條 glob 告訴 pnpm:packages/和packages-private/下的每個子目錄都是一個獨立套件。pnpm 會為它們建立符號連結,使@vue/runtime-core引用@vue/reactivity時直接指向本地原始碼目錄,而非從 registry 下載。

緊接著的catalog:段是 pnpm 的依賴版本目錄機制:

📎 pnpm-workspace.yaml:5-13

yaml
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

json
"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

json
"postinstall": "simple-git-hooks"

simple-git-hooks讀取根package.json中的simple-git-hooks欄位,把 Git 鉤子寫入.git/hooks/:

📎 package.json:48-51

json
"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

yaml
allowBuilds:
  '@parcel/watcher': true
  '@swc/core': true
  'esbuild': true
  'puppeteer': true
  'simple-git-hooks': true
  'unrs-resolver': true

pnpm 預設禁止依賴套件執行安裝腳本(postinstall),因為這是供應鏈攻擊的常見入口。allowBuilds是白名單:只有列出的套件才被允許執行建置腳本。@swc/core、esbuild需要下載平台相關的原生二進位檔,puppeteer需要下載 Chromium,simple-git-hooks需要寫 Git 鉤子——這些都是合法的建置期行為,因此被顯式放行。

minimumReleaseAge: 1440的深意。這行配置要求新發布的依賴版本必須「滿 24 小時」(1440 分鐘)才允許被安裝:

📎 pnpm-workspace.yaml:33-33

yaml
minimumReleaseAge: 1440
〔設計推斷與架構權衡〕

這是防禦 npm 供應鏈投毒的冷卻期機制。攻擊者劫持某個套件並發布惡意版本後,通常會在數小時內被發現並撤下。設定 24 小時冷卻期,可以讓 core 倉庫避開這個窗口。而minimumReleaseAgeExclude則允許對特定安全補丁破例:

📎 pnpm-workspace.yaml:36-38

yaml
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

json
"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

json
"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

json
"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

json
"types": ["vitest/globals", "puppeteer", "node"]

這三個型別套件被全域注入,意味著測試檔案可以直接使用describe、it、expect而無需 import,e2e 測試可以直接使用puppeteer的型別。這是便利性與污染性的權衡——全域型別越多,命名衝突風險越大,但測試程式碼的書寫體驗越好。

---

三、Rollup 配置:从 buildOptions 到多格式产物的统一工厂

直觉模型

Rollup 配置是 core 仓库的总装车间。它不关心某个包具体做什么,只关心「这个包要产出哪些格式、每种格式的入口文件在哪、哪些依赖要外部化」。每个子包的package.json中的buildOptions字段是贴在包裹上的发货单,总装车间照着单子干活。

数据结构与内存布局

配置文件的入口处就确立了「按包构建」的模型:

📎 rollup.config.js:32-44

js
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

js
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

js
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

js
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

js
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

js
const replacements = {
  __COMMIT__: `"${process.env.COMMIT}"`,
  __VERSION__: `"${masterVersion}"`,
  __TEST__: `false`,
  __BROWSER__: String(isBrowserBuild),
  __GLOBAL__: String(isGlobalBuild),
  __ESM_BUNDLER__: String(isBundlerESMBuild),
  __ESM_BROWSER__: String(isBrowserESMBuild),
  __CJS__: String(isCJSBuild),
  __SSR__: String(!isGlobalBuild),
  __COMPAT__: String(isCompatBuild),
  __FEATURE_SUSPENSE__: `true`,
  __FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__` : `true`,
  __FEATURE_PROD_DEVTOOLS__: isBundlerESMBuild ? `__VUE_PROD_DEVTOOLS__` : `false`,
  __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__: isBundlerESMBuild ? `__VUE_PROD_HYDRATION_MISMATCH_DETAILS__` : `false`,
}

这里有一个精妙的分层: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

js
// 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

js
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

js
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的包可以退出这个机制。

整个决策流可以用下面的控制流图概括:

mermaid
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

js
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

js
onwarn: (msg, warn) => {
  if (msg.code !== 'CIRCULAR_DEPENDENCY') {
    warn(msg)
  }
},

循环依赖警告被静默。Vue 的runtime-core与reactivity之间存在合法的循环引用(响应式系统需要引用组件实例类型),这些循环在运行时是安全的,因此被过滤。

treeshake.moduleSideEffects: false的激进假设。

📎 rollup.config.js:355-355

js
treeshake: {
  moduleSideEffects: false,
},

这告诉 Rollup:所有模块都没有副作用,可以放心移除未引用的导入。这是一个激进假设——如果某个模块在顶层执行了副作用代码(如注册全局变量),它可能被错误移除。Vue 源码通过约定保证所有模块都是纯的,因此可以开启这个优化。

swc-minify 的pure_getters陷阱。

📎 rollup.config.js:373-388

js
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

js
__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 等多格式產物。

AI 賦能程式碼庫精讀 · 本地優先架構

讀完了本章?為你自己的私有專案生成專屬架構全景書

基於 Tauri 2 + Rust 本地原生引擎,100% 源碼離線隱私安全,零程式碼上傳雲端。像閱讀一本傳世專著一樣拆解你的複雜系統。

⚡ Tauri 2 · Rust 原生引擎 · 100% 離線私密安全 · 適配超百萬行程式碼庫

CHAPTER 02

第 2 章:主幹生命週期:一次建置請求的端到端旅程

Upstream: vuejs/core · Commit @4ab865a8 · 閱讀進度:第 2 章 / 共 14 章

上一章我們釐清了 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。

下圖展示了從環境變數到最終配置的資料流:

mermaid
flowchart LR
    env["process.env<br/>TARGET, FORMATS, NODE_ENV"] --> pkg_load["require(package.json)"]
    pkg_load --> pkg_opts["packageOptions<br/>= pkg.buildOptions"]
    env --> fmt_sel["packageFormats<br/>= 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()<br/>__DEV__, __BROWSER__ ..."]
    create_cfg --> replace["resolveReplace()<br/>enumDefines, __DEV__"]
    create_cfg --> external["resolveExternal()<br/>treeShakenDeps / deps"]
    create_cfg --> node_plugins["resolveNodePlugins()<br/>commonJS, nodeResolve"]
    define --> rollup_cfg["RollupOptions<br/>{ 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中只有一個 Promisee,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 預編譯協作,實現毫秒級的開發回饋循環。

AI 賦能程式碼庫精讀 · 本地優先架構

讀完了本章?為你自己的私有專案生成專屬架構全景書

基於 Tauri 2 + Rust 本地原生引擎,100% 源碼離線隱私安全,零程式碼上傳雲端。像閱讀一本傳世專著一樣拆解你的複雜系統。

⚡ Tauri 2 · Rust 原生引擎 · 100% 離線私密安全 · 適配超百萬行程式碼庫

CHAPTER 03

第 3 章:開發態鏈路:dev 腳本與 SFC 預編譯的協作機制

Upstream: vuejs/core · Commit @4ab865a8 · 閱讀進度:第 3 章 / 共 14 章

上一章我們追蹤了生產建置從參數解析到多格式產物落盤的完整鏈路,那條鏈路追求的是產物的完整與規範。而開發態的核心訴求只有一個:改一行程式碼,瀏覽器裡立刻能看到效果。生產建置那套「解析參數 → 生成配置 → 全量打包 → 落盤」的鏈路,動輒數十秒,完全無法滿足這個訴求。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列印日誌。

mermaid
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),主建置繼續。

mermaid
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直接 importentries作為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

mermaid
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 替換為字面量,並確保按需引入的承諾不被破壞。

AI 賦能程式碼庫精讀 · 本地優先架構

讀完了本章?為你自己的私有專案生成專屬架構全景書

基於 Tauri 2 + Rust 本地原生引擎,100% 源碼離線隱私安全,零程式碼上傳雲端。像閱讀一本傳世專著一樣拆解你的複雜系統。

⚡ Tauri 2 · Rust 原生引擎 · 100% 離線私密安全 · 適配超百萬行程式碼庫

CHAPTER 04

第 4 章:編譯期魔法:列舉內聯與 Tree-shaking 驗證機制

Upstream: vuejs/core · Commit @4ab865a8 · 閱讀進度:第 4 章 / 共 14 章

上一章我們看到開發態鏈路如何用檔案監聽與增量建置換取「改一行立即生效」的速度。但速度之外,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 到替換的完整決策路徑:

mermaid
flowchart TD
    grep["spawnSync git grep 'export enum'"] --> files["去重得到文件列表"]
    files --> parse["@babel/parser 解析 AST"]
    parse --> check{"顶层节点是<br/>ExportNamedDeclaration<br/>且 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 約定應改用extendhelper 以避免額外程式碼。

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

mermaid
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-332Rollup 的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 如何保證原始碼型別與發布型別嚴格一致。

AI 賦能程式碼庫精讀 · 本地優先架構

讀完了本章?為你自己的私有專案生成專屬架構全景書

基於 Tauri 2 + Rust 本地原生引擎,100% 源碼離線隱私安全,零程式碼上傳雲端。像閱讀一本傳世專著一樣拆解你的複雜系統。

⚡ Tauri 2 · Rust 原生引擎 · 100% 離線私密安全 · 適配超百萬行程式碼庫

CHAPTER 05

第 5 章:型別產物流水線:從原始碼 .d.ts 到發布級型別包

Upstream: vuejs/core · Commit @4ab865a8 · 閱讀進度:第 5 章 / 共 14 章

上一章我們拆解了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 是否以.開頭。相對路徑導入若未解析,說明第一階段產物有缺失,是真問題,必須報警。這個區分讓警告噪音降到最低,同時不放過真錯誤。

流水線全景

mermaid
flowchart TD
    src["packages/*/src/*.ts<br/>源码类型"] --> tsc{"tsc -p tsconfig.build.json<br/>--noCheck"}
    tsc -->|"include 白名单命中"| temp["temp/packages/*/src/*.d.ts<br/>单包校样"]
    tsc -->|"不在 include 列表"| skip["不产出<br/>私有包/测试包被隔离"]
    temp --> check{"temp/packages 存在?"}
    check -->|"否"| exit["process.exit(1)<br/>提示先跑 tsc"]
    check -->|"是"| rollup["rollup-plugin-dts<br/>聚合为单文件"]
    rollup --> patch["patchTypes(pkg)<br/>内联导出 + 追加 types/"]
    patch --> vue{"pkg === 'vue'?"}
    vue -->|"是"| mts["copyMts()<br/>写 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.jsonexports 規範,要為 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專門守住這最後一公里。

型別流水線的完整時序

mermaid
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,也不 prependexport 。📎 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 契約」變成可回歸的自動化測試。

AI 賦能程式碼庫精讀 · 本地優先架構

讀完了本章?為你自己的私有專案生成專屬架構全景書

基於 Tauri 2 + Rust 本地原生引擎,100% 源碼離線隱私安全,零程式碼上傳雲端。像閱讀一本傳世專著一樣拆解你的複雜系統。

⚡ Tauri 2 · Rust 原生引擎 · 100% 離線私密安全 · 適配超百萬行程式碼庫

CHAPTER 06

第 6 章:型別契約測試:dts-test 如何守護 API 表面

Upstream: vuejs/core · Commit @4ab865a8 · 閱讀進度:第 6 章 / 共 14 章

上一章我們追蹤了型別宣告的生成鏈路,看到 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 | undefined
  • aa: { type: Number as PropType<number | undefined>, default: 1 }—— 有 default,推導為非可選number
  • aaaa: { type: Number, required: true as const } —— as const防止true被拓寬為boolean,保留字面量型別
  • b: { type: String, required: true as true } —— required: true讓屬性非 void
  • bb: { default: 'hello' }—— 無type,僅靠 default 推導型別
  • cc: Array as PropType<string[]>—— 顯式型別轉換
  • l: [Date]—— 陣列語法,推導為Date | undefined
  • ll: [Date, Number]—— 多型別陣列,推導為Date | number | undefined
  • lll: [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')。

整條鏈路可以用一張數據流圖概括:

mermaid
flowchart LR
    A["props 声明对象<br/>L57-158"] --> B["defineComponent<br/>泛型推导"]
    B --> C["ExtractPropTypes<br/>运行时声明 → 类型"]
    C --> D["setup(props)<br/>L162-217"]
    C --> E["render() this.$props<br/>L221-279"]
    C --> F["TSX 消费端<br/>L296-345"]
    D --> G["expectType 断言<br/>契约锁定"]
    E --> G
    F --> G
    G --> H{"全部通过?"}
    H -->|是| I["类型契约成立"]
    H -->|否| J["tsc 报错<br/>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)的宿主節點不是 DOMElement,所以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.tsxL168-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 明確要求這個組合報錯:

code
// @ts-expect-error
;<Comp color="white" appearance="normal" />

若型別被拍平,這行不再報錯,@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)、「泛型參數順序穩不穩定」(DefineComponent13 參數)、「渲染器無關性」(__typeEl不約束為Element)。這些約束一旦被打破,使用者側的 IDE 提示、vue-tsc生成的型別都會漂移。而型別契約的穩定性,最終要服務於開發者日常的除錯體驗——下一章我們將走進packages-private/sfc-playground,看一個純前端 Playground 如何在瀏覽器內完成 SFC 編譯與即時預覽的閉環。

AI 賦能程式碼庫精讀 · 本地優先架構

讀完了本章?為你自己的私有專案生成專屬架構全景書

基於 Tauri 2 + Rust 本地原生引擎,100% 源碼離線隱私安全,零程式碼上傳雲端。像閱讀一本傳世專著一樣拆解你的複雜系統。

⚡ Tauri 2 · Rust 原生引擎 · 100% 離線私密安全 · 適配超百萬行程式碼庫

CHAPTER 07

第 7 章:SFC Playground:瀏覽器內的即時編譯與除錯子系統

Upstream: vuejs/core · Commit @4ab865a8 · 閱讀進度:第 7 章 / 共 14 章

上一章我們用 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
// @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中,本材料未含)。

mermaid
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

ts
const props = defineProps<{
  store: ReplStore
  prod: boolean
  ssr: boolean
  autoSave: boolean
  theme: 'dark' | 'light'
}>()

五個 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:

ts
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

ts
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

html
<VersionSelect
  :model-value="vueVersion"
  @update:model-value="setVueVersion"
  pkg="vue"
  label="Vue Version"
>

注意這裡沒有用v-model,而是顯式拆成:model-value + @update:model-value。原因在於vueVersion是 computed(唯讀),不能直接雙向綁定;必須透過setVueVersion這個 setter 函式寫入store.vueVersion:

📎 packages-private/sfc-playground/src/Header.vue:39-41

ts
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

html
<VersionSelect
  v-model="store.typescriptVersion"
  pkg="typescript"
  label="TypeScript Version"
/>

TypeScript 版本用了v-model,因為store.typescriptVersion是可寫的普通屬性,不需要 computed 包裝。同一個元件在同一個模板裡用兩種綁定方式,正是「受控 vs 非受控」的直觀體現。

主題切換:副作用與 emit 的組合

📎 packages-private/sfc-playground/src/Header.vue:58-66

ts
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

ts
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 而非線上選定的版本。

mermaid
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

ts
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

ts
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

ts
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 的產物連起來看:

mermaid
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 的vueVersioncomputed 把兩者統一成一個顯示字串。

本章思考與自測

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 裡切換到replapp。更隱蔽的是:由於@vue/repl內部也會建立 app,晚賦值可能導致 DevTools 預設選中 Playground 自身而非使用者 REPL,除錯使用者程式碼時需要手動切換。這體現了「全域副作用注入順序」在除錯工具中的重要性。

Q2: Header.vue的toggleDark()同時操作了 DOM class、localStorage 和 emit,但沒有直接修改props.theme。如果父元件收到toggle-theme事件後拒絕更新themeprop,會出現什麼 UI 不一致?如何從原始碼層面定位?

參考解析:toggleDark()在📎 packages-private/sfc-playground/src/Header.vue:58-66直接呼叫document.documentElement.classList.toggle('dark'),這會立即改變 DOM 上的darkclass,觸發 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 建立原始碼與產物的映射,從而把編譯器的內部行為變成可觀察、可反推的探針。

AI 賦能程式碼庫精讀 · 本地優先架構

讀完了本章?為你自己的私有專案生成專屬架構全景書

基於 Tauri 2 + Rust 本地原生引擎,100% 源碼離線隱私安全,零程式碼上傳雲端。像閱讀一本傳世專著一樣拆解你的複雜系統。

⚡ Tauri 2 · Rust 原生引擎 · 100% 離線私密安全 · 適配超百萬行程式碼庫

CHAPTER 08

第 8 章:Template Explorer:編譯器行為的視覺化探針

Upstream: vuejs/core · Commit @4ab865a8 · 閱讀進度:第 8 章 / 共 14 章

上一章我們看到 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這種物件類型的選項不會被持久化——它太複雜,且預設值已經足夠示範。

mermaid
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套件。

mermaid
flowchart LR
    subgraph reactive_state["reactive 状态层"]
        ssrMode["ssrMode: Ref<boolean>"]
        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導出一個符合 MonacoIStandaloneThemeData介面的物件📎 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,揭示一次正式發版背後完整的狀態流轉與失敗回滾策略。

AI 賦能程式碼庫精讀 · 本地優先架構

讀完了本章?為你自己的私有專案生成專屬架構全景書

基於 Tauri 2 + Rust 本地原生引擎,100% 源碼離線隱私安全,零程式碼上傳雲端。像閱讀一本傳世專著一樣拆解你的複雜系統。

⚡ Tauri 2 · Rust 原生引擎 · 100% 離線私密安全 · 適配超百萬行程式碼庫

CHAPTER 09

第 9 章:發布自動化:release.js 的狀態機與互動式編排

Upstream: vuejs/core · Commit @4ab865a8 · 閱讀進度:第 9 章 / 共 14 章

上一章我們藉助 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:

js
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定義了一個看似簡單卻至關重要的函式:

js
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構造了互動式選單的候選項:

js
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是整章最精妙的設計之一:

js
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 : run

run把子程序的 stdio 設為inherit,讓建置/測試的輸出直接透傳到終端——這對長時間執行的建置至關重要,使用者能看到即時進度。dryRun則只列印命令不執行。runIfNotDry是一個「策略選擇」:在模組載入時就把函式指標綁定到dryRun或run,後續所有呼叫點無需再判斷isDryRun。

〔設計推斷與架構權衡〕

這種「在初始化時決定策略」的模式比「在每個呼叫點判斷」更不易出錯:如果某個呼叫點忘了判斷isDryRun,在 dry run 模式下就會真的執行副作用。而runIfNotDry把判斷集中到一處,消除了這類遺漏的可能。

mermaid
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這一行:

js
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:

js
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:

js
skipTests ||= isCIPassed

||=是邏輯或賦值:只有當skipTests為假值(undefined或false)時才賦值為isCIPassed。這意味著如果使用者顯式傳了--skipTests(true),這行不會改變它;如果使用者沒傳(undefined),則把它設為 CI 結果。但緊接著📎 scripts/release.js:287-298又會在 CI 通過時重新賦值——所以||=這行的實際作用只是「若 CI 未通過,把skipTests設為false」,從而讓後續的if (!skipTests)分支執行本地測試。

〔設計推斷與架構權衡〕

這個邏輯繞了一圈,本質是想表達:「CI 通過 → 可以跳過本地測試(但問一下使用者);CI 未通過 → 必須跑本地測試(除非使用者明確要求跳過)」。用||=加後續覆蓋的寫法雖然緊湊,但可讀性不高,是典型的「狀態位被多處修改」的程式碼異味。

mermaid
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:

js
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:

js
} 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的附加標誌:

js
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:

js
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,此時若失敗,版本號不會被回滾。這是一個潛在的邊界問題,見章末思考題。

mermaid
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 把工程規範固化為不可繞過的流水線。

AI 賦能程式碼庫精讀 · 本地優先架構

讀完了本章?為你自己的私有專案生成專屬架構全景書

基於 Tauri 2 + Rust 本地原生引擎,100% 源碼離線隱私安全,零程式碼上傳雲端。像閱讀一本傳世專著一樣拆解你的複雜系統。

⚡ Tauri 2 · Rust 原生引擎 · 100% 離線私密安全 · 適配超百萬行程式碼庫

CHAPTER 10

第 10 章:CI/CD 工作流:從 PR 到 Release 的自動化守門人

Upstream: vuejs/core · Commit @4ab865a8 · 閱讀進度:第 10 章 / 共 14 章

上一章我們看到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

yaml
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

yaml
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

yaml
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

yaml
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,yarn

continuous-releasejob 只在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 控制流圖

mermaid
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

yaml
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

yaml
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.0tag,這個條件會阻止發布流程運行。

第二層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

yaml
- 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

yaml
- 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-tag action。tag_name: ${{ github.ref }}直接使用觸發事件的 ref(即refs/tags/v3.x.x)。Release body 不寫具體變更內容,而是指向 CHANGELOG.md—— 因為 Vue 的 changelog 由 conventional-changelog 自動生成,手動維護 Release body 會與 changelog 產生不一致。

release.yml 時序圖

mermaid
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

yaml
on:
  workflow_run:
    workflows: ['size data']
    types:
      - completed

workflow_run事件監聽名為size data的 workflow 完成。這是一個兩階段設計:size-data.yml(本章未提供原始碼)負責在 PR 上建置並測量體積,把結果作為 artifact 上傳;size-report.yml在size data完成後,下載 artifact,生成報告,並評論到 PR 上。

📎 .github/workflows/size-report.yml:20-23

yaml
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

yaml
- 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-dataartifact 到temp/size。然後並行讀取 PR 編號和 base 分支:

📎 .github/workflows/size-report.yml:48-59

yaml
- 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.txt

parallel是 GitHub Actions 的語法糖,讓兩個無依賴的步驟同時執行。number.txt和base.txt是size-data.yml在測量時寫入的元資料檔案。

接著下載 base 分支的歷史體積資料用於對比:

📎 .github/workflows/size-report.yml:61-69

yaml
- 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

yaml
- 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 }}
      <!-- VUE_CORE_SIZE -->
    body-include: '<!-- VUE_CORE_SIZE -->'

scripts/size-report.js讀取temp/size和temp/size-prev下的資料,生成 Markdown 報告。maintain-one-comment-backupaction 用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

yaml
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

yaml
- 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 資料流圖

mermaid
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.ymlL81 的註解更是直接說明原 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中releasejob 的if: github.repository == 'vuejs/core'和environment: Release分別防禦什麼場景?如果去掉其中一個會怎樣?

參考解析:if: github.repository == 'vuejs/core' 📎 .github/workflows/release.yml:14防禦的是 fork 場景。如果有人 fork 了 vuejs/core 並推送一個v3.99.0tag,沒有這個條件,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 如何在體積超標時阻斷合併。

AI 賦能程式碼庫精讀 · 本地優先架構

讀完了本章?為你自己的私有專案生成專屬架構全景書

基於 Tauri 2 + Rust 本地原生引擎,100% 源碼離線隱私安全,零程式碼上傳雲端。像閱讀一本傳世專著一樣拆解你的複雜系統。

⚡ Tauri 2 · Rust 原生引擎 · 100% 離線私密安全 · 適配超百萬行程式碼庫

CHAPTER 11

第 11 章:體積預算機制:size-report 與 usage-size 的度量哲學

Upstream: vuejs/core · Commit @4ab865a8 · 閱讀進度:第 11 章 / 共 14 章

上一章我們看到,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。

mermaid
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-40
  • createApp:只匯入createApp,保留 Options API📎 scripts/usage-size.js:35-40
  • createSSRApp:SSR 場景📎 scripts/usage-size.js:35-40
  • defineCustomElement:Web Components 場景📎 scripts/usage-size.js:35-40
  • overall:匯入六個核心 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

這是整個腳本最精巧的部分。它不寫臨時檔案到磁碟,而是建構一個虛擬模組 IDvirtual: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,用於除錯。

mermaid
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-dataartifact,下載目標分支的基線 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 搭建一個極簡調試沙盒,把「在真實源碼上做最小復現」變成可操作的日常實踐。

AI 賦能程式碼庫精讀 · 本地優先架構

讀完了本章?為你自己的私有專案生成專屬架構全景書

基於 Tauri 2 + Rust 本地原生引擎,100% 源碼離線隱私安全,零程式碼上傳雲端。像閱讀一本傳世專著一樣拆解你的複雜系統。

⚡ Tauri 2 · Rust 原生引擎 · 100% 離線私密安全 · 適配超百萬行程式碼庫

CHAPTER 12

第 12 章:最小調試沙盒:vite-debug 與本地開發閉環

Upstream: vuejs/core · Commit @4ab865a8 · 閱讀進度:第 12 章 / 共 14 章

上一章我們完成了體積預算的度量閉環: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

ts
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

vue
<script setup>
import { ref } from 'vue'

const count = ref(0)
</script>

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

<style>
button {
  color: red;
}
</style>

代入一個具象場景:當用戶在瀏覽器中點擊按鈕時,發生了什麼?

第一步: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。

整個鏈路可以用下面的資料流圖表示:

mermaid
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

ts
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

json
{
  "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'時,解析鏈路是怎樣的?

mermaid
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:"協議:

json
"@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

json
"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 Playgroundvite-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 之前完成?下一章將彙總前十二章暴露的關鍵決策點與生產踩坑記錄,為你提供一份完整的避坑清單與決策依據。

AI 賦能程式碼庫精讀 · 本地優先架構

讀完了本章?為你自己的私有專案生成專屬架構全景書

基於 Tauri 2 + Rust 本地原生引擎,100% 源碼離線隱私安全,零程式碼上傳雲端。像閱讀一本傳世專著一樣拆解你的複雜系統。

⚡ Tauri 2 · Rust 原生引擎 · 100% 離線私密安全 · 適配超百萬行程式碼庫

CHAPTER 13

第 13 章:架構權衡與避坑指南:monorepo 工程化的邊界條件

Upstream: vuejs/core · Commit @4ab865a8 · 閱讀進度:第 13 章 / 共 14 章

上一章我們以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:

js
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。

mermaid
flowchart LR
  src["源码 enum 定义"] --> scan["scanEnums()<br/>scripts/inline-enums.js"]
  scan --> cache["临时缓存文件"]
  cache --> inline["inlineEnums()<br/>rollup.config.js"]
  inline --> plugin["enumPlugin<br/>transform 阶段替换"]
  inline --> defines["enumDefines<br/>replace 替换表"]
  plugin --> bundle["Rollup 产物<br/>字面量已内联"]
  defines --> bundle
  bundle --> cleanup["removeCache()<br/>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:

js
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()的分支邏輯值得單獨展開:

mermaid
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 Error<br/>CI 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 的變更軌跡,展望下一代工程化體系的演進方向。

AI 賦能程式碼庫精讀 · 本地優先架構

讀完了本章?為你自己的私有專案生成專屬架構全景書

基於 Tauri 2 + Rust 本地原生引擎,100% 源碼離線隱私安全,零程式碼上傳雲端。像閱讀一本傳世專著一樣拆解你的複雜系統。

⚡ Tauri 2 · Rust 原生引擎 · 100% 離線私密安全 · 適配超百萬行程式碼庫

CHAPTER 14

第 14 章:未來演進:從 3.x 到下一代工程化體系

Upstream: vuejs/core · Commit @4ab865a8 · 閱讀進度:第 14 章 / 共 14 章

上一章我們梳理了 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

code
    "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

code
    "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

code
* **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

code
* **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

code
* **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 遷移會觸及的節點:

mermaid
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

code
  "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

code
    "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

code
* 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

code
* **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

code
* **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 刻畫

下面這張圖刻畫了當前類型測試與運行時測試的分離結構,以及融合後的目標形態:

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

code
* **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

code
  "packageManager": "pnpm@12.4.2",

pnpm 的node_modules是符號連結結構,快取的是 pnpm 的 content-addressable store,而不是扁平的node_modules。這意味著快取鍵應該基於pnpm-lock.yaml的雜湊,而不是package.json。

第二類,建置產物。clean腳本揭示了產物的物理位置:

📎 package.json:10

code
    "clean": "rimraf --glob packages/*/dist temp .eslintcache",

packages/*/dist、temp、.eslintcache——這三類產物可以獨立快取。dist是建置輸出,temp是臨時檔案(如bench.json),.eslintcache是 lint 快取。

第三類,型別檢查快取。check腳本用了--incremental:

📎 package.json:15

code
    "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

code
  "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 刻畫

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

code
    "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

code
    "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 本身拉遠,探討這些經驗如何遷移到更廣泛的工程化場景中。

AI 賦能程式碼庫精讀 · 本地優先架構

讀完了本章?為你自己的私有專案生成專屬架構全景書

基於 Tauri 2 + Rust 本地原生引擎,100% 源碼離線隱私安全,零程式碼上傳雲端。像閱讀一本傳世專著一樣拆解你的複雜系統。

⚡ Tauri 2 · Rust 原生引擎 · 100% 離線私密安全 · 適配超百萬行程式碼庫

讀懂任何複雜專案,你真正需要的是一本專著

本書由 AiReadCode 掃描官方開源倉庫全自動編撰,結合真實不可變 Commit 節點與 FACT 藥丸行號溯源,提供純靜態、零服務依賴的極致雙欄互動式線上閱讀體驗。

在 GitHub 上 Star 本專案 ★ 瀏覽更多架構專著 →