Vue 3 核心源码深度剖析系列(v3.5.44)-第 1 章 项目全景:monorepo 架构与工程化体系

Vue 3 核心源码深度剖析系列(v3.5.44)-第 1 章 项目全景:monorepo 架构与工程化体系

本章是「Vue 3 核心源码深度剖析系列」的起点。我们不谈 API 用法,而是从仓库根目录出发,逐层剥开 vuejs/core@3.5.44 的物理结构 、依赖拓扑 、构建流水线 、编译期开关 、类型声明产物 、测试矩阵 与发布工程。读完本章,你将能回答这样一类问题:

  • 为什么 @vue/runtime-core 里搜不到 document,却能渲染出真实 DOM?
  • 为什么 pnpm build vue 会产出 7 种不同后缀的 dist 文件,它们分别给谁用?
  • __DEV__、__FEATURE_OPTIONS_API__ 这些"变量"在源码里从未被赋值,运行时却不报错?
  • tsc 明明能生成 .d.ts,为什么 Vue 还要再跑一遍 Rollup + rollup-plugin-dts?
  • 一个 feat(compiler): ... 的 commit,是如何被 CI 校验、被生成 CHANGELOG、最终被打包发布的?

本章所有结论均来自仓库一手源码,代码引用统一标注 文件路径:行号(行号对应 3.5.44 版本)。全文分 11 个大节,建议配合仓库实际文件对照阅读。


1.1 仓库全景:目录树与包的职责边界

1.1.1 顶层目录树

先建立"物理地图"。vuejs/core 是一个标准的 pnpm monorepo ,顶层结构如下(省略 node_modules、.git 等噪声目录):

text 复制代码
core-3.5.44/
├── .github/                    # CI/CD、issue 模板、commit 规范
│   ├── workflows/              # ci.yml / test.yml / release.yml / size-*.yml ...
│   ├── commit-convention.md    # 提交信息规范(Angular 约定)
│   ├── contributing.md
│   └── renovate.json5          # 依赖自动升级配置
├── .git-blame-ignore-revs      # 格式化等噪声提交的 SHA 清单
├── packages/                   # ★ 11 个公开发布的核心包
│   ├── shared/                 # 跨包基础工具(无任何 @vue 依赖)
│   ├── reactivity/             # 响应式系统(无 DOM、无 @vue 运行时依赖)
│   ├── runtime-core/           # 平台无关运行时(渲染器、组件、调度器)
│   ├── runtime-dom/            # 浏览器平台适配(DOM 节点操作、属性 patch)
│   ├── runtime-test/           # 测试用运行时(private)
│   ├── compiler-core/          # 平台无关编译器(模板 → AST → 代码)
│   ├── compiler-dom/           # 浏览器编译特化(DOM 指令、表达式)
│   ├── compiler-ssr/           # SSR 代码生成
│   ├── compiler-sfc/           # 单文件组件(SFC)编译器
│   ├── server-renderer/        # 服务端渲染运行时
│   ├── vue/                    # ★ 面向用户的"全量包"入口
│   └── vue-compat/             # Vue 2 兼容构建(@vue/compat)
├── packages-private/           # 不发布的内部工具与测试包
│   ├── sfc-playground/         # SFC 在线演练场
│   ├── template-explorer/       # 模板编译产物可视化
│   ├── dts-test/               # .d.ts 类型测试(.test-d.ts)
│   ├── dts-built-test/         # 对"已构建的 dts"做类型测试
│   ├── vite-debug/             # Vite 调试 demo
│   ├── global.d.ts
│   └── tsconfig.json
├── scripts/                    # 构建/发布/校验脚本(Node ESM)
├── docs/                       # 本系列文档(非官方站点源码)
├── rollup.config.js            # 生产构建配置(核心)
├── rollup.dts.config.js        # 类型声明打包配置
├── vitest.config.ts            # 测试矩阵配置
├── tsconfig.json               # 开发/类型检查配置
├── tsconfig.build.json         # dts 构建专用配置
├── eslint.config.js            # 扁平化 ESLint 配置
├── package.json                # 根 manifest(private + workspace 脚本)
└── pnpm-workspace.yaml         # workspace 划分 + catalog + 策略

这张地图有三个关键信息值得注意:

  1. packages/ 与 packages-private/ 是两类完全不同的存在 。前者是发布到 npm 的包,后者永远不会被 publish。它们的区分不靠"命名巧合",而是靠 package.json 的 private 字段------详见 1.1.4。
  2. docs/ 目录不属于官方文档站 。Vue 官方文档在另一个仓库(vuejs/docs),本目录是我们这个剖析系列的产物。
  3. 工程配置全部在顶层:Rollup、Vitest、TypeScript、ESLint 的配置都不在各自 package 里,而是集中管理。这与很多"每包各配一套"的 monorepo 不同,是 Vue 有意的取舍------详见 1.1.5。

1.1.2 11 个核心包的职责边界

Vue 3 的分包哲学是**"按抽象层级切"而非"按功能点切"**。下面这张表给出每个包的单一职责、对外暴露面与"依赖谁":

包名 package 目录 单一职责 npm 包名 平台
shared packages/shared 类型判断、字符串工具、patchFlags/shapeFlags/slotFlags 常量、DOM 标签配置等无状态工具 @vue/shared 通用
reactivity packages/reactivity reactive/ref/effect/computed/watch 的独立响应式系统,可单独使用 @vue/reactivity 通用
runtime-core packages/runtime-core 渲染器 createRenderer、VNode、组件实例、props/emits/slots、调度器、生命周期、内置组件(Suspense/KeepAlive/Teleport/BaseTransition) @vue/runtime-core 平台无关
runtime-dom packages/runtime-dom 用 createRenderer 注入 DOM 版 nodeOps/patchProp,产出 createApp/render,并实现 v-model、Transition、v-show 等浏览器专属能力 @vue/runtime-dom 浏览器
runtime-test packages/runtime-test 用内存节点树(TestElement)实现第二个平台适配层,专供单元测试 private 测试
compiler-core packages/compiler-core 平台无关编译流水线:baseParse → transform → generate,v-if/v-for/v-slot 等结构指令、静态提升 @vue/compiler-core 通用
compiler-dom packages/compiler-dom 在 core 之上补 DOM 语法:v-html/v-text/v-on/v-model 特殊处理、DOM 标签/属性白名单、表达式解析(@babel/parser) @vue/compiler-dom 浏览器
compiler-ssr packages/compiler-ssr 把 compiler-dom 的 AST 转成 SSR 字符串拼接代码(ssrRender),仅 cjs @vue/compiler-ssr Node
compiler-sfc packages/compiler-sfc .vue 文件解析、<script setup> 宏、模板/样式编译、scoped CSS、CSS vars、PostCSS 管线 @vue/compiler-sfc Node
server-renderer packages/server-renderer renderToString/renderToStream、ssrRender 执行、hydration 数据序列化 @vue/server-renderer Node
vue packages/vue 用户入口 :runtime + compiler 全量包,提供 compileToFunction 运行时编译 vue 浏览器
vue-compat packages/vue-compat 面向 Vue 2 迁移的兼容构建(@vue/compat),通过 configureCompat 分级降级 @vue/compat 浏览器

注:runtime-test 虽然不是公开包,但它是与 runtime-dom 平级的平台适配层 ,理解它对理解 createRenderer 的抽象至关重要,因此纳入讨论。

1.1.3 从职责边界看设计动机

这 11 个包的划分不是拍脑袋的结果,而是一条**"依赖只能向下流动"的单向链**:

graph TD shared[&#34;@vue/shared<br/>(无依赖)&#34;] reactivity[&#34;@vue/reactivity&#34;] runtime_core[&#34;@vue/runtime-core&#34;] runtime_dom[&#34;@vue/runtime-dom&#34;] runtime_test[&#34;@vue/runtime-test<br/>(private)&#34;] compiler_core[&#34;@vue/compiler-core&#34;] compiler_dom[&#34;@vue/compiler-dom&#34;] compiler_ssr[&#34;@vue/compiler-ssr&#34;] compiler_sfc[&#34;@vue/compiler-sfc&#34;] server_renderer[&#34;@vue/server-renderer&#34;] vue[&#34;vue (全量入口)&#34;] compat[&#34;@vue/compat&#34;] reactivity --> shared runtime_core --> shared runtime_core --> reactivity runtime_dom --> runtime_core runtime_dom --> reactivity runtime_dom --> shared runtime_test --> runtime_core runtime_test --> shared compiler_core --> shared compiler_dom --> compiler_core compiler_dom --> shared compiler_ssr --> compiler_dom compiler_ssr --> shared compiler_sfc --> compiler_core compiler_sfc --> compiler_dom compiler_sfc --> compiler_ssr compiler_sfc --> shared server_renderer --> runtime_dom server_renderer --> compiler_ssr server_renderer --> shared vue --> runtime_dom vue --> compiler_dom vue --> compiler_sfc vue --> server_renderer vue --> shared compat --> vue

这张图直接对应各包 package.json 的 dependencies 字段。例如 packages/runtime-core/package.json:48-51:

json 复制代码
"dependencies": {
  "@vue/shared": "workspace:*",
  "@vue/reactivity": "workspace:*"
}

而 packages/runtime-dom/package.json:51-56:

json 复制代码
"dependencies": {
  "@vue/shared": "workspace:*",
  "@vue/runtime-core": "workspace:*",
  "@vue/reactivity": "workspace:*",
  "csstype": "^3.2.3"
}

观察这两组依赖,能读出几条硬约束:

  1. reactivity 只依赖 shared 。它不 import runtime-core,因此 @vue/reactivity 可以被任何框架(包括 React 生态、Node 服务端)独立引入。这也是 @vue/reactivity 能单独发布并被 @vueuse 等库使用的原因。
  2. runtime-core 只依赖 shared + reactivity,绝不依赖 runtime-dom 。这正是"平台无关"的物理保证------renderer.ts 里只操作抽象的 nodeOps/patchProp,真实 DOM 操作被隔离在 runtime-dom。详见 1.2 的深入剖析。
  3. runtime-dom 依赖 csstype :因为 patchProp 需要给 style 提供类型约束;这是唯一的第三方运行时依赖。

1.1.4 packages-private:为什么不发布

packages-private/* 与 packages/* 的唯一判别字段是 package.json 中是否设置了 "private": true。举例:packages/runtime-test/package.json:2 与 packages/vue/package.json 对比------runtime-test 声明了 "private": true 且 "version": "0.0.0",而 vue 没有 private 字段、版本号为 3.5.44。

这个字段在构建与发布阶段被两处代码消费:

  • 发布筛选 (scripts/release.js:72-83):

    js 复制代码
    const packages = fs
      .readdirSync(path.resolve(__dirname, '../packages'))
      .filter(p => {
        // ...
        const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
        return !pkg.private
      })

    注意:发布逻辑只扫描 packages/ ,根本不看 packages-private/;再加上 !pkg.private 过滤,runtime-test 也不会被 publish。双保险。

  • 全量构建跳过 (scripts/build.js:166-170):

    js 复制代码
    // if this is a full build (no specific targets), ignore private packages
    if ((isRelease || !targets.length) && pkg.private) {
      return
    }

    即 pnpm build(不指定 target)时会自动跳过 private 包;但如果你显式 node scripts/build.js runtime-test,仍会构建它------这正是测试场景所需。

packages-private/ 内部又分四类用途不同的东西:

目录 用途 关键文件
sfc-playground/ 在线 SFC 演练场(Vite 应用,构建后部署到 sfc.vuejs.org) vite.config.ts、src/vue-dev-proxy.ts
template-explorer/ 把模板编译成 AST/render 的可视化工具 local.html、src/index.ts
dts-test/ 类型层测试(.test-d.ts),用 expectType/@ts-expect-error 断言类型 defineComponent.test-d.tsx 等
dts-built-test/ 对打包后 的 dist/*.d.ts 做类型测试,验证产物可用性 src/index.ts

dts-test 与 dts-built-test 的区别是本章 1.7、1.8 的关键伏笔:前者验证源码类型 ,后者验证构建产物类型。

1.1.5 为什么配置集中在顶层

与 Yarn/Lerna 时代"每个包一份 rollup/tsconfig"不同,Vue 3 把 rollup.config.js、tsconfig.json、eslint.config.js 全部放在仓库根。这样做有三个直接收益:

  1. 统一编译期开关注入 。__DEV__ 等常量的替换表只需维护一份(rollup.config.js:170-218),避免各包口径不一致导致 tree-shaking 失效。
  2. 跨包类型引用可用 。tsconfig.json:23-27 的 paths 把 @vue/* 全部映射到 packages/*/src,于是 runtime-dom 里可以直接 import ... from '@vue/runtime-core' 而无需先构建对方。
  3. 单一版本号真相 。rollup.config.js:34 从根 package.json 读取 masterVersion,所有包共享同一版本,避免版本漂移。

1.2 包依赖图与分层:编译期 vs 运行时 vs 适配

1.2.1 三类包的划分标准

如果把 11 个包按"是否需要真实宿主环境"重新分类,会得到三组:

(A)编译期包(Compiler,纯字符串/数据变换)

  • compiler-core → compiler-dom → compiler-ssr → compiler-sfc

它们的共同点是输入模板字符串、输出代码字符串 ,不触碰 DOM。唯一的例外是 compiler-sfc 里对 Node API 的使用(读文件、PostCSS),但那是工具链层面而非"运行时平台"。

(B)运行时包(Runtime,处理真实节点树)

  • reactivity(无节点概念)
  • runtime-core(操作抽象节点树)
  • runtime-dom / runtime-test(操作具体节点树)

(C)适配/聚合包(Adapter / Aggregator)

  • server-renderer(把运行时适配到"字符串输出"这一特殊"宿主")
  • vue(聚合 runtime + compiler,对用户暴露单一入口)
  • vue-compat(在 vue 之上包装兼容层)
  • shared(横切,被所有包依赖)

这个分类的关键洞察:runtime-core 属于 (B) 却不属于 runtime-dom 所在的具体平台,因为"节点树"在 core 眼里是抽象的。

1.2.2 runtime-core 为什么必须平台无关

这是 Vue 3 架构最核心的一条设计决策,值得展开。runtime-core 提供的是 createRenderer,它接收一个宿主环境配置对象(宿主 = Host):

ts 复制代码
// 概念示意(等价于 packages/runtime-core/src/renderer.ts 中 createRenderer 的入参)
export function createRenderer<HostNode, HostElement>(
  options: RendererOptions<HostNode, HostElement>,
): Renderer<HostNode, HostElement>

RendererOptions 是由若干"原子 DOM 操作"组成的接口,例如 createElement、insert、remove、setElementText、patchProp、nextSibling......runtime-core 的渲染流程只调用这些抽象方法,从不出现 document.createElement。

真实实现中,平台差异通过两个包分别注入:

  • runtime-dom(packages/runtime-dom/src/nodeOps.ts + patchProp.ts)实现浏览器版本;
  • runtime-test(packages/runtime-test/src/nodeOps.ts)实现内存树版本。

两者都调用 core 的 createRenderer。以 runtime-test 为例(packages/runtime-test/src/index.ts:12-17):

ts 复制代码
const { render: baseRender, createApp: baseCreateApp } = createRenderer(
  extend({ patchProp }, nodeOps),
)

export const render = baseRender as RootRenderFunction<TestElement>
export const createApp = baseCreateApp as CreateAppFunction<TestElement>

runtime-test 只提供了 nodeOps 与 patchProp 两样东西,就"变出"了完整的 render 和 createApp------这证明平台无关性是真的,不是口号。

如果 runtime-core 直接依赖 runtime-dom,会发生什么?

  • 循环依赖 :runtime-dom 需要 core 的 createRenderer,core 又需要 dom 的 nodeOps,形成环。
  • 测试不能跑在内存里:单元测试将被迫依赖 jsdom,速度与可靠性都变差。
  • SSR 无法复用 :server-renderer 依赖 runtime-dom(因为它要复用运行时的组件/vnode 逻辑),若 core 绑死 DOM,SSR 就得重写一套运行时。
  • 自定义渲染器消亡 :小程序、Canvas、NativeScript 等第三方渲染器正是基于 createRenderer 构建,剥夺这层抽象等于砍掉整个生态。

因此 "runtime-core 不依赖任何具体平台" 不是风格偏好,而是保持多个宿主(浏览器/内存/服务端/第三方)共用同一套渲染算法的必要条件。

1.2.3 依赖方向的不变量(可校验)

把依赖图翻译成可机械校验的规则,共有 4 条:

  1. shared 依赖列表为空------它是 DAG 的源头。(packages/shared/package.json 中确实没有 dependencies 字段)
  2. reactivity 只允许依赖 shared。
  3. runtime-core 只允许依赖 shared + reactivity(packages/runtime-core/package.json:48-51)。
  4. runtime-dom / runtime-test 只允许依赖 runtime-core + reactivity + shared(外加各平台第三方,如 csstype)。

这 4 条一旦被打破,循环依赖检查(Rollup 的 CIRCULAR_DEPENDENCY 警告)会立刻报警。但注意 rollup.config.js:344-348 对循环依赖做了静默处理:

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

这说明 Vue 允许包内 存在循环引用(例如 runtime-core 内部模块互相引用很常见),但在包间层面严格保持单向。

1.2.4 编译期包与运行时包的"两条腿"

一个易被忽视的事实:vue(全量包)同时依赖编译期的 compiler-dom/compiler-sfc 与运行时的 runtime-dom。为什么要有两条腿?

  • 生产环境 (Vite/webpack + @vitejs/plugin-vue):模板在构建时就被 compiler-sfc/compiler-dom 预编译为 render 函数,运行时完全不需要编译器 。此时 vue 会走 dist/vue.runtime.esm-bundler.js(注意是 .runtime. 后缀),把编译器整块 tree-shake 掉,包体积骤减。
  • 运行时编译 :当模板来自 template 选项或 innerHTML(如 CDN 直接 <script> 引入 vue.global.js、或字符串模板),才需要 compileToFunction(packages/vue/src/index.ts:30-102)。

packages/vue/src/index.ts:104-107 是这条腿的"注册点":

ts 复制代码
registerRuntimeCompiler(compileToFunction)

export { compileToFunction as compile }
export * from '@vue/runtime-dom'

registerRuntimeCompiler 把编译器挂进 runtime,让 runtime-core 在处理 template 选项时能回调它。如果引入的是 .runtime.esm-bundler.js(不含 packages/vue/src/index.ts,只 re-export runtime-dom),编译器就是 undefined,Vue 会在开发环境警告"Component provided template option but runtime compilation is not supported"。这正是"两条腿"的开关。

1.2.5 与 Vue 2 依赖体系的对比

理解 Vue 3 为何如此分层,对比 Vue 2 最直观(Vue 2 发布时无 monorepo 分包):

维度 Vue 2.x Vue 3.5.x
交付形态 单包 vue,内部一个 src/core 12 包 monorepo,用户侧仍暴露单入口 vue
渲染器抽象 无(patch 直接操作 DOM) createRenderer + 宿主配置,支持多平台
响应式 Object.defineProperty,与组件强耦合 独立 @vue/reactivity,与组件解耦
编译器 单文件 compiler(含 parser + codegen) compiler-core/dom/ssr/sfc 四层
第三方复用 几乎不可能 @vue/reactivity、@vue/runtime-core 可独立引用
tree-shaking 有限(依赖 UMD) 完整(ESM + 编译期开关)

一句话总结:Vue 2 的架构是"一个包、一个平台、一个渲染器";Vue 3 的架构是"多个包、多个平台、共享算法"。本章后续的构建、类型、测试体系,都是为支撑这个"多平台共享"目标服务的。


1.3 monorepo 工具链:pnpm workspace 与工程约束

1.3.1 pnpm-workspace.yaml 逐字段解读

整个 monorepo 的"地基"是 pnpm-workspace.yaml,全文仅 38 行,却定义了三件事:workspace 范围 、依赖版本目录(catalog) 、安装策略。

yaml 复制代码
# core-3.5.44\pnpm-workspace.yaml:1-3
packages:
  - 'packages/*'
  - 'packages-private/*'

前 3 行划定 workspace 成员。两行 glob 分别把 packages/ 下的 11 个公开包和 packages-private/ 下的 5 个内部包纳入同一个 pnpm workspace。这样 link-workspace-packages 生效,包之间用 workspace:* 引用时会被软链接到源码目录,而非去 npm 拉取。

(1)catalog 协议:集中管理"版本真相"

yaml 复制代码
# pnpm-workspace.yaml:5-13
catalog:
  '@babel/parser': ^7.29.9
  '@babel/types': ^7.29.8
  'entities': '^7.0.1'
  'estree-walker': ^2.0.2
  'magic-string': ^0.30.21
  'source-map-js': ^1.2.2
  'vite': ^8.3.4
  '@vitejs/plugin-vue': ^6.0.10

catalog: 是 pnpm 的"版本目录"特性。它的作用是:把同一个第三方依赖的版本号收敛到一处 ,各包只需写 "magic-string": "catalog:",pnpm 就会解析成 ^0.30.21。

对照实际使用------packages/compiler-sfc/package.json:45-53:

json 复制代码
"dependencies": {
  "@babel/parser": "catalog:",
  "@vue/compiler-core": "workspace:*",
  "@vue/compiler-dom": "workspace:*",
  "@vue/compiler-ssr": "workspace:*",
  "@vue/shared": "workspace:*",
  "estree-walker": "catalog:",
  "magic-string": "catalog:",
  "postcss": "^8.5.29",
  "source-map-js": "catalog:"
}

这里 @babel/parser、estree-walker、magic-string、source-map-js 都走 catalog:,只有 postcss 用了显式 semver(因为它是 compiler-sfc 专属依赖,无需共享)。

为什么用 catalog 而不是各包各写版本? 因为 compiler-core 与 vue-compat 都依赖 @babel/parser,若两处版本不一致,会安装两份(pnpm 的 content-addressable 存储虽去重,但仍可能产生语义漂移)。catalog 让"编译器系列"始终锁在同一份 AST 解析器版本上。根 package.json:65-66 也用了 catalog:

json 复制代码
"@babel/parser": "catalog:",
"@babel/types": "catalog:",

(2)allowBuilds:白名单式构建脚本

yaml 复制代码
# pnpm-workspace.yaml:15-21
allowBuilds:
  '@parcel/watcher': true
  '@swc/core': true
  'esbuild': true
  'puppeteer': true
  'simple-git-hooks': true
  'unrs-resolver': true

pnpm 默认不执行依赖的 postinstall 脚本(防止供应链攻击)。allowBuilds 显式放行 6 个必须编译原生代码/安装二进制的依赖:@swc/core(Rust 压缩器)、esbuild(Go 打包器)、puppeteer(下载 Chromium)、simple-git-hooks(安装 git hooks)等。如果哪天升级引入需要构建的新依赖而忘了加白名单,构建会静默跳过------这是一个典型的"构建慢半拍"踩坑点。

(3)其余安装策略

yaml 复制代码
# pnpm-workspace.yaml:23-38
dedupePeers: true

peerDependencyRules:
  allowedVersions:
    'typescript-eslint>eslint': '^9.0.0'
    '@typescript-eslint/eslint-plugin>eslint': '^9.0.0'
    # ...

minimumReleaseAge: 1440

trustPolicy: no-downgrade
minimumReleaseAgeExclude:
  # Renovate security update: vitest@4.1.11
  - vitest@4.1.11
  • dedupePeers: true:让 peer 依赖尽量复用同一实例,减少重复安装。
  • peerDependencyRules.allowedVersions:抑制"typescript-eslint 期望 eslint ^9 但仓库装了 eslint ^10"这类 peer 冲突告警。注意根 package.json:86 装的是 eslint: ^10.12.0,而行内注释指向 eslint ^9 兼容窗口。
  • minimumReleaseAge: 1440:单位是分钟,即新发布的包必须"冷却"满 24 小时才允许安装------这是抵御 npm 供应链攻击(恶意版本发布后立刻被大面积安装)的实用手段。
  • trustPolicy: no-downgrade:禁止依赖降级。
  • minimumReleaseAgeExclude:为安全补丁开例外口子(日志里明确标注是 Renovate 的安全更新)。

1.3.2 根 package.json:脚本编排与钩子

根 package.json(core-3.5.44\package.json)是整个工程的操作台。先看元信息:

json 复制代码
// package.json:2-5
"private": true,
"version": "3.5.44",
"packageManager": "pnpm@12.11.2",
"type": "module",

四个字段各司其职:

  • "private": true:根包不可发布,防止误 npm publish。
  • "version": "3.5.44":全仓库的版本真相 ,被 rollup.config.js:34 读取用于生成 banner。
  • "packageManager": "pnpm@12.11.2":由 Corepack 强制校验的包管理器版本。
  • "type": "module":让所有 .js 脚本按 ESM 解析,因此 scripts/*.js 里能直接 import。

(1)only-allow + preinstall:锁死包管理器

json 复制代码
// package.json:45-46
"preinstall": "npx only-allow pnpm",
"postinstall": "simple-git-hooks"

preinstall 阶段运行 only-allow pnpm:若有人用 npm install 或 yarn install,脚本会直接报错退出。这保证了 lockfile 只有 pnpm-lock.yaml 一份。postinstall 则调用 simple-git-hooks 把 git 钩子写入 .git/hooks。

(2)simple-git-hooks:零依赖的钩子方案

json 复制代码
// package.json:48-51
"simple-git-hooks": {
  "pre-commit": "pnpm lint-staged && pnpm check",
  "commit-msg": "node scripts/verify-commit.js"
}

对比 husky,simple-git-hooks 无需额外目录、无需 prepare 脚本,直接把配置写进 package.json 即可。这里定义两个钩子:

  • pre-commit:先跑 lint-staged(只对暂存文件做 lint/format),再跑 pnpm check(全量 tsc --incremental --noEmit)。这意味着提交前必过类型检查。
  • commit-msg:调用 scripts/verify-commit.js,强制 conventional commits 格式(见 1.10)。

(3)lint-staged:按文件类型分流

json 复制代码
// package.json:52-60
"lint-staged": {
  "*.{js,json}": [
    "prettier --write"
  ],
  "*.ts?(x)": [
    "eslint --fix",
    "prettier --parser=typescript --write"
  ]
}

规则很克制:.js/.json 只格式化;.ts/.tsx 先 eslint --fix(自动修掉一部分规则)再格式化。注意 TS 走的是 prettier --parser=typescript 而非直接 prettier,这是为了在 lint-staged 的临时文件场景下显式指定解析器。

(4)engines:Node 版本下限

json 复制代码
// package.json:61-63
"engines": {
  "node": ">=20.0.0"
}

>=20 不是随意选的:scripts/build.js、scripts/release.js 大量使用 node:util 的 parseArgs(Node 18.3+ 提供,但稳定可用性在 20 更佳)、node:fs 的 rmSync、原生 fetch(release.js:322)等。CI 中通过 .node-version 文件固定版本。

1.3.3 依赖版本的地板:devDependencies 一览

根 package.json:64-116 的 devDependencies 可归为五类:

类别 代表依赖 作用
打包 rollup@^4.64.5、rollup-plugin-dts@^6.5.1、rollup-plugin-esbuild@^6.2.1、@rollup/plugin-* 生产构建与 dts 打包
压缩/转译 @swc/core、esbuild prod 压缩、dev 快速构建
测试 vitest、@vitest/coverage-v8、jsdom、playwright、puppeteer、@vitest/browser-playwright 多环境测试矩阵
语言/校验 typescript@~6.0.3、typescript-eslint、eslint@^10、prettier@^3 类型与风格
发布/工具 conventional-changelog-angular、semver、enquirer、simple-git-hooks、lint-staged、rimraf、picocolors、pretty-bytes 发布流水线

注意 typescript 用了 ~6.0.3(波浪号,只允许补丁位浮动),说明 Vue 对 TS 版本相当敏感------因为 isolatedDeclarations 等新特性直接影响 dts 产物正确性(见 1.7)。

1.3.4 脚本编排:npm-run-all2 与并行/串行

根脚本用 npm-run-all2 的 run-s(串行)/run-p(并行)来组合子任务,例如:

json 复制代码
// package.json:33-36
"dev-sfc": "run-s dev-sfc-prepare dev-sfc-run",
"dev-sfc-run": "run-p \"dev compiler-sfc -f esm-browser\" \"dev vue -if esm-bundler-runtime\" \"dev vue -ipf esm-browser-runtime\" \"dev server-renderer -if esm-bundler\" dev-sfc-serve",

dev-sfc-run 同时启动 5 个 esbuild watch 进程 + 1 个 Vite 服务器,用于本地调试 SFC 编辑器。这种"一个仓库、多条产物、并行 watch"的编排,是 monorepo 开发体验的典型形态。

再看 sfc-playground 的整链构建(package.json:39-44):

json 复制代码
"build-sfc-playground": "run-s build-all-cjs build-runtime-esm build-browser-esm build-ssr-esm build-sfc-playground-self",

它串行构建 5 组产物,每一步都是对 scripts/build.js 的不同 target/format 组合调用。这说明同一个构建脚本通过参数组合覆盖了所有产物形态,而非为每种场景写一个脚本。


1.4 包入口矩阵:exports 字段与三种分发形态

1.4.1 三种分发形态(Dist Formats)

Vue 3 的每个包都会产出多种"分发形态",它们的划分依据是 "谁来打包/在哪运行" 。先给出全量对照(以 vue 的 buildOptions.formats 为准,packages/vue/package.json:72-83):

json 复制代码
"buildOptions": {
  "name": "Vue",
  "formats": [
    "esm-bundler",
    "esm-bundler-runtime",
    "cjs",
    "global",
    "global-runtime",
    "esm-browser",
    "esm-browser-runtime"
  ]
}

7 种格式的语义如下表(前三行在 rollup.config.js:57-88 有一一对应的 outputConfigs,可直接核对):

format 产物文件名 模块系统 目标环境 依赖处理 典型使用者
esm-bundler vue.esm-bundler.js ESM 由 Vite/webpack 消费 依赖 external,编译期开关保留占位符 @vitejs/plugin-vue 项目
esm-bundler-runtime vue.runtime.esm-bundler.js ESM 同上(不含编译器) 同上 同上(默认)
esm-browser vue.esm-browser.js ESM 浏览器直接 <script type=module> 内联全部依赖,开关硬编码 免构建原型
esm-browser-runtime vue.runtime.esm-browser.js ESM 同上(不含编译器) 同上 同上
cjs vue.cjs.js / vue.cjs.prod.js CJS Node external 依赖 SSR / 老工具链
global vue.global.js / vue.global.prod.js IIFE 浏览器 <script>(挂 window.Vue) 内联全部依赖 CDN、无构建项目
global-runtime vue.runtime.global.js / .prod.js IIFE 同上(不含编译器) 同上 自带编译链的 CDN 项目

"runtime" 后缀表示只含运行时、不含编译器 ;.prod.js 后缀表示已用 SWC 压缩的生产版。

1.4.2 vue 的 exports 字段逐项精读

现代 Node 的 exports 字段决定了"不同 import 语法解析到哪个文件"。packages/vue/package.json:19-71 是最复杂的入口矩阵,值得逐块拆:

json 复制代码
// packages/vue/package.json:20-35
".": {
  "import": {
    "types": "./dist/vue.d.mts",
    "node": "./index.mjs",
    "default": "./dist/vue.runtime.esm-bundler.js"
  },
  "require": {
    "types": "./dist/vue.d.ts",
    "node": {
      "production": "./dist/vue.cjs.prod.js",
      "development": "./dist/vue.cjs.js",
      "default": "./index.js"
    },
    "default": "./index.js"
  }
}

解析逻辑(Node/Vite 会按顺序匹配第一个命中的条件):

  1. import 分支 (被 ESM import 命中):
    • types → ./dist/vue.d.mts(ESM 专用类型文件,见 1.7 的 copyMts)。
    • node 条件 → ./index.mjs(Node 下走 ESM 代理)。
    • default → ./dist/vue.runtime.esm-bundler.js(默认给 bundler 用运行时版,即树摇掉编译器的版本)。
  2. require 分支 (被 CJS require 命中):
    • types → ./dist/vue.d.ts。
    • node.production → ./dist/vue.cjs.prod.js(生产 CJS)。
    • node.development → ./dist/vue.cjs.js(开发 CJS,含警告)。
    • default → ./index.js(NODE_ENV 运行时代理,见下)。

注意 node 条件里嵌套了 production/development:这是 Node 减少"运行时条件判断"的优化------在 NODE_ENV=production 时直接指向不含 dev 检查的产物。但旧工具链不认识 production 子条件,所以还有 default: "./index.js" 兜底。

兜底的 packages/vue/index.js:1-7 是一个极简的运行时代理:

js 复制代码
'use strict'

if (process.env.NODE_ENV === 'production') {
  module.exports = require('./dist/vue.cjs.prod.js')
} else {
  module.exports = require('./dist/vue.cjs.js')
}

同样的模式出现在 packages/runtime-core/index.js 等每一个运行时包中------这就是为什么每个 runtime 包都有 index.js 与 dist/*.cjs.js / dist/*.cjs.prod.js。

1.4.3 子路径入口:vue/server-renderer、vue/compiler-sfc

vue 包同时是个"聚合入口",把常用子包以子路径导出(packages/vue/package.json:36-70):

json 复制代码
"./server-renderer": {
  "import": {
    "types": "./server-renderer/index.d.mts",
    "default": "./server-renderer/index.mjs"
  },
  "require": {
    "types": "./server-renderer/index.d.ts",
    "default": "./server-renderer/index.js"
  }
},
"./compiler-sfc": {
  "import": {
    "types": "./compiler-sfc/index.d.mts",
    "browser": "./compiler-sfc/index.browser.mjs",
    "default": "./compiler-sfc/index.mjs"
  },
  "require": {
    "types": "./compiler-sfc/index.d.ts",
    "browser": "./compiler-sfc/index.browser.js",
    "default": "./compiler-sfc/index.js"
  }
}

compiler-sfc 子路径多了一个 browser 条件 :因为 SFC 编译器在浏览器(如 sfc-playground)里也要跑,但浏览器版不能含 fs/path/PostCSS 等 Node 能力,于是单独产出 index.browser.mjs。这背后是 rollup.config.js:242-248 对 isBrowserESMBuild 的 process.env/process.platform 替换(见 1.5)。

vue 包的 files 字段(packages/vue/package.json:10-18)列出发布时携带的目录:

json 复制代码
"files": [
  "index.js", "index.mjs", "dist",
  "compiler-sfc", "server-renderer", "jsx-runtime", "jsx.d.ts"
]

即发布产物是"根目录几个代理/代理文件 + dist + 若干子包再导出目录"。

1.4.4 各包 exports 的两种范式

对比两个典型包的 exports,能看出两套范式。

范式 A:运行时包(runtime-dom / runtime-core / shared / reactivity / server-renderer)------统一"五条件"结构:

json 复制代码
// packages/runtime-dom/package.json:13-26
".": {
  "types": "./dist/runtime-dom.d.ts",
  "node": {
    "production": "./dist/runtime-dom.cjs.prod.js",
    "development": "./dist/runtime-dom.cjs.js",
    "default": "./index.js"
  },
  "module": "./dist/runtime-dom.esm-bundler.js",
  "import": "./dist/runtime-dom.esm-bundler.js",
  "require": "./index.js"
},
"./*": "./*"

顺序上:types 优先、node 次之、module(非标准但被 webpack 识别)、import、require。注意 module 与 import 都指向同一个 esm-bundler 文件。

范式 B:编译期包(compiler-sfc / compiler-ssr)------更简单,因其只服务 Node:

json 复制代码
// packages/compiler-sfc/package.json:11-20
".": {
  "types": "./dist/compiler-sfc.d.ts",
  "node": "./dist/compiler-sfc.cjs.js",
  "module": "./dist/compiler-sfc.esm-browser.js",
  "import": "./dist/compiler-sfc.esm-browser.js",
  "require": "./dist/compiler-sfc.cjs.js"
},
"./*": "./*"

compiler-sfc 的 module/import 指向的是 esm-browser 而非 esm-bundler(因为它没有 esm-bundler 产物,buildOptions.formats 只有 cjs 与 esm-browser)。compiler-ssr 更极端,只有 cjs 一种格式(packages/compiler-ssr/package.json:10-15),因为它只在服务端构建期运行。

两者都带 "./*": "./*"------允许 import '@vue/compiler-sfc/dist/...' 这类深路径访问。

1.4.5 传统字段(main/module/types/unpkg)为何还在

exports 是 ESM/Node 的新标准,但旧工具(webpack 4、老 Babel、unpkg CDN)不认它,所以要保留传统字段。以 packages/vue/package.json:5-9 为例:

json 复制代码
"main": "index.js",
"module": "dist/vue.runtime.esm-bundler.js",
"types": "dist/vue.d.ts",
"unpkg": "dist/vue.global.js",
"jsdelivr": "dist/vue.global.js",
  • main:老 Node 的 CJS 入口 → index.js 代理。
  • module:rollup/webpack 认的 ESM 入口 → runtime esm-bundler。
  • types:TS 的类型入口。
  • unpkg/jsdelivr:CDN 的"默认文件",指向 vue.global.js(可直接 <script src="https://unpkg.com/vue">)。

reactivity 与 compiler-dom 也有 unpkg/jsdelivr(packages/reactivity/package.json:8-9),因为它们有 global 产物;而 runtime-core、shared、server-renderer 没有------因为它们不产出 global 包(buildOptions.formats 里不含 global)。

1.4.6 exports 顺序的陷阱

exports 条件匹配是顺序敏感 的。一个经典踩坑:把 "default" 写在 "import" 前面会导致 ESM 也命中 default,破坏 ESM 消费。Vue 在 compiler-sfc 的 "." 里把 node 放在 module 前、import 放在 require 前,就是因为 Node 环境下 node 条件应优先命中 CJS,而浏览器/打包器环境应命中 ESM。这看似细微,却直接决定 vite 里 import @vue/compiler-sfc 拿到的是 CJS 还是 ESM 版本。


1.5 构建体系:从 scripts/build.js 到 rollup.config.js

1.5.1 scripts/build.js:命令行参数解析

构建的入口是 scripts/build.js,它负责解析命令行参数、调度并发、调用 Rollup、汇总产物体积 。先看参数定义(scripts/build.js:34-68):

js 复制代码
const { values, positionals: targets } = parseArgs({
  allowPositionals: true,
  options: {
    formats: { type: 'string', short: 'f' },
    devOnly: { type: 'boolean', short: 'd' },
    prodOnly: { type: 'boolean', short: 'p' },
    withTypes: { type: 'boolean', short: 't' },
    sourceMap: { type: 'boolean', short: 's' },
    release: { type: 'boolean' },
    all: { type: 'boolean', short: 'a' },
    size: { type: 'boolean' },
  },
})

用 Node 内置的 parseArgs 定义 8 个选项。各选项语义:

选项 短标志 语义 底层如何透传
--formats -f 指定要构建的格式(逗号分隔) 传 FORMATS:<v> 环境变量
--devOnly -d 只构建开发版(NODE_ENV=development) env 取 development
--prodOnly -p 只构建生产版(不产 dev 产物) 传 PROD_ONLY:true
--withTypes -t 同时构建 .d.ts 额外 pnpm run build-dts
--sourceMap -s 输出 sourcemap 传 SOURCE_MAP:true
--release 无 标记发布构建,跳过 private 包 isRelease 分支
--all -a 模糊匹配时构建所有命中项 fuzzyMatchTarget 第二参
--size 无 把体积写入 temp/size/*.json writeSize

位置参数 targets 是包名(支持模糊匹配),例如 pnpm build dom 会命中 compiler-dom、runtime-dom。

commit 注入:构建一开始就取当前 git 短哈希:

js 复制代码
// scripts/build.js:30-32
const commit = spawnSync('git', ['rev-parse', '--short=7', 'HEAD'])
  .stdout.toString()
  .trim()

它最终会被塞进 __COMMIT__(见 1.6),用于识别"这个产物是哪个提交构建的"。

1.5.2 主流程与并发控制

js 复制代码
// scripts/build.js:85-112
async function run() {
  if (writeSize) fs.mkdirSync(sizeDir, { recursive: true })
  const removeCache = scanEnums()
  try {
    const resolvedTargets = targets.length
      ? fuzzyMatchTarget(targets, buildAllMatching)
      : allTargets
    await buildAll(resolvedTargets)
    await checkAllSizes(resolvedTargets)
    if (buildTypes) {
      await exec('pnpm', ['run', 'build-dts', ...], { stdio: 'inherit' })
    }
  } finally {
    removeCache()
  }
}

流程分五步:

  1. scanEnums():预处理全仓库的 enum (见 1.5.6),返回一个清理缓存的闭包 removeCache。
  2. 解析 targets:显式给了 target 就用 fuzzyMatchTarget,否则用全部 allTargets(来自 scripts/utils.js)。
  3. buildAll():按 CPU 核数并发跑 Rollup。
  4. checkAllSizes():打印产物体积(gzip/brotli)。
  5. 若需要类型,调用 pnpm run build-dts。
  6. finally 里 removeCache() 删除 temp/enum.json。

并发实现(scripts/build.js:131-151)是一个"信号量式"的手写并发池:

js 复制代码
async function runParallel(maxConcurrency, source, iteratorFn) {
  const ret = []
  const executing = []
  for (const item of source) {
    const p = Promise.resolve().then(() => iteratorFn(item))
    ret.push(p)
    if (maxConcurrency <= source.length) {
      const e = p.then(() => {
        executing.splice(executing.indexOf(e), 1)
      })
      executing.push(e)
      if (executing.length >= maxConcurrency) {
        await Promise.race(executing)
      }
    }
  }
  return Promise.all(ret)
}

关键技巧是 await Promise.race(executing):每当在飞任务数达到 cpus().length,就等任意一个完成再继续排下一个。这避免了"一次性 spawn 几十个 Rollup 进程把机器打爆"的问题。

1.5.3 单个 target 的构建:环境变量透传

build(target)(scripts/build.js:160-199)决定每个包如何被构建:

js 复制代码
const privatePackages = fs.readdirSync('packages-private')

async function build(target) {
  const pkgBase = privatePackages.includes(target)
    ? `packages-private`
    : `packages`
  const pkgDir = path.resolve(`${pkgBase}/${target}`)
  const pkg = JSON.parse(fs.readFileSync(`${pkgDir}/package.json`, 'utf-8'))

  // if this is a full build (no specific targets), ignore private packages
  if ((isRelease || !targets.length) && pkg.private) {
    return
  }

  // if building a specific format, do not remove dist.
  if (!formats && fs.existsSync(`${pkgDir}/dist`)) {
    fs.rmSync(`${pkgDir}/dist`, { recursive: true })
  }

  const env =
    (pkg.buildOptions && pkg.buildOptions.env) ||
    (devOnly ? 'development' : 'production')

  await exec('rollup', ['-c', '--environment', [
      `COMMIT:${commit}`,
      `NODE_ENV:${env}`,
      `TARGET:${target}`,
      formats ? `FORMATS:${formats}` : ``,
      prodOnly ? `PROD_ONLY:true` : ``,
      sourceMap ? `SOURCE_MAP:true` : ``,
    ].filter(Boolean).join(','),
  ], { stdio: 'inherit' })
}

逐点说明:

  • private 判定 :fs.readdirSync('packages-private') 得到私有包名清单,决定去哪个目录找。
  • 全量构建跳过 private :(isRelease || !targets.length) && pkg.private 时直接 return。
  • dist 清理策略 :只有当没有指定 --formats 时才删 dist。原因很实用------若你正在增量构建某个格式(如只重建 esm-bundler),删掉整个 dist 会连带删掉其它已构建产物,导致后续引用失败。
  • NODE_ENV 决策 :优先读 pkg.buildOptions.env(Vue 里目前没有包用),否则由 devOnly 决定 development/production。
  • 透传方式 :所有信息通过 --environment KEY:VALUE,... 传给 Rollup,Rollup 会把它塞进 process.env,供 rollup.config.js 读取。这就是为什么 rollup.config.js:27-29 开头就断言 TARGET 必须存在。

1.5.4 体积账本:checkAllSizes / checkFileSize

构建完成后打印/记录体积(scripts/build.js:206-264)。先看跳过条件:

js 复制代码
async function checkAllSizes(targets) {
  if (devOnly || (formats && !formats.includes('global'))) {
    return
  }
  console.log()
  for (const target of targets) {
    await checkSize(target)
  }
  console.log()
}

只有"构建了 global 产物"时才统计体积------因为 .prod.js 体积才是用户真正关心的。

checkSize 检查两个文件:<target>.global.prod.js 与 <target>.runtime.global.prod.js。

checkFileSize(scripts/build.js:235-264)给出三种度量:

js 复制代码
const gzipped = gzipSync(file)
const brotli = brotliCompressSync(file)
console.log(
  `${pico.gray(pico.bold(fileName))} min:${prettyBytes(file.length)} ` +
  `/ gzip:${prettyBytes(gzipped.length)} / brotli:${prettyBytes(brotli.length)}`,
)
if (writeSize)
  fs.writeFileSync(path.resolve(sizeDir, `${fileName}.json`),
    JSON.stringify({ file: fileName, size: file.length, gzip: gzipped.length, brotli: brotli.length }), 'utf-8')

min 是 SWC 压缩后的原始字节数,gzip/brotli 是两种压缩算法后的字节数。--size 选项会把结果写成 JSON,供 scripts/size-report.js 生成 PR 体积对比表(见 1.9)。

实测思路:在本地 pnpm build vue -f global -p --size(对应脚本 size-global),即可得到 vue.global.prod.js 的三项指标并落地到 temp/size/vue.global.prod.js.json。Vue 官方对外宣传的 "约 34KB min / 12KB gzip" 就是这一类数字。

1.5.5 rollup.config.js 逐项解读

rollup.config.js 是全仓库最核心的构建文件(393 行),它的产出是一个 RollupOptions 数组 (export default packageConfigs,:116)。注意它是一次只构建一个 target ------TARGET 来自环境变量(:27-29),所以 build.js 要为每个包 spawn 一次 Rollup。

(1)格式与输出配置对象

js 复制代码
// rollup.config.js:57-88
const outputConfigs = {
  'esm-bundler': { file: resolve(`dist/${name}.esm-bundler.js`), format: 'es' },
  'esm-browser': { file: resolve(`dist/${name}.esm-browser.js`), format: 'es' },
  cjs:           { file: resolve(`dist/${name}.cjs.js`),          format: 'cjs' },
  global:        { file: resolve(`dist/${name}.global.js`),        format: 'iife' },
  'esm-bundler-runtime': { file: resolve(`dist/${name}.runtime.esm-bundler.js`), format: 'es' },
  'esm-browser-runtime': { file: resolve(`dist/${name}.runtime.esm-browser.js`), format: 'es' },
  'global-runtime':      { file: resolve(`dist/${name}.runtime.global.js`),      format: 'iife' },
}

Rollup 的 format 只有 es/cjs/iife 三种,而 Vue 的 format 名有 7 种------二者的映射就在这张表里。name 来自 packageOptions.filename || basename(:47),所以 vue-compat 通过 filename: "vue"(packages/vue-compat/package.json:29)让产物名与 vue 一致。

(2)prod 产物的二次生成

Rollup 配置先为每个 format 生成 dev 配置,再在 NODE_ENV=production 时追加 prod 配置:

js 复制代码
// rollup.config.js:102-114
if (process.env.NODE_ENV === 'production') {
  packageFormats.forEach(format => {
    if (packageOptions.prod === false) return
    if (format === 'cjs') {
      packageConfigs.push(createProductionConfig(format))
    }
    if (/^(global|esm-browser)(-runtime)?/.test(format)) {
      packageConfigs.push(createMinifiedConfig(format))
    }
  })
}

规则值得记忆:

  • packageOptions.prod === false 的包(compiler-ssr、compiler-sfc)不产出 prod 文件------因为编译器只在构建期运行、不进入用户产物,压缩无意义。
  • cjs 走 createProductionConfig(只换 NODE_ENV,不额外压缩)。
  • global/esm-browser(含 runtime 变体)走 createMinifiedConfig(SWC 压缩)。

(3)createConfig 输出的关键字段

js 复制代码
// rollup.config.js:319-352
return {
  input: resolve(entryFile),
  external: resolveExternal(),
  plugins: [
    json({ namedExports: false }),
    alias({ entries }),
    enumPlugin,
    ...resolveReplace(),
    esbuild({ tsconfig: ..., sourceMap: output.sourcemap, minify: false,
      target: isServerRenderer || isCJSBuild ? 'es2019' : 'es2016',
      define: resolveDefine() }),
    ...resolveNodePlugins(),
    ...plugins,
  ],
  output,
  onwarn: (msg, warn) => {
    if (msg.code !== 'CIRCULAR_DEPENDENCY') warn(msg)
  },
  treeshake: { moduleSideEffects: false },
}

逐个说明:

  • input :由 entryFile 决定(:159-168),runtime 格式走 src/runtime.ts,其余走 src/index.ts;compat 包的 ESM 构建走 src/esm-index.ts/src/esm-runtime.ts(为同时提供 default + named export)。
  • external :由 resolveExternal() 决定。
  • json 插件 :配合 namedExports: false,把 JSON 作为默认导出(用于 @vue/compiler-sfc 内联的模板引擎元数据等)。
  • alias :entries 来自 scripts/aliases.js,把 @vue/* 指向 packages/*/src。
  • enumPlugin:enum 内联插件(见下)。
  • replace :见 resolveReplace()。
  • esbuild :转译 TS。注意 minify: false------Rollup 阶段不压缩 ,压缩交给后面的 SWC 插件(仅 prod)。target 对 server-renderer/CJS 用 es2019,其余用 es2016------因为 es2016 不支持 async/await(这也是 eslint 禁用 async/await 的原因,见 1.10)。
  • onwarn:放行包内循环依赖警告。
  • treeshake.moduleSideEffects: false :告诉 Rollup 假定所有模块无副作用,激进 tree-shake。这就是 packages/*/package.json 里 "sideEffects": false 的构建期对应物 (packages/runtime-dom/package.json:27)。

1.5.6 插件的体积账:inline-enums.js

enumPlugin 来自 scripts/inline-enums.js。Vue 早期用 const enum 遭到 #1228 的问题,后来改用普通 enum,但普通 enum 会被 esbuild 转成运行时对象,体积偏大。于是有了这套"预扫描 → 全局替换 → 声明改字面量"的方案。

scanEnums()(scripts/inline-enums.js:45-214)步骤:

  1. git grep 'export enum' 找出所有含导出的 enum 的文件。
  2. 用 @babel/parser 解析,收集每个 enum 成员的 name/value,支持 1 << 2(BinaryExpression)、一元表达式、成员引用等初始化形式,并生成反向映射(数字枚举才有)。
  3. 把结果写入 temp/enum.json:包含 declarations(哪个文件哪个范围定义了什么)与 defines(Enum.Member → 字面量)。

inlineEnums()(scripts/inline-enums.js:219-287)返回 [plugin, defines]:

  • plugin.transform :把声明处的 export enum X {...} 用 MagicString 改写成 export const X = {...} 对象字面量(:250-273)。数字成员同时生成反向映射 "0": "MemberName"。
  • defines :并入 esbuild 的 define,于是源码里引用 X.Member 的地方会被直接替换为字面量,不再有属性查找。

体积收益:若某 enum 有 10 个成员,运行时对象形态约占数百字节,替换为字面量后调用点零成本。这与 rollup.config.js:220-222 的注释所说的"esbuild define is a bit strict and only allows literal json or identifiers"相呼应。

1.5.7 resolveDefine():编译期常量的替换表

这是 1.6 的前置。resolveDefine()(rollup.config.js:170-218)返回一个 Record<string,string>,最终交给 esbuild 的 define:

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

三个设计要点:

  1. isBundlerESMBuild 时 feature flag 保留占位符 (如 __VUE_OPTIONS_API__),把决定权交给下游打包器 (Vite/webpack 用 DefinePlugin 注入)。这样用户才能在构建时选择是否保留 Options API。
  2. 非 bundler 构建时硬编码 (true/false),因为 global/esm-browser 直接跑到浏览器,没有打包器来注入。
  3. __DEV__ 只在非 bundler 时由构建决定,bundler 版另有处理(见下)。

随后还有一段"环境变量覆盖"逻辑(:210-216),允许这样调试:

bash 复制代码
# 强制开启运行时编译
__RUNTIME_COMPILE__=true pnpm build runtime-core

1.5.8 resolveReplace():@rollup/plugin-replace 的三组替换

js 复制代码
// rollup.config.js:222-255
function resolveReplace() {
  const replacements = { ...enumDefines }
  if (isProductionBuild && isBrowserBuild) {
    Object.assign(replacements, {
      'context.onError(': `/*@__PURE__*/ context.onError(`,
      'emitError(': `/*@__PURE__*/ emitError(`,
      'createCompilerError(': `/*@__PURE__*/ createCompilerError(`,
      'createDOMCompilerError(': `/*@__PURE__*/ createDOMCompilerError(`,
    })
  }
  if (isBundlerESMBuild) {
    Object.assign(replacements, {
      __DEV__: `!!(process.env.NODE_ENV !== 'production')`,
    })
  }
  if (isBrowserESMBuild) {
    Object.assign(replacements, {
      'process.env': '({})',
      'process.platform': '""',
      'process.stdout': 'null',
    })
  }
  if (Object.keys(replacements).length) {
    return [replace({ values: replacements, preventAssignment: true })]
  } else {
    return []
  }
}

三组替换各有用途:

  • /*@__PURE__*/ 注解 :在 production 浏览器构建中,给四个"错误构造"函数调用贴上 PURE 注解,告诉 Rollup 这些调用无副作用、可删。这样当 __DEV__ 为 false 使 if (__DEV__) createCompilerError(...) 分支被消除后,函数体也能被 tree-shake。
  • bundler 的 __DEV__ :替换成 !!(process.env.NODE_ENV !== 'production'),让下游打包器按 NODE_ENV 决定 dev/prod 分支------这是 Vue 长期以来的标准做法。
  • browser ESM 的 process 屏蔽 :浏览器里没有 process,而 compiler-sfc 的部分代码引用了它,于是把 process.env → ({})、process.platform → ""、process.stdout → null。

注意 preventAssignment: true:防止把 process.env = x 这类赋值 也误替换(这是 @rollup/plugin-replace 的经典陷阱)。

1.5.9 resolveExternal():external 的三种口径

js 复制代码
// rollup.config.js:257-283
function resolveExternal() {
  const treeShakenDeps = ['source-map-js', '@babel/parser', 'estree-walker', 'entities/decode']
  if (isGlobalBuild || isBrowserESMBuild || isCompatPackage) {
    if (!packageOptions.enableNonBrowserBranches) {
      return treeShakenDeps
    }
  } else {
    return [
      ...Object.keys(pkg.dependencies || {}),
      ...Object.keys(pkg.peerDependencies || {}),
      ...['path', 'url', 'stream'],
      ...treeShakenDeps,
    ]
  }
}

三档策略:

  1. 纯浏览器构建 (global / esm-browser,且未开启非浏览器分支):只把"会被 tree-shake 掉的 Node-only 依赖"列为 external(其实是为了抑制警告),其余依赖内联进产物。
  2. Node / bundler 构建 (cjs、esm-bundler):把所有 dependencies + peerDependencies + Node 内置 path/url/stream 全部 external------因为这些环境有模块解析能力。
  3. enableNonBrowserBranches 的包 (如 compiler-sfc):即使浏览器构建也 external 依赖(因为它内含的 consolidate 需要动态 require)。

1.5.10 resolveNodePlugins() 与 compiler-sfc 的特例

js 复制代码
// rollup.config.js:285-317
function resolveNodePlugins() {
  let cjsIgnores = []
  if (pkg.name === '@vue/compiler-sfc') {
    cjsIgnores = [
      ...Object.keys(consolidatePkg.devDependencies),
      'vm', 'crypto', 'react-dom/server', 'teacup/lib/express',
      'arc-templates/dist/es5', 'then-pug', 'then-jade',
    ]
  }
  const nodePlugins =
    (format === 'cjs' && Object.keys(pkg.devDependencies || {}).length) ||
    packageOptions.enableNonBrowserBranches
      ? [commonJS({ sourceMap: false, ignore: cjsIgnores }),
         ...(format === 'cjs' ? [] : [polyfillNode()]),
         nodeResolve()]
      : []
  return nodePlugins
}

条件是"cjs 且有 devDependencies"或"开启了非浏览器分支"。compiler-sfc 内联了分叉版 @vue/consolidate,它会 require 一大堆模板引擎(pug、jade、svelte...),这些必须被 commonJS 的 ignore 列表忽略,否则会尝试解析不存在的模块。这正是 cjsIgnores 存在的理由。非 cjs 时用 polyfillNode() 补上 process/Buffer 等 Node 全局的浏览器 shim。

1.5.11 createMinifiedConfig():SWC 压缩插件

js 复制代码
// rollup.config.js:362-391
function createMinifiedConfig(format) {
  return createConfig(format, {
    file: outputConfigs[format].file.replace(/\.js$/, '.prod.js'),
    format: outputConfigs[format].format,
  }, [{
    name: 'swc-minify',
    async renderChunk(contents, _, { format }) {
      const { code } = await minifySwc(contents, {
        module: format === 'es',
        format: { comments: false },
        compress: { ecma: 2016, pure_getters: true },
        safari10: true,
        mangle: true,
      })
      return { code: banner + code, map: null }
    },
  }])
}

用 @swc/core 的 minify(Rust 实现,比 Terser 快得多)在 renderChunk 阶段压缩。compress.ecma: 2016 与前面 esbuild 的 target: es2016 一致;pure_getters: true 允许把无副作用的 getter 访问当作纯表达式优化;safari10 处理 Safari 10 的已知 bug。

1.5.12 dev 构建:为什么用 esbuild 而非 Rollup

scripts/dev.js 用 esbuild 做开发期 watch 构建 ,注释说得明白(scripts/dev.js:3-5):

js 复制代码
// Using esbuild for faster dev builds.
// We are still using Rollup for production builds because it generates
// smaller files and provides better tree-shaking.

即:开发用 esbuild(快),生产用 Rollup(小) 。dev.js 的 define 表(:141-159)比 Rollup 简化------例如 __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__ 直接为 true、__DEV__ 由 --prod 决定、__COMMIT__ 固定 "dev"。它还贴心地注入了 log-rebuild 插件(:117-123)在每次重建后打印产物路径。

体积账小结 :同一次 pnpm build vue -f global -p,Rollup 产出的 .prod.js 之所以比 esbuild 小,关键在于 Rollup 的 tree-shaking 更彻底(能跨模块消除未使用导出、识别 /*@__PURE__*/),而 esbuild 的 tree-shaking 相对保守。这就是"开发要快、生产要小"两个目标用两套工具的根本原因。


1.6 编译期开关:从 __DEV__ 到 __FEATURE_*

1.6.1 什么是"编译期开关"

Vue 源码里大量出现形如 if (__DEV__) { ... }、if (__FEATURE_OPTIONS_API__) { ... } 的代码。这些标识符从未在源码里被赋值 ,任何标准 JS 引擎都会在运行时报 ReferenceError。它们能正常工作,是因为构建期被替换成了字面量 (true/false/"..."),随后被死代码消除(DCE)与 tree-shaking 移除。

这套机制带来三类收益:

  1. 零运行时开销 :if (false) {...} 整块被删,if (true) 的检查也被删,运行时无分支、无属性查找。
  2. 体积最小化:dev 分支(包含警告、代码帧、错误详情)在 prod 构建中完全消失,这是 Vue prod 包体积远小于 dev 包的根本原因。
  3. 功能可选化 :__FEATURE_OPTIONS_API__ 让用户能在构建时决定"不要 Options API",从而把相关代码(resolveMergedOptions 等)整块摇掉。

1.6.2 类型声明:packages/global.d.ts

TypeScript 需要知道这些标识符"存在"。声明在 packages/global.d.ts:1-17:

ts 复制代码
// Global compile-time constants
declare var __DEV__: boolean
declare var __TEST__: boolean
declare var __BROWSER__: boolean
declare var __GLOBAL__: boolean
declare var __ESM_BUNDLER__: boolean
declare var __ESM_BROWSER__: boolean
declare var __CJS__: boolean
declare var __SSR__: boolean
declare var __VERSION__: string
declare var __COMPAT__: boolean

// Feature flags
declare var __FEATURE_OPTIONS_API__: boolean
declare var __FEATURE_PROD_DEVTOOLS__: boolean
declare var __FEATURE_SUSPENSE__: boolean
declare var __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__: boolean

这个文件通过 tsconfig.json:32 的 include: ["packages/global.d.ts", ...] 被纳入编译范围。注意它用的是 declare var(全局变量),而非模块导出------所以任何 .ts 文件无需 import 即可引用这些标识符。

packages/global.d.ts:19-38 还声明了 *.vue 与 estree-walker 的模块类型,以及一个刻意 @deprecated 的 String.prototype.substring(Vue 团队要求统一用 slice,以便多字节字符安全)。

1.6.3 每个开关的语义与典型用法

下表给出 14 个开关的精确语义与对应值(对照 rollup.config.js:170-218):

开关 类型 语义 dev 常用值 prod 常用值
__DEV__ boolean 是否为开发构建 true false
__TEST__ boolean 是否处于 Vue 内部测试 源码固定 false;vitest 设 true false
__BROWSER__ boolean 是否面向浏览器 true true
__GLOBAL__ boolean 是否为 IIFE global 构建 false true(global)
__ESM_BUNDLER__ boolean 是否为 esm-bundler 构建 false true
__ESM_BROWSER__ boolean 是否为 esm-browser 构建 false true
__CJS__ boolean 是否为 cjs 构建 true false
__SSR__ boolean 是否需要 SSR 分支 true true(非 global)
__VERSION__ string 版本号 "3.5.44" "3.5.44"
__COMMIT__ string 构建时 commit 短哈希 git hash git hash
__COMPAT__ boolean 是否为兼容构建 false true(compat)
__FEATURE_OPTIONS_API__ boolean 是否打包 Options API true 用户决定
__FEATURE_PROD_DEVTOOLS__ boolean prod 是否支持 devtools false 用户决定
__FEATURE_SUSPENSE__ boolean 是否打包 Suspense true true
__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__ boolean prod 是否输出 hydration 失配详情 false 用户决定

注意几点:

  • __SSR__ 的判断是 !isGlobalBuild (rollup.config.js:185)------即"非 global 构建都保留 SSR 分支"。这解释了一个常见困惑:为什么 esm-bundler/cjs 里能看到 if (__SSR__) 分支。
  • __FEATURE_SUSPENSE__ 恒为 true (:191)。Vue 3.5 后 Suspense 已是稳定特性,不再提供关闭选项,但开关保留着以兼容历史构建。
  • __TEST__ 在构建中恒为 false (:176),注释明确"only used during Vue's internal tests"。运行时测试用 vitest 的 define 把它设为 true(vitest.config.ts:8)。
  • __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__ 是较新的开关,用于在生产环境 选择性保留 hydration 失配详情(默认 false 以省体积)。

1.6.4 三类开关的替换路径不同

把上面的表按"谁负责替换"再分一次层,能看清设计的精妙:

第一类:构建时硬编码(非 bundler 构建) __VERSION__、__COMMIT__、__GLOBAL__、__ESM_BROWSER__、__CJS__、__SSR__、__BROWSER__ 等------它们由 createConfig 根据 format 直接算出。

第二类:交给下游打包器(仅 bundler 构建) feature flags:

js 复制代码
// rollup.config.js:192-200
__FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__` : `true`,
__FEATURE_PROD_DEVTOOLS__: isBundlerESMBuild ? `__VUE_PROD_DEVTOOLS__` : `false`,
__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__: isBundlerESMBuild ? `__VUE_PROD_HYDRATION_MISMATCH_DETAILS__` : `false`,

在 esm-bundler 产物里,__FEATURE_OPTIONS_API__ 被替换成字符串 __VUE_OPTIONS_API__(一个未定义的占位符) ,等待 Vite/webpack 的 define 在用户项目中注入。这就是你在 Vite 项目 vite.config.ts 里写 define: { __VUE_OPTIONS_API__: false } 能生效的原因。

第三类:__DEV__ 的双重处理

js 复制代码
// rollup.config.js:203-206(非 bundler)
if (!isBundlerESMBuild) {
  replacements.__DEV__ = String(!isProductionBuild)
}
js 复制代码
// rollup.config.js:234-239(bundler,走 @rollup/plugin-replace)
if (isBundlerESMBuild) {
  Object.assign(replacements, {
    __DEV__: `!!(process.env.NODE_ENV !== 'production')`,
  })
}

为什么 bundler 版的 __DEV__ 要单独用 replace 而非 esbuild 的 define?因为 esbuild 的 define 要求值是字面量或标识符 ,而 !!(process.env.NODE_ENV !== 'production') 是一个表达式 。rollup.config.js:220-222 的注释解释了这一点------"esbuild define is a bit strict"。这也说明:replace 插件与 esbuild define 是互补的,前者处理表达式,后者处理常量。

1.6.5 未定义开关的运行时兜底:initFeatureFlags

如果用户用了 esm-bundler 构建却没在打包器里注入 feature flags,会怎样?Vue 有一个优雅的兜底(packages/runtime-core/src/featureFlags.ts:8-37):

ts 复制代码
export function initFeatureFlags(): void {
  const needWarn = []

  if (typeof __FEATURE_OPTIONS_API__ !== 'boolean') {
    __DEV__ && needWarn.push(`__VUE_OPTIONS_API__`)
    getGlobalThis().__VUE_OPTIONS_API__ = true
  }
  if (typeof __FEATURE_PROD_DEVTOOLS__ !== 'boolean') {
    __DEV__ && needWarn.push(`__VUE_PROD_DEVTOOLS__`)
    getGlobalThis().__VUE_PROD_DEVTOOLS__ = false
  }
  if (typeof __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__ !== 'boolean') {
    __DEV__ && needWarn.push(`__VUE_PROD_HYDRATION_MISMATCH_DETAILS__`)
    getGlobalThis().__VUE_PROD_HYDRATION_MISMATCH_DETAILS__ = false
  }
  if (__DEV__ && needWarn.length) {
    console.warn(`Feature flag... not explicitly defined. ...`)
  }
}

关键机制:typeof __FEATURE_OPTIONS_API__ !== 'boolean'。在未注入的场景,打包器没有替换这个标识符,于是运行时它会作为全局变量 被 typeof 检查(typeof 对未声明变量不报错,返回 "undefined")。此时 Vue 把默认值挂到 globalThis 上,并在 dev 环境打印警告。

否则,如果用户注入了 __VUE_OPTIONS_API__: false,替换后 typeof false !== 'boolean' 为 false,跳过兜底。这个函数只在 baseCreateRenderer 创建渲染器时调用一次,保证"引入 runtime-core 不产生副作用"。

易错点 :很多人在 Vite 里看到这个警告,是因为 vue 的 esm-bundler 构建默认不注入这些 flag。解决方式是三条路------(a) 在打包器 define 里显式声明;(b) 使用 Vue 官方插件(@vitejs/plugin-vue 会处理一部分);(c) 忽略警告(行为上等价于默认值)。注意从某个版本起,@vitejs/plugin-vue/vue-loader 不再自动注入,因为 Vue 认为"让用户显式声明更符合 tree-shaking 语义"。

1.6.6 tree-shaking 收益的量化

以 __FEATURE_OPTIONS_API__ 为例,看它在源码里控制了什么(来自 grep 结果):

ts 复制代码
// packages/runtime-core/src/componentPublicInstance.ts:416
$options: i => (__FEATURE_OPTIONS_API__ ? resolveMergedOptions(i) : i.type),

// packages/runtime-core/src/componentPublicInstance.ts:423
$watch: i => (__FEATURE_OPTIONS_API__ ? instanceWatch.bind(i) : NOOP),

还有 componentProps.ts:521-533、componentEmits.ts:244-255 等多处。当 __FEATURE_OPTIONS_API__ 被替换为 false:

  • $options 直接退化为 i.type(不合并 options);
  • $watch 退化为 NOOP;
  • resolveMergedOptions、instanceWatch、Options API 的 normalizer 等一大片代码因不再被引用而被 tree-shake 掉。

scripts/usage-size.js:35-42 用真实构建量化了这一收益:

js 复制代码
{
  name: 'createApp (CAPI only)',
  imports: ['createApp'],
  replace: { __VUE_OPTIONS_API__: 'false' },
},
{ name: 'createApp', imports: ['createApp'] },

它对比"只导入 createApp + 关闭 Options API"与"只导入 createApp + 开启 Options API"的产物体积。实测中,关闭 Options API 通常能省下数 KB(min)量级------这正是 Composition API 用户能获得更小包体积的原因。

同理,Suspense、KeepAlive、Transition 都不被导入时会被完全摇掉,这也是 scripts/usage-size.js 里 overall preset 与单项 preset 体积差异的来源。

1.6.7 开关的"语义冲突"与覆盖优先级

当同一标识符既出现在 esbuild define(resolveDefine)又出现在 @rollup/plugin-replace(resolveReplace)里时,谁生效?答案是replace 插件先生效 ------因为 resolveReplace() 返回的插件被放进了 plugins 数组(rollup.config.js:332)在 esbuild 插件(:333)之前 。对 __DEV__ 而言:

  • 非 bundler:resolveReplace 不含 __DEV__,由 esbuild define 处理。
  • bundler:resolveReplace 覆盖 __DEV__ 为表达式,esbuild define 里根本没有 __DEV__(因为 :203 的 if (!isBundlerESMBuild) 排除了它)。

所以两者不会真正冲突,是精心分配的结果。

另一个易错点:__VERSION__ 被定义为 "3.5.44"(带引号的字符串字面量)。如果没有引号,esbuild 会把它当标识符解析,导致把源码里的 "3.5.44" 当成变量名而报错。同理 __COMMIT__ 也是 "${...}" 形式。

1.6.8 与 Vue 2 的编译期开关对比

Vue 2 只有 process.env.NODE_ENV 一个开关(靠 webpack DefinePlugin 替换),没有细粒度的 feature flag。Vue 3 引入 __FEATURE_* 的直接动机就是让 tree-shaking 粒度更细:

能力 Vue 2 Vue 3
dev/prod 切换 process.env.NODE_ENV __DEV__(进一步区分 bundler/browser)
Options API 可选 不可(总是打包) __FEATURE_OPTIONS_API__
prod devtools 可选 不可 __FEATURE_PROD_DEVTOOLS__
Suspense 可选 无此特性 __FEATURE_SUSPENSE__
compat 层可选 无 compat 概念 __COMPAT__
hydration 详情可选 无 __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__

这张表也解释了为什么 Vue 3 的 prod 包能做得比 Vue 2 更小------不是算法更省,而是更多代码被框进了可摇掉的开关里。


1.7 类型声明构建:tsconfig.build.json + rollup.dts.config.js

1.7.1 为什么不能只用 tsc 直接输出 .d.ts

一个自然的疑问:TypeScript 自带 declaration: true 就能生成 .d.ts,为什么 Vue 还要"tsc 生成临时 dts → Rollup 打包 → 后处理"三段式(见根 package.json:9)?

原因有三,都是"发布级"要求:

  1. .d.ts 文件爆炸 。tsc 会为每个源文件 生成一个 .d.ts,packages/vue 依赖的整棵子树可能有几十上百个 .d.ts。npm 包若是这样"目录散落式"分发,用户 node_modules 会膨胀,且 import 解析成本高。
  2. internal 与 @private 泄漏 。Vue 源码里有大量内部类型(如 InternalRenderFunction),若不 stripInternal 或打包,会污染公开类型面。
  3. 类型面需要"整理" 。tsc 生成的 dts 使用 export {...} 大声明块,VitePress/Volar 在 defineComponent 等场景会报"inferred type cannot be named without a reference"。Vue 需要把它改成 inline export(见 1.7.4)。

所以 Vue 的流程是:先用 tsc 做类型检查并吐出"每文件的 dts",再用 rollup-plugin-dts 把整棵类型树压成单文件,最后用自定义插件做 AST 级修补。

1.7.2 tsconfig.build.json:dts 专用配置

它继承主 tsconfig.json 并覆盖几个关键项(tsconfig.build.json:1-23):

json 复制代码
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "types": ["node"],
    "declaration": true,
    "emitDeclarationOnly": true,
    "stripInternal": true,
    "composite": false
  },
  "include": [
    "packages/global.d.ts",
    "packages/vue/src",
    "packages/vue-compat/src",
    "packages/compiler-core/src",
    "packages/compiler-dom/src",
    "packages/runtime-core/src",
    "packages/runtime-dom/src",
    "packages/reactivity/src",
    "packages/shared/src",
    "packages/compiler-sfc/src",
    "packages/compiler-ssr/src",
    "packages/server-renderer/src"
  ]
}

逐字段:

  • emitDeclarationOnly: true :只产 .d.ts,不产 .js(JS 由 Rollup 负责)。
  • stripInternal: true :删除标注了 /** @internal */ 的声明。这是控制公开类型面的关键开关。
  • composite: false :关闭项目引用/增量构建的约束。主配置里 composite: true(tsconfig.json:29)是为了增量类型检查(pnpm check),构建时不需要。
  • types: ["node"] :构建时的全局类型只留 Node;测试用的 vitest/globals、puppeteer 不需要进入产物。
  • include 白名单 :显式列出 11 个包(不含 runtime-test,因为它是 private 且不发布)。这就是"哪些包产出 dts"的权威清单------与 packages/*/package.json 的 types 字段一一对应。

关键细节:主 tsconfig.json:3 的 outDir: "temp"。因为 emitDeclarationOnly 会把每个 .d.ts 按 rootDir 结构输出到 temp/packages/<pkg>/src/*.d.ts。rollup.dts.config.js:8-13 正是从这里读取:

js 复制代码
if (!existsSync('temp/packages')) {
  console.warn('no temp dts files found. run `tsc -p tsconfig.build.json --noCheck` first.')
  process.exit(1)
}

注意根 package.json:9 的命令带 --noCheck:

json 复制代码
"build-dts": "tsc -p tsconfig.build.json --noCheck && rollup -c rollup.dts.config.js"

--noCheck 让 tsc 只做 emit、跳过类型检查 ,大幅提速。类型正确性由 pnpm check(tsc --incremental --noEmit)单独保证,职责分离。

1.7.3 tsconfig.json 的 isolatedDeclarations

主配置里有一个特别值得注意的选项(tsconfig.json:28):

json 复制代码
"isolatedDeclarations": true,

它要求每个导出都必须有显式可推导的类型标注 (例如函数返回值必须写明,不能依赖推断)。这是为 dts 构建服务的:当 isolatedDeclarations 开启,即使单文件转译(如 esbuild/swc/babel 逐文件处理)也能正确生成声明,而不需要全局类型上下文。

这条约束对源码风格有实质影响,例如 packages/shared/src/general.ts 里到处是这种显式标注:

ts 复制代码
// packages/shared/src/general.ts:34-37
export const hasOwn = (
  val: object,
  key: string | symbol,
): key is keyof typeof val => hasOwnProperty.call(val, key)
ts 复制代码
// packages/shared/src/general.ts:125-128
export const capitalize: <T extends string>(str: T) => Capitalize<T> =
  cacheStringFunction(<T extends string>(str: T) => {
    return (str.charAt(0).toUpperCase() + str.slice(1)) as Capitalize<T>
  })

这些写法(给箭头函数变量写完整类型签名)在普通项目里是"冗余"的,但在 Vue 里是 isolatedDeclarations 的硬要求。

1.7.4 rollup.dts.config.js:打包流程

该配置导出的是每个包一个 RollupOptions 的数组 (rollup.dts.config.js:15-43):

js 复制代码
const packages = readdirSync('temp/packages')
const targets = process.env.TARGETS ? process.env.TARGETS.split(',') : null
const targetPackages = targets ? packages.filter(pkg => targets.includes(pkg)) : packages

export default targetPackages.map(pkg => ({
  input: `./temp/packages/${pkg}/src/index.d.ts`,
  output: {
    file: `packages/${pkg}/dist/${pkg}.d.ts`,
    format: 'es',
  },
  plugins: [dts(), patchTypes(pkg), ...(pkg === 'vue' ? [copyMts()] : [])],
  onwarn(warning, warn) {
    // during dts rollup, everything is externalized by default
    if (warning.code === 'UNRESOLVED_IMPORT' && !warning.exporter?.startsWith('.')) {
      return
    }
    warn(warning)
  },
}))

要点:

  • 输入 是 tsc 产出的 temp/packages/<pkg>/src/index.d.ts,输出 是 packages/<pkg>/dist/<pkg>.d.ts。注意输出名用的是包目录名 (如 runtime-core.d.ts),而非 npm 名(@vue/runtime-core)。
  • dts() 是 rollup-plugin-dts,它把整棵 .d.ts 依赖树合并成单文件。所有外部包默认被 external。
  • patchTypes(pkg) 是自定义后处理器(见下)。
  • copyMts() 只为 vue 包追加(见 1.7.6)。
  • onwarn 忽略对非相对路径(包名)的未解析导入------因为 dts 打包时这些类型引用会被保留为 import 语句,而不是试图解析。

1.7.5 patchTypes:AST 级的类型面整理

patchTypes(rollup.dts.config.js:56-186)是 Vue 自己写的 Rollup 插件,注释明确了它的两个目标(:47-53):

js 复制代码
/**
 * Patch the dts generated by rollup-plugin-dts
 * 1. Convert all types to inline exports
 *    and remove them from the big export {} declaration
 *    otherwise it gets weird in vitepress `defineComponent` call with
 *    "the inferred type cannot be named without a reference"
 * 2. Append custom augmentations (jsx, macros)
 */

它用 MagicString + @babel/parser 做三次 AST 遍历:

  1. pass 0 (:90-100):收集所有导出的类型名,存入 isExported。
  2. pass 1 (:102-125):遍历 VariableDeclaration/TSTypeAliasDeclaration/TSInterfaceDeclaration/TSDeclareFunction/TSEnumDeclaration/ClassDeclaration,把需要导出的声明就地加 export 前缀 (s.prependLeft(start, 'export ')),并记入 shouldRemoveExport。名字以 _ 开头的跳过(内部符号)。
  3. pass 2 (:127-171):从大 export {} 声明块里移除已经变成 inline export 的条目,避免重复导出。

最后(:174-183)追加包专属类型:

js 复制代码
const additionalTypeDir = `packages/${pkg}/types`
if (existsSync(additionalTypeDir)) {
  code += '\n' + readdirSync(additionalTypeDir)
    .map(file => readFileSync(`${additionalTypeDir}/${file}`, 'utf-8'))
    .join('\n')
}

例如 packages/runtime-dom/types/jsx.d.ts(全局 JSX 类型增强)就是通过这一步被追加进 runtime-dom.d.ts 的。

pass 1 里还有一个防御性断言(:106-115):若遇到含多个 declarator 的 declare const 会直接抛错,说明该写法未被支持。这是一个"边界条件明确化"的典型做法。

1.7.6 copyMts:为什么需要 vue.d.mts

copyMts(rollup.dts.config.js:196-204):

js 复制代码
function copyMts() {
  return {
    name: 'copy-vue-mts',
    writeBundle(_, bundle) {
      assert('code' in bundle['vue.d.ts'])
      writeFileSync('packages/vue/dist/vue.d.mts', bundle['vue.d.ts'].code)
    },
  }
}

注释(:188-194)解释了原因:根据 TS 4.7 的"package.json exports/imports 与自引用"规则,要同时正确提供 Node ESM 与 CJS 的类型,必须有两个独立声明文件 。因此把 vue.d.ts 原样复制一份为 vue.d.mts。

这就与 1.4.2 的 exports 呼应了------import 分支的 types 指向 vue.d.mts,require 分支指向 vue.d.ts:

json 复制代码
"import": { "types": "./dist/vue.d.mts", ... },
"require": { "types": "./dist/vue.d.ts", ... }

注意只有 vue 包做了这个复制。因为只有它是用户 import 的直接入口;内部包(如 @vue/runtime-core)用户一般不直接 import ESM 版,且其 exports 的 types 统一指 .d.ts。

1.7.7 dts 构建的校验闭环

类型声明构建正确与否,靠 packages-private/dts-built-test 验证。根脚本(package.json:22-23):

json 复制代码
"test-dts": "run-s build-dts test-dts-only",
"test-dts-only": "tsc -p packages-private/dts-built-test/tsconfig.json && tsc -p ./packages-private/dts-test/tsconfig.test.json"

流程是:先构建 dts,再跑两个 tsc:

  • packages-private/dts-built-test/tsconfig.json:针对构建产物 做类型检查(验证 .d.ts 可用)。
  • packages-private/dts-test/tsconfig.test.json:针对源码 做类型测试(.test-d.ts 文件,用 expectType/@ts-expect-error 断言)。

这套"源码类型测试 + 产物类型测试"的双保险,是 Vue 类型质量长期稳定的秘诀------很多库只测源码类型,产物类型损坏却无法自知。

1.7.8 实战:本地复现 dts 构建

powershell 复制代码
# 1) 只 emit 声明(跳过类型检查,快)
pnpm exec tsc -p tsconfig.build.json --noCheck

# 2) 查看中间产物结构
Get-ChildItem temp/packages/vue/src/*.d.ts

# 3) 打包 dts
pnpm exec rollup -c rollup.dts.config.js

# 4) 检查产物
Get-Content packages/vue/dist/vue.d.ts -TotalCount 30
Get-ChildItem packages/vue/dist/*.d.mts

# 5) 或一键
pnpm run build-dts

第 5 步就是 CI 中 lint-and-test-dts job 执行的内容(见 1.8、1.9)。一个常见踩坑:直接 tsc(不带 -p tsconfig.build.json)会走主配置,composite: true + include 里含 __tests__,导致输出结构与 rollup.dts.config.js 期望的 temp/packages/<pkg>/src/index.d.ts 不匹配,从而触发 no temp dts files found 的警告并退出。


1.8 测试体系:vitest.config.ts 的多 project 矩阵

1.8.1 全局 define 与别名

vitest.config.ts 顶部先注入测试期的编译期常量(:5-24):

ts 复制代码
export default defineConfig({
  define: {
    __DEV__: true,
    __TEST__: true,
    __VERSION__: '"test"',
    __BROWSER__: false,
    __GLOBAL__: false,
    __ESM_BUNDLER__: true,
    __ESM_BROWSER__: false,
    __CJS__: true,
    __SSR__: true,
    __FEATURE_OPTIONS_API__: true,
    __FEATURE_SUSPENSE__: true,
    __FEATURE_PROD_DEVTOOLS__: false,
    __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
    __COMPAT__: true,
  },
  resolve: {
    alias: entries,
  },
  // ...
})

两点值得注意:

  1. __TEST__: true ------这是唯一与生产构建不同、且为"真"的 dev-only 开关。它让测试能走一些仅在测试中启用的分支(如 runtime-core 里的 __TEST__ 断言)。
  2. alias: entries ------直接复用 scripts/aliases.js 的 entries(与 Rollup 同源),保证"源码级"路径映射一致。scripts/aliases.js:16-21 定义:
js 复制代码
const entries = {
  vue: resolveEntryForPkg('vue'),
  'vue/compiler-sfc': resolveEntryForPkg('compiler-sfc'),
  'vue/server-renderer': resolveEntryForPkg('server-renderer'),
  '@vue/compat': resolveEntryForPkg('vue-compat'),
}

随后自动为 packages/ 下所有非特殊目录补 @vue/<dir> 映射(:25-35)。这个"Rollup 与 Vitest 共享 alias"设计,意味着测试引用的 @vue/runtime-core 与构建引用的必然是同一份源码,杜绝了两套映射漂移。

1.8.2 顶层 test 配置

ts 复制代码
// vitest.config.ts:25-49
test: {
  globals: true,
  pool: 'threads',
  setupFiles: 'scripts/setup-vitest.ts',
  sequence: { hooks: 'list' },
  coverage: {
    provider: 'v8',
    reporter: ['text', 'html'],
    include: ['packages/*/src/**'],
    exclude: [
      'packages/vue-compat/**',
      'packages/vue/src/dev.ts',
      'packages/vue/src/runtime.ts',
      'packages/runtime-core/src/profiling.ts',
      'packages/runtime-core/src/featureFlags.ts',
      'packages/runtime-core/src/customFormatter.ts',
      'packages/runtime-core/src/hydrationStrategies.ts',
      'packages/runtime-dom/src/components/Transition*',
    ],
  },
  // ...
}

逐项解读:

  • globals: true :让 describe/it/expect 无需 import(配合 tsconfig.json:21 的 types: ["vitest/globals"])。
  • pool: 'threads' :默认用 worker 线程池,比 forks 轻量。
  • setupFiles: scripts/setup-vitest.ts :设置 JSDOM 之外的公共前置逻辑(如全局 __DEV__ 相关的 mock、requestAnimationFrame 等)。
  • sequence.hooks: 'list' :按定义顺序执行 hooks(而非并行),避免"后置清理先于前置初始化"的竞态;对 effectScope、nextTick 类测试尤为重要。
  • coverage 排除清单 :注释给出了排除原因(:37-48)------vue-compat 是"entries that are not really used during tests";profiling.ts/featureFlags.ts/customFormatter.ts 是"not testable during unit tests";Transition* 与 hydrationStrategies.ts 是"tested via e2e so no coverage is collected"。这是一份"为什么某些文件不纳入覆盖率"的说明书。

1.8.3 五个 project:环境分层的艺术

projects(:51-118)是 Vitest 4 的多项目特性,等价于"多套 Vitest 配置并存在一个 run 里"。Vue 定义了 5 个:

(1)unit------主单元测试

ts 复制代码
{
  extends: true,
  test: {
    name: 'unit',
    exclude: [
      ...configDefaults.exclude,
      '**/e2e/**',
      '**/{vue,vue-compat,runtime-dom}/**',
      'packages/server-renderer/__tests__/ssrWatch.spec.ts',
      'packages/server-renderer/__tests__/ssrRender.spec.ts',
    ],
  },
}

关键在 exclude:排除 e2e、排除依赖 DOM 的 vue/vue-compat/runtime-dom(它们由 unit-jsdom 接管)、排除需要 GC 的两个 SSR 测试(由 unit-gc 接管)。这样 unit 跑在纯 Node 环境,速度最快。

(2)unit-gc------需要手动 GC 的 SSR 测试

ts 复制代码
{
  extends: true,
  test: {
    name: 'unit-gc',
    pool: 'forks',
    include: [
      'packages/server-renderer/__tests__/ssrWatch.spec.ts',
      'packages/server-renderer/__tests__/ssrRender.spec.ts',
    ],
    execArgv: ['--expose-gc'],
  },
}

为什么要单列?因为 execArgv: ['--expose-gc'] 需要把 --expose-gc 透传给 Node 进程,而线程池不支持 execArgv------所以这里必须用 pool: 'forks'(子进程池)。这两个测试验证的是"SSR 渲染时 effect 是否被正确释放、内存是否被回收",必须能手动触发 global.gc()。

(3)unit-jsdom------依赖 DOM 的单元测试

ts 复制代码
{
  extends: true,
  test: {
    name: 'unit-jsdom',
    include: ['packages/{vue,vue-compat,runtime-dom}/**/*.{test,spec}.*'],
    exclude: [...configDefaults.exclude, '**/e2e/**'],
    environment: 'jsdom',
  },
}

runtime-dom 的 patchProp、v-model、v-show、Transition(部分)都需要真实 DOM API,故用 jsdom 环境。

(4)e2e------jsdom 下的端到端

ts 复制代码
{
  extends: true,
  test: {
    name: 'e2e',
    environment: 'jsdom',
    isolate: true,
    include: ['packages/vue/__tests__/e2e/*.spec.ts'],
    exclude: [
      'packages/vue/__tests__/e2e/Transition.spec.ts',
      'packages/vue/__tests__/e2e/TransitionGroup.spec.ts',
    ],
  },
}

这里的 "e2e" 并非"真浏览器",而是构建产物级别的集成测试 ------它加载打包后的 vue.global.js(见 package.json:21 的 test-e2e 先跑 node scripts/build.js vue -f global -d)。isolate: true 保证每个测试文件在独立环境运行,避免全局状态串扰。特别注意 Transition/TransitionGroup 被排除到 e2e-browser------因为 jsdom 对 CSS transition 事件的模拟不真实。

(5)e2e-browser------Playwright 真浏览器

ts 复制代码
{
  extends: true,
  define: { __BROWSER__: true },
  test: {
    name: 'e2e-browser',
    include: [
      'packages/vue/__tests__/e2e/Transition.spec.ts',
      'packages/vue/__tests__/e2e/TransitionGroup.spec.ts',
    ],
    browser: {
      enabled: true,
      provider: playwright(),
      headless: true,
      instances: [{ browser: 'chromium' }],
    },
  },
}

这是唯一的真浏览器 project:用 @vitest/browser-playwright 的 playwright() provider,在 chromium 里跑。并且单独覆盖了 __BROWSER__: true ------因为它在浏览器里运行,这个开关必须为真。这印证了 1.6 的开关不是"全局一刀切",而是按 project 精细设置。

1.8.4 测试矩阵一览

project 环境 池 覆盖范围 特殊配置
unit Node threads 除 DOM/e2e 外的所有单测 最快
unit-gc Node forks SSR 内存回收测试 --expose-gc
unit-jsdom jsdom threads vue/vue-compat/runtime-dom DOM API
e2e jsdom threads vue/__tests__/e2e/* isolate、依赖 global 构建
e2e-browser chromium - Transition/TransitionGroup Playwright、__BROWSER__: true

对应根脚本(package.json:19-24):

json 复制代码
"test": "vitest",
"test-unit": "vitest --project unit*",
"test-e2e": "node scripts/build.js vue -f global -d && vitest --project e2e --project e2e-browser",
"test-coverage": "vitest run --project unit* --coverage",

注意 --project unit* 会同时命中 unit、unit-gc、unit-jsdom 三个(通配),也就是"所有单测"。而 test-e2e 必须先构建 vue -f global -d(development 版 global),否则 e2e 找不到 dist/vue.global.js。

1.8.5 覆盖率策略

用 @vitest/coverage-v8(V8 引擎的 native 覆盖率,比 Istanbul 快)。test-coverage 只跑 unit* 三个 project------因为 jsdom 与 e2e 的覆盖率口径不同,混在一起会失真。include: ['packages/*/src/**'] 表示只统计源码,不含测试文件本身。

1.8.6 test-dts:类型测试的两种形态

类型测试不在 Vitest 里跑,而是独立走 tsc(见 1.7.7):

  • packages-private/dts-test/ :*.test-d.ts / *.test-d.tsx 文件,用 expectType/@ts-expect-error 断言。例如 defineComponent.test-d.tsx 验证 defineComponent 的类型推断、h.test-d.ts 验证 h 函数的类型。
  • packages-private/dts-built-test/ :对打包后的 dts 做类型检查,确保产物可用。

两者在 CI 中都由 lint-and-test-dts job 覆盖(pnpm run test-dts)。

1.8.7 tree-shaking 的 CI 校验

除了体积统计,Vue 还有一道"树摇正确性"的守门员------scripts/verify-treeshaking.js。CI 里在 e2e 后运行(test.yml:94-95):

js 复制代码
// scripts/verify-treeshaking.js:5-48(节选)
exec('pnpm', ['build', 'vue', '-f', 'global-runtime']).then(() => {
  const errors = []
  const devBuild = fs.readFileSync('packages/vue/dist/vue.runtime.global.js', 'utf-8')
  if (devBuild.includes('__spreadValues')) {
    errors.push('dev build contains unexpected esbuild object spread helper. ...')
  }
  const prodBuild = fs.readFileSync('packages/vue/dist/vue.runtime.global.prod.js', 'utf-8')
  if (prodBuild.includes('Vue warn')) {
    errors.push('prod build contains unexpected warning-related code. ...')
  }
  if (prodBuild.includes('html,body,base') || ...) {
    errors.push('prod build contains unexpected domTagConfig lists. ...')
  }
  // ...
})

它做了三项断言:

  1. dev 产物不含 __spreadValues:说明源码没用对象展开语法(与 1.10 的 eslint 规则呼应)。
  2. prod 产物不含 Vue warn :说明所有警告都被 __DEV__ 守卫,没有泄漏到生产环境。
  3. prod 产物不含 domTagConfig 列表 (html,body,base 等):说明编译器专用的 DOM 标签判断没有被误打进运行时。

这是一套用字符串包含检测来守卫体积的巧妙机制------比逐帧 diff 更简单、更可靠。

1.8.8 常见踩坑与调试建议

  • unit 里跑 DOM 测试会报错 :因为 unit 项目 exclude 了 DOM 包且未设 jsdom 环境。要跑 DOM 测试用 pnpm test-unit 的 unit-jsdom 项目。
  • e2e 报"找不到 vue.global.js" :忘记先 node scripts/build.js vue -f global -d。test-e2e 脚本已内置该步骤。
  • unit-gc 报 --expose-gc 未生效 :必须用 pool: 'forks',threads 不支持 execArgv。
  • 改完 scripts/aliases.js 后 Vitest 与 Rollup 行为都要重测 :因为二者共享同一份 entries,一处错则两处错。

1.9 发布流程:scripts/release.js 与 CI 工作流

1.9.1 发布的两条路径

Vue 3 的发布有两条路径,理解它们的区别是理解整个流程的关键:

  1. 本地交互式发布 :pnpm release,运行 scripts/release.js,一步步交互式确认版本、跑测试、生成 CHANGELOG、打 tag、推送。它不直接 publish (除非显式加 --publish)。
  2. CI 自动发布 :推送 v* tag 后,.github/workflows/release.yml 触发,在 CI 中执行 pnpm release --publishOnly,真正 publish 到 npm。

scripts/release.js:256-263 明确提示了这一点:

js 复制代码
if (!args.publish) {
  console.log(
    pico.yellow(
      '\nRelease will be done via GitHub Actions.\n' +
        'Check status at https://github.com/vuejs/core/actions/workflows/release.yml',
    ),
  )
}

即:本地 release 只负责"改版本 + 生成 CHANGELOG + commit + 打 tag + push",真正的 publish 交给 CI。这样 npm token 不必出现在开发者本地,也更符合供应链安全规范。

1.9.2 参数与前置检查

scripts/release.js:27-62 定义了参数:

参数 语义
--preid 预发布标识(alpha/beta/rc)
--dry 试运行,只打印命令不执行
--tag 显式指定 npm dist-tag
--skipBuild / --skipTests / --skipGit / --skipPrompts 跳过对应步骤
--publish 本地也执行 publish
--publishOnly 只发布(供 CI 使用,跳过版本变更交互)
--registry 指定 registry

一开场先校验"本地 HEAD 是否与远端同步"(scripts/release.js:144-148):

js 复制代码
if (!(await isInSyncWithRemote())) {
  return
} else {
  console.log(`${pico.green(`✓`)} commit is up-to-date with remote.\n`)
}

isInSyncWithRemote(:337-363)通过 GitHub API 拉取当前 branch 的 HEAD sha 与本地 git rev-parse HEAD 对比。若不一致则交互式确认是否继续。这避免了"在落后于远端的提交上发版"。

1.9.3 版本选择:semver 与 versionIncrements

核心逻辑(scripts/release.js:110-186):

js 复制代码
const versionIncrements = [
  'patch', 'minor', 'major',
  ...(preId ? ['prepatch', 'preminor', 'premajor', 'prerelease'] : []),
]
const inc = i => semver.inc(currentVersion, i, typeof preId === 'string' ? preId : undefined)

流程:

  1. 读当前版本 currentVersion(:24,来自根 package.json)。
  2. 若存在 preid(如当前版本是 3.0.0-alpha.1,semver.prerelease 会提取出 alpha),则额外提供预发布增量选项。
  3. 用 enquirer 的 select 交互(:155-176):列出 patch (3.5.45)、minor (3.6.0)、major (4.0.0)、custom 等选项,或输入自定义版本。
  4. semver.valid(targetVersion) 校验(:184-186),非法直接抛错。

updateVersions(targetVersion, keepThePackageName)(:206-208)把新版本写回根 package.json 与所有 packages/(:377-398):

js 复制代码
function updateVersions(version, getNewPackageName = keepThePackageName) {
  updatePackage(path.resolve(__dirname, '..'), version, getNewPackageName)
  packages.forEach(p => updatePackage(getPkgRoot(p), version, getNewPackageName))
}
function updatePackage(pkgRoot, version, getNewPackageName) {
  const pkgPath = path.resolve(pkgRoot, 'package.json')
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
  pkg.name = getNewPackageName(pkg.name)
  pkg.version = version
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
}

注意 pkg.name = getNewPackageName(pkg.name) 这一行看似多余(默认 keepThePackageName 是恒等函数),但它保留了"发布预发布版本时改包名"的可能性------历史上 Vue 会用这种方式发布 vue@next 之类的并行包。

1.9.4 dist-tag 与预发布通道

publishPackage 里根据版本号推导 dist-tag(scripts/release.js:442-451):

js 复制代码
let releaseTag = null
if (args.tag) {
  releaseTag = args.tag
} else if (version.includes('alpha')) {
  releaseTag = 'alpha'
} else if (version.includes('beta')) {
  releaseTag = 'beta'
} else if (version.includes('rc')) {
  releaseTag = 'rc'
}

于是:

  • 3.5.0-alpha.1 → 发布到 alpha tag(用户 npm i vue@alpha)。
  • 3.5.0-beta.1 → beta tag。
  • 3.5.0-rc.1 → rc tag。
  • 正式版 → latest(无 tag 参数时 pnpm/npm 默认)。

有一个易混点:dist-tag 与语义化版本是两个正交的维度 。beta tag 可以指向任意版本,但它通常只在预发布时被设置。用户安装 vue@next(Vue 3 早期)或 vue@alpha 都是通过 dist-tag 实现的。

1.9.5 幂等发布与已有版本跳过

发布流程对"重复发布"做了处理(scripts/release.js:453-488):

js 复制代码
if (!isDryRun && (await isPackagePublished(packageName, version))) {
  const pkgVersion = `${packageName}@${version}`
  console.log(pico.yellow(`Skipping already published: ${pkgVersion}`))
  alreadyPublishedPackages.push(pkgVersion)
  return
}

isPackagePublished(:491-513)用 npm view <pkg>@<version> version 探测;若报错匹配 E404|No match found|No matching version|notarget(:515-517)则视为未发布。这保证了即使发布中途失败、重跑时也不会因"版本已存在"而整体崩掉------这是幂等发布的关键。

1.9.6 发布顺序:vue 最后发

一个精妙的细节(scripts/release.js:85-90):

js 复制代码
const sortPackagesForPublishing = packageNames => [
  // Publish vue last so users cannot install the new entry package before
  // the matching internal packages are available.
  ...packageNames.filter(p => p !== 'vue'),
  ...packageNames.filter(p => p === 'vue'),
]

注释点明了原因:先发内部包(@vue/runtime-core 等),最后发 vue 。若先发 vue,用户在其它内部包还没更新到同版本时安装 vue@最新,会因为 vue 的依赖指向了不存在的版本而失败。这是"发布顺序即一致性"的经典实践。

1.9.7 CHANGELOG 生成

package.json:30:

json 复制代码
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s"

release.js:210-213 调用它:

js 复制代码
step('\nGenerating changelog...')
await run(`pnpm`, ['run', 'changelog'])

它基于 conventional commits 生成 CHANGELOG:feat → "Features" 段、fix → "Bug Fixes" 段、带 BREAKING CHANGE 的 commit → "Breaking Changes"。这就把 1.10 的 commit 规范与 1.9 的发布流程串成了一条链:commit 格式正确 → CHANGELOG 自动生成 → 用户能看到清晰的变更说明。

生成后还有交互确认(:214-225)------让发布者人工检查 CHANGELOG 是否可读。

1.9.8 CI 工作流全景

.github/workflows/ 下 8 个 workflow,与本章各节一一对应:

workflow 触发 职责
ci.yml push 任意分支 / PR 到 main/minor 汇总入口:调用 test.yml;另跑 continuous-release(pkg-pr-new 预览包)
test.yml workflow_call 单元测试(ubuntu + windows)、e2e、lint + dts
release.yml push v* tag 跑测试 → pnpm release --publishOnly → 创建 GitHub Release
size-data.yml push/PR 构建并上传体积数据 artifact
size-report.yml size data 完成后 对比体积并评论到 PR
autofix.yml - 自动格式化修复
ecosystem-ci-trigger.yml - 触发第三方生态 CI
lock-closed-issues.yml - 锁定已关闭 issue

ci.yml 的巧妙条件 (ci.yml:22):

yaml 复制代码
if: ${{ ! startsWith(github.event.head_commit.message, 'release:') && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository) }}

两点:跳过以 release: 开头的提交(那是发布机器人的工作,无需重复跑 CI),以及跳过同仓库分支的 PR 首轮(避免重复)。

test.yml 的四个 job (test.yml:8-128):

  1. unit-test:ubuntu 上跑 pnpm run test-unit。
  2. unit-test-windows:windows 上并行跑 compiler 与 server-renderer 单测------专门捕获路径分隔符、文件系统差异导致的平台 bug。
  3. e2e-test:缓存 puppeteer/playwright 二进制,跑 pnpm run test-e2e,最后 node scripts/verify-treeshaking.js。
  4. lint-and-test-dts:并行跑 eslint + prettier,再跑 pnpm check(tsc)与 pnpm run test-dts。

这些 job 与 1.7、1.8、1.10 完全对应------CI 就是工程规范的自动化执行。

release.yml 的关键点 (release.yml:12-57):

yaml 复制代码
release:
  if: github.repository == 'vuejs/core'
  needs: [test]
  permissions:
    contents: write
    id-token: write
  environment: Release
  steps:
    # ...
    - name: Build and publish
      id: publish
      run: |
        pnpm release --publishOnly
  • needs: [test]:publish 前必须先过测试。
  • permissions.id-token: write + npm provenance:允许发布带来源证明的包,release.js:425-427 会在 CI 环境自动加 --provenance。
  • environment: Release:使用 GitHub Environment 的部署保护,需人工审批才能发布。

1.9.9 体积报告工作流

size-data.yml 负责构建并生成体积数据,size-report.yml 负责对比并评论。核心脚本 scripts/size-report.js:

js 复制代码
// scripts/size-report.js:43-75
async function renderFiles() {
  const curr = filterFiles(await readdir(currDir))
  const prev = existsSync(prevDir) ? filterFiles(await readdir(prevDir)) : []
  const fileList = new Set([...curr, ...prev])
  const rows = []
  for (const file of fileList) {
    const curr = await importJSON(currPath)
    const prev = await importJSON(prevPath)
    // ...
    rows.push([fileName,
      `${prettyBytes(curr.size)}${getDiff(curr.size, prev?.size)}`,
      `${prettyBytes(curr.gzip)}${getDiff(curr.gzip, prev?.gzip)}`,
      `${prettyBytes(curr.brotli)}${getDiff(curr.brotli, prev?.brotli)}`])
  }
  output += markdownTable([['File', ...sizeHeaders], ...rows])
}

getDiff(:124-130)把差值格式化为 (**+1.2 kB**),于是 PR 里会出现一张"本 PR 让哪个产物胖了多少"的表格。size-report.yml:80-88 用 actions-cool/maintain-one-comment-backup 把这张表以 <!-- VUE_CORE_SIZE --> 为锚点更新到 PR ------同一个 PR 只保留一条评论,反复更新而非刷屏。

1.9.10 实战:一次本地 release 的预期行为

powershell 复制代码
# 需要 git 与远端同步;dry 模式只打印命令
pnpm release --dry

预期输出(节选,实际会因版本/环境而异):

text 复制代码
✓ commit is up-to-date with remote.

Select release type
  patch (3.5.45)
  minor (3.6.0)
  major (4.0.0)
  custom

...(选定后)...
Releasing v3.5.45. Confirm? 

Checking CI status for HEAD...
CI for this commit passed. Skip local tests? 

Updating cross dependencies...
Generating changelog...
Changelog generated. Does it look good? 

Updating lockfile...
Committing changes...
Pushing to GitHub...

Release will be done via GitHub Actions.

注意到两个"人机协作"点:确认版本、确认 CHANGELOG。这体现了 Vue 发布流程"自动化但保留人工把关"的哲学。


1.10 工程规范:ESLint、Prettier 与提交约定

1.10.1 eslint.config.js:扁平化配置

Vue 3.5.44 用 ESLint 的扁平配置 (flat config),导出的是一个配置数组 (eslint.config.js:16-184),而非旧式 .eslintrc。它按"文件匹配 + 规则覆盖"组织,先宽后窄。

(1)基础规则块 (:17-79)------对所有 JS/TS/TSX 生效:

js 复制代码
{
  files: ['**/*.js', '**/*.ts', '**/*.tsx'],
  extends: [tseslint.configs.base],
  plugins: { 'import-x': importX },
  rules: {
    'no-debugger': 'error',
    'no-console': ['error', { allow: ['warn', 'error', 'info'] }],
    'no-restricted-globals': ['error', ...DOMGlobals, ...NodeGlobals],
    'no-restricted-syntax': ['error', banConstEnum, /* ... */],
    'sort-imports': ['error', { ignoreDeclarationSort: true }],
    'import-x/no-nodejs-modules': ['error', { allow: builtinModules.map(m => `node:${m}`) }],
    '@typescript-eslint/ban-ts-comment': ['error', { minimumDescriptionLength: 0 }],
    '@typescript-eslint/consistent-type-imports': ['error', { fixStyle: 'inline-type-imports', disallowTypeAnnotations: false }],
    '@typescript-eslint/no-import-type-side-effects': 'error',
  },
}

其中 no-restricted-syntax 是一组"体积守卫规则"(:29-55),每条都给出了具体原因:

js 复制代码
const banConstEnum = {
  selector: 'TSEnumDeclaration[const=true]',
  message: 'Please use non-const enums. This project automatically inlines enums.',
}
// ...
{
  selector: 'ObjectPattern > RestElement',
  message: 'Our output target is ES2016, and object rest spread results in verbose helpers and should be avoided.',
},
{
  selector: 'ObjectExpression > SpreadElement',
  message: 'esbuild transpiles object spread into very verbose inline helpers.\nPlease use the `extend` helper from @vue/shared instead.',
},
{
  selector: 'AwaitExpression',
  message: 'Our output target is ES2016, so async/await syntax should be avoided.',
},
{
  selector: 'ChainExpression',
  message: 'Our output target is ES2016, and optional chaining results in verbose helpers and should be avoided.',
},

把这五条与前面章节串起来,会发现它们构成一个自洽的体系:

  • 禁 const enum (banConstEnum)↔ 呼应 scripts/inline-enums.js:Vue 用普通 enum + 构建期内联替代 const enum。
  • 禁对象剩余/展开 (ObjectPattern > RestElement、ObjectExpression > SpreadElement)↔ 呼应 rollup.config.js 的 target: es2016(es2016 无对象展开)与 scripts/verify-treeshaking.js 对 __spreadValues 的检查------三者共同守护"不使用对象展开"。
  • 禁 async/await (AwaitExpression)↔ 呼应 esbuild 的 target: es2016:es2016 环境下 async/await 需转译为体积巨大的 generator 状态机。
  • 禁可选链 (ChainExpression)↔ 同样是 es2016 目标下的体积考量。

这四条规则是"约束写法来换体积"的典范:如果团队不主动禁用这些语法,写出来的源码在 es2016 目标下会膨胀。

(2)分层覆盖 ::82-139 按包类型放宽或收紧规则:

js 复制代码
// tests,无限制
{ files: ['**/__tests__/**', 'packages-private/dts-test/**'], rules: { 'no-restricted-globals': 'off', 'no-console': 'off', 'no-restricted-syntax': 'off' } },
// shared,任何环境可用
{ files: ['packages/shared/**', 'eslint.config.js'], rules: { 'no-restricted-globals': 'off' } },
// 面向 DOM 的包
{ files: ['packages/{vue,vue-compat,runtime-dom}/**'], rules: { 'no-restricted-globals': ['error', ...NodeGlobals] } },
// 面向 Node 的包
{ files: ['packages/{compiler-sfc,compiler-ssr,server-renderer}/**'], rules: { 'no-restricted-globals': ['error', ...DOMGlobals] } },

这四块把"平台边界"编码进了 lint 规则:DOM 包不能用 module/require;Node 包不能用 window/document 。如果有人在 runtime-core(通用包)里写了 document,会因为基础规则的 no-restricted-globals 报错------这正是 1.2 说的"平台无关性"的静态保证。

(3)compiler-sfc 的 Node 模块例外 (:167-173):

js 复制代码
{
  files: ['packages/compiler-sfc/src/**'],
  rules: { 'import-x/no-nodejs-modules': ['error', { allow: builtinModules }] },
}

因为 compiler-sfc 必须用 fs/path/postcss 等,这里允许内置模块(而非仅 node: 前缀)。

(4)忽略清单 (:175-184):**/dist/、**/temp/、**/coverage/、explorations/、dts-build/packages------避免对产物做 lint。

1.10.2 Prettier

Prettier 用最简配置(仓库根 .prettierrc / 默认),通过 format、format-check 脚本驱动:

json 复制代码
"format": "prettier --write --cache .",
"format-check": "prettier --check --cache ."

--cache 是关键------它把已格式化文件的哈希缓存到 .prettier-cache,第二次运行只处理变更文件。CI 中 format-check 与 eslint 并行跑(test.yml:117-122)。

值得一提的是 .git-blame-ignore-revs(全文仅 1 行):

text 复制代码
# update prettier & eslint config (#9162)
bfe6b459d3a0ce6168611ee1ac7e6e789709df9d

它记录了一次"纯格式化提交"的 SHA。配置 git config blame.ignoreRevsFile .git-blame-ignore-revs 后,git blame 会跳过这些噪声提交,让"最后真正改逻辑的人"而非"最后格式化的人"出现在 blame 里。这是一个小而重要的工程细节。

1.10.3 verify-commit.js:commit 规范守卫

scripts/verify-commit.js 在 commit-msg 钩子里执行(package.json:50),读取 .git/COMMIT_EDITMSG 并做正则校验(scripts/verify-commit.js:6-27):

js 复制代码
const msgPath = path.resolve('.git/COMMIT_EDITMSG')
const msg = readFileSync(msgPath, 'utf-8').trim()

const commitRE =
  /^(revert: )?(feat|fix|docs|dx|style|refactor|perf|test|workflow|build|ci|chore|types|wip|release)(\(.+\))?: .{1,50}/

if (!commitRE.test(msg)) {
  console.log()
  console.error(`  ${pico.white(pico.bgRed(' ERROR '))} ${pico.red(`invalid commit message format.`)}...`)
  process.exit(1)
}

正则的语义拆解:

  • ^(revert: )?:允许 revert: 前缀(回滚提交)。
  • (feat|fix|docs|dx|style|refactor|perf|test|workflow|build|ci|chore|types|wip|release):允许的 type 枚举,共 15 个。
  • (\(.+\))?:可选的 scope(如 (compiler)、(v-model))。
  • : :冒号加空格。
  • .{1,50}:subject 长度在 1~50 字符之间。

与 .github/commit-convention.md:10 的正则相比,代码实现多了 release 一个 type ------因为发布机器人会提交 release: v3.5.45,必须放行。

commit-convention.md:63-67 补充了语义规则:feat/fix/perf 会进入 CHANGELOG;BREAKING CHANGE 出现在 footer 时强制进入 CHANGELOG。

1.10.4 三条规范如何联动

把 1.10 的三件套串起来看,它们其实是一条自动化流水线:

text 复制代码
开发者写代码
   │
   ├─ pre-commit:  lint-staged (eslint --fix + prettier)  ← 只修暂存文件
   │              + pnpm check (tsc --noEmit)             ← 全量类型检查
   │
   ├─ commit-msg:  verify-commit.js 校验 conventional commits
   │
   └─ CI (lint-and-test-dts): eslint + prettier --check + tsc + test-dts
                              ← 兜底,防止 --no-verify 绕过本地钩子

本地钩子负责"快反馈",CI 负责"不可绕过"。这正是为什么 pre-commit 只跑 lint-staged(增量)而非全量 lint------本地要快,全量交给 CI。

1.10.5 工程规范与"体积优先"价值观

回顾本章出现过的所有工程约束,会发现它们都指向同一个价值观------体积优先:

规范 位置 守卫的体积目标
禁对象展开 eslint.config.js:38-43 es2016 不生成 __spreadValues
禁 async/await eslint.config.js:45-49 不生成 generator 状态机
禁可选链 eslint.config.js:51-55 不生成链式判空 helper
禁 const enum eslint.config.js:10-14 用 inline enum 替代
tree-shaking 校验 scripts/verify-treeshaking.js prod 产物不含 dev 代码
体积报告 size-report.yml PR 级别监控体积回归

对任何想"深度使用"或"二次构建" Vue 的工程团队来说,这张表就是可以直接抄的清单。


1.11 本章要点回顾

1.11.1 全景速查表

主题 一句话结论 关键文件
目录结构 packages/ 发布、packages-private/ 不发布,两套配置全部集中在根 pnpm-workspace.yaml
包数量与职责 11 个公开包 + runtime-test(private),按抽象层级切分 packages/*/package.json
依赖方向 shared 是源头;runtime-core 只依赖 shared+reactivity,不依赖平台 packages/runtime-core/package.json:48-51
平台适配 runtime-dom/runtime-test 各自用 createRenderer 注入 nodeOps/patchProp packages/runtime-test/src/index.ts:12-17
pnpm 策略 catalog 收敛版本、allowBuilds 白名单、minimumReleaseAge 24h 冷却 pnpm-workspace.yaml
分发形态 7 种 format × (dev + prod) 覆盖 bundler/browser/node/CDN rollup.config.js:57-88
入口矩阵 exports 条件顺序敏感;module/import 多指 esm-bundler packages/vue/package.json:19-71
构建入口 build.js 解析参数 → 透传 --environment → 并发跑 Rollup scripts/build.js:34-199
编译期开关 dev/bundler/browser 三套替换路径,feature flag 交给下游打包器 rollup.config.js:170-255
类型构建 tsc --noCheck 出临时 dts → rollup-plugin-dts 打包 → AST 修补 rollup.dts.config.js
测试矩阵 5 个 vitest project:unit/unit-gc/unit-jsdom/e2e/e2e-browser vitest.config.ts:51-118
发布流程 本地打 tag、CI publish;vue 最后发;幂等跳过已发布 scripts/release.js
工程规范 conventional commits + 体积守卫 lint + blame 豁免 scripts/verify-commit.js、eslint.config.js

1.11.2 五条核心认知

  1. 分层是手段,复用是目的 。Vue 3 把渲染算法(runtime-core)与平台实现(runtime-dom/runtime-test)分离,使得浏览器、内存测试、SSR、第三方渲染器共享同一套算法。判断一个包是否"放对位置",就看它的 dependencies 是否越过了抽象边界。

  2. 编译期开关是体积与功能的调节旋钮 。__DEV__ 决定 dev/prod,__FEATURE_* 决定功能取舍,__SSR__/__ESM_BUNDLER__ 等决定平台分支。理解了它们各自的替换路径,就理解了 Vue 包体积的底层账本。

  3. 构建工具的组合是"各司其职" 。esbuild 负责快速转译与 dev 构建,Rollup 负责精细 tree-shaking 与多形态产物,SWC 负责生产压缩,rollup-plugin-dts 负责类型打包。没有哪个工具能独揽全部,组合才是答案。

  4. 类型与测试是"双闭环"。类型上有"源码测试 + 产物测试",测试上有"五种环境 project + tree-shaking 校验"。任何一环缺失,都会让"看起来正常"的改动在生产环境翻车。

  5. 规范即自动化。commit 规范驱动 CHANGELOG,lint 规则守卫体积,CI 覆盖本地钩子,体积报告监控回归。Vue 的工程质量不是靠自觉,而是靠一层层可执行的自动化。

1.11.3 面向后续章节的伏笔

本章建立的地图,是后续所有章节的坐标参照:

  • 第 02~04 章的 reactivity,其"无 DOM、无 runtime 依赖"的独立性能成立,正是因为 1.2 的 reactivity → shared 单依赖链。
  • 第 05~10 章的编译器,其"core/dom/ssr/sfc"四层结构,对应 1.1.2 与 1.4.4 的编译期包范式。
  • 第 11~17 章的 runtime,其"抽象节点 → 具体平台"的调用链,正是 1.2.2 的 createRenderer 宿主注入模型。
  • 第 18 章的 vue-compat 与全链路,其双语构建(default + named export 的 esm-index.ts)正是 1.5.5 提到的 compat 入口特例。

理解了"包怎么划分、产物怎么产出、开关怎么替换、怎么测、怎么发",再去看任何一个包的源码,都不会在"这段代码会不会被打进产物"这类问题上迷失。

1.11.4 练习题(面向资深工程师)

  1. 如果要新增一个第三方渲染器(如 Canvas),应该新增一个包还是复用 runtime-core?它需要实现 RendererOptions 里的哪些方法?(提示:对照 runtime-test 只提供了 nodeOps + patchProp。)
  2. 为什么 compiler-sfc 的 buildOptions 里设了 prod: false 与 enableNonBrowserBranches: true?这两个选项分别影响 rollup.config.js 的哪几段逻辑?
  3. 假设你在 Vite 项目里把 __VUE_OPTIONS_API__ 设为 false,请列出至少 5 处会因此被 tree-shake 的源码文件(提示:grep __FEATURE_OPTIONS_API__)。
  4. vue 包的 exports.require.node 里嵌套了 production/development 子条件,若去掉这两个子条件会有什么后果?为什么还要保留 default?
  5. unit-gc 为什么必须用 pool: 'forks'?如果把它的两个测试合并进 unit 会发生什么?
  6. 一次 release 中,若 @vue/compiler-core 已发布成功而 vue 发布失败,重跑 pnpm release --publishOnly 会发生什么?请结合 isPackagePublished 与 sortPackagesForPublishing 说明。

本章小结 :我们从目录树出发,依次拆解了包的职责边界与依赖拓扑、pnpm 工具链的版本收敛策略、多形态入口矩阵、Rollup 构建流水线的每一段逻辑、编译期开关的替换路径与体积收益、dts 类型产物的打包与修补、Vitest 的五环境测试矩阵、release 的两条路径与 CI 工作流,以及 ESLint/Prettier/commit 三重工程规范。这些"骨架"构成了后续逐包剖析的坐标系。下一章开始,我们将深入 packages/reactivity,从 effect.ts 的依赖收集机制讲起------那是 Vue 3 一切响应式能力的源头。

相关推荐
流星。。方1 小时前
小团队官网SEO落地:前端语义化与静态生成的工程实践
前端·状态模式
用户530429765791 小时前
盘点危化品搬运诊断的6类工具与自处理边界判断
前端
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第6章 Git 底层封装与仓库存储
git·开源
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第2章 启动流程与配置体系
git·开源
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第4章 数据库层与 ORM 体系(xorm)
git·开源
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第5章 权限体系
git·开源
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第13章 前端工程与模板体系
git·开源
curtain1 小时前
从古法编程到 Agent:我入行这两年,AI 改变了什么
前端·openai
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第9章 REST API v1 体系
git·开源