跪拜 Guibai
← Back to the summary

Vue 3.6 Swaps Rollup for Rolldown and Adopts Vite Plus in a Full Toolchain Overhaul

Comprehensive Overhaul ⚡️ The Foundational Implementation of a Vue 3.6 Project

Author: VaJoy Tags: Vue.js, Frontend

Vue 3.6 is still in the beta stage, but starting from beta 5, it has completely abandoned the previous Rollup + esbuild build solution and embraced Rolldown. Recently, it has also fully integrated Vite Plus to enhance the development experience for source code developers.

This article, based on v3.6.0-beta.17, will start from scratch to build a prototype of the Vue 3.6 source code project, discuss the technical choices in its engineering, and ultimately implement a basic front-end engineering solution.

cover.jpg

💡 Source Code for This Chapter:

1. Initialize Project

We create a folder named vue as the source code project, execute pnpm init to initialize it, and generate the package.json file.

The Vue source code project strictly requires using pnpm as the package manager. We can add a preinstall hook command in the script field of package.json — when a source code developer installs project dependencies, the hook will be automatically triggered first and can be used to audit whether the developer's package manager is compliant:

{
  "name": "vue",
  "version": "3.6.0",
  "type": "module",
  "packageManager": "[email protected]",
  "scripts": {
    "preinstall": "npx only-allow pnpm"    // Ensure the project can only use pnpm as the package manager
  }
}

Among them, only-allow is a detection tool officially provided by pnpm, used to detect whether the specified package manager is currently being used. If the match fails, it will report an error and forcefully exit the program.

💡 Vue is based on the Monorepo architecture to maintain various modules, and pnpm has good support for Monorepo, which is the key reason Vue chose pnpm. We will learn about this later in the article.

2. Monorepo

In daily business projects, we can download and use Vue through methods like npm install vue etc.. However, in addition to this npm package covering full functionality, Vue also provides multiple independent functional module packages:

For example, you can independently download Vue's reactivity module package @vue/reactivity in a business project:

npm install @vue/reactivity

And use it in the project:

import { reactive } from '@vue/reactivity'

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

This action will only download @vue/reactivity (and its dependency modules), not all of Vue's module packages.

However, Vue does not create an independent Git repository for each package. Instead, they are uniformly placed under the packages folder of the Vue source code project for maintenance:

vue
├── packages
│   ├── shared
│   ├── reactivity
│   ├── compiler-core
│   ├── compiler-dom
│   ├── runtime-core
│   ├── ...

This architectural form of "single repository with multiple packages" is called Monorepo.

2.1 Create the shared Module Package

Based on the Monorepo architecture, we first create the packages/shared folder to maintain and publish the shared utility module package shared — used to store reusable utility functions.

The basic structure of the packages/shared folder is as follows:

vue
├── packages
│   ├── shared
│   │   ├── src           // Stores the actual source code
│   │   │   ├── makeMap.ts
│   │   │   ├── general.ts
│   │   │   ├── ...
│   │   │   └── index.ts
│   │   ├── dist          // Stores the built artifacts
│   │   ├── package.json  // npm package configuration
│   │   └── index.ts      // Package entry

src Folder

The shared/src folder is used to store the source code files for shared — for simplicity, we will only create two functional sub-files, makeMap.ts and general.ts, for now:

/** packages/shared/src/makeMap.ts **/

/**
 * Preprocesses a comma-separated string (e.g., "a,b,c") into a "membership check function",
 * used to frequently determine at runtime whether a key belongs to a fixed set.
 * 
 * Example:
 * 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
}
/** packages/shared/src/general.ts **/

import { makeMap } from './makeMap'

/** Empty function */
export const NOOP = (): void => {}

/** Generates a method to determine if a property name is a reserved property */
export const isReservedProp: (key: string) => boolean = /*@__PURE__*/ makeMap(
  ',key,ref,ref_for,ref_key,' +
    'onVnodeBeforeMount,onVnodeMounted,' +
    'onVnodeBeforeUpdate,onVnodeUpdated,' +
    'onVnodeBeforeUnmount,onVnodeUnmounted',
)

Next, create the src entry file shared/src/index.ts, exporting the interfaces of all functional sub-files:

/** packages/shared/src/index.ts **/

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

Subsequently, simply referencing this file allows you to indirectly import all functional interfaces of the shared module in a "one-to-many" manner.

dist Folder

The shared/dist folder will be used to store the artifacts after building the shared/src source code.

We will introduce the technical selection and implementation of the build tool later, but we need to discuss a key point first — what specific build artifacts does a Vue Monorepo package need?

First, the build artifacts are intended for business developers who install and use the Vue framework, so they need to be differentiated for the "development environment" and "production environment" in business application scenarios:

Taking "defining an empty object constant" as an example, its development environment needs to freeze it via Object.freeze to easily expose issues of constant tampering, while the production environment can just use an empty object {}:

/** Source Code **/
const EMPTY_OBJ: { readonly [key: string]: any } = !!(process.env.NODE_ENV !== "production")
  ? Object.freeze({})
  : {}

/** Development Environment **/
const EMPTY_OBJ = Object.freeze({})

/** Production Environment **/
const EMPTY_OBJ = {}

Secondly, business developers may use the Vue framework on different platforms and need to be compatible with various mainstream module specifications:

In summary, a Monorepo module needs to build the following formats of output into its dist folder (distinguished by the file name suffix):

Note that [pkg].esm-bundler.js does not need to be split into "development environment" and "production environment" files. This is thanks to the static import feature of ES Module; downstream business-side build tools (such as vite or webpack) can precisely remove unreachable environment logic through Tree-shaking at compile time:

1.png

Finally, it is also necessary to build a type declaration file shared/dist/shared.d.ts for the shared module, used to provide type checking and intelligent prompts in TypeScript projects.

Package Entry File

We create shared/index.ts as the traditional entry file for the shared package (it will be declared later in shared/package.json as the traditional entry based on the CommonJS specification, which is a standard implementation form for npm package distribution).

It will introduce the corresponding CommonJS build artifact based on the current runtime environment:

/** 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')
}

Note that in this type of CommonJS entry file, strict mode ('use strict') is enabled for them. This is to ensure the code can promptly expose some potential issues (such as defining global variables that pollute the global scope).

💡 Strict mode is enabled by default in ES Modules, so there is no need to mark 'use strict' in ES Module files.

Package Configuration File

We create shared/package.json as the npm package configuration file for the shared package. Its responsibility is not only to provide an "npm publish description" but also to contain engineering-related configuration information:

/** packages/shared/package.json **/

{
  "name": "@vue/shared",                  // Package name, namespaced as @vue
  "version": "3.6.0",
  "main": "index.js",                     // Specifies the traditional entry under the CommonJS specification, mainly for traditional Node.js (CommonJS) toolchains
  "module": "dist/shared.esm-bundler.js", // Specifies the entry under the ES Module specification
  "types": "dist/shared.d.ts",            // Specifies the type declaration file
  "files": [                              // Specifies which files will be included in the release package when publishing to npm (avoids publishing irrelevant content like src, test code, build scripts to npm)
    "index.js",
    "dist"
  ],
  "exports": {                            // A more comprehensive new specification entry configuration field
    ".": {
      "types": "./dist/shared.d.ts",                 // Specifies the type declaration file
      "node": {                                      // Traditional Node.js (CommonJS), entries for development and production environments
        "production": "./dist/shared.cjs.prod.js",
        "development": "./dist/shared.cjs.js",
        "default": "./index.js"
      },
      "module": "./dist/shared.esm-bundler.js",      // Entry under the ES Module specification (for recognition by some build tools)
      "import": "./dist/shared.esm-bundler.js",      // Entry under the ES Module specification (for recognition on the Node.js side)
      "require": "./index.js"                        // Default entry for traditional Node.js (CommonJS) (fallback)
    },
    "./*": "./*"                                     // Specifies accessible subpath mappings
  },
  "sideEffects": false          // Declares that the package has no side effects during module initialization. When a module's exports are not used, the entire module can be removed (Tree-shaking)
  }
}

Among them, the main, module, and types fields are conventional entries retained for compatibility with the old ecosystem, while exports is a more comprehensive new specification field (which can specify more fine-grained entry mappings). Modern Node.js and build tools will prioritize the entry specified by the exports field.

Build Information Field buildOptions

The configuration that needs to be built and the format of build artifacts may be inconsistent for each package in Vue. We can extract this information and place it into the package.json of each package for independent maintenance.

Taking the shared package as an example, we add a custom, extensible buildOptions field inside shared/package.json to store the build information for the shared package:

/** packages/shared/package.json **/

{
  "name": "@vue/shared",
  // ...
  "buildOptions": {       // Build information field
    "formats": [
      "esm-bundler",
      "cjs"
    ]
  }
}

Here, formats specifies that the shared package only needs to build two formats: esm-bundler and cjs — the shared package is positioned as an "internal utility" and does not need a distribution form "directly consumable by browsers," so its required build formats are the most streamlined.

Subsequently, when the build tool builds each package, it only needs to read the buildOptions field in [pkg]/package.json to obtain the build information for that package.

This pattern of "package configuration goes with the package" also fits well with the management philosophy of Monorepo.

2.2 Connections Between Monorepo Modules

Create the reactivity Module Package

To better understand the connections between independent packages under Monorepo, we follow the structure of the shared package and create a reactivity module package named reactivity under packages:

vue
├── packages
│   ├── reactivity        // New reactivity module folder
│   │   ├── src           // Stores the actual source code
│   │   │   ├── reactive.ts
│   │   │   └── index.ts
│   │   ├── package.json  // npm package configuration
│   │   └── index.ts      // Package entry

The content of each file under reactivity is basically the same as that of shared.

Currently, we only intend to build a project prototype, so the content of the reactivity module will be simplified as much as possible. The code in src/reactive.ts is only used to simulate the import and export of the shared module interface:

/** packages/reactivity/src/reactive.ts **/

export * from '@vue/shared' // Import the shared module via the package name defined in packages/shared/package.json

At this point, an error will appear in the IDE, indicating that TypeScript cannot find @vue/shared:

2.png

To address this, we can add a tsconfig.json file in the vue root directory to configure TypeScript compilation options:

/** tsconfig.json **/

{
  "compilerOptions": {
    "rootDir": ".",
    "paths": {                         // IDE type checking paths
      "@vue/*": ["./packages/*/src"],  // Map @vue/* to packages/*/src
      "*": ["./*"]
    }
  },
  "include": [
    "packages/*/src",
  ]
}

Now, the red error underline in src/reactive.ts disappears, and the IDE's TypeScript type checking feature can successfully recognize the @vue/shared module.

Additionally, since reactivity is a core functional module package of Vue for business developers, it needs to support build formats for multiple usage scenarios. We can supplement this requirement in the buildOptions field of reactivity/package.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 variable name in the global build artifact
    "formats": [
      "esm-bundler",
      "esm-browser",          // New ES Module build format for browsers
      "cjs",
      "global"                // New global script build format
    ]
  }
}

pnpm's workspace Protocol

@vue/reactivity will eventually be published independently to npm. We need to declare its dependency on the @vue/shared npm package:

/** packages/reactivity/package.json **/

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

  "dependencies": {    // Supplement dependency information for the shared module
    "@vue/shared": "3.6.0"
  }
}

There is a problem with the @vue/shared version number 3.6.0 filled in here — the content of this remote version cannot synchronize with the content in our local packages/shared folder, and the dependency package version number needs to be manually updated before each npm package release. A single omission will lead to errors.

pnpm officially provides a solution, which is to use the workspace protocol to replace the dependency package version number. It indicates that the dependency package must be resolved from the workspace packages declared in the current Monorepo, rather than being downloaded from the remote npm registry:

/** packages/reactivity/package.json **/

  "dependencies": {      
    "@vue/shared": "workspace:*"    // Replace with the workspace protocol
  }

We also need to create a pnpm-workspace.yaml file in the vue root directory to tell pnpm "which folders can serve as independent workspaces":

/** pnpm-workspace.yaml **/

packages:
  - "packages/*"    // Treat every first-level subdirectory within the packages folder as an independent workspace package

When pnpm executes, it scans the directories configured in pnpm-workspace.yaml, registering subdirectories containing package.json as workspace members. Subsequently, when resolving workspace:*, it will search and match among these workspace members.

After this configuration, when publishing the @vue/reactivity package via the pnpm publish command, pnpm will automatically fill in the version number of the @vue/shared dependency package in the manifest to be submitted to the npm registry with the version number from the package.json of the corresponding local workspace member (i.e., packages/shared/package.json).

💡 What's mentioned here is the solution for the "dependency package version number," but each package's package.json also has a version field describing its own version number. We will create a script later to uniformly modify the version numbers of all packages.

3. Application of Vite Plus

Starting from Vue 3.6 Beta 8, the Vue source code project has fully adopted Vite Plus as the peripheral infrastructure solution (excluding the build process).

💡 For an introduction to Vite Plus, please refer to the article "A First Look at the New Frontend Toolchain Benchmark Vite Plus".

3.1 Project Infrastructure

Install Basic Dependencies

After installing Vite Plus, we execute the following Vite Plus command in the project root directory:

vp add -D vite-plus typescript @types/node

to install the basic project dependency modules and write them into the devDependencies of the root package.json:

💡 Vite Plus will automatically detect the packageManager information in package.json, pnpm-workspace.yaml, and other package manager-related files to learn what package manager the current project uses. When installing dependencies via the vp add command, it will use that package manager for processing.

Lock Node.js Version

To unify the Node.js version (24.15.0) used by source code developers, we execute the following Vite Plus command in the project root directory:

vp env pin 24.15.0

Vite Plus will automatically generate a .node-version file in the project root directory, recording the specified Node.js version number (here, 24.15.0).

Subsequently, whenever any source code developer executes a vp command, Vite Plus will first check if the Node.js version used by the current environment matches the version number recorded in the .node-version file. If it does not match, it will automatically download and switch to the specified Node.js version.

Code Checking and Formatting

Vite Plus has built-in capabilities for code checking (Lint) and formatting (Format), which can be executed using the vp command:

vp lint   // Code syntax check
vp fmt    // Code formatting

We create the Vite Plus configuration file vite.config.ts in the project root directory to customize these two functions:

/** `vite.config.ts` **/

import { defineConfig } from "vite-plus";

export default defineConfig({
  /** Configure Lint rules **/
  lint: {
    categories: {
      correctness: 'off',            // Turn off rules in the correctness category
    },
    env: {
      builtin: true,                 // Allow the use of global objects provided by the built-in runtime environment (e.g., console, Promise, etc.)
    },
    ignorePatterns: [ '**/dist/' ],  // Files or directories to ignore
    overrides: [                     // Override default rules (https://eslint.org/docs/latest/rules/)
      {
        files: ['**/*.js', '**/*.ts', '**/*.tsx'],
        rules: {
          'no-debugger': 'error',          // Forbid the use of debugger statements in production code, report an error if found
          // ...
        }
      },
      {
        files: ['packages/shared/**'],
        rules: {
          'no-restricted-globals': 'off',  // Allow the use of global objects like window, document
        },
      },
      // ...
    ],
  },

  /** Configure formatting rules **/
  fmt: {
    semi: false,                 // No semicolons at the end
    singleQuote: true,           // Use single quotes for strings
    arrowParens: 'avoid',        // When an arrow function has only one parameter, do not use parentheses
    printWidth: 80,              // Format according to a maximum of 80 characters per line, wrap when exceeding
    experimentalSortPackageJson: false,                      // Do not reorder keys in package.json
    ignorePatterns: [ 'dist', 'CHANGELOG*.md', '*.toml' ],   // Files or directories to ignore
  },
});

Note that on line 9, the very core correctness category rules of the Lint function are turned off. This is because the installed TypeScript compiler (tsc) in the project has more powerful syntax checking and cross-file type inference capabilities for these core rules, so this overlapping part in Lint needs to be disabled, and tsc should be used instead to implement the core rule checks:

tsc --incremental --noEmit  // --incremental: Enable incremental compilation. --noEmit: Only output error messages, do not modify code

We also improve the tsconfig.json configuration in the root directory:

/** tsconfig.json **/

{
  "compilerOptions": {
    "sourceMap": false,                   // Do not generate source map files during compilation
    "target": "es2016",                   // Specify the JavaScript version after compilation
    "newLine": "LF",                      // Force LF (\n) as the line ending symbol for compiled files, ensuring cross-platform consistency
    "useDefineForClassFields": false,     // Handle class fields the old way (assign in the constructor), instead of using Object.defineProperty
    "module": "esnext",                   // Use ESNext module syntax (import/export), preserved for future standards
    "moduleResolution": "bundler",        // Use a bundler's strategy for module resolution to support exports conditional exports, path mapping, etc.
    "allowJs": false,                     // Forbid compiling JavaScript files
    "strict": true,                       // Enable all strict type-checking options
    "noUnusedLocals": true,               // Check for unused local variables
    "experimentalDecorators": true,       // Enable experimental decorator syntax
    "resolveJsonModule": true,            // Allow importing .json files as modules
    "isolatedModules": true,              // Force explicit type imports/exports, ensuring each file can be independently transpiled by build tools (e.g., Rolldown)
    "skipLibCheck": true,                 // Skip type checking for all declaration files (.d.ts)
    "esModuleInterop": true,              // Provide ES Module compatibility for CommonJS modules
    "removeComments": false,              // Keep comments in the source code
    "jsx": "preserve",                    // Preserve JSX syntax unchanged, to be further processed by downstream business-side tools (like Vite, Babel)
    "lib": ["es2016", "dom"],             // Host environment type libraries, including ES2016 standard library (Promise, Array, etc.) and DOM API (document, window) types
    "types": ["node"],                    // Only load the @types/node type declaration package
    "isolatedDeclarations": true,         // Exported variables/functions in each file must have explicit type declarations (cannot rely on context inference)
    "rootDir": ".",
    "paths": {
      "@vue/*": ["./packages/*/src"],
      "*": ["./*"]
    },
    "composite": true                     // Mark the current project as a composite project, allowing it to be referenced by the references field in other projects' tsconfig.json
  },
  "include": ["packages/*/src"]
}

💡 Details on each configuration in tsconfig.json can be found in the official documentation.

Finally, we write the relevant commands for code checking and formatting into the scripts of the project root's package.json file for unified maintenance and use:

/** package.json **/

{
  "name": "vue",
  // ...
    
  "scripts": {
    "preinstall": "npx only-allow pnpm",
    "check": "tsc --incremental --noEmit",    // Use tsc to check core syntax rules
    "lint": "vp lint",                        // Execute Vite Plus Lint syntax check
    "format": "vp fmt",                       // Execute Vite Plus formatting (directly modify code)
    "format-check": "vp fmt --check"          // Execute Vite Plus formatting (only check and throw error prompts, do not modify code)
  }
}

Staging Area File Check

As the Vue source code project grows larger, it would be very inefficient if Lint and formatting covered the entire project every time. Vite Plus provides a command vp stage, similar to lint-staged, to handle this issue — when executing vp stage, code checking and formatting operations are only performed on files in the Git staging area.

To automate the "staging area file check" operation, we execute vp config to initialize Git hooks — Vite Plus will create a .vite-hooks folder and automatically trigger hook scripts in this folder during the Git operation process:

.vite-hooks
  └── pre-commit     // Corresponds to the Git pre-commit hook

We manually modify the content of the .vite-hooks/pre-commit file automatically generated by Vite Plus to:

vp staged && vp run check

This way, before each git commit, Lint and formatting will be performed on the files in the staging area, and an incremental tsc syntax check will be executed on the project code.

💡 vp run check is equivalent to pnpm run check.

We also let vp config be triggered in the npm prepare hook to ensure that source code developers will initialize the Git hook functionality through this command:

/** package.json **/

{
  "name": "vue",
  // ...
    
  "scripts": {
    "preinstall": "npx only-allow pnpm",
    "prepare": "vp config",    // New npm prepare hook command
    // ...
  }
}

Finally, we can customize the rules for vp staged in vite.config.ts:

/** vite.config.ts **/

export default defineConfig({
  /** Configure Staged rules **/
  staged: {
    '*.{js,json}': ['vp fmt --no-error-on-unmatched-pattern'],                // Match staged .js and .json files and execute formatting; do not exit with an error if the match fails
    '*.ts?(x)': ['vp lint --fix', 'vp fmt --no-error-on-unmatched-pattern'],  // Match staged .ts and .tsx files and execute Lint; do not exit with an error if the match fails
  },
  // ...
}

Git Commit Message Check

In frontend projects that previously applied Husky and lint-staged, commitlint is usually installed to check if the message during git commit conforms to the specification.

However, for the Vue source code project, its repository's commit specification is very simple (refer to the specification document verify-commit.js). It only requires a regex match on the commit content, rather than relying on a heavy tool package like commitlint.

We create a scripts folder in the project root directory to store various engineering-related scripts, then create a verify-commit.js file in this folder to verify the compliance of Git commit messages:

/** 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 check fails
  console.error(              // Output 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)             // Exit the process
}

This script file mainly does two things:

We then add a commit-msg hook script file under the .vite-hooks in the root directory:

node scripts/verify-commit.js

This way, every time git commit is executed, verify-commit.js will automatically run to perform a compliance check on the current message.

💡 Adding new npm packages requires using vp add pkgname -D to install and synchronously write to the dependencies of package.json; when adding new folders or files, it's necessary to review whether they need to be supplemented in the include field of tsconfig.json or the lint configuration of vite.config.ts. These basic operations will not be elaborated on later.

3.2 Test Cases

A large framework project usually needs to configure test cases to ensure the functional correctness of each module package, discover defects promptly, and prevent problem regression.

In the Vue source code project, Vitest is used as the test case tool, and a __tests__ folder is uniformly created under the directory of each Monorepo package to store the test cases for that package.

Below is an example of a test case for the shared package:

/** 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)
})

Note that this code uses the global variables test and expect. We need to do two things to support this shorthand:

At this point, executing Vite Plus's built-in command vp test will call Vitest to execute all test case scripts whose filenames contain .spec.:

3.gif

4. Source Code Build

We enter the key part of Vue source code engineering — implementing a reliable build solution to build the source code scattered under packages/*/src into various formats of artifacts into packages/*/dist.

It should be noted that Vue needs to be built as a "universal frontend framework," not as a "Web application." Therefore, the readability of the built artifact code needs to be ensured as much as possible. Tools like Webpack wrap a large amount of runtime code like __webpack_require__ around each module; such build tools oriented towards "Web applications" are not suitable for building the Vue framework.

Although we can execute the package build of a Monorepo project through Vite Plus's vp pack command, Vue is a large project with multiple packages, multiple formats, and multiple macro branches, making its build requirements relatively complex. Therefore, it is necessary to independently develop build scripts to achieve a more customized and controllable build implementation.

Starting from Vue 3.6 Beta 5, the Vue source code project unified all build tools to Rolldown. It is a high-performance modern bundler written in Rust. In fact, Vite Plus's build capability is also implemented through Rolldown paired with Vite.

4.1 Build Command and Parameter Acquisition

We create the build task script build.js in the scripts folder of the project root directory and add the build command in package.json:

/** package.json **/

{
  "name": "vue",
  // ...
    
  "scripts": {
    "build": "node scripts/build.js",    // New build task command
    // ...
  }
}

It is expected that executing the vp run build command later will execute the build process of the Vue source code project.

Additionally, we hope to flexibly control information such as the modules to be built and the build formats through passed parameters:

And the passed package names can support fuzzy matching:

In summary, the expected format of the build command is:

vp run build [fuzzyTarget1 fuzzyTarget2 ...] [--formats xxx] [--devOnly] [--prodOnly] [--sourceMap] [--all]
vp run build [fuzzyTarget1 fuzzyTarget2 ...] [-f xxx] [-d] [-p] [-s] [-a]    // Shorthand form

Therefore, the first thing scripts/build.js needs to implement is to obtain and destructure the passed command parameters:

/** scripts/build.js **/
// @ts-check

import { parseArgs } from 'node:util'

const { values, positionals: targets } = parseArgs({
  allowPositionals: true,    // Allow command input of ordinary parameters without - or -- prefix
  options: {
    formats: {               // Formats to build. Multiple formats can be connected by +, if starting with a tilde ~ it means negation
      type: 'string',
      short: 'f',
    },
    devOnly: {               // Only build for the development environment
      type: 'boolean',
      short: 'd',
    },
    prodOnly: {              // Only build for the production environment
      type: 'boolean',
      short: 'p',
    },
    sourceMap: {             // Additionally generate sourcemap files
      type: 'boolean',
      short: 's',
    },
    all: {                   // When fuzzy matching, whether to match multiple
      type: 'boolean',
      short: 'a',
    },
  },
})

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

/**
 * @type {string[] | undefined}
 */
let formats
let isNegation = false
if (rawFormats) {
  isNegation = rawFormats.startsWith('~')  // Whether to negate. Will be used later when creating Rolldown configuration
  formats = (isNegation ? rawFormats.slice(1) : rawFormats).split('+')  // Array of specified build formats
}

run()

function run() {
  // TODO - Execute build task
}

This code uses Node.js's built-in util.parseArgs method to obtain the passed command parameters.

Next, we will implement the logic of "executing the build task" in the run function step by step.

4.2 Create Utility Module

We first implement two utility interfaces: "all target package names" and "fuzzy match passed package names." To refine the granularity of responsibilities and improve reusability, we create a scripts/utils.js module to independently maintain these utility interfaces.

All Target Package Names

We can use Node.js's fs module interface to get all target package names under the packages folder:

/** scripts/utils.js **/

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

const require = createRequire(import.meta.url)                          // Used to import json files
const packagesPath = path.resolve(import.meta.dirname, '../packages')   // Absolute path of the packages folder

/**
 * Names of all target 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
  })

Given that the repository currently only has two packages, shared and reactivity, the value of allTargets is ["shared", "reactivity"].

Fuzzy Match Passed Package Names

We create a fuzzyMatchTarget method for fuzzy matching passed package names:

/** scripts/utils.js **/

/**
 * Fuzzy match given target package names from all target packages
 * @param {ReadonlyArray<string>} partialTargets - List of given target package names
 * @param {boolean | undefined} includeAllMatching - Whether to return all matches; if empty, only return the first matched target package
 * @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    // If includeAllMatching is not passed, only hit the first match
        }
      }
    }
  })
  if (matched.length) {
    return matched
  } else {           // No package matched, report error and exit process
    console.log()    // Print newline for aesthetics
    console.error(
      `  ${pico.white(pico.bgRed(' ERROR '))} ${pico.red(
        `Target ${pico.underline(partialTargets.toString())} not found!`,
      )}`,
    )
    console.log()
    process.exit(1)
  }
}

However, there is a problem with this code — suppose the repository has both shared and runtime-shared packages. When a source code developer passes the shared package name for fuzzy matching and only matches a single target package, it might hit runtime-shared first, not the shared the developer actually wants.

To address this, we add a piece of logic before the traversal:

/** scripts/utils.js **/

export function fuzzyMatchTarget(partialTargets, includeAllMatching) {
  const matched = []
  partialTargets.forEach(partialTarget => {
    // New logic to ensure the hit for "only return the first matched target package" is more precise
    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 {
    // ...
  }
}

Use in Build Script

We introduce the above two utility interfaces in scripts/build.js and encapsulate an async buildAll method to asynchronously execute the Rolldown build:

/** 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)
}

/**
 * Asynchronously build all target packages
 * @param {Array<string>} targets - List of target package names
 * @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)     // Create Rolldown configuration for the target package
    if (configs) {
      all.push(
        Promise.all(
          configs.map(c => {
            return rolldown(c).then(bundle => {   // Call Rolldown interface to execute build. API reference https://rolldown.rs/apis/bundler-api
              return bundle.write(c.output).then(() => {
                return c.output.file
              })
            })
          }),
        ).then(files => {
          const from = process.cwd()              // Absolute path of the project root directory
          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 - Create rolldown build configuration for the specified target package 
}

4.3 Create Rolldown Configuration

Currently, scripts/build.js has completed the general framework of the build function, but one piece of the puzzle remains: "Create Rolldown Configuration."

Complete Preliminary Work

Before creating the Rolldown configuration, it is necessary to read the buildOptions field in the target package's package.json to learn the build information for that package. Operations like negating the build format and removing the dist folder also need to be performed.

We complete this preliminary work in createConfigsForTarget and additionally encapsulate a createConfigsForPackage interface to specifically handle the matter of "creating Rolldown configuration":

/** scripts/build.js **/

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

/**
 * Create rolldown build configuration for the specified target package
 * @param {string} target - Name of the target package
 * @returns {import('rolldown').RolldownOptions[] | void} - Array of build configurations or void
 */
function createConfigsForTarget(target) {
  const pkgDir = path.resolve(__dirname, `../packages/${target}`)
  const pkg = JSON.parse(readFileSync(`${pkgDir}/package.json`, 'utf-8'))  // Get target package configuration

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

  // When specifying build formats, delete the existing dist folder first to avoid leaving artifacts from previous builds in other formats
  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 - Create Rolldown configuration  
}

Finally, we further implement the createConfigsForPackage method. Since the logic here is quite tedious, we create a create-rolldown-config.js script in the scripts folder to maintain it independently and reference it in scripts/build.js:

/** scripts/build.js **/

// Create an independent module to maintain the "Create Rolldown Configuration" functionality
import { createConfigsForPackage } from './create-rolldown-config.js'

function createConfigsForTarget(target) {
  // ...

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

Implement the Rolldown Configuration Prototype

According to Rolldown's official API guide, we first implement a most basic configuration creation function:

/** 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)                 // Used to directly import JSON files
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const packagesDir = path.resolve(__dirname, '../packages')     // Absolute path of the packages folder
const masterVersion = require('../package.json').version       // Version number, uniformly taken from the version in the project root package configuration

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])  // Formats to build (array)
  const packageConfigs = resolvedFormats.map(format => createConfig(format, outputConfigs[format]))      // Rolldown configuration for each package
  
  /** Create and return the Rolldown configuration object **/
  function createConfig(format, output) {
    const isBundlerESMBuild = /esm-bundler/.test(format)
    const isGlobalBuild = /global/.test(format)

    output.postBanner = banner           // Fixed banner content at the head of the build artifact
    output.sourcemap = sourceMap
    
    if (isGlobalBuild) {
      output.name = packageOptions.name  // Specify the global variable name in the global build artifact
    }
    
    return {
      input: resolve('src/index.ts'),    // Package source code entry file
      output,
      treeshake: {
        moduleSideEffects: false,        // Declare that all modules have no side effects; Tree-shaking will remove all exported but unused modules
      },
      experimental: {
        nativeMagicString: true,         // Enable Rust-based MagicString to improve SourceMap generation efficiency
      },
    }
  }
  
  return packageConfigs
}

At this point, the createConfigsForPackage method can already create a simplest Rollup configuration. We will gradually improve it on this basis.

💡 MagicString is a tool specifically for source code editing. It can efficiently modify source code based on position information provided by the AST, hence it is widely adopted by many build tools.

Alias Handling

Rolldown cannot recognize custom package names like @vue/shared during build runtime. It needs to be informed of the "mapping between the package alias and its absolute path" through resolve.alias. We create an additional aliases.js script in the scripts folder to maintain these aliases:

/** scripts/aliases.js **/

// @ts-check

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

// Get the absolute path of the source code entry for the passed package name
const resolveEntryForPkg = (/** @type {string} */ p) =>
  path.resolve( fileURLToPath(import.meta.url), `../../packages/${p}/src/index.ts`)

// Read all files and subfolders in the packages directory
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 }

Then use it in scripts/create-rolldown-config.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,  // Use aliases
      },
      // ...
    }
  }
  
  // ...
}

Use and Build Replacement of Macro Constants

Earlier in the article, we gave an example:

/** Source Code **/
const EMPTY_OBJ: { readonly [key: string]: any } = !!(process.env.NODE_ENV !== "production")
  ? Object.freeze({})
  : {}

/** Development Environment **/
const EMPTY_OBJ = Object.freeze({})

/** Production Environment **/
const EMPTY_OBJ = {}

This was used to show the state of a piece of "source code" after being built into "development environment" and "production environment" artifacts.

In fact, it is the "source code" for downstream business-side build tools, already the ESM Bundler artifact after Vue's original code has been built.

In the real source code, we don't write the environment check directly as !!(process.env.NODE_ENV !== "production") — this expression is too verbose and inconvenient to be AST-ized and replaced during build runtime.

Instead, "macro constant" placeholders are used. We add two lines of code to the packages/shared/src/general.ts source file as an example:

/** packages/shared/src/general.ts **/

// Empty object (new)
export const EMPTY_OBJ: { readonly [key: string]: any } = __DEV__
  ? Object.freeze({})
  : {}

// Empty array (new)
export const EMPTY_ARR: readonly never[] = __DEV__ ? Object.freeze([]) : []

// ...

Here, __DEV__ is a "macro constant." Usually, in build tools, macro constants are configured to specified values:

We adopt the first form to define and replace __DEV__:

/** scripts/create-rolldown-config.js **/

export function createConfigsForPackage({ ... }){
  // ...
  
  function createConfig(format, output) {
     function resolveDefine() {
      const defines = {
        __DEV__: isBundlerESMBuild
          ? `!!(process.env.NODE_ENV !== 'production')`  // Artifacts for downstream build tools need to retain the environment check expression (handled by downstream build tools)
          : String(!isProductionBuild),                  // In other scenarios, replace with boolean to ensure it can be Tree-shaken during the current build
      }
      return defines
    }
  
    return {
      input: resolve('src/index.ts'), 
      transform: {
        define: resolveDefine(),      // Replace macro constants
      },
      output,
      treeshake: {
        moduleSideEffects: false, 
      },
      experimental: {
        nativeMagicString: true,  
      },
    }
}

💡 Previously, most build tools required the value assigned to macro constants in the define configuration to strictly conform to JSON specifications, thus could not accommodate the template string syntax of `!!(process.env.NODE_ENV !== 'production')` and had to switch to the Replace Plugin form for replacement. The latest Vue project, however, uses the oxc tool at the bottom layer, which is already compatible with template string assignments. As of the time of this writing, the Vue source code project has not yet migrated the old logic in the Replace Plugin form to define.

We also supplement more macro constants and allow overriding the default define definitions through commands for future convenience:

/** 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),
      }

      // Allow manually inputting environment variables in the command line to override default define definitions, e.g.,
      // __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
    }

    // ...
  }
}

However, due to the lack of type declarations, the IDE's TypeScript compiler will report errors for these macro constants:

4.jpg

Therefore, it is necessary to add a global.d.ts file in the packages directory to make global type declarations for the macro constants:

/** 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;

💡 Any new custom macro constants added in the future also need to be supplemented in this file, which will not be elaborated on later.

Configure Aliases and Macros for Test Cases

At this point, if you execute the vp test test case command, it will report an error:

ReferenceError: __DEV__ is not defined

This is because we haven't supplemented the macro definition configuration for the tool executing the test cases (Vite Plus). We synchronously add this configuration item in the vite.config.ts file:

/** vite.config.ts **/

import { defineConfig } from 'vite-plus'

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

Similarly, we also need to synchronously supplement the alias definitions:

/** vite.config.ts **/

import { defineConfig } from 'vite-plus'
import { entries } from './scripts/aliases.js'    // Reuse the alias module

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

Now, executing the vp test command no longer reports an error.

Additional Handling for Production Environment

Given that the build artifact names for the production environment should have a .prod suffix, and browser-oriented build artifacts need to be minified to reduce reference size, we need to do additional processing for the production environment build configuration:

/** 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
    ? []           // Configurations for building only the production environment can be initialized as empty (since no development environment config needs to be generated)
    : resolvedFormats.map(format => createConfig(format, outputConfigs[format]))

  if (!devOnly) {  // "Only build production environment" and "Build both production and development environments" are equivalent to scenarios where "production environment needs to be built"
    resolvedFormats.forEach(format => {
      if (format === 'cjs') {
        packageConfigs.push(createProductionConfig(format))        // Rename
      }
      if (/^(global|esm-browser)$/.test(format)
      ) {
        packageConfigs.push(createProductionConfig(format, true))  // Rename + browser-oriented build artifacts need minification
      }
    })
  }
  
  /** Create basic configuration (equivalent to creating development environment configuration) **/
  function createConfig(format, output) {
    // ...
  }
  
  /** Create production environment configuration (modified based on the development environment configuration of createConfig) **/
  function createProductionConfig(/** @type {PackageFormat} */ format, minify = false) {
    return createConfig(format, {
      file: resolve(`dist/${name}.${format}.prod.js`),
      format: outputConfigs[format].format,
      minify,   // Code minification
    })
  }

  return packageConfigs
}

At this point, createConfig is equivalent to creating the "development environment" configuration, while createProductionConfig generates the "production environment" configuration based on createConfig.

Compatibility Handling

Rolldown provides some configuration items that can improve the compatibility of build artifacts in different scenarios:

/** scripts/create-rolldown-config.js **/

export function createConfigsForPackage({ ... }){
  // ...
  
  function createConfig(format, output) {
    // ...
    
    // Force the module's exports to be bundled as CJS properties (i.e., key-value pair objects), improving compatibility when "downstream build tools handle projects mixing ESM + CJS"
    output.exports = 'named'

    if (isCJSBuild) {
      // Add an __esModule: true property to the export object (module.exports), informing downstream build tools that "this CJS file was converted from ESM"
      output.esModule = true
    }
    
    return {
      input: resolve('src/index.ts'), 
      output,
      transform: {
        define: resolveDefine(),
        target: isCJSBuild ? 'es2019' : 'es2016',  // Browser-oriented build artifacts adopt the more compatible es2016 standard
      },
      platform: format === 'cjs' ? 'node' : isBundlerESMBuild ? 'neutral' : 'browser',
      // ...
    }
  }
  
  // ...
}

Here, platform indicates the "platform where the built code is expected to run." Rolldown will perform build adaptation and optimization for the specified platform:

💡 In the build artifact code corresponding to a platform value of browser, if interfaces of Node.js built-in modules (e.g., path) are executed, they will error out because they cannot be found. Later in the article, for special packages like compiler-sfc that "need to run in the browser but also need to use Node.js built-in modules," polyfill handling will be performed through plugins.

Reduce Build Artifact Size

We have enabled code minification for production environment builds, but there are other means to further reduce the size of build artifacts.

First, we can start with external dependencies — taking the reactivity package as an example, although it depends on the shared package, when building artifacts not targeting the browser platform (e.g., building esm-bundler or cjs formats), there is no need to inline the code of the shared package into the build artifact of the reactivity package.

The dependency pruning function can be achieved through Rolldown's external property:

/** scripts/create-rolldown-config.js **/

export function createConfigsForPackage({ ... }){
  // ...
  
  function createConfig(format, output) {
    // ...
    
    // Prune external dependencies; non-browser builds should not inline dependency module code into the build artifact
    function resolveExternal() {
      if (!isGlobalBuild && !isBrowserESMBuild) {
        // For non-browser-oriented builds, externalize all dependencies
        return [
          ...Object.keys(pkg.dependencies || {}),
          ...Object.keys(pkg.peerDependencies || {}),
        ]
      }
    }
    
    return {
      input: resolve('src/index.ts'), 
      output,
      external: resolveExternal(),
      // ...
    }
  }
  
  // ...
}

Secondly, Rolldown's build artifacts enable ESM's "dynamic binding" feature by default, using Object.defineProperty to define the properties of imported modules:

// Input
export { x } from 'external';
// Built CJS artifact
var external = require('external');

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

Setting Rolldown's externalLiveBindings configuration to false can turn off the "dynamic binding" feature, thereby reducing the build artifact size:

// Built CJS artifact (externalLiveBindings set to false)
var external = require('external');

exports.x = external.x;

We add a line of code in scripts/create-rolldown-config.js to turn off this feature:

/** scripts/create-rolldown-config.js **/

export function createConfigsForPackage({ ... }){
  // ...
  
  function createConfig(format, output) {
    // Turn off the dynamic binding feature (do not use Object.defineProperty to define properties of imported modules), reducing build artifact size
    output.externalLiveBindings = false
    
    // ...
  }
  
  // ...
}

At this point, we have completed a basic source code build solution:

5.gif

5. Type Declaration File Build

If a business-side project is developed based on TypeScript, importing the code built earlier will report errors due to the lack of type declarations. Therefore, it is necessary to specifically build .d.ts declaration files for use by the business-side TypeScript compiler.

We create a build-types.js file in the scripts folder to implement this functionality and first add the relevant command in the project root's package configuration:

/** 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",    // Build type declaration files
    // ...
  },
  // ...
}

5.1 .d.ts Built for Independent Source Modules

Although the tsc command can achieve "complete project semantic analysis + declaration generation," the Vue source code project is essentially a multi-package repository with a Monorepo architecture. It is more reasonable to independently generate type declaration files for each package and store them in the corresponding dist folder.

We can use oxc's isolatedDeclaration interface to generate .d.ts files for each independent source module under src and write them to a temp temporary folder:

/** 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 })    // First remove the temp/packages temporary folder (if it exists)
}

const files = await glob('packages/*/src/**/*.ts')   // Read all independent source modules under src of each package
for (const file of files) {
  const ts = fs.readFileSync(file, 'utf-8')
  const dts = await isolatedDeclaration(file, ts, {  // Use oxc interface to generate type declaration file
    sourcemap: false,
    stripInternal: true,
  })

  write(path.join('temp', file.replace(/\.ts$/, '.d.ts')), dts.code)  // Write to the temp folder
}

/** Write content to a specified file **/
function write(file, content) {
  const dir = path.dirname(file)
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })     // If the folder does not exist, create it directly
  fs.writeFileSync(file, content)
}

Executing the vp run build-dts command will create a temp/packages temporary folder in the project root directory and store the type declarations of each package's independent source modules in it:

temp
└── packages
    ├── reactivity
    │   └── src
    │       ├── index.d.ts
    │       └── reactive.d.ts
    └── shared
        └── src
            ├── general.d.ts
            ├── index.d.ts
            └── makeMap.d.ts

The reason for storing the output of type declaration files in a temporary folder, rather than the dist folder of each package, is that the type declaration file for each package facing the business side should be "one and only one," not multiple scattered type declaration files.

Therefore, the next thing to do is to merge the scattered .d.ts files under temp/packages for each package into a single file.

5.2 Merge Type Declarations

Similar to the previous approach of bundling multiple .ts source files under each package's src into a single build artifact under dist, we can continue to use Rolldown to bundle multiple .d.ts source files under each package's src into a single type declaration file under dist.

We create a rolldown.dts.config.js file in the project root directory to maintain the Rolldown configuration for bundling type declarations:

/** 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')
// Target packages can be obtained from environment variables, e.g., execute 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

/** Remove external dependencies **/
function resolveExternal(packageName) {
  const pkg = require(`${packagesDir}/${packageName}/package.json`)
  return [
    ...Object.keys(pkg.dependencies || {}),
    ...Object.keys(pkg.devDependencies || {}),
    ...Object.keys(pkg.peerDependencies || {}),
  ]
}

// Export configuration items (array)
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,  // Enable Rust-based MagicString to improve efficiency during the code transformation phase
      },
      external: resolveExternal(pkg),
      plugins: [ dts() ],         // Let Rolldown "understand" how to bundle TypeScript declaration files
    }
  },
)

Note that by default, Rolldown cannot "understand" how to bundle TypeScript declaration files, so the rolldown-plugin-dts plugin is needed to support this capability.

We go back to build-types.js to supplement the Rolldown bundling logic:

/** 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  // Import target package configurations

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))
      })
    }),
  ),
)

Note that both line 10 and line 12 use await to handle the Rolldown process asynchronously — this is because there is already asynchronous logic for obtaining .ts files earlier (line 7), and the subsequent logic needs to "wrap" itself as an asynchronous microtask to ensure the asynchronous logic of the prerequisite conditions executes first.

At this point, executing vp run build-dts will build two type declaration files: packages/shared/dist/shared.d.ts and packages/reactivity/dist/reactivity.d.ts. The content of the shared package's type declaration file is as follows:

/** 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[];
/** Empty function */
declare const NOOP: () => void;
/** Generates a method to determine if a property name is a reserved property */
declare const isReservedProp: (key: string) => boolean;
//#endregion
//#region temp/packages/shared/src/makeMap.d.ts
/**
* Preprocesses a comma-separated string (e.g., "a,b,c") into a "membership check function",
* used to frequently determine at runtime whether a key belongs to a fixed set.
*
* Example:
* 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 };

Finally, we add the type declaration file build as an optional task to the overall source code build process:

/** scripts/build.js **/

const { values, positionals: targets } = parseArgs({
  allowPositionals: true,
  options: {
    formats: {
      type: 'string',
      short: 'f',
    },
    withTypes: {              // New optional command parameter for asynchronously building type declaration files
      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')    // Asynchronously build type declaration files
  }
  await buildAll(resolvedTargets)
}

// ...

Now, executing vp run build -t will, on top of the previous build process, also asynchronously build type declaration files into the dist of each package.

6. Source Code Development Environment Build

The build solution we implemented earlier can build "development environment" and "production environment" artifacts for the business side. However, for "Vue source code developers," we also need to implement a "source code development environment" build solution that helps improve our own developer experience. It needs to be a "faster, sustainable, and suitable for local debugging" solution, which differs from the build in some ways:

Among these, the first three can effectively improve build speed, and the last one ensures the continuity of local development builds.

We create a dev.js file in the scripts folder to implement the source code development environment build functionality:

/** 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`)

  // Prune external dependencies; non-browser builds should not inline dependency module code into the build artifact
  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'),
      },
    },
  }

  // Call Rolldown's watch interface to monitor local changes and trigger builds in real-time
  watch(config).on('event', event => {
    if (event.code === 'BUNDLE_END') {
      console.log(`built ${config.output.file} in ${event.duration}ms`)
    }
  })
}

Its essence is a simplified version of build.js + create-rolldown-config.js, and it calls Rolldown's watch interface to monitor local source code changes, achieving hot build capability.

💡 In older versions of the Vue project, because the build tool Rollup's module graph was mainly designed for "one-time builds" and lacked incremental build capabilities for long-running processes, it was necessary to switch to esbuild when building the source code development environment. However, unlike Rollup, the new tool Rolldown treats the module graph as a long-lived first-class citizen, supporting incremental rebuilding from the architectural level, thus adapting to the high-frequency change scenarios of the development state. This is the reason Vue 3.6 can uniformly use Rolldown as the build base.

Finally, add the dev command in the project root's package configuration:

/** package.json **/

{
  "name": "vue",
  "version": "3.6.0",
  "scripts": {
    "preinstall": "npx only-allow pnpm",
    "dev": "node scripts/dev.js",         // Source code development environment build
    "build": "node scripts/build.js",
    // ...
  },
  // ...
}

The execution effect is as follows:

6.jpg

7. Release

The release process is the last link in project engineering. It needs to be responsible for functions such as "updating version numbers of each package, generating CHANGELOG, executing builds, and publishing to npm."

We create a release.js file in the scripts folder and first implement the simplest release process:

/** scripts/release.js **/

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

const step = (msg) => console.log(pico.cyan(msg))  // Dedicated utility method, uses picocolors to print the current step information
const currentVersion = createRequire(import.meta.url)('../package.json').version  // Current version number (the version field in the project root package configuration)

// Update the version in each package's package.json
function updateVersions(version) {
  // TODO
}

// Build, execute command vp run build --withTypes
async function buildPackages() {
  step('\nBuilding all packages...')
  // TODO
}

// Publish all packages to npm
async function publishPackages(version) {
  step('\nPublishing packages...')
  // TODO
}

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

main()

Also, add the release command in the project root's package configuration:

/** 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 package
    // ...
  },
  // ...
}

We will gradually improve the logic of each method in release.js.

7.1 Update Version Numbers

Each Monorepo package's package configuration file has its own version number. If you have to manually modify it before each release, it would be a tedious and error-prone task.

We can read the version field in the project root's package.json, then uniformly traverse and modify the package configuration files of all packages under the packages folder:

/** scripts/release.js **/

// ...

const currentVersion = createRequire(import.meta.url)('../package.json').version  // Current version number (the version field in the project root package configuration)
const getPkgRoot = (pkg) => path.resolve(__dirname, '../packages/' + pkg)  // Get the absolute path of the specified package

// Names of all packages under the packages folder
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 // Filter out empty packages
    }
    return true
  })

/**
 * Update the version in each package's package.json
 * @param {string} version
 */
function updateVersions(version = currentVersion) {
  // Traverse and modify the package configuration files of all packages under the packages folder
  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')
}

// ...

💡 The value of the version field in the project root package.json is "3.6.0-beta.17", which will not be elaborated on later.

Although this form eliminates the need to manually modify the package configuration version numbers of each package, the version number of the project root package configuration still needs to be manually modified first. If this step is missed, it will still lead to version number errors.

A more reasonable approach is to let command parameters and scripts flexibly handle "new version number generation" and "updating version numbers of each package":

We improve this requirement in the script:

/** scripts/release.js **/

import semver from 'semver'
import { parseArgs } from 'node:util'
import enquirer from 'enquirer'    // enquirer is a CommonJS module, cannot be directly written as import { prompt } from 'enquirer'
const { prompt } = enquirer
// ...

// Destructure command line parameters
const { values: args, positionals } = parseArgs({
  allowPositionals: true,
  options: {
    preid: {           // Pre-release type identifier, e.g., alpha, beta, rc
      type: 'string',
    },
  },
})

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

// Pre-release type identifier, e.g., alpha, beta, rc (if it's a formal release, the value is undefined)
const preId = args.preid || semver.prerelease(currentVersion)?.[0]  // semver.prerelease('3.6.0-beta.17') returns ['beta', 17]; semver.prerelease('3.6.0') returns null

// List of release type names
const versionIncrements = [
  'patch', 'minor', 'major',
  ...(preId ? ['prepatch', 'preminor', 'premajor', 'prerelease'] : []),  // If it's a pre-release, supplement more pre-release related types
]

// Generate a new semantic version based on the release type
const inc = (/** @type {import('semver').ReleaseType} */ i) => semver.inc(currentVersion, i, String(preId ?? ''))

function updateVersions(version) {
  currentVersion = version
  updatePackage(path.resolve(__dirname, '..'), version)  // Synchronously modify the version number of the project root package configuration
  packages.forEach(p => updatePackage(getPkgRoot(p), version))
}

// ...

async function main() {
  const targetVersion = positionals[0]
  
  if (!targetVersion) {                 // If no version number is specified when executing the command, ask and confirm the version number
    const { release } = await prompt({
      type: 'select',
      name: 'release',
      message: 'Select release type',
      choices: versionIncrements.map(i => `${i} (${inc(i)})`).concat(['custom']),
    })
    
    if (release === 'custom') {         // Custom version number
      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)   // If the input is a release type, call the inc function to generate a new semantic version
  }

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

main()

Here, semver is used as the version semantic tool. For example, semver.inc on line 30 can generate a new version very conveniently and standardly:

semver.inc('3.6.0', 'patch')              // Increment patch version, returns "3.6.1"
semver.inc('3.6.0', 'minor')              // Increment minor version, returns "3.7.0"
semver.inc('3.6.0-alpha.2', 'major')      // Increment major version, returns "4.6.0" (ignores pre-release info like alpha)

semver.inc('3.6.0-rc', 'prepatch')        // Increment pre-release patch version, returns "3.6.1-rc"
semver.inc('3.6.0-beta.17', 'prerelease') // Directly advance the pre-release version, returns "3.6.0-beta.18"

enquirer is also used as the CLI prompting tool. When executing the vp run release command, enquirer will let you choose and input version number information according to a custom prompting flow:

7.gif

Finally, given that we have modified the package.json content of multiple packages, we also need to synchronously modify the metadata (such as version numbers and hashes) of the corresponding packages in the project root's pnpm-lock.yaml file:

/** scripts/release.js **/

// ...

async function main() {
  // ...

  updateVersions(targetVersion)
  
  step('\nUpdating lockfile...')
  await run('vp', ['install', '--prefer-offline'])  // Synchronously modify the metadata of corresponding packages in the project root's `pnpm-lock.yaml` file

  await buildPackages()
  await publishPackages(currentVersion)
}

The newly added run('vp', ['install', '--prefer-offline']) will trigger pnpm to reinstall dependencies (prioritizing locally cached dependency packages for installation), thereby updating the pnpm-lock.yaml file.

7.2 Generate CHANGELOG

Each time a package is released, the main Vue repository needs to generate/update the changelog for the current version, informing users of the changes corresponding to the new package. For this, Vue, while strictly standardizing Git commit content, uses conventional-changelog in conjunction to automatically generate the CHANGELOG.md file:

/** 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"   // Generate changelog
  },
  // ...
}

When the changelog script task on line 11 is executed, conventional-changelog will read Git commit information, categorize it, and apply the Angular preset to generate the CHANGELOG.md file in the project root directory.

We can also add a "Generate CHANGELOG" step to the release process:

/** scripts/release.js **/

// ...

async function main() {
  // ...

  updateVersions(targetVersion)
  
  // Generate CHANGELOG file via conventional-changelog
  step('\nGenerating changelog...')
  await run('vp', ['run', 'changelog'])
  
  // ...
}

7.3 Execute Build Task

After updating the version number, it is necessary to trigger vp run build --withTypes to build the source code and generate the latest business-side build artifacts. We can first use Node.js's child_process.spawn to encapsulate a utility method for "executing commands":

/** 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. Start child process
    const _process = spawn(command, args, { 
      stdio: [
        'ignore', // stdin configuration, defaults to not reading input (suitable for non-interactive commands, avoids getting stuck waiting for unnecessary input)
        'pipe',   // stdout configuration, defaults to piping standard output back to the current Node process, read by the script itself (stdoutChunks)
        'pipe',   // stderr configuration, defaults to piping error output back, read by the script itself (stderrChunks)
      ],
      ...options,
      shell: process.platform === 'win32',  // Compatibility handling, enable shell for Windows
    })

    // 2. Collect output
    /**
     * @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. Judge result and return
    _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}`,
          ),
        )
      }
    })
  })
}

Then use it in the release.js script and improve the buildPackages method:

/** scripts/release.js **/

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

const { values: args, positionals } = parseArgs({
  allowPositionals: true,
  options: {
    skipBuild: {          // Can skip the build step via command parameter
      type: 'boolean',
    },
    // ...
  },
})

// ...

// Build, execute command vp run build --withTypes
async function buildPackages() {
  step('\nBuilding all packages...')
  
  if (!args.skipBuild) {
    await run('vp', ['run', 'build', '--withTypes'])  // Trigger build task
  } else {
    console.log(`(skipped)`)
  }
}

// Used to execute commands that "do not need to process output information" and "need to view information in real-time"
const run = async (bin, args, opts = {}) => exec(bin, args, { stdio: 'inherit', ...opts })

Note that the run method adjusts the stdio configuration to inherit, which will make exec no longer collect and process output, but instead pass the output information through to the terminal. This is to facilitate the user seeing the command execution process in real-time.

7.4 Execute Package Publishing

After sequentially executing the "Update Version Numbers" and "Build" tasks, the build artifacts of each package can be published to npm. We improve the publishPackages method for publishing:

/** scripts/release.js **/

const { values: args, positionals } = parseArgs({
  allowPositionals: true,
  options: {
    tag: {
      type: 'string',       // Can specify the tag information to be submitted during publishing via command parameter
    },
    // ...
  },
})

// ...

// Traverse all packages and execute publishing via the publishPackage method
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] : []),  // Set publish tag
        '--access',
        'public',                                      // Set the package's access permission to public
      ],
      {
        cwd: getPkgRoot(pkgName),
        stdio: 'pipe',
      },
    )
    console.log(pico.green(`Successfully published ${packageName}@${version}`))
  } catch (e) {  // Handling when an error occurs
    if (e.message?.match(/previously published/)) {
      console.log(pico.red(`Skipping already published: ${packageName}@${version}`))  // Prompt that this package has already been published
    } else {
      throw e
    }
  }
}

Its essence is to publish each package to the npm registry by executing the pnpm publish [--tag tagName] --access public command.

7.5 Exception Handling

When an exception occurs in the main process, it is necessary to print the error message promptly and stop the process:

/** scripts/release.js **/

// ...

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

But there is still a problem here — it is very likely that we have already modified the version numbers of all packages through updateVersions, for example, changing them from the original 3.6.0-beta.17 to 3.6.0-beta.18. However, because the release process failed, when re-executing the release task later, the version number will be incrementally updated again to 3.6.0-beta.19, causing the released version number to be wrong.

Therefore, when the main process fails, it is also necessary to revert the version numbers of each package back to the old version:

/** scripts/release.js **/

// ...

let versionUpdated = false   // Record whether the version number has been updated
let currentVersion = createRequire(import.meta.url)('../package.json').version
const originalVersion = currentVersion

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

main().catch(err => {
  if (versionUpdated) {
    // If the version number was updated, it needs to be reset
    updateVersions(originalVersion)
  }
  console.error(err)
  process.exit(1)
})

8. Summary

Looking back at the journey of this chapter, we started from the most basic pnpm init and gradually built up a prototype of the Vue 3.6 source code project's engineering. Readers need to focus on mastering how to use Vite Plus and Rolldown, and distinguishing between builds for the "business side" and "source code developers." A mind map for the latter is referenced below:

8.png

Additionally, through a series of practices, several core philosophies in Vue's engineering design can be extracted: