前言
vue3 使用 pnpm 搭建 Monorepo 开发环境。所以,本文的开发环境也采用这种方式。
Monorepo 是什么?
Monorepo 是一种新的仓库管理方式。
你可以将其理解为把多个项目放在同一个代码仓库里进行管理。它区别于以往的 multirepo(即每个项目对应一个代码仓库),其主要优势在于简易的版本管理和便利的代码复用。
而且,这种管理方式便于通过 Tree-shaking(一种优化机制),来帮助我们删除项目中用不到的代码或者永远不会执行的代码,从而减少打包后的项目体积。
pnpm 是什么?
pnpm 本质上就是一个包管理工具,和 npm/yarn 没有区别,主要优势在于:
- 包安装速度极快
- 磁盘空间利用效率高
关于上述两点,感兴趣的同学,可自行搜索进行详细的了解,小编就不做过多的介绍了。下面这张图,是对 vue3 各模块的简要说明,简单了解一下,对阅读源码有一定的帮助。
Monorepo 环境搭建
全局安装 pnpm
npm install -g pnpm
初始化项目
创建一个项目文件夹(例如,mini-vue3),然后切换至目录下,并初始化。
pnpm init
配置 pnpm-workspace.yaml
pnpm-workspace.yaml 定义了 工作区 的根,它能够使您在工作区中包含或排除目录。 注意,默认情况下,包含所有子目录。
在根目录下创建 pnpm-workspace.yaml 文件,做如下配置。
packages:
- 'packages/*'
目的是,将 packages 目录下所有的目录都作为单独的包进行管理。
安装依赖包
pnpm add -D -w typescript esbuild minimist execa rollup rollup-plugin-typescript2 @rollup/plugin-json @rollup/plugin-node-resolve @rollup/plugin-commonjs
-w:monorepo 默认将依赖安装到具体的 package 中。使用 -w 参数,是告诉 pnpm 将依赖安装到 workspace-root,也就是项目的根目录中。
| 依赖包 | 说明 |
|---|---|
| esbuild | 开发阶段,所用的构建工具 |
| minimist | 用于解析命令行中的参数 |
| execa | 用于生产阶段开启子进程 |
| 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 模块 |
| typescript | TypeScript 是一种给 JavaScript 添加特性的语言扩展,是 JavaScript 的一个超集 |
注意,这里安装了 esbuild 和 rollup 两个构建工具,分别用于 scripts/dev.js 和scripts/build.js,这两个脚本文件。
修改根目录 package.json
初始化完成后,我们会在根目录下的得到一个 package.json 文件。这里,我们需要修改一下它的脚本命令,也就是 scripts。
{
"name": "mini-vue3",
"private": true,
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "node scripts/dev.js reactivity -f global",
"build": "node scripts/build.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.5",
"@rollup/plugin-json": "^5.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"esbuild": "^0.16.7",
"execa": "^6.1.0",
"minimist": "^1.2.7",
"rollup": "^3.7.4",
"rollup-plugin-typescript2": "^0.34.1",
"typescript": "^4.9.4"
}
}
初始化 Typescript
pnpm tsc --init
该命令会在项目的根目录生成一个 tsconfig.json 文件,下面是对它的一些配置。详细配置,可查看文档 。
{
"compilerOptions": {
"outDir": "dist", // 输出目录
"sourceMap": true,
"target": "es2016", // js 语言版本
"module": "esnext", // 指定生成的模块代码
"moduleResolution": "node", // 模块解析方式
"strict": false, // 关闭严格模式
"resolveJsonModule": true, // 解析json
"esModuleInterop": true, // 允许通过es6语法引入commonJs
"jsx": "preserve", // jsx 不转译
"lib": [
"esnext",
"dom"
],
"baseUrl": ".",
"paths": {
// 针对路径的别名配置,当通过 @vue/* 引入文件模块时,就会去 packages/*/src 中找文件
"@vue/*": [
"packages/*/src"
]
}
}
}
创建模块
根目录下,创建 packages 目录,在其下创建两个文件夹,分别为 reactivity 响应式模块 和 shared 工具库模块,而后分别对其进行初始化操作。
注意:package.json 中 buildOptions,是自定义属性
- buildOptions 中的 name 属性,用来给打包的模块指定一个全局变量名。
- buildOptions 中的 formats 属性,用来指定模块能够打包的代码格式,例如,下面四种代码格式:
esm-bundler,在构建工具中使用的代码格式。esm-browser,是浏览器的原生模块化的方式,在浏览器中可以直接使用type="module"的方式直接导入模块。cjs,commonjs 规范,在 node 中使用的代码格式。global,立即执行函数的格式,可以直接通过 script 标签来导入。
- 特定的环境,有特定的入口文件。
例如,若是我们在 webpack 中导入了 @vue/reactivity 模块,那么它就会引入module 指定的文件。
reactivity
- 切换到 reactivity 目录下,执行初始化操作。
pnpm init
- 修改 package.json
{
"name": "@vue/reactivity",
"version": "1.0.0",
"description": "",
"main": "index.js",
"module": "dist/reactivity.esm-bundler.js",
"types": "dist/reactivity.d.ts",
"unpkg": "dist/reactivity.global.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"buildOptions": {
"name": "VueReactivity",
"formats": [
"esm-bundler",
"esm-browser",
"cjs",
"global"
]
},
"keywords": [],
"author": "",
"license": "ISC"
}
- 在 reactivity 下,新建 src / index.ts,编写代码。
import { isString } from '@vue/shared';
function build(value) {
if (isString(value)) console.log('测试完成');
}
build('123');
- 在 reactivity 模块中安装 shared 模块。
pnpm add @vue/shared@workspace --filter @vue/reactivity
当一个模块依赖了另一个模块时,需要安装。这行命令的意思,就是将本地workspace内的@vue/shared模块,安装到 @vue/reactivity模块中。
shared
- 切换到 shared 目录下,执行初始化操作。
pnpm init
- 修改 package.json
{
"name": "@vue/shared",
"version": "1.0.0",
"description": "",
"main": "index.js",
"module": "dist/shared.esm-bundler.js",
"types": "dist/shared.d.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"buildOptions": {
"formats": [
"esm-bundler",
"cjs"
]
},
"keywords": [],
"author": "",
"license": "ISC"
}
- 在 shared 下,新建 src / index.ts,编写代码。
export const isString = val => typeof val === 'string'; // 字符串类型判断
到这里,环境就算搭建好了。接下来,我们需要编写打包用的脚本。
编写构建脚本
vue3 源码中有许多脚本文件,它们都放在 scripts 目录中。这里我们只介绍两个脚本: dev.js 和 build.js。
两者的主要区别是,dev.js 使用 esbuild 构建工具进行打包,而 build.js 使用rollup 构建工具进行打包。
另外,为了便于理解,本文所展示的脚本代码,同 vue3 中的脚本代码相比,做出了一些删减。
dev 脚本
执行pnpm run dev,运行根目录package.json中配置的打包命令。
node scripts/dev.js reactivity -f global
其中,reactivity 和 global 都是传入 scripts/dev.js 中的参数,目的是为说明,对那个模块打包,代码格式是什么样的。当然,你可以不设置参数,就像下面这样:
node scripts/dev.js
这样的话,就需要对整个 vue 进行打包,这需要设置默认值来实现。
代码分析
- 把需要的模块都加载进来,放在最顶部。
const path = require('path');
const esbuild = require('esbuild');
- 解析脚本命令行,确定打包模块的名称和代码格式。
const args = require('minimist')(process.argv.slice(2)); // 解析脚本命令行参数
const target = args._[0] || 'reactivity'; // 需要打包的-模块
const format = args.f || 'global'; // 打包格式
minimist 能够解析命令行中的参数,并将它们放在一个普通对象中返回。例如,node scripts/dev.js reactivity -f global 会被解析成下面这样:
{ _: [ 'reactivity' ], f: 'global' }
- 加载目标模块的
package.json
// 获取模块的 package.json 的数据
const pkg = require(path.resolve(__dirname, `../packages/${target}/package.json`));
由于,我们要打包的目标模块的信息,都在其package.json中。所以,要用 require 来加载它 ,以便在后续的代码中使用。
- 定义打包所需的参数
// 打包的代码格式
const outputFormat = format.startsWith('global') ? 'iife' : format === 'cjs' ? 'cjs' : 'esm';
// 打包完成后的文件及其位置
const outfile = path.resolve(__dirname, `../packages/${target}/dist/${target}.${format}.js`);
// 打包的入口文件
const entryFile = path.resolve(__dirname, `../packages/${target}/src/index.ts`)
// 打包文件,在项目中的路径
const relativeOutfile = path.relative(process.cwd(), outfile);
relativeOutfile 这条路径,不是展示文件在你电脑中的详细位置,而是其在项目中的位置。它可以让你通过监听,快速定位当前打包好的文件。
- 使用
esbuild进行打包
esbuild.build({
entryPoints: [entryFile], // 打包入口文件
outfile, // 打包后的文件
bundle: true, // 把所有包打包到一起
format: outputFormat, // 代码格式
globalName: pkg?.buildOptions.name, // 打包后的全局变量名
platform: format === 'cjs' ? 'node' : 'browser', // 平台
sourcemap: true, // 生成映射文件
watch: { // 监听
onRebuild(err) {
if (!error) console.log(`rebuilt: ${relativeOutfile}`);
}
}
}).then(() => {
console.log(`watching: ${relativeOutfile}`);
});
build 脚本
执行pnpm run build,运行根目录package.json中配置的打包命令。
node scripts/build.js
这里,我们不再对单个模块打包,而是打包所有模块。
代码分析
- 把需要的模块都加载进来,放在最顶部。
const fs = require('fs');
const execa = require('execa'); // 用于开启子进程,使用 rollup 打包
- 解析脚本命令行,确定打包的环境、代码格式和映射。
const args = require('minimist')(process.argv.slice(2)); // 解析命令行参数
const formats = args.formats || args.f; // 代码格式
const devOnly = args.devOnly || args.d; // 用于控制打包环境
const sourceMap = args.sourcemap || args.s; // 是否生成映射文件
- 获取所有模块
// 所有模块
const targets = fs.readdirSync('packages').filter(f => {
// 不是文件夹,就过滤掉
if (!fs.statSync(`packages/${f}`).isDirectory()) {
return false;
}
// 文件夹(模块)下的 package.json,若是其 private 为真,但 buildOptions 不存在,就过滤掉
const pkg = require(`../packages/${f}/package.json`);
if (pkg.private && !pkg.buildOptions) {
return false;
}
return true;
});
- 并行打包
将打包核心函数 build 和 所有的模块 targets(是一个数组) 传入 runParallel 函数,然后用 for of 循环遍历 targets,每次循环都创建一个 Promise,并添加到新数组 ret 中。
遍历完成后,将 ret 传入 Promise.all,并返回。
runParallel(targets, build);
// 通过 Promise.all 实现并行打包
async function runParallel(source, iteratorFn) {
const ret = [];
for (const item of source) {
const p = Promise.resolve().then(() => iteratorFn(item, source));
ret.push(p);
}
return Promise.all(ret);
}
// 打包
async function build(target) {
const env = devOnly ? 'development' : 'production';
// -c 表示使用配置文件 rollup.config.js 打包
// –environment 设置需要传递到文件中的环境变量,例如,NODE_ENV、TARGET
// 这些变量可以在 js 文件中,通过 process.env 读取,例如,process.env.TARGET
await execa(
'rollup', // 执行命令
[
'-c',
'--environment',
[
`NODE_ENV:${env}`, // 这个数组里面全是 环境变量
`TARGET:${target}`,
formats ? `FORMATS:${formats}` : ``,
sourceMap ? `SOURCE_MAP:true` : ``
].filter(Boolean).join(',') // filter(Boolean) 会排除假值
],
{ stdio: 'inherit' } // 子进程打包的信息 共享 给父进程
);
}
build.js 中的代码,主要是获取打包的相关参数,然后通过 rollup 进行打包。下面,我们要接着编写 rollup.config.mjs 中的代码。
rollup.config.mjs
根目录下,新建 rollup 配置文件 rollup.config.mjs。注意,配置文件的扩展名为 .mjs,而不是 .js。
代码分析
- 引入所有需要的模块,放到最顶部
import path from 'path';
import ts from 'rollup-plugin-typescript2';
import json from '@rollup/plugin-json';
import commonJS from '@rollup/plugin-commonjs';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
上面这些模块的作用,在上文都已介绍过,这里不再叙述。需要注意的是,fileURLToPath 和createRequire 这两个函数。
由于,本人将配置文件的扩展名改为了 .mjs (Node 13+,需要改),它导致原本在 rollup.config.js 中,可以直接使用的 require 和 __dirname,不能正常使用(rollup.js 文档中有说明原因)。
可是仍要用到它们,为此,就只能根据 vue3 源码,对其重新定义。就像下面这样。
const require = createRequire(import.meta.url);
const __dirname = fileURLToPath(new URL('.', import.meta.url));
- 获取对应模块中的相关信息
// 获取 packages 目录
const packagesDir = path.resolve(__dirname, 'packages');
// 获取要打包的模块
const packageDir = path.resolve(packagesDir, process.env.TARGET);
// 获取对应打包目录下的文件(这里用来取 package.json 文件)
const resolve = p => path.resolve(packageDir, p);
// 获取 package.json
const pkg = require(resolve(`package.json`));
// 获取在 package.json 中,自定义的属性 buildOptions
const packageOptions = pkg.buildOptions || {};
// 获取文件名
const name = packageOptions.filename || path.basename(packageDir);
- 一个关于代码格式的映射表,用于确定打包的代码格式和文件名
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 defaultFormats = ['esm-bundler', 'cjs']; // 默认代码格式
const inlineFormats = process.env.FORMATS && process.env.FORMATS.split(','); // 环境变量中的代码格式
const packageFormats = inlineFormats || packageOptions.formats || defaultFormats;
- 生成配置
// 获取 roullup 配置
const packageConfigs = packageFormats.map(format => createConfig(format, outputConfigs[format]));
// 导出配置
export default packageConfigs;
// 生成 roullup 配置
function createConfig(format, output) {
// 如果是全局模式,则需要配置全局变量名,以便在浏览器中引入时,能够通过变量名来使用
const isGlobalBuild = /global/.test(format);
if (isGlobalBuild) {
output.name = packageOptions.name;
}
output.sourcemap = true; // 生成映射文件
return {
input: resolve('src/index.ts'), // 入口
output, // 出口
plugins: [
// 插件是自上而下执行
json(),
ts({
tsconfig: path.resolve(__dirname, 'tsconfig.json'),
}),
commonJS({
sourceMap: false,
}),
nodeResolve(),
],
};
}
完整代码
每个文件各自的完整代码。
dev.js 脚本
const path = require('path');
const esbuild = require('esbuild');
// minimist 解析命令行参数
// 例如: node scripts/dev.js reactivity -f global ——> { _: [ 'reactivity' ], f: 'global' }
const args = require('minimist')(process.argv.slice(2));
const target = args._[0] || 'reactivity'; // 需要打包的-文件夹名
const format = args.f || 'global'; // 打包格式
const pkg = require(path.resolve(__dirname, `../packages/${target}/package.json`));
// iife 立即执行函数 (function () {})()
// cjs node模块 module.exports
// esm 浏览器中的esModule模块 import
const outputFormat = format.startsWith('global') ? 'iife' : format === 'cjs' ? 'cjs' : 'esm';
const outfile = path.resolve(__dirname, `../packages/${target}/dist/${target}.${format}.js`);
const entryFile = path.resolve(__dirname, `../packages/${target}/src/index.ts`)
const relativeOutfile = path.relative(process.cwd(), outfile);
esbuild.build({
entryPoints: [entryFile], // 打包入口文件
outfile, // 打包后的文件
bundle: true, // 把所有包打包到一起
format: outputFormat, // 代码格式
globalName: pkg?.buildOptions.name, // 打包后的全局变量名
platform: format === 'cjs' ? 'node' : 'browser', // 平台
sourcemap: true, // 生成映射文件
watch: { // 监听
onRebuild(err) {
if (!error) console.log(`rebuilt: ${relativeOutfile}`);
}
}
}).then(() => {
console.log(`watching: ${relativeOutfile}`);
})
build.js 脚本
const fs = require('fs');
const execa = require('execa'); // 用于开启子进程
const args = require('minimist')(process.argv.slice(2)); // 解析命令行参数
const formats = args.formats || args.f; // 代码格式
const devOnly = args.devOnly || args.d; // 用于控制打包环境
const sourceMap = args.sourcemap || args.s; // 是否生成映射文件
// 所有模块
const targets = fs.readdirSync('packages').filter(f => {
// 不是文件夹,就过滤掉
if (!fs.statSync(`packages/${f}`).isDirectory()) {
return false;
}
// 文件夹(模块)下的 package.json,若是其 private 为真,但 buildOptions 不存在,就过滤掉
const pkg = require(`../packages/${f}/package.json`);
if (pkg.private && !pkg.buildOptions) {
return false;
}
return true;
});
runParallel(targets, build);
// 通过 Promise.all 实现并行打包
async function runParallel(source, iteratorFn) {
const ret = [];
for (const item of source) {
const p = Promise.resolve().then(() => iteratorFn(item, source));
ret.push(p);
}
return Promise.all(ret);
}
// 打包
async function build(target) {
const env = devOnly ? 'development' : 'production';
// -c 表示使用配置文件 rollup.config.js 打包
// –environment 设置需要传递到文件中的环境变量,例如,NODE_ENV、TARGET
// 这些变量可以在 js 文件中,通过 process.env 读取,例如,process.env.TARGET
await execa(
'rollup', // 执行命令
[
'-c',
'--environment',
[
`NODE_ENV:${env}`, // 这个数组里面全是 环境变量
`TARGET:${target}`,
formats ? `FORMATS:${formats}` : ``,
sourceMap ? `SOURCE_MAP:true` : ``
].filter(Boolean).join(',') // filter(Boolean) 会排除假值
],
{ stdio: 'inherit' } // 子进程打包的信息 共享 给父进程
);
}
rollup.config.mjs
import path from 'path';
import ts from 'rollup-plugin-typescript2';
import json from '@rollup/plugin-json';
import commonJS from '@rollup/plugin-commonjs';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const __dirname = fileURLToPath(new URL('.', import.meta.url));
// 获取 packages 目录
const packagesDir = path.resolve(__dirname, 'packages');
// 获取要打包的模块
const packageDir = path.resolve(packagesDir, process.env.TARGET);
// 获取对应打包目录下的文件(这里用来取 package.json 文件)
const resolve = p => path.resolve(packageDir, p);
// 获取 package.json
const pkg = require(resolve(`package.json`));
// 获取在 package.json 中,自定义的属性 buildOptions
const packageOptions = pkg.buildOptions || {};
// 获取文件名
const name = packageOptions.filename || path.basename(packageDir);
// 一个关于代码格式的映射表,用于确定打包的代码格式和文件名
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`, // 立即执行函数
},
};
// 获取 formats,代码打包格式
const defaultFormats = ['esm-bundler', 'cjs']; // 默认 formats
const inlineFormats = process.env.FORMATS && process.env.FORMATS.split(','); // 环境变量中的 fromats
const packageFormats = inlineFormats || packageOptions.formats || defaultFormats;
// 循环调用 createConfig 处理 formats 中的所有代码格式
const packageConfigs = packageFormats.map(format => createConfig(format, outputConfigs[format]));
// 导出配置
export default packageConfigs;
function createConfig(format, output) {
// 如果是全局模式,则需要配置全局变量名
const isGlobalBuild = /global/.test(format);
if (isGlobalBuild) {
output.name = packageOptions.name;
}
output.sourcemap = true; // 生成映射文件
// 生成roullup 配置
return {
input: resolve('src/index.ts'), // 入口
output, // 出口
plugins: [
// 插件是自上而下执行
json(),
ts({
tsconfig: path.resolve(__dirname, 'tsconfig.json'),
}),
commonJS({
sourceMap: false,
}),
nodeResolve(),
],
};
}
测试
打包
切换到根目录下,执行打包命令:
pnpm run dev
由于是指定对 reactivity 模块打包,当运行命令后,会在其目录下出现一个 dist 文件夹,打包后的代码块就放在这里。
引入
在 dist 文件夹下,新建 index.html,引入 dist / reactivity.global.js,然后按 F12 打开控制台。
最后
搭建时最好按着上述流程走,否则可能在第一次尝试时,出现错误。