当pnpm遇到上了monorepo

前端开发
2022年12月27日
2136

pnpm 是新一代的包管理工具,它主要的优点有两个:一是采用了 hard-link 机制,避免了包的重复安装,节省了空间,同时提高项目依赖的安装速度;二是对 monorepo 的支持非常友好,只需要一条配置即可实现。

monorepo 是一种新的仓库管理方式。过去的项目,大多采用一个仓库维护一个项目的方案。对于庞大的项目,哪怕只是一处小小的修改也会影响到整体。而采用 monorepo 的方式,我们可以在一个仓库中管理多个项目,每个项目都可以单独发布和使用,就像是一个仓库中又有若干个小仓库。

搭建开发环境

创建项目

首先需要安装 pnpm

bash
npm i -g pnpm

新建一个目录并初始化:

bash
mkdir pnpm-monorepo cd pnpm-monorepo pnpm init mkdir packages

配置 monorepo

在项目的根目录中创建 pnpm-workspace.yaml 文件,并添加以下内容:

yaml
packages: - 'packages/*'

这段代码的意思就是将 packages 目录下所有的目录都当作单独的包进行管理。通过上面简单的配置,monorepo 的开发环境就搭建完成了。

安装依赖

正如 vite 一样,我们在开发阶段采用 esbuild 作为构建工具,在生产阶段采用 rollup 进行打包,同时采用 typescript 作为开发语言:

bash
pnpm add -D -w typescript esbuild rollup rollup-plugin-typescript2 @rollup/plugin-json @rollup/plugin-node-resolve @rollup/plugin-commonjs minimist execa

使用 -w 参数整依赖安装到根目录。下面简单说一下各个依赖的作用:

  • esbuild:开发阶段的构建工具;
  • rollup:生产阶段的打包工具;
  • rollup-plugin-typescript2:rollup 编译 ts 的插件;
  • @rollup/plugin-json:将 json 解析为 esm 供 rollup 处理;
  • @rollup/plugin-node-resolve:解析安装在 node_modules 下的第三方模块;
  • @rollup/plugin-commonjs:将 commonjs 解析为 esm;
  • minimist:解析命令行参数;
  • execa:生产阶段开启子进程。

初始化 typescript

执行以下命令,该命令会在项目的根目录生成一个 tsconfig.json 文件:

bash
pnpm tsc --init

tsconfig.json 进行配置:

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": { // 路径别名配置 // "@test/*": ["packages/*/src"] // 当引入 @test/xxxx 时,去 packages/*/src 中找 "helper": ["packages/helper/src"], "core": ["packages/core/src"] }, } }

创建模块

正如上面 tsconfig.jsonpaths 的配置一样,我们需要创建 helpercore 两个模块。

helper 模块

首先是创建 helper 模块,在 packages 目录下新建 helper 目录,并进行目录初始化:

bash
cd packages mkdir helper cd helper # 初始化 pnpm init # 创建 src 目录 mkdir src # 新建 index.ts 文件 touch src/index.ts

src/index.ts 添加测试用代码:

typescript
export const hello = () => { return 'Hello world!' }

然后在 package.json 中增加以下代码:

json
"main": "dist/helper.cjs.js", "module": "dist/helper.esm-bundler.js", "buildOptions": { "name": "Helper" },

core 模块

接下来创建 core 模块,与 helper 模块类似:

bash
mkdir core cd core pnpm init mkdir src touch src/index.ts

修改 packjson.json 文件:

json
"main": "dist/core.cjs.js", "module": "dist/core.esm-bundler.js", "buildOptions": { "name": "Core" },

src/index.ts 中编写测试代码:

typescript
import { hello } from 'helper' const str = hello() console.log(str)

可以看到,我们在 core 模块中引入了 helper 模块的内容,所以需要安装一下:

bash
pnpm add helper@workspace --filter core

这段命令的意思是将 worksapce 中的 helper 模块安装到 core 模块中去,此时可以看到在 core/package.json 中已经有了相关的依赖信息:

json
"dependencies": { "helper": "workspace:^1.0.0" }

编写构建脚本

在根目录创建 scripts 目录,并增加 dev.js 作为开发阶段的构建脚本:

bash
mkdir scripts touch scripts/dev.js

dev.js 中增加以下代码:

js
const minimist = require('minimist') const path = require('path') const { build } = require('esbuild') const args = minimist(process.argv.slice(2)) // 需要打包的模块,默认打包 core 模块 const target = args._[0] || 'core' // 打包的格式,默认为 global 即 IIFE 模式 const format = args.f || 'global' // 打包的入口文件 const entry = path.resolve(__dirname, `../packages/${target}/src/index.ts`) // 打包文件的输出格式 const outputFormat = format.startsWith('global') ? 'iife' : format === 'cjs' ? 'cjs' : 'esm' // 输出文件路径 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 pkgGlobalName = pkg?.buildOptions?.name // 使用 esbuild 进行打包 build({ entryPoints: [entry], // 入口 outfile, // 输出文件路径 bundle: true, // 将依赖的文件递归地打包到一个文件中,默认不会进行打包 sourcemap: true, // 开启 sourcemap format: outputFormat, // 打包文件输出的格式 'iife' | 'cjs' | 'esm' globalName: pkgGlobalName, // 如果输出格式为 iife,则需要指定一个全局变量名 platform: format === 'cjs' ? 'node' : 'browser', // 监听文件变化,进行重新构建 watch: { onRebuild (error, result) { if (error) { console.log('构建失败:', error) } else { console.log('构建成功:', result) } } } }).then(() => { console.log('监听中...') })

编写完开发阶段的构建脚本后,给根目录的 package.json 增加一条 scripts 命令进行测试:

json
"scripts": { "dev": "node scripts/dev.js core -f global" },

在终端中运行该命令:

bash
pnpm run dev

image-20221222151807753.png

然后给 core 模块根目录中增加一个 index.html 文件:

html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Core</title> </head> <body> <script src="./dist/core.global.js"></script> </body> </html>

然后在控制台中打开 index.html 文件,打开浏览器控制台,可以看到:

image-20221222152051291.png

至此,一个简单的 monorepo 开发环境就搭建完毕了。

直接使用 Vite 来搭建

首先是创建项目:

bash
pnpm create vite pnpm-monorepo

选择 Vanilla

image-20221227095416395.png

和 Typescript`

image-20221227095430404.png

接下来进入 pnpm-monorepo 目录,移除 srcpublic 目录,并增加 packages 目录,然后在根目录新增 pnpm-workspace.yaml

yaml
packages: - 'packages/*'

创建模块

进入 packages 目录,通过以下命令创建 helper 模块 和 core 模块。

helper

bash
cd packages # 同样是选择 Vanilla 和 Typescript pnpm create vite helper pnpm create vite core

接下来进入 helper 模块,删除 srcpublic 目录,并删除 index.html 文件,之后新增 index.ts 文件:

bash
cd helper rm -rf src public index.html touch index.ts

然后在 index.ts 增加测试代码:

ts
export const plus = (a: number, b: number): number => { return a + b }

由于我们没有了 src 目录,所以需要移除 tsconfig.json 中的 includes 字段。完成之后创建 vite.config.ts 来指定模块打包的入口文件:

ts
// vite.config.ts import { defineConfig } from 'vite' export default defineConfig({ build: { lib: { entry: './index.ts', name: 'helper', fileName: 'helper' } } })

执行打包命令:

bash
pnpm install pnpm run build

然后给 packages.json 增加字段:

json
"main": "dist/helper.js",

core

移除 public 目录,删除 src 目录中除了 main.ts 之外的其他文件,然后把 helper 包引入:

bash
rm -rf public rm -f src/counter.ts src/style.css src/typescript.svg pnpm install pnpm add helper@workspace --filter core

然后在 src/main.ts 写入以下代码:

ts
// src/main.ts import { plus } from 'helper' const sum = plus(1, 2) console.log(sum)

执行开发环境命令:

bash
pnpm run dev

在浏览器中打开指定地址,可以在控制台中看到打印 3。至此,core 模块也处理完毕。

开发环境构建

首先需要监听 helper 代码的变化,进行构建:

bash
# packages/helper pnmp run build --watch

然后还需要进入 core 的开发环境:

bash
# packages/core pnmp run dev

我们都知道,build --watch 会阻止后续的进程执行,所以我们需要引入 concurrently 来完成同步执行这两条脚本的操作。

进入 pnpm-monorepo 根目录,安装 concurrently:

bash
cd pnpm-monorepo # -w 表示把依赖安装到根目录 pnpm add concurrently -D -w

接下来在 package.json 里面增加两条 script 脚本命令:

json
"script": { "build:helper": "cd packages/helper && pnpm run build --watch", "dev:core": "concurrently \"pnpm run build:helper\" \"cd packages/core && pnpm run dev\"" }

然后运行 pnpm run dev:core 脚本,启动 core 的开发环境。我们可以在浏览器的控制台看到打印 3

接下来在 helper 中的 index.ts 里面增加一个方法:

ts
// packages/helper/index.ts export const plus = (a: number, b: number): number => { return a + b } export const minus = (a: number, b: number): number => { return a - b }

core 中的 src/main.ts 里面引入新增加的 minus() 方法,并使用:

ts
// packages/core/src/main.ts import { plus, minus } from 'helper' const sum = plus(1, 2) console.log(sum) const result = minus(1000, 7) console.log(result)

保存之后,我们可以看到控制台输出:

image-20221227110549656.png

如此一来,我们使用 vite 来搭建的 monorepo 也完成了。