本文介绍如何在Vue3源码阅读中,使用新一代包管理工具pnpm搭建Monorepo开发环境。通过配置pnpm-workspace.yaml,实现多包管理,利用硬链接节省空间并提升安装速度。文章详细演示了项目初始化、依赖安装、TypeScript配置以及使用esbuild和rollup进行构建调试的全过程,帮助开发者高效梳理Vue3响应式、运行时及编译器模块的核心原理。
前言
在Vue3源码阅读系列中,计划从环境搭建入手,逐步梳理Vue3的响应式模块、运行时模块和编译器模块,以及状态库Pinia和路由库Vue-Router的核心原理。这或许是一个漫长的过程,希望自己能坚持完成,也祝愿读者能从中有所收获。
Pnpm 和 Monorepo
Pnpm是新一代的nodejs包管理工具。其名称中的第一个“P”代表Performance,象征着更卓越的性能表现。
它的主要优势体现在两个方面:一是采用了hard-link机制,有效避免了包的重复安装,既节省了磁盘空间,又显著提升了项目依赖的安装速度;二是对monorepo的支持非常友好,仅需一条简单配置即可实现。
Monorepo是一种新兴的仓库管理方式。以往的项目多采用单一仓库维护单一项目的模式,对于庞大复杂的项目而言,哪怕微小的修改也可能影响整体。而采用monorepo的形式,我们可以在一个仓库中管理多个包,每个包均可单独发布和使用,仿佛在一个仓库中构建了若干个小仓库。
Vue3源码正是采用monorepo方式进行管理,将众多模块拆分存放于packages目录中。

这种架构带来的最直观好处是便于管理和维护。此外,它不像Vue2那样将源码整体打包对外暴露。Vue3的这种组织形式,方便地实现了Tree-shaking,即按需引入对应模块,从而大大减少打包后项目的体积。
搭建开发环境
创建项目
首先,需要全局安装pnpm:
npm install -g pnpm
接下来,新建一个目录并进行初始化操作:
mkdir vue3-learn
cd vue3-learn
pnpm init
mkdir packages
配置 monorepo
在项目根目录下新建pnpm-workspace.yaml文件:
packages:
- 'packages/*'
该配置的含义是,将packages目录下所有的子目录都作为独立的包进行管理。
通过这样简单的配置,Monorepo开发环境便搭建完成了。
如果之前接触过lerna + yarn workspace方案,便会深有体会,使用pnpm确实更加便捷。例如,Vue3和Element Plus以前采用的方案是前者,现在均已改用后者。
安装依赖
如果你使用过Vite,一定体验过它的快速。这是因为Vite内置了esbuild作为开发阶段的构建工具,而esbuild的特点正是快。
Vue3做出了与vite一致的选择:在开发阶段使用esbuild作为构建工具,而在生产阶段则采用rollup进行打包。
我们先安装一些必要的依赖:
# 源码采用 typescript 编写
pnpm add -D -w typescript
# 构建工具,命令行参数解析工具
pnpm add -D -w esbuild rollup rollup-plugin-typescript2 @rollup/plugin-json @rollup/plugin-node-resolve @rollup/plugin-commonjs minimist execa
说明:
-D:表示作为开发依赖安装
-w:在monorepo环境中,默认认为应将依赖安装到具体的package中。使用-w参数,则是告诉pnpm将依赖安装到workspace-root,即项目的根目录。
依赖说明:
| 依赖 | 描述 |
|---|---|
| typescript | 项目使用 typescript 进行开发 |
| esbuild | 开发阶段的构建工具 |
| rollup | 生产阶段的构建工具 |
| rollup-plugin-typescript2 | rollup 编译 ts 的插件 |
| @rollup/plugin-json | rollup 默认采用 esm 方式解析模块,该插件将 json 解析为 esm 供 rollup 处理 |
| @rollup/plugin-node-resolve | rollup 默认采用 esm 方式解析模块,该插件可以解析安装在 node_modules 下的第三方模块 |
| @rollup/plugin-commonjs | 将 commonjs 模块 转化为 esm 模块 |
| minimist | 解析命令行参数 |
| execa | 生产阶段开启子进程 |
初始化Typescript
pnpm tsc --init
pnpm的使用基本与npm一致。这里的用法相当于在npm中使用npx:
npx tsc --init
其含义是,去node_modules下的.bin目录中找到tsc命令,并执行它。
执行完该命令后,会在项目根目录生成一个tsconfig.json文件,进行一些配置:
{
"compilerOptions": {
"outDir": "dist", // 输出的目录
"sourceMap": true, // 开启 sourcemap
"target": "es2016", // 转译的目标语法
"module": "esnext", // 模块格式
"moduleResolution": "node", // 模块解析方式
"strict": false, // 关闭严格模式,就能使用 any 了
"resolveJsonModule": true, // 解析 json 模块
"esModuleInterop": true, // 允许通过 es6 语法引入 commonjs 模块
"jsx": "preserve", // jsx 不转义
"lib": ["esnext", "dom"], // 支持的类库 esnext及dom
"baseUrl": ".", // 当前目录,即项目根目录作为基础目录
"paths": { // 路径别名配置
"@my-vue/*": ["packages/*/src"] // 当引入 @my-vue/时,去 packages/*/src中找
},
}
}
准备两个模块
我们先在packages目录下新建两个模块,分别是reactivity响应式模块和shared工具库模块。然后编写构建脚本进行第一次的开发调试。
shared
在packages下新建shared目录,并初始化:
pnpm init
然后修改package.json:
{
"name": "@my-vue/shared",
"version": "1.0.0",
"description": "@my-vue/shared",
"main": "dist/shared.cjs.js",
"module": "dist/shared.esm-bundler.js"
}
注意name字段的值,我们使用了一个@scope作用域,它相当于npm包的命名空间,可以使项目结构更加清晰,也能减少包的重名问题。
编写该模块的入口文件:
// src/index.ts
/**
* 判断对象
*/
export const isObject = (value) =>{
return typeof value === 'object' && value !== null
}
/**
* 判断函数
*/
export const isFunction= (value) =>{
return typeof value === 'function'
}
/**
* 判断字符串
*/
export const isString = (value) => {
return typeof value === 'string'
}
/**
* 判断数字
*/
export const isNumber =(value)=>{
return typeof value === 'number'
}
/**
* 判断数组
*/
export const isArray = Array.isArray
reactivity
在packages下新建reactivity目录,并初始化:
pnpm init
然后修改package.json:
{
"name": "@my-vue/reactivity",
"version": "1.0.0",
"description": "@my-vue/reactivity",
"main": "dist/reactivity.cjs.js",
"module": "dist/reactivity.esm-bundler.js",
"buildOptions": {
"name": "VueReactivity"
}
}
在浏览器中以IIFE格式使用响应式模块时,需要给模块指定一个全局变量名字,通过buildOptions.name进行指定,将来打包时会作为配置使用。
main指定的文件支持commonjs规范进行导入,也就是说在nodejs环境中,通过require方法导入该模块时,会导入main指定的文件。
同理,module指定的是使用ES Module规范导入模块时的入口文件。
编写该模块的入口文件:
// src/index.ts
import { isObject } from '@my-vue/shared'
const obj = {name: 'Vue3'}
console.log(isObject(obj))
在reactivity包中用到了另一个包shared,需要安装才能使用:
pnpm add @my-vue/shared@workspace --filter @my-vue/reactivity
其含义是,将本地workspace内的@my-vue/shared包,安装到@my-vue/reactivity包中去。
此时,查看reactivity包的依赖信息:
"dependencies": {
"@my-vue/shared": "workspace:^1.0.0"
}
编写构建脚本
在根目录下新建scripts目录,存放项目构建的脚本。
新建dev.js,作为开发阶段的构建脚本。
// scripts/dev.js
// 使用 minimist 解析命令行参数
const args = require('minimist')(process.argv.slice(2))
const path = require('path')
// 使用 esbuild 作为构建工具
const { build } = require('esbuild')
// 需要打包的模块。默认打包 reactivity 模块
const target = args._[0] || 'reactivity'
// 打包的格式。默认为 global,即打包成 IIFE 格式,在浏览器中使用
const format = args.f || 'global'
// 打包的入口文件。每个模块的 src/index.ts 作为该模块的入口文件
const entry = path.resolve(__dirname, `../packages/${target}/src/index.ts`)
// 打包文件的输出格式
const outputFormat = format.startsWith('global') ? 'iife' : format === 'cjs' ? 'cjs' : 'esm'
// 文件输出路径。输出到模块目录下的 dist 目录下,并以各自的模块规范为后缀名作为区分
const outfile = path.resolve(__dirname, `../packages/${target}/dist/${target}.${format}.js`)
// 读取模块的 package.json,它包含了一些打包时需要用到的配置信息
const pkg = require(path.resolve(__dirname, `../packages/${target}/package.json`))
// buildOptions.name 是模块打包为 IIFE 格式时的全局变量名字
const pgkGlobalName = pkg?.buildOptions?.name
console.log('模块信息:\n', entry, '\n', format, '\n', outputFormat, '\n', outfile)
// 使用 esbuild 打包
build({
// 打包入口文件,是一个数组或者对象
entryPoints: [entry],
// 输入文件路径
outfile,
// 将依赖的文件递归的打包到一个文件中,默认不会进行打包
bundle: true,
// 开启 sourceMap
sourcemap: true,
// 打包文件的输出格式,值有三种:iife、cjs 和 esm
format: outputFormat,
// 如果输出格式为 IIFE,需要为其指定一个全局变量名字
globalName: pgkGlobalName,
// 默认情况下,esbuild 构建会生成用于浏览器的代码。如果打包的文件是在 node 环境运行,需要将平台设置为node
platform: format === 'cjs' ? 'node' : 'browser',
// 监听文件变化,进行重新构建
watch: {
onRebuild (error, result) {
if (error) {
console.error('build 失败:', error)
} else {
console.log('build 成功:', result)
}
}
}
}).then(() => {
console.log('watching ...')
})
使用该脚本,会使用esbuild对packages下的包进行构建,打包的结果放到各个包的dist目录下。
在开发阶段,我们默认打包成IIFE格式,方便在浏览器中使用html文件进行测试。在生产阶段,会分别打包成CommonJS、ES Module和IIFE的格式。
完成第一次调试
给项目增加一条scripts命令:
// package.json
"scripts": {
"dev": "node scripts/dev.js reactivity -f global"
}
其含义是,以IIFE的格式,打包reactivity模块,打包后的文件可以运行在浏览器中。
在终端中执行:
pnpm dev
输出:
PS D:\vue3-learn> pnpm dev > vue3-learn@1.0.0 dev D:\vue3-learn > node scripts/dev.js reactivity -f global 模块信息: D:\vue3-learn\packages\reactivity\src\index.ts global iife D:\demo3\vue3-learn\packages\reactivity\dist\reactivity.global.js watching ...
编写一个html文件进行测试:
// packages/reactivity/test/index.html
<body>
<div id="app"></div>
<script src="../dist/reactivity.global.js"></script>
</body>
打开浏览器控制台:

小结
到此,一个基本的monorepo开发环境就搭建完毕了。
代码已上传至Github,点击访问。
更多关于Vue3 pnpm搭建monorepo的资料请关注本站其它相关文章!