全面革新⚡️ Vue 3.6 项目工程的基础实现

Vue 3.6 目前仍属于 beta 阶段,但从 beta 5 开始就彻底摒弃了过往 Rollup + esbuild 的构建方案,转投 Rolldown 的怀抱,近期更是全面接入 Vite Plus 来提升源码开发者的开发体验。

本文将基于 v3.6.0-beta.17,从零起步搭建 Vue 3.6 源码的项目雏形、讨论其工程化中的技术选型,最终实现一个基础前端工程化方案。

💡 本章案例源码:

1. 初始化项目

我们创建一个名为 vue 的文件夹作为源码项目,执行 pnpm init 进行初始化、生成 package.json 文件。

Vue 源码项目是严格要求使用 pnpm 来作为包管理器的,我们可以在 package.jsonscript 字段添加 preinstall 钩子指令 ------ 在源码开发者安装项目依赖时,钩子会先被自动触发,可以用来审核开发者的包管理器是否合规:

json 复制代码
{
  "name": "vue",
  "version": "3.6.0",
  "type": "module",
  "packageManager": "pnpm@11.6.0",
  "scripts": {
    "preinstall": "npx only-allow pnpm"    // 确保项目只能使用 pnpm 作为包管理器
  }
}

其中 only-allow 是 pnpm 官方提供的一个检测工具,用于检测当前是否使用了指定的包管理器,若匹配失败会报错并强行退出程序

💡 Vue 是基于 Monorepo 架构来维护各模块的,而 pnpm 对 Monorepo 有良好的支持,这是 Vue 选用 pnpm 的关键原因。我们会在后文进行了解。

2. Monorepo

在日常业务项目中,我们可以通过 npm install vue 等方式来下载和使用 Vue,不过除了这个覆盖完整功能的 npm 包,Vue 还提供了多个独立的功能模块包:

  • @vue/shared:被各模块共享的工具函数模块包。
  • @vue/reactivity:响应式模块包。
  • @vue/compiler-core:模板编译核心模块包。
  • @vue/compiler-dom:DOM 编译模块包。
  • @vue/runtime-core:运行时核心模块包。
  • ...

例如可以在业务项目中独立下载 Vue 的响应式模块包 @vue/reactivity

bash 复制代码
npm install @vue/reactivity

并在项目中使用它:

js 复制代码
import { reactive } from '@vue/reactivity'

const state = reactive({ msg: 'hi' })

此举仅会下载 @vue/reactivity(及其依赖模块),不会下载 Vue 的所有模块包。

然而 Vue 并没有给每个包都独立创建一个 Git 仓库,而是将它们统一放在 Vue 源码项目的 packages 文件夹下进行维护:

js 复制代码
vue
├── packages
│   ├── shared
│   ├── reactivity
│   ├── compiler-core
│   ├── compiler-dom
│   ├── runtime-core
│   ├── ...

此类「单仓库多包」的架构形式,称为 Monorepo

2.1 创建 shared 模块包

基于 Monorepo 架构,我们先创建 packages/shared 文件夹,来维护和发布共享工具模块包 shared ------ 用于存放可复用的工具函数。

packages/shared 文件夹的基础结构如下:

js 复制代码
vue
├── packages
│   ├── shared
│   │   ├── src           // 存放实际源码
│   │   │   ├── makeMap.ts
│   │   │   ├── general.ts
│   │   │   ├── ...
│   │   │   └── index.ts
│   │   ├── dist          // 存放构建后的产物
│   │   ├── package.json  // npm 包配置
│   │   └── index.ts      // 包入口

src 文件夹

shared/src 文件夹用于存放 shared 的源码文件 ------ 简单起见,我们暂时只创建 makeMap.tsgeneral.ts 两个功能子文件:

js 复制代码
/** packages/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
}
js 复制代码
/** packages/shared/src/general.ts **/

import { makeMap } from './makeMap'

/** 空函数 */
export const NOOP = (): void => {}

/** 生成一个方法,用于判断一个属性名是否是保留属性 */
export const isReservedProp: (key: string) => boolean = /*@__PURE__*/ makeMap(
  ',key,ref,ref_for,ref_key,' +
    'onVnodeBeforeMount,onVnodeMounted,' +
    'onVnodeBeforeUpdate,onVnodeUpdated,' +
    'onVnodeBeforeUnmount,onVnodeUnmounted',
)

接着创建 src 入口文件 shared/src/index.ts,导出所有功能子文件的接口:

js 复制代码
/** packages/shared/src/index.ts **/

export * from './general'
export * from './makeMap'

后续仅需引用该文件,就能「一拖多」地间接引入 shared 模块的全部功能接口。

dist 文件夹

shared/dist 文件夹将用于存放 shared/src 源码构建后的产物。

我们会在后文介绍构建工具的技术选型和构建的实现,但目前需要先讨论一个重点 ------ Vue 的 Monorepo 包具体需要哪些构建产物?

首先构建产物是面向安装并使用 Vue 框架的业务开发者的,那它需要被区分出业务应用场景下的「开发环境」和「生产环境」:

  • 「开发环境」的产物需要保留开发期逻辑,比如告警、调试钩子、内部一致性检查等,让业务开发者能在开发阶段便于调试、尽早发现问题。
  • 「生产环境」的产物需要裁剪掉开发期的冗余逻辑,让代码更精简、性能更高。

以「定义一个空对象常量」为例,其开发环境需通过 Object.freeze 进行冻结、方便暴露常量被篡改的问题,而生产环境使用空对象 {} 即可:

typescript 复制代码
/** 源码 **/
const EMPTY_OBJ: { readonly [key: string]: any } = !!(process.env.NODE_ENV !== "production")
  ? Object.freeze({})
  : {}

/** 开发环境 **/
const EMPTY_OBJ = Object.freeze({})

/** 生产环境 **/
const EMPTY_OBJ = {}

其次业务开发者可能会在不同的平台上使用 Vue 框架,且需要兼容多种主流模块规范:

  • 在 Node.js 侧使用,且需要兼容 CommonJS 规范。
  • 在 Node.js 侧使用,且需要兼容 ES Module 规范。
  • 在客户端(浏览器)使用,且需要兼容 IIFE / UMD 规范。
  • 在客户端使用,且需要兼容 ES Module 规范。

综上所述,一个 Monorepo 模块需要构建如下格式的产出物到其 dist 文件夹(通过文件名后段进行区分):

  • [pkg].cjs.js:在 Node.js 侧使用、CommonJS 规范的「开发环境」产物。
  • [pkg].cjs.prod.js:在 Node.js 侧使用、CommonJS 规范的「生产环境」产物。
  • [pkg].esm-bundler.js:在 Node.js 侧使用、ES Module 规范的「开发环境」+「生产环境」产物。
  • [pkg].global.js:在客户端使用、IIFE 规范的「开发环境」产物。
  • [pkg].global.prod.js:在客户端使用、IIFE 规范的「生产环境」产物。
  • [pkg].esm-browser.js:在客户端使用、ES Module 规范的「开发环境」产物。
  • [pkg].esm-browser.prod.js:在客户端使用、ES Module 规范的「生产环境」产物。

留意 [pkg].esm-bundler.js 不需要拆分出「开发环境」和「生产环境」两套文件,这得益于 ES Module 的静态导入特性,下游的业务侧构建工具(例如 vitewebpack)可在编译期通过 Tree-shaking 精确移除不可达的环境逻辑:

最后,还需要为 shared 模块构建类型声明文件 shared/dist/shared.d.ts,用于在 TypeScript 项目中提供类型检查与智能提示。

包入口文件

我们创建 shared/index.ts 作为 shared 包的传统入口文件(后续会在 shared/package.json 中声明它为基于 CommonJS 规范的传统入口,属于 npm 包分发的标准实现形式)。

它将根据当前的运行环境,引入对应的 CommonJS 构建产物:

js 复制代码
/** packages/shared/index.ts **/

'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 入口文件中,会对它们启用严格模式('use strict'),这是为了确保代码能及时暴露一些潜在的问题(例如定义了污染全局的全局变量)。

💡 严格模式在 ES Module 里是默认开启的,因此 ES Module 模块里无需标记 'use strict'

包配置文件

我们创建 shared/package.json 作为 shared 包的 npm 包配置文件,其职责不只是提供「npm 发布描述」,还包含了工程化相关的配置信息:

json 复制代码
/** packages/shared/package.json **/

{
  "name": "@vue/shared",                  // 包名,命名空间为 @vue
  "version": "3.6.0",
  "main": "index.js",                     // 指定 CommonJS 规范下的传统入口,主要面向传统 Node.js(CommonJS)工具链
  "module": "dist/shared.esm-bundler.js", // 指定 ES Module 规范下的入口
  "types": "dist/shared.d.ts",            // 指定类型声明文件
  "files": [                              // 指定了在发布到 npm 时,哪些文件会被包含在发布包中(避免将 src、测试代码、构建脚本等无关内容发布到 npm)
    "index.js",
    "dist"
  ],
  "exports": {                            // 更为完善的新规范入口配置字段
    ".": {
      "types": "./dist/shared.d.ts",                 // 指定类型声明文件
      "node": {                                      // 传统 Node.js(CommonJS),开发环境和生产环境对应的入口
        "production": "./dist/shared.cjs.prod.js",
        "development": "./dist/shared.cjs.js",
        "default": "./index.js"
      },
      "module": "./dist/shared.esm-bundler.js",      // ES Module 规范下的入口(用于部分构建工具识别)
      "import": "./dist/shared.esm-bundler.js",      // ES Module 规范下的入口(用于 Node.js 侧识别)
      "require": "./index.js"                        // 传统 Node.js(CommonJS)默认入口(兜底)
    },
    "./*": "./*"                                     // 指定可访问的子路径映射
  },
  "sideEffects": false          // 声明该包在模块初始化阶段不存在副作用,当模块的导出未被使用时,整个模块可被剔除(Tree-shaking)
  }
}

其中 mainmoduletypes 三个字段是为了兼容旧生态所保留的约定式入口,exports 则是更为完善的新规范字段(可以指定更细粒度的入口映射),现代 Node.js 和构建工具会优先采用 exports 字段指定的入口。

构建信息字段 buildOptions

Vue 中的每一个包需要构建的配置、构建产物的格式可能是不一致的,我们可以把这块信息抽离出来,放到各包的 package.json 中去独立维护。

shared 包为例,我们在 shared/package.json 内部新增一个自定义的、可拓展的 buildOptions 字段,来存储 shared 包的构建信息:

json 复制代码
/** packages/shared/package.json **/

{
  "name": "@vue/shared",
  // 略...
  "buildOptions": {       // 构建信息字段
    "formats": [
      "esm-bundler",
      "cjs"
    ]
  }
}

其中 formats 指定了 shared 包只需构建 esm-bundlercjs 两种格式 ------ shared 包的定位是「内部工具」,不需要面向「直接被浏览器消费」的分发形态,因此其所需的构建格式是最精简的。

后续构建工具在构建各包时,只需读取 [pkg]/package.json 中的 buildOptions 字段,即可获得该包的构建信息。

这种「包配置随包走」的模式也很契合 Monorepo 的管理理念。

2.2 Monorepo 模块之间的联系

创建 reactivity 模块包

为了更好地了解 Monorepo 下各独立包之间的联系,我们仿照 shared 包的结构,在 packages 下创建一个名为 reactivity 的响应式模块包:

js 复制代码
vue
├── packages
│   ├── reactivity        // 新增响应式模块文件夹
│   │   ├── src           // 存放实际源码
│   │   │   ├── reactive.ts
│   │   │   └── index.ts
│   │   ├── package.json  // npm 包配置
│   │   └── index.ts      // 包入口

reactivity 下各文件的内容和 shared 的基本一致。

目前我们仅打算搭建一个项目雏形,因此 reactivity 模块的内容将尽量简化,其中 src/reactive.ts 的代码仅用来模拟 shared 模块接口的引入和导出:

js 复制代码
/** packages/reactivity/src/reactive.ts **/

export * from '@vue/shared' // 通过 packages/shared/package.json 中定义的包名引入 shared 模块

此时在 IDE 中会出现报错,提示 TypeScript 找不到 @vue/shared

对此可以在 vue 根目录下新增 tsconfig.json 文件,用于配置 TypeScript 的编译选项:

json 复制代码
/** tsconfig.json **/

{
  "compilerOptions": {
    "rootDir": ".",
    "paths": {                         // IDE 类型检查路径
      "@vue/*": ["./packages/*/src"],  // 把 @vue/* 映射到 packages/*/src 下
      "*": ["./*"]
    }
  },
  "include": [
    "packages/*/src",
  ]
}

此时 src/reactive.ts 中不再标红报错,IDE 的 TypeScript 类型检查功能已可成功识别到 @vue/shared 模块。

另外,由于 reactivity 属于 Vue 面向业务开发者的核心功能模块包,需要支持多种使用场景的构建格式,我们可以在 reactivity/package.jsonbuildOptions 字段上补充这块需求:

json 复制代码
/** packages/reactivity/package.json **/

{
  "name": "@vue/reactivity",
  "version": "3.6.0",
  "main": "index.js",
  "module": "dist/reactivity.esm-bundler.js",
  // 略...

  "buildOptions": {
    "name": "VueReactivity",  // global 构建产物中的全局变量名
    "formats": [
      "esm-bundler",
      "esm-browser",          // 新增面向浏览器的 ES Module 构建格式
      "cjs",
      "global"                // 新增全局脚本构建格式
    ]
  }
}

pnpm 的 workspace 协议

@vue/reactivity 最终会被独立发布到 npm 上,我们需要为其声明对 @vue/shared npm 包的依赖:

json 复制代码
/** packages/reactivity/package.json **/

{
  "name": "@vue/reactivity",
  // 略...

  "dependencies": {    // 补充对 shared 模块的依赖信息
    "@vue/shared": "3.6.0"
  }
}

这里所填入的 @vue/shared 版本号 3.6.0 存在一个问题 ------ 该远程版本的内容无法同步我们本地 packages/shared 文件夹下的内容,而且每次发布 npm 包之前都需要手动更新依赖包的版本号,一旦遗漏就会出错。

pnpm 官方提供了一个解决方案,即使用 workspace 协议来替换依赖包的版本号,它表示依赖包必须解析自当前 Monorepo 中声明的 workspace 包,而不会从远程 npm registry 下载:

js 复制代码
/** packages/reactivity/package.json **/

  "dependencies": {      
    "@vue/shared": "workspace:*"    // 更替为 workspace 协议
  }

我们还需在 vue 根目录下新增 pnpm-workspace.yaml 文件,用于告知 pnpm「哪些文件夹可以作为独立的 workspace」:

js 复制代码
/** 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 成员 package.json(即 packages/shared/package.json)中的版本号。

💡 此处提及的是「依赖包版本号」的解决方案,但每个包的 package.json 里还有个 version 字段,用于描述自己的版本号。我们会在后文创建一个脚本,用于统一修改所有包自己的版本号。

3. Vite Plus 的应用

从 Vue 3.6 Beta 8 版本开始,Vue 源码项目全面使用了 Vite Plus 来作为(除构建流程之外的)外围基建解决方案。

💡 关于 Vite Plus 的介绍请查阅《前端工具链新标杆 Vite Plus 初探》一文。

3.1 项目基建

安装基础依赖

安装好了 Vite Plus 之后,我们在项目根目录执行如下 Vite Plus 指令:

sql 复制代码
vp add -D vite-plus typescript @types/node

来安装项目基础依赖模块、写入到根目录 package.json 中的 devDependencies 中:

  • vite-plus:确保所有源码开发者都安装和使用 Vite Plus。
  • typescript:TypeScript 编译器,提供 tsc 命令,实现类型检查、生成 .d.ts 等 TypeScript 相关的功能。
  • @types/node:Node.js API 的类型声明,让 TypeScript 编译器能「知道」Node.js 各 API 的类型。

💡 Vite Plus 会自行检测 package.json 中的 packageManager 信息、pnpm-workspace.yaml 等包管理器相关的文件,来获悉当前项目使用的包管理器是什么,在通过 vp add 指令安装依赖时会使用该包管理器进行处理。

锁定 Node.js 版本号

为了统一源码开发者所使用的 Node.js 版本号(24.15.0),我们在项目根目录执行如下 Vite Plus 指令:

bash 复制代码
vp env pin 24.15.0

Vite Plus 会自动在项目根目录生成一个 .node-version 文件,记录指定的 Node.js 版本号(这里为 24.15.0)。

后续任意源码开发者在执行 vp 指令时,Vite Plus 都会先检查当前环境所使用的 Node.js 版本号是否匹配 .node-version 文件中所记录的版本号,若不匹配会自动下载和切换到指定的 Node.js 版本。

代码检查与格式化

Vite Plus 内置了代码检查(Lint)与格式化(Format)的能力,可以借助 vp 指令执行对应的功能:

js 复制代码
vp lint   // 代码语法检查
vp fmt    // 代码格式化

我们在项目根目录创建 Vite Plus 配置文件 vite.config.ts,来对这两项功能进行自定义配置:

js 复制代码
/** `vite.config.ts` **/

import { defineConfig } from "vite-plus";

export default defineConfig({
  /** 配置 Lint 规则 **/
  lint: {
    categories: {
      correctness: 'off',            // 关闭 correctness 类别的规则
    },
    env: {
      builtin: true,                 // 允许使用内建运行时环境提供的全局对象(例如 console、Promise 等)
    },
    ignorePatterns: [ '**/dist/' ],  // 忽略的文件或目录
    overrides: [                     // 覆盖默认规则(https://eslint.org/docs/latest/rules/)
      {
        files: ['**/*.js', '**/*.ts', '**/*.tsx'],
        rules: {
          'no-debugger': 'error',          // 禁止在生产代码中使用 debugger 语句,出现即报错
          // 略...
        }
      },
      {
        files: ['packages/shared/**'],
        rules: {
          'no-restricted-globals': 'off',  // 允许使用 window、document 等全局对象
        },
      },
      // 略...
    ],
  },

  /** 配置格式化规则 **/
  fmt: {
    semi: false,                 // 末尾不加分号
    singleQuote: true,           // 字符串使用单引号
    arrowParens: 'avoid',        // 箭头函数参数只有一个时,不使用括号
    printWidth: 80,              // 按每行最多 80 个字符的宽度排版,超过时换行
    experimentalSortPackageJson: false,                      // 不对 package.json 里的键序重新排序
    ignorePatterns: [ 'dist', 'CHANGELOG*.md', '*.toml' ],   // 忽略的文件或目录
  },
});

留意在第 9 行关闭了 Lint 功能非常核心的 correctness 类别规则,这是因为项目中已安装的 TypeScript 编译器(tsc)针对这块核心规则具备更强大的语法检查、跨文件类型推导能力,所以需要禁用 Lint 中这块重叠的部分,改为使用 tsc 来落实核心规则的检查:

js 复制代码
tsc --incremental --noEmit  // --incremental:启用增量编译。--noEmit:仅输出错误信息,不修改代码

我们顺便完善根目录的 tsconfig.json 配置:

json 复制代码
/** tsconfig.json **/

{
  "compilerOptions": {
    "sourceMap": false,                   // 编译不生成 source map 文件
    "target": "es2016",                   // 指定编译后的 JavaScript 版本
    "newLine": "LF",                      // 强制使用 LF(\n)作为编译后文件的行尾符号,确保多平台的一致性
    "useDefineForClassFields": false,     // 类字段按照旧方式处理(在构造函数中赋值),而不是使用 Object.defineProperty
    "module": "esnext",                   // 使用 ESNext 模块语法(import/export),保留为未来标准
    "moduleResolution": "bundler",        // 模块解析使用打包器的策略,以支持 exports 条件导出、路径映射等
    "allowJs": false,                     // 禁止编译 JavaScript 文件
    "strict": true,                       // 启用所有严格类型检查选项
    "noUnusedLocals": true,               // 检查未使用的局部变量
    "experimentalDecorators": true,       // 启用实验性装饰器语法
    "resolveJsonModule": true,            // 允许导入 .json 文件作为模块
    "isolatedModules": true,              // 强制写出明确的类型导入/导出,确保每个文件都可以被构建工具(例如 Rolldown)独立转译
    "skipLibCheck": true,                 // 跳过所有声明文件(.d.ts)的类型检查
    "esModuleInterop": true,              // 为 CommonJS 模块提供 ES Module 兼容性
    "removeComments": false,              // 保留源代码中的注释
    "jsx": "preserve",                    // 保留 JSX 语法不变,由下游业务侧工具(如 Vite、Babel)进一步处理
    "lib": ["es2016", "dom"],             // 宿主环境类型库,包含 ES2016 标准库(Promise、Array 等)和 DOM API(document、window)的类型
    "types": ["node"],                    // 只加载 @types/node 类型声明包
    "isolatedDeclarations": true,         // 每个文件的导出变量/函数都必须显式声明类型(不能依赖上下文推断)
    "rootDir": ".",
    "paths": {
      "@vue/*": ["./packages/*/src"],
      "*": ["./*"]
    },
    "composite": true                     // 标记当前项目为复合项目,允许被其它项目 tsconfig.json 中的 references 字段引用
  },
  "include": ["packages/*/src"]
}

💡 tsconfig.json 各项配置的详情可查阅官方文档

最后,我们把代码检查和格式化的相关指令,都写入项目根目录的 package.json 文件的 scripts 中统一维护和使用:

json 复制代码
/** package.json **/

{
  "name": "vue",
  // 略...
    
  "scripts": {
    "preinstall": "npx only-allow pnpm",
    "check": "tsc --incremental --noEmit",    // 使用 tsc 检查核心语法规则
    "lint": "vp lint",                        // 执行 Vite Plus Lint 语法检查
    "format": "vp fmt",                       // 执行 Vite Plus 格式化(直接修改代码)
    "format-check": "vp fmt --check"          // 执行 Vite Plus 格式化(仅检查并抛出错误提示,不修改代码)
  }
}

暂存区文件检查

随着 Vue 源码项目越来越庞大,如果每次 Lint 与格式化都覆盖整个项目,会是很低效的事情。Vite Plus 提供了类似 lint-staged 的指令 vp stage 来处理该问题 ------ 执行 vp stage 时,只会针对 Git 暂存区的文件执行代码检查与格式化操作。

为了让「暂存区文件检查」的操作自动化,我们执行 vp config 来初始化 Git 钩子 ------ Vite Plus 会创建 .vite-hooks 文件夹,在 Git 操作流程中自动触发该文件夹下的钩子脚本:

js 复制代码
.vite-hooks
  └── pre-commit     // 对应 Git pre-commit hook

我们手动修改 Vite Plus 自动生成的 .vite-hooks/pre-commit 文件内容为:

bash 复制代码
vp staged && vp run check

这样每次 git commit 前,都会先对暂存区的文件进行 Lint 与格式化处理,并对项目代码执行 tsc 增量式语法检查。

💡 vp run check 等价于 pnpm run check

我们顺便让 vp config 在 npm prepare 钩子中触发执行,确保源码开发者都会通过该指令初始化 Git 钩子功能:

json 复制代码
/** package.json **/

{
  "name": "vue",
  // 略...
    
  "scripts": {
    "preinstall": "npx only-allow pnpm",
    "prepare": "vp config",    // 新增 npm prepare 钩子指令
    // 略...
  }
}

最后,我们可以在 vite.config.ts 中自定义 vp staged 的规则:

js 复制代码
/** vite.config.ts **/

export default defineConfig({
  /** 配置 Staged 规则 **/
  staged: {
    '*.{js,json}': ['vp fmt --no-error-on-unmatched-pattern'],                // 匹配已暂存的 .js 和 .json 文件并执行格式化操作,若匹配失败不要因此报错退出
    '*.ts?(x)': ['vp lint --fix', 'vp fmt --no-error-on-unmatched-pattern'],  // 匹配已暂存的 .ts 和 .tsx 文件并执行 Lint 操作,若匹配失败不要因此报错退出
  },
  // 略...
}

Git 提交信息检查

在以往应用了 Huskylint-staged 的前端项目,通常会再安装 commitlint 来检查 git commit 时的 message 是否符合规范。

然而对于 Vue 源码项目而言,其仓库的提交规范非常简单(参考规范文档 verify-commit.js),只需要对提交内容进行一次正则匹配即可,而不是依赖 commitlint 这么重的工具包。

我们在项目根目录创建 scripts 文件夹,用于存放后续各种工程化相关的脚本,接着在该文件夹内创建 verify-commit.js 文件来校验 Git 提交信息的合规性:

js 复制代码
/** verify-commit.js **/

// @ts-check
import pico from 'picocolors'
import { readFileSync } from 'node:fs'
import path from 'node:path'

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)) {    // git commit message 校验不合格
  console.error(              // 输出报错
    `  ${pico.white(pico.bgRed(' ERROR '))} ${pico.red(
      `invalid commit message format.`,
    )}\n\n` +
      pico.red(
        `  Proper commit message format is required for automated changelog generation. Examples:\n\n`,
      ) +
      `    ${pico.green(`feat(compiler): add 'comments' option`)}\n` +
      `    ${pico.green(
        `fix(v-model): handle events on blur (close #28)`,
      )}\n\n` +
      pico.red(`  See .github/commit-convention.md for more details.\n`),
  )
  process.exit(1)             // 退出进程
}

该脚本文件主要做了两件事:

  • 读取 .git/COMMIT_EDITMSG 信息(即当次提交的 message 内容)。
  • 用正则校验 header 格式,若不符合规范,则使用 picocolors 高亮输出自定义提示并退出进程。

我们接着在根目录的 .vite-hooks 下新增 commit-msg 钩子脚本文件:

bash 复制代码
node scripts/verify-commit.js

这样在每次 git commit 时,都会自动跑一遍 verify-commit.js 对当前的 message 进行合规性校验。

💡 新增 npm 包需要通过 vp add pkgname -D 来安装和同步写入 package.jsondependencies;新增文件夹或文件时需审视是否需要在 tsconfig.jsoninclude 字段,或 vite.config.tslint 配置中进行补充。这些基础操作后文不赘述。

3.2 测试用例

一个大型的框架项目通常需要配置测试用例,来确保各模块包的功能正确性、及时发现缺陷并防止问题回归。

在 Vue 源码项目中,使用了 Vitest 来作为测试用例工具,并统一在各个 Monorepo 包的目录下创建 __tests__ 文件夹来存放该包的测试用例。

下方是一个 shared 包测试用例的示例:

js 复制代码
/** packages/shared/__tests__/reservedProp.spec.ts **/

import { isReservedProp } from '../src'

test('isReservedProp', () => {
  expect(isReservedProp('key')).toBe(true)
  expect(isReservedProp('ref')).toBe(true)
  expect(isReservedProp('ref_for')).toBe(true)
  expect(isReservedProp('ref_key')).toBe(true)
  expect(isReservedProp('onVnodeBeforeMount')).toBe(true)
  expect(isReservedProp('onVnodeMounted')).toBe(true)
  expect(isReservedProp('onVnodeBeforeUpdate')).toBe(true)
  expect(isReservedProp('onVnodeUpdated')).toBe(true)
  expect(isReservedProp('onVnodeBeforeUnmount')).toBe(true)
  expect(isReservedProp('onVnodeUnmounted')).toBe(true)
})

留意这段代码使用了全局变量 testexpect ,我们需要做两件事情来支持这个快捷写法:

  • 通过 vp add vitest -D 安装 Vitest,并在 tsconfig.json 中添加 Vitest 包体内置的全局 API 类型声明:

    json 复制代码
    /** tsconfig.json **/
    
    {
      // 略...
    
      "compilerOptions": {
        "types": ["vitest/globals", "node"],  // 补充 vitest/globals,让 TypeScript 解释器了解 Vitest 的全局 API 类型
      }
    }
  • vite.config.ts 中开启全局 API 能力的支持:

    js 复制代码
    /** vite.config.ts **/
    
    export default defineConfig({
      /** Vitest 配置 **/
      test: {
        globals: true,
      },
      
      // 略...
    }

此时执行 Vite Plus 的内置指令 vp test,即可调用 Vitest 执行所有文件名含有 .spec. 的测试用例脚本:

4. 源码构建

我们进入 Vue 源码工程化的重点环节 ------ 实现一个可靠的构建方案,将分散在 packages/*/src 下的源码,构建出各种格式的产物到 packages/*/dist

要注意的是,Vue 需要被构建为一个「通用的前端框架」,而不是被构建为一个「Web 应用」,因此需要尽可能地保证构建产物代码的可阅读性。像 Webpack 会在每个模块周围包裹大量的 __webpack_require__ 等运行时代码,这类面向「Web 应用」的构建工具是不适合用来构建 Vue 框架的。

虽然我们可以通过 Vite Plus 的 vp pack 指令来执行 Monorepo 项目的包构建,但 Vue 是一个多包、多格式、多宏分支的大型项目,构建需求相较复杂,因此需要独立开发构建脚本,来达成更定制化、更可控的构建实现。

Vue 源码项目从 3.6 Beta 5 版本开始把全线构建工具统一为 Rolldown,它是使用 Rust 编写的高性能现代化打包器,像 Vite Plus 的构建能力实际上也是通过 Rolldown 搭配 Vite 来实现的。

4.1 build 指令与参数获取

我们在项目根目录的 scripts 文件夹下创建构建任务脚本 build.js,并在 package.json 中补充 build 指令:

json 复制代码
/** package.json **/

{
  "name": "vue",
  // 略...
    
  "scripts": {
    "build": "node scripts/build.js",    // 新增 build 构建任务指令
    // 略...
  }
}

预期后续执行 vp run build 指令时可以执行 Vue 源码项目的构建流程。

另外我们希望可以通过传入的参数来灵活控制需构建的模块、构建的格式等信息:

  • vp run build:构建所有模块所有环境下的所有格式产出物。

  • vp run build --devOnly:构建所有模块「开发环境」下的所有格式产出物(不构建「生产环境」的 .prod.js 文件)。

  • vp run build shared --prodOnly:只构建 shared 模块「生产环境」下的所有格式产出物。

  • vp run build shared reactivity --formats esm-bundler+cjs --sourceMap:只构建 sharedreactivity 模块所有环境下的「esm-bundler」和「cjs」格式产出物,并额外生成 .map 文件。

  • vp run build shared --formats ~cjs:只构建 shared 模块所有环境下的,除「cjs」之外的格式的产出物。

且传入的包名能支持模糊匹配:

  • vp run build re:模糊匹配到 reactivity 并执行构建(留意仅会构建一个包)。
  • vp run build re --all:模糊匹配到 reactivityshared 并执行构建。

综上所述,构建指令的预期格式为:

js 复制代码
vp run build [fuzzyTarget1 fuzzyTarget2 ...] [--formats xxx] [--devOnly] [--prodOnly] [--sourceMap] [--all]
vp run build [fuzzyTarget1 fuzzyTarget2 ...] [-f xxx] [-d] [-p] [-s] [-a]    // 简写形式

因此 scripts/build.js 需要实现的首件事情,是获取与解构传入的指令参数:

js 复制代码
/** scripts/build.js **/
// @ts-check

import { parseArgs } from 'node:util'

const { values, positionals: targets } = parseArgs({
  allowPositionals: true,    // 允许指令输入不带 - 或 -- 前缀的普通参数
  options: {
    formats: {               // 要构建的格式。可以通过 + 连接多种格式,若由波浪符 ~ 开头表示取反
      type: 'string',
      short: 'f',
    },
    devOnly: {               // 只构建开发环境
      type: 'boolean',
      short: 'd',
    },
    prodOnly: {              // 只构建生产环境
      type: 'boolean',
      short: 'p',
    },
    sourceMap: {             // 额外生成 sourcemap 文件
      type: 'boolean',
      short: 's',
    },
    all: {                   // 模糊匹配时,是否匹配多个
      type: 'boolean',
      short: 'a',
    },
  },
})

const {
  formats: rawFormats,
  devOnly,
  prodOnly,
  sourceMap,
} = values

/**
 * @type {string[] | undefined}
 */
let formats
let isNegation = false
if (rawFormats) {
  isNegation = rawFormats.startsWith('~')  // 是否取反。后续创建 Rolldown 配置时会用到
  formats = (isNegation ? rawFormats.slice(1) : rawFormats).split('+')  // 指定构建的格式数组
}

run()

function run() {
  // TODO - 执行构建任务
}

这段代码使用了 Node.js 内置的 util.parseArgs 方法来取得传入的指令参数。

接下来我们分步实现 run 函数中「执行构建任务」的逻辑。

4.2 创建工具模块

我们先实现「所有目标包名」和「模糊匹配传入的包名」两个工具接口,为了细化职责粒度、提高复用性,我们创建一个 scripts/utils.js 模块,来独立维护这些工具接口。

所有目标包名

我们可以通过 Node.js 的 fs 模块接口,来获取 packages 文件夹下的所有目标包名:

js 复制代码
/** scripts/utils.js **/

import fs from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'

const require = createRequire(import.meta.url)                          // 用于导入 json 文件
const packagesPath = path.resolve(import.meta.dirname, '../packages')   // packages 文件夹的绝对路径

/**
 * 所有目标包的名称
 * @type {string[]}
 */
export const allTargets = fs
  .readdirSync(packagesPath)
  .filter(f => {
    const folder = path.resolve(packagesPath, f)
    if (
      !fs.statSync(folder).isDirectory() ||
      !fs.existsSync(`${folder}/package.json`)
    ) {
      return false
    }
    const pkg = require(`${folder}/package.json`)
    if (!pkg.buildOptions) {
      return false
    }
    return true
  })

鉴于仓库目前只有 sharedreactivity 两个包,因此 allTargets 的值为 ["shared", "reactivity"]

模糊匹配传入的包名

我们创建一个 fuzzyMatchTarget 方法用于模糊匹配传入的包名:

js 复制代码
/** scripts/utils.js **/

/**
 * 从所有目标包中模糊匹配给定的目标包名称
 * @param {ReadonlyArray<string>} partialTargets - 给定的目标包名称列表
 * @param {boolean | undefined} includeAllMatching - 是否返回所有匹配项,传空表示仅返回首次匹配到的目标包
 * @returns {Array<string>}
 */
export function fuzzyMatchTarget(partialTargets, includeAllMatching) {
  /** @type {Array<string>} */
  const matched = []
  partialTargets.forEach(partialTarget => {
    for (const target of allTargets) {
      if (target.match(partialTarget)) {
        matched.push(target)
        if (!includeAllMatching) {
          break    // 若未传入 includeAllMatching,只命中第一个匹配项
        }
      }
    }
  })
  if (matched.length) {
    return matched
  } else {           // 没有匹配到任何包,报错并退出进程
    console.log()    // 打印换行,确保美观
    console.error(
      `  ${pico.white(pico.bgRed(' ERROR '))} ${pico.red(
        `Target ${pico.underline(partialTargets.toString())} not found!`,
      )}`,
    )
    console.log()
    process.exit(1)
  }
}

然而这段代码存在一个问题 ------ 假设仓库中同时存在 sharedruntime-shared 两个包,源码开发者在传入 shared 包名进行模糊匹配,且仅匹配单个目标包时,可能会先命中 runtime-shared,而不是开发者真正想要的 shared

对此我们在遍历前补充一段逻辑:

js 复制代码
/** scripts/utils.js **/

export function fuzzyMatchTarget(partialTargets, includeAllMatching) {
  const matched = []
  partialTargets.forEach(partialTarget => {
    // 新增逻辑,确保「仅返回首次匹配到的目标包」的命中更精准
    if (!includeAllMatching && allTargets.includes(partialTarget)) {
      matched.push(partialTarget)
      return
    }
    
    for (const target of allTargets) {
      if (target.match(partialTarget)) {
        matched.push(target)
        if (!includeAllMatching) {
          break
        }
      }
    }
  })
  if (matched.length) {
    return matched
  } else {
    // 略...
  }
}

在构建脚本中使用

我们在 scripts/build.js 中引入上述的两个工具接口,并封装 async buildAll 方法来异步执行 Rolldown 构建:

js 复制代码
/** scripts/build.js **/

import { allTargets, fuzzyMatchTarget } from './utils.js'
import { rolldown } from 'rolldown'

// 略...

await run()

async function run() {
  const resolvedTargets = targets.length ? fuzzyMatchTarget(targets, buildAllMatching) : allTargets
  await buildAll(resolvedTargets)
}

/**
 * 异步构建所有目标包
 * @param {Array<string>} targets - 目标包名称列表
 * @returns {Promise<void>}
 */
async function buildAll(targets) {
  const start = performance.now()
  const all = []
  let count = 0
  for (const t of targets) {
    const configs = createConfigsForTarget(t)     // 创建目标包的 Rolldown 配置
    if (configs) {
      all.push(
        Promise.all(
          configs.map(c => {
            return rolldown(c).then(bundle => {   // 调用 Rolldown 接口执行构建。API 参考 https://rolldown.rs/apis/bundler-api
              return bundle.write(c.output).then(() => {
                return c.output.file
              })
            })
          }),
        ).then(files => {
          const from = process.cwd()              // 项目根目录绝对路径
          files.forEach((/** @type {string} */ f) => {
            count++
            console.log(pico.gray('built: ') + pico.green(path.relative(from, f)))
          })
        }),
      )
    }
  }
  await Promise.all(all)
  console.log(`\n${count} files built in ${(performance.now() - start).toFixed(2)}ms.`)
}

function createConfigsForTarget(target) {
  // TODO - 针对指定的目标包创建 rolldown 构建配置 
}

4.3 创建 Rolldown 配置

目前 scripts/build.js 已完成了构建功能的大致框架,还剩余一块「创建 Rolldown 配置」的拼图。

完善前置工作

在创建 Rolldown 配置前,需要先读取目标包 package.json 中的 buildOptions 字段来获悉该包的构建信息,还需要做构建格式取反、移除 dist 文件夹等操作。

我们在 createConfigsForTarget 中完善这块前置工作,并额外封装一个 createConfigsForPackage 接口来专项处理「创建 Rolldown 配置」这件事:

js 复制代码
/** scripts/build.js **/

import { existsSync, readFileSync, rmSync } from 'node:fs'

/**
 * 针对指定的目标包创建 rolldown 构建配置
 * @param {string} target - 目标包的名称
 * @returns {import('rolldown').RolldownOptions[] | void} - 构建配置数组或 void
 */
function createConfigsForTarget(target) {
  const pkgDir = path.resolve(__dirname, `../packages/${target}`)
  const pkg = JSON.parse(readFileSync(`${pkgDir}/package.json`, 'utf-8'))  // 获取目标包配置

  let resolvedFormats
  if (formats) {
    const pkgFormats = pkg.buildOptions?.formats
    if (pkgFormats) {
      if (isNegation) {    // 取反处理
        resolvedFormats = pkgFormats.filter(
          (f) => !formats.includes(f),
        )
      } else {
        resolvedFormats = formats.filter(f => pkgFormats.includes(f))
      }
    }
    if (!resolvedFormats.length) {
      return
    }
  }

  // 指定构建格式时,先删除已有的 dist 文件夹,避免遗留前次构建的其它格式产物
  if (!formats && existsSync(`${pkgDir}/dist`)) {
    rmSync(`${pkgDir}/dist`, { recursive: true })
  }

  return createConfigsForPackage({
    target,
    formats: resolvedFormats,
    prodOnly,
    devOnly,
    sourceMap,
  })
}

function createConfigsForPackage({
  target,
  formats,
  devOnly = false,
  prodOnly = false,
  sourceMap = false,
  inlineDeps = false,
}) {
  // TODO - 创建 Rolldown 配置  
}

最后进一步实现 createConfigsForPackage 方法即可,由于这块的逻辑比较繁琐,我们在 scripts 文件夹下创建一个 create-rolldown-config.js 脚本来独立维护,并在 scripts/build.js 中引用:

js 复制代码
/** scripts/build.js **/

// 创建独立模块来维护「创建 Rolldown 配置」的功能
import { createConfigsForPackage } from './create-rolldown-config.js'

function createConfigsForTarget(target) {
  // 略...

  return createConfigsForPackage({
    target,
    formats: resolvedFormats,
    prodOnly,
    devOnly,
    sourceMap,
  })
}

实现 Rolldown 配置雏形

根据 Rolldown 官方的 API 指引,我们先实现一个最基础的配置创建功能:

js 复制代码
/** scripts/create-rolldown-config.js **/

import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import path from 'node:path'

const require = createRequire(import.meta.url)                 // 用于直接引入 JSON 文件
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const packagesDir = path.resolve(__dirname, '../packages')     // packages 文件夹的绝对路径
const masterVersion = require('../package.json').version       // 版本号,统一取项目根目录包配置中的 version

export function createConfigsForPackage({
  target, pkg, formats, devOnly = false, prodOnly = false, sourceMap = false,
}) {
  const packageOptions = pkg.buildOptions || {}
  const packageDir = path.resolve(packagesDir, target)
  const name = path.basename(packageDir)
  const resolve = (p) => path.resolve(packageDir, p)
  const banner = `/**
  * ${pkg.name} v${masterVersion}
  * (c) 2018-present Vue
  * @license MIT
  **/`
  
  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',
    },
  }
  
  const resolvedFormats = (formats || ['esm-bundler', 'cjs']).filter((format) => outputConfigs[format])  // 要构建的格式(数组)
  const packageConfigs = resolvedFormats.map(format => createConfig(format, outputConfigs[format]))      // 各包的 Rolldown 配置
  
  /** 创建并返回 Rolldown 配置对象 **/
  function createConfig(format, output) {
    const isBundlerESMBuild = /esm-bundler/.test(format)
    const isGlobalBuild = /global/.test(format)

    output.postBanner = banner           // 在构建产物头部固定的横幅内容
    output.sourcemap = sourceMap
    
    if (isGlobalBuild) {
      output.name = packageOptions.name  // 指定 global 构建产物中的全局变量名
    }
    
    return {
      input: resolve('src/index.ts'),    // 包源码入口文件
      output,
      treeshake: {
        moduleSideEffects: false,        // 声明所有模块没有副作用,Tree-shaking 时会移除所有导出了但未使用的模块
      },
      experimental: {
        nativeMagicString: true,         // 启用基于 Rust 的 MagicString 来提升 SourceMap 的生成效率
      },
    }
  }
  
  return packageConfigs
}

此时 createConfigsForPackage 方法已能创建一个最简单的 Rollup 配置,我们在这个基础上逐步对它进行完善。

💡 MagicString 是一个专门用于源码编辑的工具,能够根据 AST 提供的位置信息高效修改源码,因此被众多构建工具广泛采用。

别名处理

Rolldown 在构建运行时是无法识别 @vue/shared 这种自定义包名的,需要通过 resolve.alias 将「包的别名与该包绝对路径的映射」告知 Rolldown。我们在 scripts 文件夹下额外创建一个 aliases.js 脚本用于维护这些别名:

js 复制代码
/** scripts/aliases.js **/

// @ts-check

import { readdirSync, statSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

// 获取传入包名的源码入口绝对路径
const resolveEntryForPkg = (/** @type {string} */ p) =>
  path.resolve( fileURLToPath(import.meta.url), `../../packages/${p}/src/index.ts`)

// 读取 packages 目录下所有文件和子文件夹
const dirs = readdirSync(new URL('../packages', import.meta.url))

/** @type {Record<string, string>} */
const entries = {}

for (const dir of dirs) {
  const key = `@vue/${dir}`
  if ( statSync(new URL(`../packages/${dir}`, import.meta.url)).isDirectory() ) {
    entries[key] = resolveEntryForPkg(dir)
  }
}

export { entries }

接着在 scripts/create-rolldown-config.js 中使用:

js 复制代码
/** scripts/create-rolldown-config.js **/

import { entries } from './aliases.js'

export function createConfigsForPackage({ ... }){
  // 略...
  
  function createConfig(format, output) {
    // 略...
    
    return {
      input: resolve('src/index.ts'), 
      output,
      resolve: {
        alias: entries,  // 使用别名
      },
      // 略...
    }
  }
  
  // 略...
}

宏常量的使用与构建替换

在前文我们曾举过一个示例:

ts 复制代码
/** 源码 **/
const EMPTY_OBJ: { readonly [key: string]: any } = !!(process.env.NODE_ENV !== "production")
  ? Object.freeze({})
  : {}

/** 开发环境 **/
const EMPTY_OBJ = Object.freeze({})

/** 生产环境 **/
const EMPTY_OBJ = {}

用来展示一份「源码」被构建为「开发环境」和「生产环境」产物后的状态。

实际上它是面向下游业务侧构建工具的「源码」,已经是 Vue 原始代码经过构建后的 ESM Bundler 产物。

在真正的源码中,我们并不会把环境判断直接写作 !!(process.env.NODE_ENV !== "production") ------ 该表达式过于冗长,且不便在构建运行时被 AST 化和替换。

取而代之的是「宏常量」占位符,我们往 packages/shared/src/general.ts 源码文件新增两行代码作为示例:

ts 复制代码
/** 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([]) : []

// 略...

其中 __DEV__ 就是一个「宏常量」,通常会在构建工具中将宏常量配置为指定值:

  • 通过 define 的形式对宏常量进行定义,通常在 AST 转换后按标识符做替换,会更安全、不会影响到注释里的同名内容。
  • 通过 Replace Plugin 的插件形式进行替换,通常使用正则匹配的形式进行简单粗暴的替换。

我们采用第一种形式,对 __DEV__ 进行定义和替换:

js 复制代码
/** scripts/create-rolldown-config.js **/

export function createConfigsForPackage({ ... }){
  // 略...
  
  function createConfig(format, output) {
     function resolveDefine() {
      const defines = {
        __DEV__: isBundlerESMBuild
          ? `!!(process.env.NODE_ENV !== 'production')`  // 面向下游构建工具的产物需保留环境判断表达式(交由下游构建工具去处理)
          : String(!isProductionBuild),                  // 其它场景替换为 boolean,确保在当次构建时能被 Tree-shake 掉
      }
      return defines
    }
  
    return {
      input: resolve('src/index.ts'), 
      transform: {
        define: resolveDefine(),      // 替换宏常量
      },
      output,
      treeshake: {
        moduleSideEffects: false, 
      },
      experimental: {
        nativeMagicString: true,  
      },
    }
}

💡 以往大部分构建工具要求 define 配置中赋予宏常量的值必须符合严格的 JSON 规范,因此无法兼容 !!(process.env.NODE_ENV !== 'production') 的模板字符串写法,只能改为 Replace Plugin 的形式去替换。而最新的 Vue 项目底层采用了 oxc 工具,已能兼容模板字符串的赋值。本文截止前,Vue 源码项目暂未将 Replace Plugin 形式的旧逻辑迁移到 define 中。

我们顺便补充更多宏常量,并允许通过指令覆盖默认的 define 定义,方便后续的使用:

js 复制代码
/** scripts/create-rolldown-config.js **/

export function createConfigsForPackage({ ... }){
  // 略...
  
  function createConfig(format, output) {
    const isProductionBuild = /\.prod\.js$/.test(String(output.file) || '')
    const isBundlerESMBuild = /esm-bundler/.test(format)
    const isBrowserESMBuild = /esm-browser/.test(format)
    const isCJSBuild = format === 'cjs'
    const isGlobalBuild = /global/.test(format)
    const isBrowserBuild =
      (isGlobalBuild || isBrowserESMBuild || isBundlerESMBuild) &&
      !packageOptions.enableNonBrowserBranches

    output.sourcemap = sourceMap

    if (isGlobalBuild) {
      output.name = packageOptions.name
    }

    function resolveDefine() {
      const defines = {
        __VERSION__: `"${masterVersion}"`,
        __TEST__: `false`,
        __BROWSER__: String(isBrowserBuild),
        __GLOBAL__: String(isGlobalBuild),
        __ESM_BUNDLER__: String(isBundlerESMBuild),
        __ESM_BROWSER__: String(isBrowserESMBuild),
        __CJS__: String(isCJSBuild),
        __DEV__: isBundlerESMBuild
          ? `!!(process.env.NODE_ENV !== 'production')`
          : String(!isProductionBuild),
      }

      // 允许在命令行中手动输入环境变量来覆盖默认的 define 定义,例如
      // __DEV__=true vp run build
      Object.keys(defines).forEach(key => {
        if (key in process.env) {
          const value = process.env[key]
          assert(typeof value === 'string')
          defines[key] = value
        }
      })

      return defines
    }

    // 略...
  }
}

不过由于缺乏类型声明,IDE 的 TypeScript 编译器会针对这些宏常量报错:

因此需要在 packages 目录下新增 global.d.ts 文件,对宏常量进行全局性的类型声明:

ts 复制代码
/** packages/global.d.ts **/

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 __VERSION__: string;

💡 未来有新增的自定义宏常量也需要在该文件补齐,后文不赘述。

为测试用例配置别名和宏

此时如果执行 vp test 测试用例指令会报错:

diff 复制代码
ReferenceError: __DEV__ is not defined

这是由于我们并未给执行测试用例的工具(Vite Plus)补充宏定义的配置,我们在 vite.config.ts 文件同步补上这块配置项:

ts 复制代码
/** vite.config.ts **/

import { defineConfig } from 'vite-plus'

export default defineConfig({
  /** 宏定义 **/
  define: {
    __DEV__: process.env.MODE !== 'benchmark',
    __TEST__: true,
    __BROWSER__: false,
    __GLOBAL__: false,
    __ESM_BUNDLER__: true,
    __ESM_BROWSER__: false,
    __CJS__: true,
  },
  // 略...
})

同理还需要同步补充上别名的定义:

ts 复制代码
/** vite.config.ts **/

import { defineConfig } from 'vite-plus'
import { entries } from './scripts/aliases.js'    // 复用别名模块

export default defineConfig({
  define: {
    __DEV__: process.env.MODE !== 'benchmark',
    __TEST__: true,
    __BROWSER__: false,
    __GLOBAL__: false,
    __ESM_BUNDLER__: true,
    __ESM_BROWSER__: false,
    __CJS__: true,
  },
  /** 别名配置 **/
  resolve: {
    alias: entries,
  },
  // 略...
})

此时再执行 vp test 指令不再报错。

生产环境的额外处理

鉴于生产环境的构建产物名称应该带有 .prod 后缀,且面向浏览器的构建产物需要进行压缩来缩小引用体积,我们需要对生产环境的构建配置做额外处理:

js 复制代码
/** scripts/create-rolldown-config.js **/

export function createConfigsForPackage({ ... }){
  // 略...
  
  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',
    },
  }
  
  const resolvedFormats = (formats || ['esm-bundler', 'cjs']).filter((format) => outputConfigs[format])
  const packageConfigs = prodOnly
    ? []           // 仅构建生产环境的配置可以先置空(因为不需要生成开发环境配置)
    : resolvedFormats.map(format => createConfig(format, outputConfigs[format]))

  if (!devOnly) {  // 「仅构建生产环境」和「同时构建生产环境和开发环境」,等价于「需要构建生产环境」的场景
    resolvedFormats.forEach(format => {
      if (format === 'cjs') {
        packageConfigs.push(createProductionConfig(format))        // 重命名
      }
      if (/^(global|esm-browser)$/.test(format)
      ) {
        packageConfigs.push(createProductionConfig(format, true))  // 重命名 + 面向浏览器的构建产物需要进行压缩
      }
    })
  }
  
  /** 创建基础配置(等价于创建开发环境配置)**/
  function createConfig(format, output) {
    // 略...
  }
  
  /** 创建生产环境配置(基于 createConfig 的开发环境配置上做修改)**/
  function createProductionConfig(/** @type {PackageFormat} */ format, minify = false) {
    return createConfig(format, {
      file: resolve(`dist/${name}.${format}.prod.js`),
      format: outputConfigs[format].format,
      minify,   // 代码压缩
    })
  }

  return packageConfigs
}

此时 createConfig 相当于创建了「开发环境」的配置,而 createProductionConfig 是在 createConfig 的基础上生成「生产环境」的配置。

兼容性处理

Rolldown 提供了一些配置项,可以提升构建产物在不同场景上的兼容性:

js 复制代码
/** scripts/create-rolldown-config.js **/

export function createConfigsForPackage({ ... }){
  // 略...
  
  function createConfig(format, output) {
    // 略...
    
    // 强制将模块的导出打包为 CJS 的属性(即键值对对象),提升「下游构建工具在处理 ESM + CJS 混用项目时」的兼容性
    output.exports = 'named'

    if (isCJSBuild) {
      // 为导出对象(module.exports)添加一个 __esModule: true 的属性,告知下游构建工具「该 CJS 文件由 ESM 转换而来」
      output.esModule = true
    }
    
    return {
      input: resolve('src/index.ts'), 
      output,
      transform: {
        define: resolveDefine(),
        target: isCJSBuild ? 'es2019' : 'es2016',  // 面向浏览器的构建产物采用兼容性更高的 es2016 标准
      },
      platform: format === 'cjs' ? 'node' : isBundlerESMBuild ? 'neutral' : 'browser',
      // 略...
    }
  }
  
  // 略...
}

其中 platform 表示「构建代码预期运行的平台」,Rolldown 会针对指定的平台进行构建适配和优化:

  • 当构建格式为 cjs 时,其值为 node,表示构建产物将运行在 Node.js 平台上。构建时将保留 process.env.NODE_ENV 和其他 Node.js 全局变量。
  • 当构建格式为 esm-bundler 时,其值为 neutral,表示与平台无关。将构建为多平台通用的 ES Module 格式。
  • 其它构建格式时,其值为 browser,表示构建产物将运行在浏览器上。构建时属于 Node.js 的内置模块默认不会进行填充。

💡 platform 值为 browser 所对应的构建产物代码中,若执行 Node.js 内置模块(例如 path)的接口会因为找不到而报错,后文会针对 compiler-sfc 这类「需要在浏览器上运行,同时又需要使用 Node.js 内置模块」的特殊包,通过插件形式进行 polyfill 处理。

缩减构建产物体积

我们针对生产环境的构建启用了代码压缩功能,但还有其它进一步缩减构建产物体积的手段。

首先可以对外部依赖下手 ------ 以 reactivity 包为例,虽然它依赖了 shared 包,但在构建非面向浏览器平台的产物时(例如构建的是 esm-bundler 或 cjs 格式),并不需要将 shared 包的代码内联入 reactivity 包的构建产物中。

通过 Rolldown 的 external 属性可以实现依赖裁剪的功能:

js 复制代码
/** scripts/create-rolldown-config.js **/

export function createConfigsForPackage({ ... }){
  // 略...
  
  function createConfig(format, output) {
    // 略...
    
    // 裁剪外部依赖,非浏览器的构建不要把依赖模块的代码内联进构建产物中
    function resolveExternal() {
      if (!isGlobalBuild && !isBrowserESMBuild) {
        // 非面向浏览器的构建,外部化所有依赖项
        return [
          ...Object.keys(pkg.dependencies || {}),
          ...Object.keys(pkg.peerDependencies || {}),
        ]
      }
    }
    
    return {
      input: resolve('src/index.ts'), 
      output,
      external: resolveExternal(),
      // 略...
    }
  }
  
  // 略...
}

其次 Rolldown 的构建产物会默认启用 ESM 的「动态绑定」特性,使用 Object.defineProperty 对引入的模块属性进行定义:

js 复制代码
// 输入
export { x } from 'external';
js 复制代码
// 构建后的 CJS 产物
var external = require('external');

Object.defineProperty(exports, 'x', {
  enumerable: true,
  get: function () {
    return external.x;
  },
});

将 Rolldown 的 externalLiveBindings 配置设为 false,可以关闭「动态绑定」特性,从而减少构建产物体积:

js 复制代码
// 构建后的 CJS 产物(externalLiveBindings 设为 false)
var external = require('external');

exports.x = external.x;

我们在 scripts/create-rolldown-config.js 中新增一行代码关闭该特性:

js 复制代码
/** scripts/create-rolldown-config.js **/

export function createConfigsForPackage({ ... }){
  // 略...
  
  function createConfig(format, output) {
    // 关闭动态绑定特性(不使用 Object.defineProperty 来定义导入模块的属性),减少构建产物体积
    output.externalLiveBindings = false
    
    // 略...
  }
  
  // 略...
}

至此我们便完成了一个基础的源码构建方案:

5. 类型声明文件构建

业务侧的项目如果是基于 TypeScript 开发的,引入前文构建后的代码会因为缺少类型声明而报错,因此还需要专门构建 .d.ts 声明文件供业务侧的 TypeScript 编译器使用。

我们在 scripts 文件夹下创建 build-types.js 文件来实现此功能,并先在项目根目录的包配置中加上相关指令:

json 复制代码
/** package.json **/

{
  "name": "vue",
  "version": "3.6.0",
  "scripts": {
    "preinstall": "npx only-allow pnpm",
    "build": "node scripts/build.js",
    "build-dts": "node scripts/build-types.js",    // 构建类型声明文件
    // 略...
  },
  // 略...
}

5.1 为源码独立模块构建的 .d.ts

虽然 tsc 指令可以实现「完整项目语义分析 + 声明生成」,但 Vue 源码项目本质是一个 Monorepo 架构的多包仓库,为每个包独立生成类型声明文件并存放到对应的 dist 文件夹下更为合理。

我们可以利用 oxc 的 isolatedDeclaration 接口,对 src 下的每个独立的源码模块生成 .d.ts 文件,并将它们写入到 temp 临时文件夹下:

js 复制代码
/** scripts/build-types.js **/

import fs from 'node:fs'
import path from 'node:path'
import glob from 'fast-glob'
import { isolatedDeclaration } from 'oxc-transform'

if (fs.existsSync('temp/packages')) {
  fs.rmSync('temp/packages', { recursive: true })    // 先移除 temp/packages 临时文件夹(若有)
}

const files = await glob('packages/*/src/**/*.ts')   // 读取各包 src 下的所有独立源码模块
for (const file of files) {
  const ts = fs.readFileSync(file, 'utf-8')
  const dts = await isolatedDeclaration(file, ts, {  // 使用 oxc 接口生成类型声明文件
    sourcemap: false,
    stripInternal: true,
  })

  write(path.join('temp', file.replace(/\.ts$/, '.d.ts')), dts.code)  // 写入到 temp 文件夹下
}

/** 写入内容到指定文件 **/
function write(file, content) {
  const dir = path.dirname(file)
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })     // 若文件夹不存在,则直接创建
  fs.writeFileSync(file, content)
}

执行 vp run build-dts 指令,会在项目根目录创建 temp/packages 临时文件夹,并将各包源码独立模块的类型声明存入其中:

css 复制代码
temp
└── packages
    ├── reactivity
    │   └── src
    │       ├── index.d.ts
    │       └── reactive.d.ts
    └── shared
        └── src
            ├── general.d.ts
            ├── index.d.ts
            └── makeMap.d.ts

之所以将类型声明文件的产出存放到临时文件夹,而非各包的 dist 文件夹下,是因为每个包面向业务侧的类型声明文件应当「有且仅有一个」,而非分散的多个类型声明文件。

因此接下来要做的事情,是将 temp/packages 下各包分散的 .d.ts 文件合并为单个文件。

5.2 合并类型声明

参照之前将各包 src 下的多个 .ts 源码文件打包成 dist 下的单个构建产物的形式,可以沿用 Rolldown 将各包 src 下的多个 .d.ts 源码文件打包成 dist 下的单个类型声明文件。

我们在项目根目录创建 rolldown.dts.config.js 文件用于维护打包类型声明的 Rolldown 配置:

js 复制代码
/** rolldown.dts.config.js **/

import assert from 'node:assert/strict'
import { parseSync } from 'oxc-parser'
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { dts } from 'rolldown-plugin-dts'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import path from 'node:path'

const require = createRequire(import.meta.url)
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const packagesDir = path.resolve(__dirname, 'packages')

const packages = readdirSync('temp/packages')
// 可从环境变量获取目标包,例如执行 TARGETS=reactivity,shared vp run build-types
const targets = process.env.TARGETS ? process.env.TARGETS.split(',') : null
const targetPackages = targets
  ? packages.filter(pkg => targets.includes(pkg))
  : packages

/** 移除外部依赖 **/
function resolveExternal(packageName) {
  const pkg = require(`${packagesDir}/${packageName}/package.json`)
  return [
    ...Object.keys(pkg.dependencies || {}),
    ...Object.keys(pkg.devDependencies || {}),
    ...Object.keys(pkg.peerDependencies || {}),
  ]
}

// 导出配置项(数组)
export default targetPackages.map(
  pkg => {
    return {
      input: `./temp/packages/${pkg}/src/index.d.ts`,
      output: {
        file: `packages/${pkg}/dist/${pkg}.d.ts`,
        format: 'es',
      },
      experimental: {
        nativeMagicString: true,  // 启用基于 Rust 的 MagicString 来提升代码变换阶段的效率
      },
      external: resolveExternal(pkg),
      plugins: [ dts() ],         // 让 Rolldown 能「理解」如何打包 TypeScript 声明文件
    }
  },
)

留意 Rolldown 默认是无法「理解」如何打包 TypeScript 声明文件的,因此需要使用 rolldown-plugin-dts 插件来支持此能力。

我们回到 build-types.js 补充 Rolldown 的打包逻辑:

js 复制代码
/** scripts/build-types.js **/

import { rolldown } from 'rolldown'
import picocolors from 'picocolors'
// 略...

const files = await glob('packages/*/src/**/*.ts')
// 略...

const rolldownConfigs = (await import('../rolldown.dts.config.js')).default  // 引入目标包配置

await Promise.all(
  rolldownConfigs.map(c =>
    rolldown(c).then(bundle => {
      return bundle.write(c.output).then(() => {
        console.log(picocolors.gray('built: ') + picocolors.blue(c.output.file))
      })
    }),
  ),
)

留意第 10 行和第 12 行均是使用了 await 的异步方式来处理 Rolldown 流程 ------ 这是由于前方已存在异步获取 .ts 文件的逻辑(第 7 行),后方逻辑需要「封装」自己为异步的微任务来确保前置条件的异步逻辑先执行完毕。

此时执行 vp run build-dts 即可构建出 packages/shared/dist/shared.d.tspackages/reactivity/dist/reactivity.d.ts 两个类型声明文件,其中 shared 包的类型声明文件内容如下:

ts 复制代码
/** packages/shared/src/general.d.ts **/

//#region temp/packages/shared/src/general.d.ts
declare const EMPTY_OBJ: {
  readonly [key: string]: any;
};
declare const EMPTY_ARR: readonly never[];
/** 空函数 */
declare const NOOP: () => void;
/** 生成一个方法,用于判断一个属性名是否是保留属性 */
declare const isReservedProp: (key: string) => boolean;
//#endregion
//#region temp/packages/shared/src/makeMap.d.ts
/**
* 把一个用逗号分隔的字符串(例如 "a,b,c" )预处理成一个"成员判断函数",
* 用于在运行时高频地判断某个 key 是否属于某个固定集合。
*
* 示例:
* const isHTMLTag = makeMap('div,span,p')
* isHTMLTag('div') // true
* isHTMLTag('a')   // false
*/
declare function makeMap(str: string): (key: string) => boolean;
//#endregion
export { EMPTY_ARR, EMPTY_OBJ, NOOP, isReservedProp, makeMap };

最后,我们将类型声明文件构建作为可选任务加入到整体的源码构建流程:

js 复制代码
/** scripts/build.js **/

const { values, positionals: targets } = parseArgs({
  allowPositionals: true,
  options: {
    formats: {
      type: 'string',
      short: 'f',
    },
    withTypes: {              // 新增可选指令参数,用于异步构建类型声明文件
      type: 'boolean',
      short: 't',
    },
    sourceMap: {
      type: 'boolean',
      short: 's',
    },
    all: {
      type: 'boolean',
      short: 'a',
    },
  },
})

const {
  formats: rawFormats,
  all: buildAllMatching,
  devOnly,
  prodOnly,
  withTypes: buildTypes,
  sourceMap,
} = values

run()

async function run() {
  const resolvedTargets = targets.length
    ? fuzzyMatchTarget(targets, buildAllMatching)
    : allTargets
  if (buildTypes) {
    await import('./build-types.js')    // 异步构建类型声明文件
  }
  await buildAll(resolvedTargets)
}

// 略...

此时执行 vp run build -t 会在之前 build 构建的基础上,同时异步构建出类型声明文件到各包的 dist 中去。

6. 源码开发环境构建

我们在前文所实现的 build 方案可以构建出面向业务侧的「开发环境」与「生产环境」的构建产物,然而对于「Vue 源码开发者」而言,还需要实现一个帮助我们自己提升开发者体验的「源码开发环境」构建方案,它需要是一个「更快速的、可持续的、适合本地调试」的方案,它与 build 构建会存在一些不同:

  • 更重视指定包的构建,无需构建所有包。
  • 更重视指定格式的构建,无需构建所有格式。
  • 没有类型声明文件的构建需求(那是面向业务侧使用者的)。
  • 输出总是带 sourceMap,方便源码调试。
  • 具备 watch 的监听能力,文件修改后能自动增量重建。

其中前三条可以有效提升构建的速度,最后一条可以确保本地开发构建的持续性。

我们在 scripts 文件夹下创建 dev.js 文件来实现源码开发环境构建的功能:

js 复制代码
/** scripts/dev.js **/

import { dirname, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createRequire } from 'node:module'
import { parseArgs } from 'node:util'
import { watch } from 'rolldown'

const require = createRequire(import.meta.url)
const __dirname = dirname(fileURLToPath(import.meta.url))

const {
  values: { format: rawFormat, prod },
  positionals,
} = parseArgs({
  allowPositionals: true,
  options: {
    format: {
      type: 'string',
      short: 'f',
      default: 'global',
    },
    prod: {
      type: 'boolean',
      short: 'p',
      default: false,
    },
  },
})

const format = rawFormat || 'global'
const targets = positionals.length ? positionals : ['shared']
const outputFormat = format.startsWith('global') ? 'iife'  : (format === 'cjs' ? 'cjs'  : 'es')
const postfix = format.endsWith('-runtime') ? `runtime.${format.replace(/-runtime$/, '')}` : format

for (const target of targets) {
  const pkgBasePath = `../packages/${target}`
  const pkg = require(`${pkgBasePath}/package.json`)
  const outfile = resolve(__dirname, `${pkgBasePath}/dist/${target}.${postfix}.${prod ? `prod.` : ``}js`)

  // 裁剪外部依赖,非浏览器的构建不要把依赖模块的代码内联进构建产物中
  let external = []
  if (format === 'cjs' || format.includes('esm-bundler')) {
    external = [
      ...Object.keys(pkg.dependencies || {}),
      ...Object.keys(pkg.peerDependencies || {}),
    ]
  }

  const config = {
    input: resolve(__dirname, `${pkgBasePath}/src/index.ts`),
    output: {
      file: outfile,
      format: outputFormat,
      sourcemap: true,
      name: pkg.buildOptions?.name,
    },
    external,
    platform: format === 'cjs' ? 'node' : 'browser',
    treeshake: {
      moduleSideEffects: false,
    },
    transform: {
      define: {
        __VERSION__: `"${pkg.version}"`,
        __DEV__: prod ? `false` : `true`,
        __TEST__: `false`,
        __BROWSER__: String(format !== 'cjs'),
        __GLOBAL__: String(format === 'global'),
        __ESM_BUNDLER__: String(format.includes('esm-bundler')),
        __ESM_BROWSER__: String(format.includes('esm-browser')),
        __CJS__: String(format === 'cjs'),
      },
    },
  }

  // 调用 Rolldown 的 watch 接口监听本地改动,实时触发构建
  watch(config).on('event', event => {
    if (event.code === 'BUNDLE_END') {
      console.log(`built ${config.output.file} in ${event.duration}ms`)
    }
  })
}

其本质是一个简化版的 build.js + create-rolldown-config.js,且调用了 Rolldown 的 watch 接口监听本地源码改动,来实现热构建的能力。

💡 旧版本的 Vue 项目由于构建工具 Rollup 的模块图主要面向「一次性构建」设计,缺乏面向长期运行进程的增量构建能力,因此在构建源码开发环境时需要改为使用 esbuild。然而不同于 Rollup,新工具 Rolldown 会把模块图视为长期驻留的一等公民,能从架构层面支持增量重建,得以适配开发态的高频变更场景,这是 Vue 3.6 可以统一使用 Rolldown 作为构建基座的原因。

最后在项目根目录的包配置中加上 dev 指令:

json 复制代码
/** package.json **/

{
  "name": "vue",
  "version": "3.6.0",
  "scripts": {
    "preinstall": "npx only-allow pnpm",
    "dev": "node scripts/dev.js",         // 源码开发环境构建
    "build": "node scripts/build.js",
    // 略...
  },
  // 略...
}

执行效果如下:

7. 发布

发布的处理是项目工程化的最后一个环节,它需要负责「各包版本号的更新、生成 CHANGELOG、执行构建、发布 npm」等功能。

我们在 scripts 文件夹下创建 release.js 文件,先实现一个最简单的发布流程:

js 复制代码
/** scripts/release.js **/

import pico from 'picocolors'
import { createRequire } from 'node:module'

const step = (msg) => console.log(pico.cyan(msg))  // 专用工具方法,使用 picocolors 打印当前步骤信息
const currentVersion = createRequire(import.meta.url)('../package.json').version  // 当前版本号(项目根目录包配置中的 version 字段)

// 更新各包 package.json 中的 version
function updateVersions(version) {
  // TODO
}

// 构建,执行指令 vp run build --withTypes
async function buildPackages() {
  step('\nBuilding all packages...')
  // TODO
}

// 发布所有包到 npm
async function publishPackages(version) {
  step('\nPublishing packages...')
  // TODO
}

async function main() {
  updateVersions(currentVersion)
  await buildPackages(currentVersion)
  await publishPackages(currentVersion)
}

main()

同时在项目根目录的包配置中加上 release 指令:

json 复制代码
/** package.json **/

{
  "name": "vue",
  "version": "3.6.0",
  "scripts": {
    "preinstall": "npx only-allow pnpm",
    "dev": "node scripts/dev.js",       
    "build": "node scripts/build.js",
    "release": "node scripts/release.js",   // 发包
    // 略...
  },
  // 略...
}

我们逐步来完善 release.js 中各方法的逻辑。

7.1 更新版本号

每个 Monorepo 包的包配置文件中都有自己的版本号,如果每次发包前都要手动去修改,会是一件繁琐且容易出错的事情。

我们可以读取项目根目录 package.json 中的 version 字段,再统一遍历和修改 packages 文件夹下所有包的包配置文件:

js 复制代码
/** scripts/release.js **/

// 略...

const currentVersion = createRequire(import.meta.url)('../package.json').version  // 当前版本号(项目根目录包配置中的 version 字段)
const getPkgRoot = (pkg) => path.resolve(__dirname, '../packages/' + pkg)  // 获取指定包的绝对路径

// packages 文件夹下所有包的名称
const packages = fs
  .readdirSync(path.resolve(__dirname, '../packages'))
  .filter(p => {
    const pkgRoot = path.resolve(__dirname, '../packages', p)
    const pkgPath = path.resolve(pkgRoot, 'package.json')
    if (!fs.statSync(pkgRoot).isDirectory() || !fs.existsSync(pkgPath)) {
      return false // 过滤空包
    }
    return true
  })

/**
 * 更新各包 package.json 中的 version
 * @param {string} version
 */
function updateVersions(version = currentVersion) {
  // 遍历和修改 packages 文件夹下所有包的包配置文件
  packages.forEach(p => updatePackage(getPkgRoot(p), version))
}

/**
 * @param {string} pkgRoot
 * @param {string} version
 */
function updatePackage(pkgRoot, version) {
  const pkgPath = path.resolve(pkgRoot, 'package.json')
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
  pkg.version = version
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
}

// 略...

💡 项目根目录 package.jsonversion 字段值为 "3.6.0-beta.17",后不赘述。

这种形式虽然无需再手动去修改各包的包配置版本号,但项目根目录包配置的版本号依旧需要手动先去修改,如果遗漏了这一步依旧会导致版本号错误的问题。

更合理的方式是交由指令参数和脚本来灵活处理「新版本号生成」和「各包版本号更新」事项:

  • 可以通过指令参数指定版本号(例如 vp run release 3.6.0-beta.18),由脚本直接修改所有包配置(含项目根目录包配置)的版本信息。
  • 发布正式版的新版本时,可以通过指令参数指定发布类型(例如 vp run release patch),由脚本增量生成新的版本号,并同步修改所有包配置版本信息。
  • 发布预发布版的新版本时,可以通过指令参数指定预发布类型(例如 vp run release --preid alpha),由脚本增量生成新的版本号,并同步修改所有包配置版本信息。
  • 当不传入任何指令参数时,脚本能通过 CLI 询问用户,让用户选择或输入版本号。

我们在脚本中完善这块需求:

js 复制代码
/** scripts/release.js **/

import semver from 'semver'
import { parseArgs } from 'node:util'
import enquirer from 'enquirer'    // enquirer 是 CommonJS 模块,无法直接写作 import { prompt } from 'enquirer'
const { prompt } = enquirer
// 略...

// 解构命令行指令参数
const { values: args, positionals } = parseArgs({
  allowPositionals: true,
  options: {
    preid: {           // 预发布类型标识符,例如 alpha、beta、rc
      type: 'string',
    },
  },
})

let currentVersion = createRequire(import.meta.url)('../package.json').version

// 预发布类型标识符,例如 alpha、beta、rc(若为正式版,值为 undefined)
const preId = args.preid || semver.prerelease(currentVersion)?.[0]  // semver.prerelease('3.6.0-beta.17') 返回 ['beta', 17];semver.prerelease('3.6.0') 返回 null

// 发布类型名称列表
const versionIncrements = [
  'patch', 'minor', 'major',
  ...(preId ? ['prepatch', 'preminor', 'premajor', 'prerelease'] : []),  // 若属于预发布,补充更多预发布相关的类型
]

// 根据发布类型生成新的语义版本
const inc = (/** @type {import('semver').ReleaseType} */ i) => semver.inc(currentVersion, i, String(preId ?? ''))

function updateVersions(version) {
  currentVersion = version
  updatePackage(path.resolve(__dirname, '..'), version)  // 同步修改项目根目录的包配置版本号
  packages.forEach(p => updatePackage(getPkgRoot(p), version))
}

// 略...

async function main() {
  const targetVersion = positionals[0]
  
  if (!targetVersion) {                 // 执行指令时若未指定版本号,询问、确认版本号
    const { release } = await prompt({
      type: 'select',
      name: 'release',
      message: 'Select release type',
      choices: versionIncrements.map(i => `${i} (${inc(i)})`).concat(['custom']),
    })
    
    if (release === 'custom') {         // 自定义版本号
      const result = await prompt({
        type: 'input',
        name: 'version',
        message: 'Input custom version',
        initial: currentVersion,
      })
      targetVersion = result.version
    } else {
      targetVersion = release.match(/\((.*)\)/)?.[1] ?? ''
    }
  }
  
  if (versionIncrements.includes(targetVersion)) {
    targetVersion = inc(targetVersion)   // 如果输入的是发布类型,调用 inc 函数生成新的语义版本
  }

  updateVersions(targetVersion)
  await buildPackages()
  await publishPackages(currentVersion)
}

main()

这里使用了 semver 作为版本语义化工具,像第 30 行的 semver.inc 可以非常便捷、规范地生成新版本:

js 复制代码
semver.inc('3.6.0', 'patch')              // 新增修订版本,返回 "3.6.1"
semver.inc('3.6.0', 'minor')              // 新增次要版本,返回 "3.7.0"
semver.inc('3.6.0-alpha.2', 'major')      // 新增大版本,返回 "4.6.0"(忽略 alpha 等预发布信息)

semver.inc('3.6.0-rc', 'prepatch')        // 新增预发布版本的修订版本,返回 "3.6.1-rc"
semver.inc('3.6.0-beta.17', 'prerelease') // 直接推进预发布版本,返回 "3.6.0-beta.18"

还使用了 enquirer 作为 CLI 询问工具,在执行 vp run release 指令时,enquirer 会按自定义的询问流程让你选择、输入版本号信息:

最后,鉴于我们修改了多个包的 package.json 内容,也需要同步修改项目根目录 pnpm-lock.yaml 文件中的对应包的元信息(例如版本号和哈希值):

js 复制代码
/** scripts/release.js **/

// 略...

async function main() {
  // 略...

  updateVersions(targetVersion)
  
  step('\nUpdating lockfile...')
  await run('vp', ['install', '--prefer-offline'])  // 同步修改项目根目录 `pnpm-lock.yaml` 文件中的对应包的元信息

  await buildPackages()
  await publishPackages(currentVersion)
}

新增的 run('vp', ['install', '--prefer-offline']) 将触发 pnpm 重装依赖(优先使用本地缓存的依赖包来安装),进而更新 pnpm-lock.yaml 文件。

7.2 生成 CHANGELOG

每次发包时,Vue 主仓库都需要生成 / 更新当前版本的变更日志,告知用户新包对应的改动点有哪些。对此,Vue 在严格规范 Git commit 内容的同时,搭配使用了 conventional-changelog 来自动生成 CHANGELOG.md 文件:

json 复制代码
/** package.json **/

{
  "name": "vue",
  "version": "3.6.0-beta.17",
  "license": "MIT",
  "author": "VaJoy Lan",
  "type": "module",
  "scripts": {
    // 略...
    "release": "node scripts/release.js",
    "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s"   // 生成变更日志
  },
  // 略...
}

当执行第 11 行的 changelog 脚本任务时,conventional-changelog 会通过读取 Git 的提交信息并进行归类,应用 Angular 的预设在项目根目录生成 CHANGELOG.md 文件。

我们可以顺便在发包流程加上「生成 CHANGELOG」环节:

js 复制代码
/** scripts/release.js **/

// 略...

async function main() {
  // 略...

  updateVersions(targetVersion)
  
  // 通过 conventional-changelog 生成 CHANGELOG 文件
  step('\nGenerating changelog...')
  await run('vp', ['run', 'changelog'])
  
  // 略...
}

7.3 执行构建任务

更新版本号后,需要触发 vp run build --withTypes 对源码进行构建,生成最新的业务侧构建产物。我们可以先利用 Node.js 的 child_process.spawn 封装一个「执行指令」的工具方法:

js 复制代码
/** scripts/utils.js **/

import { spawn } from 'node:child_process'

/**
 * @param {string} command
 * @param {ReadonlyArray<string>} args
 * @param {object} [options]
 * @returns {Promise<{ ok: boolean, code: number | null, stderr: string, stdout: string }>}
 */
export async function exec(command, args, options) {
  return new Promise((resolve, reject) => {
    // 1. 启动子进程
    const _process = spawn(command, args, { 
      stdio: [
        'ignore', // stdin 配置,默认不会读取输入(适合非交互命令,避免等候不必要的输入而卡住)
        'pipe',   // stdout 配置,默认标准输出通过管道回流到当前 Node 进程,由脚本自己读取(stdoutChunks)
        'pipe',   // stderr 配置,默认错误输出也通过管道回流,由脚本自己读取(stderrChunks)
      ],
      ...options,
      shell: process.platform === 'win32',  // 兼容处理,针对 Windows 启用 shell
    })

    // 2. 收集输出
    /**
     * @type {Buffer[]}
     */
    const stderrChunks = []
    /**
     * @type {Buffer[]}
     */
    const stdoutChunks = []

    _process.stderr?.on('data', chunk => {
      stderrChunks.push(chunk)
    })

    _process.stdout?.on('data', chunk => {
      stdoutChunks.push(chunk)
    })

    _process.on('error', error => {
      reject(error)
    })

    // 3. 判断结果并返回
    _process.on('exit', code => {
      const ok = code === 0
      const stderr = Buffer.concat(stderrChunks).toString().trim()
      const stdout = Buffer.concat(stdoutChunks).toString().trim()

      if (ok) {
        const result = { ok, code, stderr, stdout }
        resolve(result)
      } else {
        reject(
          new Error(
            `Failed to execute command: ${command} ${args.join(' ')}: ${stderr}`,
          ),
        )
      }
    })
  })
}

接着在 release.js 脚本中使用并完善 buildPackages 方法:

js 复制代码
/** scripts/release.js **/

import { exec } from './utils.js'

const { values: args, positionals } = parseArgs({
  allowPositionals: true,
  options: {
    skipBuild: {          // 可通过指令参数跳过构建环节
      type: 'boolean',
    },
    // 略...
  },
})

// 略...

// 构建,执行指令 vp run build --withTypes
async function buildPackages() {
  step('\nBuilding all packages...')
  
  if (!args.skipBuild) {
    await run('vp', ['run', 'build', '--withTypes'])  // 触发构建任务
  } else {
    console.log(`(skipped)`)
  }
}

// 用于执行「无需处理输出信息」且「需要实时查看信息」的指令
const run = async (bin, args, opts = {}) => exec(bin, args, { stdio: 'inherit', ...opts })

留意 run 方法调整了 stdio 的配置为 inherit,会让 exec 不再收集和处理输出,改为把输出信息透传到终端。此举是为了方便使用者实时看到指令执行的过程。

7.4 执行发包

在依序执行完「更新版本号」和「构建」任务后,就可以将各包构建物发布到 npm 上了,我们对发包的 publishPackages 方法进行完善:

js 复制代码
/** scripts/release.js **/

const { values: args, positionals } = parseArgs({
  allowPositionals: true,
  options: {
    tag: {
      type: 'string',       // 可通过指令参数指定发布时提交的标签信息
    },
    // 略...
  },
})

// 略...

// 遍历所有包,通过 publishPackage 方法执行发包
async function publishPackages(version) {
  step('\nPublishing packages...')

  for (const pkg of packages) {
    await publishPackage(pkg, version)
  }
}

async function publishPackage(pkgName, version) {
  const packageName = JSON.parse(fs.readFileSync(path.resolve(getPkgRoot(pkgName), 'package.json')))

  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'
  }

  try {
    await run(
      'vp',
      [
        'exec',
        'pnpm',
        'publish',
        ...(releaseTag ? ['--tag', releaseTag] : []),  // 设置发布标签
        '--access',
        'public',                                      // 设置包的访问权限为公开
      ],
      {
        cwd: getPkgRoot(pkgName),
        stdio: 'pipe',
      },
    )
    console.log(pico.green(`Successfully published ${packageName}@${version}`))
  } catch (e) {  // 出错时的处理
    if (e.message?.match(/previously published/)) {
      console.log(pico.red(`Skipping already published: ${packageName}@${version}`))  // 提示该包已发布过
    } else {
      throw e
    }
  }
}

其本质是通过执行 pnpm publish [--tag tagName] --access public 指令,将各包发布到 npm 仓库。

7.5 异常处理

当主流程出现异常时,需要及时打印错误信息并停止进程:

js 复制代码
/** scripts/release.js **/

// 略...

main().catch(err => {
  console.error(err)
  process.exit(1)
})

但这里还存在一个问题 ------ 很可能我们已经通过 updateVersions 修改了所有包的版本号,例如将它们从原本的 3.6.0-beta.17 修改成了 3.6.0-beta.18,但因为发布流程失败,后续重新执行 release 任务时会再次增量更新版本号为 3.6.0-beta.19,导致发出去的版本号是错误的。

因此在主流程出错时,还需要把各包版本号更改回旧版本:

js 复制代码
/** scripts/release.js **/

// 略...

let versionUpdated = false   // 记录版本号是否更新过
let currentVersion = createRequire(import.meta.url)('../package.json').version
const originalVersion = currentVersion

function updateVersions(version) {
  versionUpdated = true
  // 略...
}

main().catch(err => {
  if (versionUpdated) {
    // 若更新过版本号,需要将其重置
    updateVersions(originalVersion)
  }
  console.error(err)
  process.exit(1)
})

8. 小结

回顾本章的旅程,我们从最基础的 pnpm init 开始,逐步搭建起了 Vue 3.6 源码项目的工程化雏形。读者需要重点掌握如何使用 Vite Plus 与 Rolldown、区分面向「业务侧」和「源码开发者」的构建,后者的思维导图参考如下:

另外通过一系列的实践,可以提炼出 Vue 工程化设计中的几个核心哲学:

  • 模块化与 Monorepo 治理 Vue 并没有将所有代码揉在一个巨大的单体中,而是通过 Monorepo 架构拆分为 sharedreactivity 等职责单一的模块。配合 pnpm 的 workspace 协议和各包独立的 buildOptions 配置,实现了依赖的精准管理与构建的按需定制。

  • 面向多场景的精细化构建 作为一个需要兼容 Node.js、浏览器、各种下游构建工具以及多种模块规范(CJS / ESM / IIFE)的底层框架,Vue 的构建方案堪称「戴着镣铐跳舞」。 通过 Rolldown 强大的定制能力与宏常量的 AST 级替换,Vue 能够用同一套源码,精准构建出面向不同环境、不同规范的产物。另外配合 Tree-shaking、依赖裁剪、关闭动态绑定等优化手段,有效压缩了构建产物的体积、提升了运行时性能。

  • 卓越的开发者体验(DX) Vue 3.6 全面拥抱了 Vite Plus 与 Rolldown,让源码开发者具备更舒适的开发体验 ------ 通过 Vite Plus 统一了 Lint、格式化、Git 钩子与测试用例的管理,降低了源码贡献者的上手门槛。

    另外得益于 Rolldown 模块图的能力,Vue 抛弃了以往需要 esbuild 辅助的妥协方案,实现了基于 Rust 的高性能 watch 增量构建。

  • 可靠的发布闭环 Vue 的发布流程是一个高度自动化的闭环 ------ 通过 CLI 交互式询问、语义化版本(semver)自动生成,以及异常回滚机制等环节,确保了每次版本迭代的可靠性,避免了人工操作可能带来的疏漏。

相关推荐
苍何2 小时前
耗时 7 天,我们开源了腾讯 WorkBuddy 实战蓝皮书(建议收藏)
前端·后端
大家的林语冰2 小时前
😭 Electron 麻了,Deno 直接支持二进制应用,无需第三方桌面应用框架!
前端·javascript·node.js
Damai3 小时前
DOMPurify 完整介绍
前端·面试
随风一样自由3 小时前
【前端+跨域】跨域问题全面解析与解决方案指南
前端·跨域
小帅不太帅3 小时前
我随手发了几张截图,白拿了一个月 AI 顶配会员
前端·github
36岁的我开始学习打篮球3 小时前
外规内化技术架构
前端
雪隐3 小时前
用Flutter做背单词APP-03为了给单词库塞满数据,我让AI给我打了上万份工
前端·人工智能·后端
hunterandroid3 小时前
Android 安全最佳实践:从数据存储到网络通信的防护思路
前端
H Journey3 小时前
web开发学习:html、css、js
前端·css·html·js