最近在尝试解析 Vue 3.6 的源码,前些天从底层构建系统着手,辛辛苦苦分析了 Vue 是如何通过 Rollup 和 esbuild 来分别构建出生产环境、开发环境产物的,并精心沉淀出了第一篇技术文章。
今天一看天塌了 ———— Vue 3.6 beta5 开始把全线构建工具统一为了 Rolldown,所有构建相关的脚本均被改写,意味着我之前案牍劳形进行的分析、写的文章好像打了水漂。
这简直比黄金暴跌更让我心疼!!(因为我其实也没买)。
官方改动具体见 Commit - build: use rolldown。
Rolldown 目前处于 RC 预发布候选版本,所以没预料到 Vue 这么快就将其应用在源码构建上,盲猜其原因为:
- Rolldown 在 Vite 生态内部已经经历了较长时间的真实项目验证,官方团队对其成熟度相对有信心,得以进一步推进到 Vue 源码仓库中集成使用。
- Rolldown 是使用 Rust 编写的现代化打包器,据称在性能上会比 Rollup 快 10~30 倍。
- Rollup 的模块图主要面向「一次性构建」设计,缺乏面向长期运行进程的增量构建能力(这是之前为何要使用 esbuild 作为开发环境构建工具的原因);而 Rolldown 把模块图视为长期驻留的一等公民,从架构层面支持精确失效与增量重建,得以适配开发态的高频变更场景。
- 开发环境和构建环境统一只使用一套「构建底座」,可以大幅提升构建配置和脚本的一致性,例如不同的需求(例如占位符替换)不再需要配套不同的构建插件,进而大幅降低维护成本。
对此个人很欣赏 Vue 与时俱进的态度,也期待 3.6 正式版本能早日顺利发布。
最后贴下 Rollup 搭配 esbuild 的「老款」构建系统解析文章,还是可以帮助大家了解 Vue 项目底层构建的流程和原理。
《Vue 3.6 beta 4 源码解析 —— 项目结构和构建雏形 Pt.1》
本文基于 Vue 3.6 beta 4 版本。
本文案例源码可在 Github 仓库(Tag 1.1.1) 上获取。
Vue 3.6 的源码并不是一个单体项目,而是一个高度模块化、工程化的系统。在正式深入分析各个核心模块之前,如果不先理解其整体的项目结构与工程形态,很容易在阅读源码时陷入局部实现细节,而缺乏对整体设计与模块协作关系的把握。
作为本专栏的开篇,本文将从工程视角出发,介绍并讨论 Vue 3.6 的项目结构雏形与构建技术选型,并实现一个最基础的工程化方案。
1、项目初始化
我们创建一个名为 vue 的文件夹作为源码项目,执行 pnpm init 进行初始化、生成 package.json 文件。
Vue 源码项目是严格要求使用 pnpm 来作为包管理器的,我们可以在 package.json 的 script 字段添加 preinstall 钩子指令,在使用者通过包管理器安装项目依赖时,能自动检查并确保项目只能使用 pnpm 作为包管理器:
{
"name": "vue",
"version": "3.6.0",
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm" // 确保项目只能使用 pnpm 作为包管理器
},
"license": "MIT"
}
其中 only-allow 是 pnpm 官方提供的一个检测工具,可以检测当前项目是否使用了指定的包管理器(若不符合条件会报错并强行退出程序)。
在 preinstall 钩子中,我们通过 npx only-allow pnpm 来确保项目只能使用 pnpm 作为包管理器。
💡 Vue 是基于 Monorepo 架构来维护各模块的,而 pnpm 对 Monorepo 有很好的支持,我们会在后文了解到这一点。
2、Monorepo
2.1 Vue 中的 Monorepo
在业务项目中,我们可以通过 npm install vue 等方式来下载和使用 Vue,不过 Vue 除了这个覆盖完整功能的 npm 包,还提供了多个独立的核心子模块包,每个包都有其特定的功能和作用:
-
@vue/shared:Vue 3 中被各模块共享的工具函数模块。
-
@vue/reactivity:Vue 3 的响应式模块。
-
@vue/compiler-core:Vue 3 的模板编译核心模块。
-
@vue/compiler-dom:Vue 3 的 DOM 编译模块。
-
@vue/runtime-core:Vue 3 的运行时核心模块。
- ...
例如你可以在业务项目中独立下载 Vue 的响应式模块 @vue/reactivity:
npm install @vue/reactivity
并在项目中使用它:
import { reactive } from '@vue/reactivity'
const state = reactive({ msg: 'hi' })
此举仅会下载 @vue/reactivity 模块(及其依赖模块)的代码,而不会下载 Vue 的所有模块。
然而 Vue 并没有给每个核心子模块都独立创建一个 Git 仓库,而是将它们统一放在 Vue 源码项目的 packages 文件夹下进行维护:
vue
├── packages
│ ├── shared
│ ├── reactivity
│ ├── compiler-core
│ ├── compiler-dom
│ ├── runtime-core
│ ├── ...
此类「单仓库多模块」的架构形式,被称为 Monorepo 。
2.2 以 shared 为例的模块结构
以「共享工具函数模块」shared 为例,其目录结构 packages/shared 非常简练:
vue
├── packages
│ ├── shared
│ │ ├── src // 存放实际源码
│ │ │ ├── general.ts
│ │ │ ├── makeMap.ts
│ │ │ ├── ...
│ │ │ └── index.ts
│ │ ├── package.json // npm 包配置
│ │ └── index.ts // 包入口
src 文件夹
shared/src 文件夹用于存放 shared 模块实际的源码文件,源码文件会按功能维度拆分成多个子模块。
简单起见,我们目前只实现 shared/src/general.ts 和 shared/src/makeMap.ts 两个功能子模块,它们的代码如下:
/** shared/src/makeMap.ts **/
/**
* 把一个用逗号分隔的字符串(例如 "a,b,c" )预处理成一个“成员判断函数”,
* 用于在运行时高频地判断某个 key 是否属于某个固定集合。
*
* 示例:
* const isHTMLTag = makeMap('div,span,p')
* isHTMLTag('div') // true
* isHTMLTag('a') // false
*/
export function makeMap(str: string): (key: string) => boolean {
const map = Object.create(null)
for (const key of str.split(',')) map[key] = 1
return val => val in map
}
/** shared/src/general.ts **/
import { makeMap } from './makeMap'
/** 空对象 */
export const EMPTY_OBJ: { readonly [key: string]: any } = {}
/** 空数组 */
export const EMPTY_ARR: readonly never[] = []
/** 空函数 */
export const NOOP = (): void => {}
/** 生成一个方法,用于判断一个属性名是否是保留属性 */
export const isReservedProp: (key: string) => boolean = /*@__PURE__*/ makeMap(
',key,ref,ref_for,ref_key,' +
'onVnodeBeforeMount,onVnodeMounted,' +
'onVnodeBeforeUpdate,onVnodeUpdated,' +
'onVnodeBeforeUnmount,onVnodeUnmounted',
)
接着我们在模块的内部出口文件 shared/src/index.ts 中,导出所有功能子模块(目前仅 general 和 makeMap)的接口:
/** shared/src/index.ts **/
export * from './general'
export * from './makeMap'
后续仅需引用该出口文件,就能通过「一拖多」的形式间接引入 shared 模块的全部功能接口。
待构建的 dist 文件夹
作为一个被多运行时、多构建环境复用的基础工具模块,通常需要提供多种构建格式(例如 ESM、CJS 等),以适配不同的加载方式与运行场景。
对于 shared 模块而言,其职责是为 Vue 各核心包提供最基础、最通用的工具能力,因此需要将 shared/src 下的源码构建为多种产出物,以覆盖以下几类典型使用场景:
-
开发环境下的 CommonJS 引用
即通过 require('@vue/shared') 引入 shared 模块,且运行于开发环境(process.env.NODE_ENV === 'development')的场景,需确保所引用的 shared 模块产出物为 CommonJS 版本,且包含用于开发期的调试逻辑(如 warning、assert 等)。
我们拟定该产出物名为 shared.cjs.js。
-
生产环境下的 CommonJS 引用
即同样通过 require('@vue/shared') 引入 shared 模块,但运行于生产环境的场景,需确保所引用的 shared 模块产出物为 CommonJS 版本,且剔除了所有用于开发期的调试逻辑(确保最终代码体积最小)。
我们拟定该产出物名为 shared.cjs.prod.js。
-
ESM 引用(Bundler 场景)
即通过 import 引入 shared 模块的场景,需确保所引用的 shared 模块产出物为 ESM bundler 版本。
我们拟定该产出物名为 shared.esm-bundler.js。
💡 与 CommonJS 不同,ESM 版本并不会拆分出「开发环境」和「生产环境」两套文件。
💡 这得益于 ESM 的静态导入特性,Vue 框架使用者在业务侧进行二次构建的过程中,构建工具可在编译期将环境变量替换为常量,并通过 Tree-shaking / Dead Code Elimination 精确移除不可达的环境逻辑,从而保证最终生产代码中不存在任何冗余分支。
在后文我们会通过构建工具,将 shared/src 的源文件构建出适配上述三个场景的产出物,并创建 shared/dist 文件夹来存放这些产物。
另外我们也将为 shared 模块构建其 TypeScript 声明文件 shared/dist/shared.d.ts,用于在 TypeScript 项目中提供类型检查与智能提示。
npm 包入口文件
shared/index.ts 是 shared 模块的包入口文件,在下文将介绍的 shared/package.json 中会通过 main 字段指定该文件为 CommonJS 生态下的传统入口。
其内容为根据当前的运行环境,引入对应的 CommonJS 版本构建产物:
'use strict'
if (process.env.NODE_ENV === 'production') {
module.exports = require('./dist/shared.cjs.prod.js')
} else {
module.exports = require('./dist/shared.cjs.js')
}
留意在这类 CommonJS 入口文件中,Vue 都对它们启用了严格模式('use strict'),这是为了确保代码能及时暴露一些潜在的问题(例如使用未声明的变量),并提升代码的质量和可维护性。
💡 严格模式在 ESM 里是默认开启的,因此 ESM 模块里无需标记 'use strict'。
npm 包配置文件
shared/package.json 是 shared 模块的 npm 包配置文件,其职责不只是提供「npm 发布描述」,还包含了工程化相关的配置信息:
/** shared/package.json **/
{
"name": "@vue/shared",
"version": "3.6.0",
"main": "index.js", // 指定 CommonJS 生态下的传统入口,主要用于 不支持 exports 的旧 NodeJS / 工具链
"module": "dist/shared.esm-bundler.js", // 指定 ESM bundler 生态下的入口
"types": "dist/shared.d.ts", // 指定类型声明文件
"files": [ // 指定了在发布到 npm 时,哪些文件会被包含在发布包中(避免将 src、测试代码、构建脚本等无关内容发布到 npm)
"index.js",
"dist"
],
"exports": { // 更为完善的新规范入口配置字段
".": {
"types": "./dist/shared.d.ts", // 指定类型声明文件
"node": { // 传统 NodeJS(CommonJS),开发环境和生产环境对应的入口
"production": "./dist/shared.cjs.prod.js",
"development": "./dist/shared.cjs.js",
"default": "./index.js"
},
"module": "./dist/shared.esm-bundler.js", // ESM bundler 入口(用于部分构建工具识别)
"import": "./dist/shared.esm-bundler.js", // ESM bundler 入口(用于 NodeJS 侧识别)
"require": "./index.js" // 传统 CommonJS 默认入口(兜底)
},
"./*": "./*" // 指定可访问的子路径映射
},
"sideEffects": false // 声明该包在模块【初始化阶段】不存在副作用,当模块的导出未被使用时,整个模块可被剔除(Tree-shaking)
}
}
其中 main、module 和 types 三个字段是为了兼容旧生态所保留的约定式入口,exports 则是更为完善的新规范字段(可以指定更细粒度的入口映射),现代 NodeJS 或构建工具会优先采用 exports 字段指定的入口。
2.3 Monorepo 模块之间的联系
新建 reactivity 模块
为了更好地了解 Monorepo 下各独立模块之间的联系,我们仿照 shared 模块的结构,在 packages 下创建一个名为 reactivity 的响应式模块:
vue
├── packages
│ ├── reactivity // 新增响应式模块文件夹
│ │ ├── src // 存放实际源码
│ │ │ ├── reactive.ts
│ │ │ └── index.ts
│ │ ├── package.json // npm 包配置
│ │ └── index.ts // 包入口
reactivity 下各文件的内容和 shared 的基本一致。
目前我们仅打算搭建一个项目雏形,因此 reactivity 模块的内容尽量简单化,其中 src/reactive.ts 的代码仅用来模拟 shared 模块接口的引入和导出:
/** src/reactive.ts **/
export * from '@vue/shared' // 仅用于调试
此时你会看到 IDE 中会标红报错,提示 TypeScript 找不到 @vue/shared 模块:

我们可以在 vue 根目录下新增 tsconfig.json 文件,用于配置 TypeScript 的编译选项:
{
"compilerOptions": {
"strict": true,
"rootDir": ".",
"paths": { // IDE 类型检查路径
"@vue/*": ["./packages/*/src"], // 把 @vue/xxx 映射到 packages/xxx/src 下
"*": ["./*"]
}
},
"include": [
"packages/*/src",
]
}
此时 src/reactive.ts 中不再标红报错,IDE 的 TypeScript 类型检查功能已可成功识别到 @vue/shared 模块。
pnpm 的 workspace 协议
如同「2.1 Vue 中的 Monorepo」所提及的,@vue/reactivity 需要被独立发布到 npm 上(供开发者下载),我们需要为其声明对 @vue/shared npm 包的依赖:
/** reactivity/package.json **/
{
"name": "@vue/reactivity",
"version": "3.6.0",
"main": "index.js",
"module": "dist/reactivity.esm-bundler.js",
// 略...(和 shared/package.json 基本一致)
"dependencies": { // 补充对 shared 模块的依赖信息
"@vue/shared": "3.6.0"
}
}
然而这里所填入的 @vue/shared 版本号 3.6.0 存在一个问题 —— 该远程版本的内容并非我们本地上最新的 packages/shared 下的内容,且每次发布 npm 包之前都需要手动更新该依赖包的版本号,一旦遗漏就会出错。
pnpm 官方提供了一个解决方案,即使用 workspace 协议来替换依赖包的版本号,它表示该依赖必须解析自当前 Monorepo 中声明的 workspace 包,而不会从远程 npm registry 下载:
/** reactivity/package.json **/
"dependencies": {
"@vue/shared": "workspace:*" // 更替为 workspace 协议
}
另外,我们还需在 vue 根目录下新增 pnpm-workspace.yaml 文件,用于告知 pnpm「哪些文件夹可以作为独立的 workspace」:
/** pnpm-workspace.yaml **/
packages:
- "packages/*" // 把 packages 文件夹里的每一个一级子目录,都当作一个独立的 workspace 包
pnpm 在执行时会扫描 pnpm-workspace.yaml 中所配置的目录,并将其中包含 package.json 的子目录注册为 workspace 成员,后续在解析 workspace:* 时,会从这些 workspace 成员中进行检索和匹配。
在经过这番配置后,通过 pnpm publish 指令来发布 @vue/reactivity 包时,pnpm 会把将要提交到 npm registry 的 manifest 中的 @vue/shared 依赖包版本号,填写为本地对应 workspace 成员 packages.json(即 packages/shared/package.json)中的版本号。
3、构建系统雏形
在理解了 Vue 3.6 的 Monorepo 结构与包之间的依赖关系之后,下一步就是要实现一个基础的构建系统,将分散在 packages/*/src 目录下的源码,构建出预期的各版本并放置在 packages/*/dist 中)。
3.1 技术选型
Vue 使用了 Rollup 作为「生产构建器」,开发态的快速构建则采用的 esbuild。
生产构建选择 Rollup 的原因
Rollup 自诞生以来就是为了打包 JavaScript 库而设计的:
-
Tree-shaking 的极致优化 —— Rollup 基于 ES Modules (ESM),它能生成非常「扁平」的输出代码,进而确保冗余代码能被精确裁剪、输出内容足够干净。
-
作用域提升(Scope Hoisting) —— Rollup 默认会将所有模块提升到同一个作用域内,这不仅进一步减小了代码体积,还能提高执行性能。
-
多格式输出支持 —— Rollup 的插件系统和配置机制,可以高效地构建出 Vue 的多版本产物:
-
esm-bundler (给 Vite / Webpack 用);
-
esm-browser (给浏览器 <script type="module"> 用);
-
cjs (CommonJS 语法,给 NodeJS 环境用);
-
global (传统的 <script> 引入) 。
最重要的是,Vue 需要被构建为一个「通用的前端框架库」,而不是被构建为一个「Web 应用」,因此需要尽可能地保证输出代码的可阅读性。相比 Webpack 会在每个模块周围包裹大量的 __webpack_require__ 等运行时代码,Rollup 输出的代码更加「原汁原味」、易于使用者阅读和调试。
开发构建选择 esbuild 的原因
虽然 Rollup 产物精美,但在速度上它并不是最快的。
在开发、调试场景中,我们对于构建速度的需求要远高于极致性能的需求,而 esbuild 非常契合这一场景:
-
极速的增量构建 —— esbuild 采用了基于 Go 语言的编译后端,编译速度非常快,能够在毫秒级完成增量构建,适合频繁改动源码、快速看效果的 watch 开发。
-
简单的配置 —— esbuild 的配置选项非常简单,无需复杂的插件系统即可实现基本的构建需求。
因此,虽然 esbuild 的输出代码不够完美(例如 Tree-Shaking 没有 Rollup 极致),但非常适用于在开发场景中用于响应迅速的「粗加工」。
💡 读者需注意区分各类构建面向的主体对象 —— Vue 源码项目的「生产构建」是面向 Vue 框架使用者的,因此「生产构建」的产物包括了使用者在其生产环境中使用的产物,也包括了使用者在其开发环境中使用的产物;而 Vue 源码项目的「开发构建」只面向 Vue 源码开发者(与使用者无关)。
3.2 shared 模块的生产构建方案实现
rollup.config.js 配置
Vue 是通过拼接 Rollup 的命令行指令来执行生产构建的,我们可以按照 Rollup 官方文档,先在项目根目录创建一个 rollup.config.js 配置文件,用于告诉 Rollup 在执行指令时「如何打包一个 package」。
以「构建一个 packages/shared/dist/shared.cjs.js 为例」,其配置内容参考如下:
/** rollup.config.js */
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import esbuild from 'rollup-plugin-esbuild'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const packageConfigs = [{
input: path.resolve(__dirname, './packages/shared/src/index.ts'),
plugins: [
// 插件
esbuild({ // TypeScript / JS 语法转译
tsconfig: path.resolve(__dirname, 'tsconfig.json'),
minify: false,
target: 'es2016',
}),
],
output: {
file: path.resolve(__dirname, './packages/shared/dist/shared.cjs.js'),
format: "cjs",
},
treeshake: {
moduleSideEffects: false, // 声明模块无副作用,允许 Rollup 安全地进行 Tree-Shaking
},
}];
export default packageConfigs
留意这里我们使用了 rollup-plugin-esbuild 插件,这是因为 Rollup 本身是无法识别 TypeScript 语法的,因此需要利用 esbuild 来充当「语法翻译官」,把 TypeScript 代码转译为 JavaScript 代码。
💡 esbuild 在开发模式中充当了构建器 + bundler + runner 的角色,但在本小节的生产构建中被降级为一个编译器插件。
💡 读者请自行安装 rollup、typescript、rollup-plugin-esbuild 等 npm 依赖包,本系列文章不会提及依赖包的安装。
此时执行 rollup -c 即可构建出 packages/shared/dist/shared.cjs.js 文件。
然而除了 cjs 格式的文件,还要为 shared 模块构建 esm-bundler 格式的文件,这需要往 packageConfigs 数组中 push 多个配置项,我们可以封装一个 createConfig 方法来创建配置项:
/** rollup.config.js */
// 略...
const outputConfigs = {
'esm-bundler': {
file: path.resolve(__dirname, './packages/shared/dist/shared.esm-bundler.js'),
format: 'es',
},
'cjs': {
file: path.resolve(__dirname, './packages/shared/dist/shared.cjs.js'),
format: 'cjs',
},
}
const packageFormats = ['esm-bundler', 'cjs']
const packageConfigs = packageFormats.map(format => createConfig(format, outputConfigs[format]))
export default packageConfigs
function createConfig(format, output, plugins = []) {
console.log(`正在创建 ${format} 格式的构建配置...`)
return {
input: path.resolve(__dirname, './packages/shared/src/index.ts'),
plugins: [
esbuild({
tsconfig: path.resolve(__dirname, 'tsconfig.json'),
minify: false,
target: 'es2016',
}),
...plugins // 支持扩展自定义插件
],
output,
treeshake: {
moduleSideEffects: false,
},
}
}
此时执行 rollup -c 可构建出 packages/shared/dist/shared.cjs.js 和 packages/shared/dist/shared.esm-bundler.js 文件。
利用 NODE_ENV 和 PROD_ONLY 参数指定产出物环境
根据「待构建的 dist 文件夹」小节所罗列的构建产物,我们还需为 shared 模块构建一个 CommonJS 格式的生产环境产物 shared.cjs.prod.js。
另外,Vue 属于一个多模块、复杂度较高的项目,会有「按需构建」的需求,用于节省构建的等待时间。例如开发者会希望通过执行不同的 Rollup 的指令来满足如下三种场景:
- 构建所有生产环境和开发环境的产物;
- 只构建生产环境的产物;
- 只构建开发环境的产物。
我们可以通过 Rollup 执行指令中的 environment 参数来传值处理:
- 执行
rollup -c --environment NODE_ENV:production 时构建出所有生产环境和开发环境的产物;
- 执行
rollup -c --environment NODE_ENV:production,PROD_ONLY:true 时只构建生产环境的产物;
- 执行
rollup -c --environment NODE_ENV:development 时只构建开发环境的产物。
这里我们自定义了两个参数 NODE_ENV 和 PROD_ONLY,分别用于指定「构建目标环境」和「是否只构建生产环境的产物」,我们可以在 rollup.config.js 中通过 process.env 来读取传入的自定义参数值,并做相应的逻辑处理:
/** rollup.config.js */
// 略...
const packageFormats = ['esm-bundler', 'cjs']
// 通过 process.env 获取传入的 PROD_ONLY 参数
const packageConfigs = process.env.PROD_ONLY ? [] : packageFormats.map(format => createConfig(format, outputConfigs[format]))
// 若需要构建生产环境产物,针对 cjs 格式,添加其生产环境的配置项
if (process.env.NODE_ENV === 'production') { // 通过 process.env 获取传入的 NODE_ENV 参数
packageFormats.forEach(format => {
if (format === 'cjs') {
packageConfigs.push(createProductionConfig(format))
}
})
}
export default packageConfigs
function createConfig(format, output, plugins = []) {
const isProductionBuild = /\.prod\.js$/.test(output.file)
console.log(`正在创建${isProductionBuild ? '生产环境' : '开发环境'} ${format} 格式的构建配置...`)
return {
// 略...
}
}
// 创建生产环境配置项
function createProductionConfig(format) {
return createConfig(format, {
...outputConfigs[format],
file: path.resolve(__dirname, './packages/shared/dist/shared.cjs.prod.js'), // 构建 cjs.prod.js 文件
})
}
此时执行 rollup -c --environment NODE_ENV:production,PROD_ONLY:true,会单独构建出 packages/shared/dist/shared.cjs.prod.js 文件。
开发环境占位符 __DEV__ 及其替换
查看前文所构建出的 shared.cjs.js 和 shared.cjs.prod.js 文件,会发现它们的内容是完全相同的,读者可能会因此感到困惑。
为了便于区分不同环境产物的内容(而不仅仅是文件名不同),我们修改 packages/shared/src/general.ts 的代码,加上一个自定义的开发环境占位符 __DEV__:
/** packages/shared/src/general.ts */
/** 空对象 */
export const EMPTY_OBJ: { readonly [key: string]: any } = __DEV__
? Object.freeze({})
: {}
/** 空数组 */
export const EMPTY_ARR: readonly never[] = __DEV__ ? Object.freeze([]) : []
此举目的是让 Vue 框架使用者在开发环境中,通过 Object.freeze 来避免改动到 Vue 源码内置的空对象和空数组两个常量,在生产环境时则改为使用性能最优的空对象和空数组即可。
IDE 在此时会对 __DEV__ 进行标红,提示「找不到名称__DEV__」,我们可以在 packages 下创建一个 global.d.ts 文件,用于声明这些自定义的变量:
// Global compile-time constants
declare var __DEV__: boolean
接着我们在 rollup.config.js 中,通过 rollup-plugin-esbuild 插件的 define 属性,对 __DEV__ 占位符进行替换:
/** rollup.config.js */
// 略...
function createConfig(format, output, plugins = []) {
const isProductionBuild = /\.prod\.js$/.test(output.file)
const isBundlerESMBuild = /esm-bundler/.test(format) // 是否为 ESM Bundler 格式的构建
// 略...
return {
input: path.resolve(__dirname, './packages/shared/src/index.ts'),
plugins: [
...resolveReplace(),
esbuild({
tsconfig: path.resolve(__dirname, 'tsconfig.json'),
minify: false,
target: 'es2016',
define: resolveDefine(), // 替换自定义占位符
}),
...plugins
],
// 略...
}
function resolveDefine() {
const replacements = {}
if (isBundlerESMBuild) {
replacements.__DEV__ = `!!(process.env.NODE_ENV !== 'production')`
} else {
replacements.__DEV__ = String(!isProductionBuild)
}
return replacements
}
}
留意在新增的 resolveDefine 方法中,是否处于 ESM Bundler 格式的构建会影响占位符 __DEV__ 的替换内容:
- ESM Bundler 格式的构建产物会把
__DEV__ 占位符替换为 !!(process.env.NODE_ENV !== 'production'),因为该产物是会交由业务侧去执行二次构建的,由业务侧的构建工具再进一步去替换 process.env.NODE_ENV 即可;
- 非 ESM Bundler 格式的构建产物属于「最终运行时代码」,不会经历二次构建,因此要把
__DEV__ 占位符根据构建环境替换为明确的 true 或 false,esbuild 插件会在构建过程通过 Tree-Shaking 移除未命中的逻辑分支,确保产物简洁且可以直接运行。
然而此时执行 rollup -c --environment NODE_ENV:production 指令,会出现报错:
- [!] (plugin esbuild) Error: Transform failed with 1 error:
- error: Invalid define value (must be an entity name or JS literal): !!(process.env.NODE_ENV !== 'production')
这是因为 esbuild 的 define 对替换内容具有严格的要求,其仅用于将全局标识符替换为静态常量,它要求替换的内容必须是布尔值、数字、字符串,或者是另一个标识符,但不能是 !!(process.env.NODE_ENV !== 'production') 这样的表达式语句。
esbuild 会试图把自定义的 Key 替换成一个合法的 AST 节点,当要替换的值是一个复杂的表达式时,esbuild 解析器无法将其作为一个单一的「值」或「标识符」插入到 AST 中,因此会直接抛出 Invalid define value 错误来阻止构建。
针对此问题,我们可以在 ESM Bundler 场景改为使用 @rollup/plugin-replace 插件来进行占位符替换:
/** rollup.config.js */
import replace from '@rollup/plugin-replace'
// 略...
function createConfig(format, output, plugins = []) {
// 略...
return {
input: path.resolve(__dirname, './packages/shared/src/index.ts'),
plugins: [
...resolveReplace(), // 使用 @rollup/plugin-replace 插件
esbuild({
tsconfig: path.resolve(__dirname, 'tsconfig.json'),
minify: false,
target: 'es2016',
define: resolveDefine(),
}),
...plugins
],
// 略...
}
function resolveDefine() {
const replacements = {}
if (!isBundlerESMBuild) {
replacements.__DEV__ = String(!isProductionBuild)
}
return replacements
}
function resolveReplace() {
const replacements = {}
// ESM Bundler 格式的构建产物使用 @rollup/plugin-replace 来替换占位符
if (isBundlerESMBuild) {
Object.assign(replacements, {
__DEV__: `!!(process.env.NODE_ENV !== 'production')`,
})
}
return [replace({
values: replacements,
preventAssignment: true // 若变量出现在赋值号( = )的左边,就不要进行替换。目的是防止把赋值语句当成常量替换(例如 __DEV__ = true)
})]
}
}
此时执行 rollup -c --environment NODE_ENV:production 指令,构建出的所有 shared 模块产物内容如下:
/** packages/shared/dist/shared.cjs.js */
'use strict';
const EMPTY_OBJ = Object.freeze({}) ;
// 略...
exports.EMPTY_OBJ = EMPTY_OBJ;
// 略...
/** packages/shared/dist/shared.cjs.prod.js */
'use strict';
const EMPTY_OBJ = {};
// 略...
exports.EMPTY_OBJ = EMPTY_OBJ;
// 略...
/** packages/shared/dist/shared.esm-bundler.js */
const EMPTY_OBJ = !!(process.env.NODE_ENV !== "production") ? Object.freeze({}) : {};
// 略...
export {
EMPTY_OBJ,
// 略...
}
我们会在下篇文章为构建系统补充 reactivity 等其它模块的构建能力。
3.3 shared 模块的开发构建方案实现
我们已经在前文了解过,Vue 源码项目只通过 esbuild 来负责开发调试时的构建,我们在 Vue 源码项目的根目录创建 ./scripts/dev.js 文件来配置 esbuild:
import esbuild from 'esbuild'
import { dirname, relative, resolve } from 'node:path'
import { fileURLToPath } from 'url'
import { parseArgs } from 'util'
const __dirname = dirname(fileURLToPath(import.meta.url))
const {
values: { format: rawFormat, prod, },
} = parseArgs({
options: {
format: {
type: 'string',
short: 'f',
default: 'cjs',
},
prod: {
type: 'boolean',
short: 'p',
default: false,
},
},
})
const format = rawFormat || 'cjs'
const outputFormat = format === 'cjs' ? 'cjs' : 'esm'
const pkgBasePath = `../packages/shared`
const outfile = resolve(__dirname, `${pkgBasePath}/dist/shared.${format}.${prod ? `prod.` : ``}js`)
const relativeOutfile = relative(process.cwd(), outfile)
/** @type {Array<import('esbuild').Plugin>} */
const plugins = [
{
name: 'log-rebuild', // 在构建完成时,打印构建完成的文件路径
setup(build) {
build.onEnd(() => {
console.log(`built: ${relativeOutfile}`)
})
},
},
]
const entry = 'index.ts'
esbuild
.context({
entryPoints: [resolve(__dirname, `${pkgBasePath}/src/${entry}`)],
outfile,
bundle: true,
sourcemap: true,
format: outputFormat,
platform: format === 'cjs' ? 'node' : 'browser',
plugins,
define: {
__DEV__: prod ? `false` : `true`,
},
})
.then(ctx => ctx.watch()) // 监听源码变化并执行实时增量构建
其中我们使用了 NodeJS util 原生模块的 parseArgs 来解析命令行参数,例如:
node scripts/dev.js -f esm-bundler
当执行该指令时,parseArgs 会捕获传入的 f 参数并将其值(esm-bundler)赋给 rawFormat 变量。
最后我们调用了 esbuild 的 context 方法来自定义配置并执行构建。以上述的指令为例,esbuild 会直接构建出 packages/shared/dist/shared.esm-bundler.js 文件,且实时监听 shared 模块的源码变化并执行增量构建。
💡 Vue 源码在开发调试环节,需要通过 esbuild 构建的场景其实不多(在开发后期可以创建一个 vite 项目来配合调试),开发的前期更多还是通过 vitest 对模块源码进行单元测试。我们会在后续的文章中进行了解。
补充:鉴于 Vue 3.6 beta 5 已全线替换构建工具,本文「后续的文章」也只能跟着断更。后续可能以 Vue 官方最新版的项目重新进行源码解析和输出解析文章。共勉。