monorepo 项目搭建 
monorepo 多包项目指的是将多个项目存放在一个仓库中进行管理,并通过工具支持互相依赖。
相对于普通的一个项目一个仓库(multirepo),monorepo 最大的好处是项目间引用更便捷,关联包更新后无需经过打包、发布、安装等步骤就能够直接使用。
monorepo 有多种搭建方式,目前较为主流(Vue3 采用)的是使用 pnpm。本文不是完整的教程文,而是在 spatial-planning 项目后对 pnpm monorepo 的一些理解。
基础搭建 
首先需要安装 pnpm,推荐使用 corepack。安装后创建测试项目:
mkdir monorepo-test
cd monorepo-test
# 初始化工程化项目
pnpm init
# 使用 VSCode 打开
code .2
3
4
5
6
INFO
项目创建后只有一个 package.json 文件,monorepo 项目为了避免根目录被作为包发布,通常会添加 private 配置,同时我们将项目模块方式设置为 ESM:
{
  "type": "module", // 使用 ESM 模块
  "private": "true"
  // ...
}2
3
4
5
使用 pnpm monorepo 要求在根目录下新建 pnpm-workspace.yaml 文件:
packages:
  - "packages/*"2
上面的配置是指将根目录 packages 文件夹下的所有子文件夹都作为子项目。之后便可以创建子项目,创建后目录结构如下:
monorepo-test
├─ package.json
├─ pnpm-workspace.yaml
└─ packages
   ├─ A
   │  ├─ index.js
   │  └─ package.json
   └─ B
      ├─ index.js
      └─ package.json2
3
4
5
6
7
8
9
10
我们在 B/index.js 中导出一个变量:
export const str = "this is B";此时 A 如果想导入 B 并使用,需要先将 B 作为 A 的依赖。为了安装依赖我们先给两个子项目取一个合适的包名 @mt/a 和 @mt/b,之后便可以使用 pnpm 命令将 B 安装为 A 的依赖:
# 根目录下执行
pnpm -F @mt/a add @mt/b2
INFO
包名也就是子项目 package.json 中 name 字段的值,通常使用 @xxx/name 的形式,将包名定义在 @xxx 的作用域中,避免与 npm 其他包重名。
安装依赖后便可以在 A 中引入并执行:
// A/index.js
import { str } from "@mt/b";
console.log(str);2
3
# 根目录下执行
node packages/A
# 打印:this is B2
3
pnpm -F 命令是筛选(filter)具体的项目并在内部执行命令,除此之外 pnpm monorepo 中常用的命令还包括:
# 在根目录中执行命令,安装、卸载等命令会操作根目录的 node_modules,子项目可以直接使用根 node_modules 中的依赖
pnpm -w [command]
# 为工作区内的每一个项目执行命令
pnpm -r [command]2
3
4
5
更详细的用法可以查看官方文档。
项目构建 
我们知道 JS 世界的代码多种多样,有 js、ts、jsx、vue 文件等。而 monorepo 只是一种项目组织方式,项目间相互引用还是等同于包之间的引用。
所以如果将上面 B 项目中的代码改为 TS 文件,由于 A 是一个 JS 项目便无法正确使用,此时需要先将 B 项目打包后再使用。
对于库的打包,通常使用 rollup,为了处理 ts,还需要安装 rollup-plugin-typescript2 插件:
# 根目录执行
pnpm -F @mt/b add rollup rollup-plugin-typescript -D2
再添加 packages/B/rollup.config.js 文件:
import typescript from "rollup-plugin-typescript2";
export default {
  input: "./index.ts",
  output: {
    file: "./index.js",
    format: "esm",
  },
  plugins: [typescript()],
};2
3
4
5
6
7
8
9
10
然后在 packages/B/package.json 中添加打包命令:
{
  "scripts": {
    "build": "rollup -c" // -c 指定配置文件,默认为 rollup.config
  }
}2
3
4
5
运行命令进行打包:
pnpm -F @mt/b buildINFO
打包如果出现 Error: Cannot find module @rollup/rollup-win32-x64-msvc. 报错,尝试运行 pnpm -F @mt/b add @rollup/rollup-win32-x64-msvc 进行解决
我们将运行 A 的语句也写为命令:
// packages/A/package.json
{
  "scripts": {
    "start": "node index.js"
  }
}2
3
4
5
6
打包完成后运行 pnpm -F @mt/a start 可以发现能够正常运行了。
只是两个子项目时,手动执行打包再执行运行尚能接受。但当子项目变多,依赖关系变复杂时,手动执行就体现不出 monorepo 的优势了。为了解决这个问题我们可以借助 turbopack 工具。
构建流水线 
turbopack 基于 Rust 构建,使用高度优化的机器代码和低层级增量计算引擎,可以缓存到单个函数的级别。除了速度快 turbopack 还提供了 pipeline,可以自定义项目命令间的依赖关系,由工具自己处理构建流程。
首先需要进行安装:
pnpm -w add turbo -D之后在根目录创建 turbo.json 文件并写入如下内容作为配置文件:
{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "start": {
      "dependsOn": ["^build"]
    },
    "build": {}
  }
}2
3
4
5
6
7
8
9
$schema 用于语法提示,pipline 则是 turbo 构建流水线的关键配置。
start 是 A 项目的启动命令,内部使用 dependsOn 命令声明了启动 A 需要依赖于 build 命令。
因为只有在 B 打包完成后 start 才能正确运行,所以在 build 前添加了 ^ 符号,这表示 start 命令需要在 build 运行结束后才能够执行。
下面还声明了 build 命令为一个空对象,因为 turbopack 需要在流水线中声明每一条语句的执行逻辑,所以用空对象表示 build 不需要依赖于任何其他命令。
完成上诉步骤后,我们先删除之前构建的 packages/B/index.js 文件,然后使用 turbopack 运行 start 命令:
# 根目录
npx turbo run start2
会看到如下的输出:
➜ npx turbo run start
• Packages in scope: @mt/a, @mt/b
• Running start in 2 packages
• Remote caching disabled
@mt/b:build: cache miss, executing df96369caba9709e
@mt/b:build:
@mt/b:build: > @mt/b@1.0.0 build D:\code\monorepo-test\packages\B
@mt/b:build: > rollup -c
@mt/b:build:
@mt/b:build:
@mt/b:build: ./index.ts → ./index.js...
@mt/b:build: created ./index.js in 353ms
@mt/a:start: cache miss, executing ea9c0d0f863e92f5
@mt/a:start:
@mt/a:start: > @mt/a@1.0.0 start D:\code\monorepo-test\packages\A
@mt/a:start: > node index.js
@mt/a:start:
@mt/a:start: this is B
 Tasks:    2 successful, 2 total
Cached:    0 cached, 2 total
  Time:    1.777s2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
从输出日志能够看到先执行了 B 项目的 build 命令,并成功创建了 index.js 文件;之后再执行了 A 项目的 start 命令,最后完成了打印。可以尝试更改 B/index.ts 中的内容,重新运行命令后输出结果也会是正确的。
turbopack 提供的构建流水线解决了需要手动按顺序执行多个命令的问题,但子项目内容修改后还是需要执行命令完成打包流程后才能使用。为了优化体验我们还可以使用 unbuild 工具,它提供了**“插桩”**功能,可以动态的执行最新代码,而不需要多次打包。
自动构建 
unbuild 是一个基于 rollup 的构建工具,内部集成了 rollup 生态中众多优秀的插件,可以零配置对代码进行打包。unbuild 还提供了插桩功能,在打包后生成带有 jiti 的包,执行代码时由 jiti 动态执行最新的源码。
还是以上面的项目为例,在 B 项目中使用 unbuild 进行打包,首先安装 unbuild:
pnpm -F @mt/b add unbuild -D使用 unbuild 后 rollup 可以不再使用
unbuild 默认将 scr/index 作为入口文件,打包后输出为 dist/index.mjs。所以我们将 B/index.ts 移动到 B/src/index.ts,然后修改 package.json 中的 main 配置(main 就是包被引用时默认查找的文件),并重写打包命令:
INFO
也可以创建 build.config 配置文件,配置选项可以查看文档
// B/package.json
{
  "main": "./dist/index.mjs",
  "scripts": {
    "build": "unbuild",
    "stub": "unbuild --stub"
  }
}2
3
4
5
6
7
8
INFO
.mjs 后缀也就是 ESModuleJS 文件的意思,对应的 CommonJS 文件后缀为 .cjs。设置这两种后缀后编译器会忽略 package.json 中的 type 配置,使用对应的模块解析方式解析文件。
build 命令作为正式打包的命令,会输出正确的打包后文件。stub 命令作为开发时打包命令,输出 jiti 包,可以避免重复打包。
build 命令这里就不测试了,我们直接让 A 项目的 start 命令依赖于 stub 命令:
// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {},
    "stub": {},
    "start": {
      "dependsOn": ["^stub"]
    }
  }
}2
3
4
5
6
7
8
9
10
11
之后运行 start 命令:
# 根目录
npx turbo run start2
运行后可以看到 B/dist/index.mjs 顺利生成了带有 jiti 的包,而且 A 项目的打印也成功执行了。
可以修改 B/src/index.ts 中的内容,然后单独执行 A 项目的 start 命令,查看打印结果是否变化:
# 根目录
pnpm -F @mt/a start2
可以看到单独执行 A 项目的 start 命令,也成功输出了改动后的值。这说明 jiti 插桩起作用了。
子项目并不是必须打包 
上面我们提到了如果 A 无法识别 B 的代码,那么需要先将 B 打包后再使用。对应的如果 A 能够识别 B 的代码,那么 B 也可以不打包。
例如 A 是一个 Vue 项目的话,运行 A 需要经过 Vite(或其他打包器)启动,启动和运行过程中 Vite 就会对 ts、vue 等代码进行处理,就算 B 没有打包也能够正常引入并使用。
减少一次打包流程,对开发体验来说也会有比较明显的提升。所以在 monorepo 项目中,需要从子项目作用范围,用途等多方面考虑是否需要进行打包,并选择合适的方案。
TypeScript 使用 
在 monorepo 项目中使用 TypeScript 时,如果所有子项目都只会用在同样的环境中,那么可以只在根目录创建一份 tsconfig.json 文件,作用到每一个子项目。
但是通常各个子项目并不会都运行在一个环境,例如浏览器端项目可能需要开启 DOM lib,需要开启 jsx 等,而服务端项目往往不需要这些配置。
所以推荐为每一个子项目都创建各自的 tsconfig.json 文件,可以复用的配置抽离在根目录文件中,然后各个子项目使用 extends 进行复用。
如果多个子项目需要复用类型接口,同样可以将类型写在根目录中。例如:
monorepo-typescript
├─ package.json
├─ pnpm-workspace.yaml
├─ tsconfig.common.json
├─ types
│  └─ common.d.ts
└─ packages
   └─ A
      ├─ index.ts
      ├─ tsconfig.json
      └─ package.json2
3
4
5
6
7
8
9
10
11
{
  "compilerOptions": {
    "paths": {
      "#/*": ["./types/*"]
    }
  }
}2
3
4
5
6
7
{
  "extends": "../../tsconfig.common.json",
  "include": ["index.ts", "../../types/*"]
}2
3
4
// 可以直接导入使用
// types 文件夹中的全局类型也可以直接使用
import { AnyType } from "#/common";2
3
环境变量使用 
许多库都提供了对环境变量的支持,但基本只能用在项目自身。如果 monorepo 中想共用一份环境变量,可以使用 dotenv-cli 库在启动命令中注入环境变量。
首先进行安装:
pnpm -w add dotenv-cli -D之后创建环境变量文件 .env、.env.local、.env.development、.env.development.local。
使用时在启动命令中添加参数(以 Nest 为例):
// package.json
{
  "scripts": {
    "dev": "dotenv -e .env -c -e .env.development -c -- nest start --watch"
  }
}2
3
4
5
6
上面的命令中使用 dotenv 注入环境变量,-e 参数指定环境变量文件路径,路径后的 -c 参数表示同时读取文件的 .local 版本。
多个环境变量文件需要分别书写,注入环境变量后使用 -- 分隔不同的命令,不然后面的 nest start 会被识别为命令参数。
INFO
部分库支持自定义环境变量文件位置,例如 Vite 提供了 envDir 配置参数,此时更推荐使用库自身配置。