跳到主要内容

构建

2024年07月31日
柏拉文
越努力,越幸运

一、认识


基于 PNPM 构建 Monorepo 多项目, 目录结构如下:

pnpm-monorepo/
├── apps/
│ └── reactA/
│ └── src
│ └── package.json
│ └── tsconfig.json
│ └── reactB/
│ └── src
│ └── package.json
│ └── tsconfig.json
├── libs/
│ ├── utils/
│ │ └── src
│ │ └── package.json
│ │ └── tsconfig.json
│ └── components/
│ └── src
│ └── package.json
│ └── tsconfig.json
├── pnpm-workspace.yaml
└── package.json

二、创建目录文件


2.1 创建根目录

1. 进入根目录,执行 pnpm init, 生成 package.json, 根目录的 package.json 可以指定工作区以及添加共享的开发依赖项。

2. 手动创建 pnpm-workspace.yaml, 这个文件用于定义 pnpm 工作区的结构,指定哪些目录下的包是工作区的一部分。, 根据你的目录结构,pnpm-workspace.yaml 应该如下:

packages:
- "apps/*"
- "libs/*"

2.2 创建 apps/appA

1. 进入 appA 目录, 执行 pnpm init , 生成 package.json, package.json 需要指定它们的依赖关系以及对工作区包的引用。

{
"name": "appa",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"utils": "workspace:*",
"components": "workspace:*"
},
"devDependencies": {
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"typescript": "^5.2.2",
"vite": "^5.2.0"
}
}

2. 手动创建 tsconfig.json: 每个子项目应该有自己的 tsconfig.json 文件。确保 TypeScript 配置正确以便它能处理项目之间的引用。

{
"compilerOptions": {
"target": "ES6",
"module": "commonjs",
"baseUrl": "./src",
"paths": {
"*": ["*", "src/*"],
// "utils": ["../../libs/utils/src"],
// "components": ["../../libs/components/src"]
},
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

2.3 创建 apps/appB

apps/appA

2.4 创建 libs/utils

1. 进入 utils 目录, 执行 pnpm init , 生成 package.json, package.json 需要指定它们的依赖关系以及对工作区包的引用。

{
"name": "utils",
"version": "1.0.0",
"description": "",
"main": "src/index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}

2. 手动创建 tsconfig.json: 每个子项目应该有自己的 tsconfig.json 文件。确保 TypeScript 配置正确以便它能处理项目之间的引用。

{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src",
"strict": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}

2.5 创建 libs/components

libs/utils

三、安装项目依赖


3.1 安装所有依赖

进入项目根目录,执行 pnpm install, 安装根目录和工作区中所有的依赖

pnpm install 

Monorepo 构建中,pnpm 使用工作区的机制来管理和链接项目中的多个包。工作区配置(通过 pnpm-workspace.yamlpackage.jsonworkspaces 字段指定)使得 pnpm 能够识别并处理 Monorepo 中的所有包。当你在根目录下执行 pnpm install 时,pnpm 会进行以下步骤

  1. 识别并解析所有工作区: pnpm 会读取根目录下的 pnpm-workspace.yaml 文件或 package.json 中的 workspaces 配置,识别出所有的工作区包(即,项目中的各个子包)。pnpm 会根据工作区配置解析所有子包,并构建一个包含所有工作区包依赖的图。

  2. 依赖解析: pnpm 会解析根目录和工作区包中的 package.json 文件,确定所有的依赖项。包括:外部依赖 即从公共注册表(如 npmyarn)下载的依赖项。和 工作区依赖 ,即工作区包之间的相互依赖(如 reactA 依赖 utilscomponents

  3. 下载外部依赖: pnpm 会从注册表下载并安装所有的外部依赖到根目录下的 node_modules/.pnpm 文件夹,并将其链接到各个工作区包的 node_modules 中。

  4. 链接工作区依赖: 对于工作区包,pnpm 使用符号链接将这些包链接到实际的工作区路径中,而不是复制整个包到每个工作区的 node_modules 中。这节省了磁盘空间并提高了依赖管理的效率。

  5. 生成 pnpm-lock.yaml: pnpm 生成并更新 pnpm-lock.yaml 文件,记录所有依赖项的精确版本和安装信息,以确保一致的安装结果。

为什么要安装所有依赖: 因为要确保所有工作区包的依赖项都被正确安装和链接,以保持一致的开发环境。而且可以保证在 Monorepo 中,多个项目可能需要共享或互相依赖包。安装所有依赖项确保开发时能够正确地解析和使用这些依赖。通过一次性安装所有依赖,pnpm 可以高效地处理工作区包之间的依赖关系,而不是在每次运行时手动处理这些依赖。

3.2 安装根目录依赖

通过 pnpm add [package] -w -S/-D 来根目录

3.3 安装工作区依赖

通过 pnpm --filter 工作区名称(package.json 中的 name)add [package] -S/-D 来安装到指定的工作区

四、启动、构建工作区


通过 pnpm --filter 工作区名称(package.json 中的 name) 命令 或者直接进入到指定工作区运行命令即可

五、问题汇总


5.1 解决 NPM 中的幽灵依赖问题

NPM 扁平结构: 在 NPM 的扁平结构中,node_modules 目录尽可能地将依赖项安装在根目录中,以减少嵌套层级。这种扁平化的安装方式旨在避免过深的目录结构和长路径问题。PNPM 是非扁平结构,没有幽灵依赖的问题,所以以前不在 项目的 package.json 中声明但是却在项目中通过 import 引入访问的依赖会导致编译报错。

解决方案: 在项目目录中,声明这些幽灵依赖。

5.2 解决依赖版本冲突,确保依赖项的一致性

PNPMMonorepo 项目中,当子项目中的依赖版本不一致时,PNPM 会将不同版本的包分别安装在每个项目中。由于 node_modules 中使用了符号链接,不同版本的包可能在子项目中被引入,从而导致依赖解析和模块加载的问题。

例如:假设 app-aapp-b 使用不同版本的 lodash,由于 PNPM 使用了符号链接,这些版本的 lodash 会分别被安装在 app-aapp-bnode_modules 中。虽然这些版本的 lodash 通过符号链接指向相同的全局存储位置,但在 Webpack 处理这些符号链接时,可能会出现问题。

Webpack 使用相应的插件进行代码压缩和混淆。假设 app-aapp-b 中的 lodash 被混淆为标识符 a,由于 lodash 版本不同,这个标识符 a 可能指向不同的实现。即使它们在代码中使用相同的标识符,实际引用的 lodash 版本却不同,导致功能不一致或运行时错误。

解决方案如下:

  1. 解决模块版本冲突,确保版本一致:

  2. overrides: overrides 是在 pnpm 中用于解决版本冲突和确保依赖一致性的一种功能。你可以在根目录的 package.json 中使用 overrides 字段来指定特定依赖的版本。

  3. .pnpmfile.cjspnpm 提供的自定义配置文件,可以用来实现更复杂的依赖处理逻辑,如动态覆盖版本、调整依赖结构等。

  4. 基于 resolve.symlinks 避免符号链接路径的问题: 在 Webpack 配置中,将 resolve.symlinks 设置为 false 可以避免因符号链接路径导致的问题。这将确保 Webpack 不会因为符号链接指向的不同版本或路径而引发依赖解析和功能不一致的问题。