前端开发走到今天,模块化已经成为标配。但当我们同时维护多个项目时,一个尴尬的问题就出现了:如何在多个项目间优雅地共享公共代码?传统的npm/yarn方案会带来各种困扰,比如重复安装导致的磁盘空间浪费、版本不一致引发的诡异bug、本地调试时繁琐的link操作...
我最近接手的一个企业级后台管理系统就遇到了这个痛点。系统由1个主应用和7个子应用组成,这些应用都依赖相同的工具函数库、UI组件和业务逻辑模块。最初我们简单粗暴地在每个项目里都npm install一遍这些共享模块,结果node_modules文件夹加起来占了近10GB空间,更糟的是某次更新后,不同项目里的工具函数版本出现了差异,导致线上出了严重bug。
pnpm的共享能力建立在两项核心技术之上:内容寻址存储和硬链接。当你在多个项目中使用相同版本的依赖时,pnpm不会像npm/yarn那样在每个项目的node_modules里都复制一份文件,而是采用硬链接指向磁盘上的同一份物理文件。
举个例子,假设你有5个项目都用了lodash@4.17.21:
实测下来,我们的项目组切换pnpm后,磁盘占用从原来的10GB降到了3GB左右,CI构建时的依赖安装时间也缩短了40%。
pnpm采用严格的node_modules结构,每个项目只能访问自己显式声明的依赖。这种设计彻底解决了"幽灵依赖"问题(即项目能意外访问到依赖的依赖)。虽然初期可能需要调整一些不规范的项目结构,但从长远看大大提高了构建的确定性。
首先全局安装pnpm(建议版本≥7.0):
bash复制npm install -g pnpm@latest
创建项目根目录并初始化:
bash复制mkdir my-monorepo && cd my-monorepo
pnpm init
关键步骤是创建pnpm-workspace.yaml文件:
yaml复制packages:
- 'packages/*'
- 'apps/*'
这种结构将代码分为两类:
在packages目录下新建工具库:
bash复制mkdir -p packages/utils && cd packages/utils
pnpm init
修改package.json重要字段:
json复制{
"name": "@my-project/utils",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"prepublishOnly": "pnpm run build"
}
}
注意:共享模块的name建议使用@scope/package格式,避免命名冲突
假设我们有个React应用在apps/admin:
bash复制cd apps/admin
pnpm add @my-project/utils
pnpm会自动识别工作区内的本地包,不会从npm仓库下载。开发时修改utils代码会实时反映在admin应用中,无需手动link。
有时我们希望某些依赖在多个包间共享但不在全局安装。在pnpm中可以通过修改.npmrc实现:
code复制shared-workspace-lockfile=true
然后在根目录的package.json声明公共依赖:
json复制"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
所有子包都会自动继承这些依赖,且保证版本一致。
对于大型monorepo,修改底层库时需要触发依赖链上所有项目的重建。推荐使用--filter参数进行精准构建:
bash复制pnpm --filter "@my-project/admin" run dev
结合Turborepo可以实现更高效的增量构建:
json复制// turbo.json
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
}
}
}
现象:明明安装了依赖却报错"Module not found"
排查步骤:
现象:共享模块还没构建完,应用就开始构建
解决方案:
json复制"scripts": {
"preinstall": "pnpm -r run build"
}
推荐采用changesets管理版本:
bash复制pnpm add -Dw @changesets/cli
npx changeset init
发布流程:
在我们的实际项目中,对比不同方案的表现:
| 指标 | npm | yarn | pnpm |
|---|---|---|---|
| 首次安装时间 | 4m12s | 3m45s | 2m08s |
| 无变更重复安装时间 | 1m30s | 45s | 8s |
| 磁盘占用 | 12.4GB | 10.7GB | 3.2GB |
| CI缓存大小 | 1.8GB | 1.5GB | 450MB |
特别在Docker构建场景下,pnpm的优势更加明显。通过合理利用layer caching,我们的Docker构建时间从平均7分钟降到了3分钟以内。
pnpm import自动转换lock文件json复制"engines": {
"pnpm": ">=7.0.0"
}
某些包可能依赖node_modules的扁平化结构,需要特殊处理:
bash复制pnpm add --shamefully-hoist some-problematic-package
或者通过.npmrc配置:
code复制hoist=true
hoist-pattern[]=*eslint*
pnpm对peerDependencies检查更严格。遇到问题时可以:
bash复制pnpm add --strict-peer-dependencies=false some-package
在tsconfig.json中配置:
json复制{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@my-project/*": ["packages/*/src"]
}
}
}
确保Jest能解析pnpm的node_modules结构:
js复制// jest.config.js
module.exports = {
moduleNameMapper: {
'^@my-project/(.*)$': '<rootDir>/packages/$1/src'
},
resolver: 'jest-pnpm-resolver'
}
在packages/eslint-config中创建共享配置:
js复制// packages/eslint-config/index.js
module.exports = {
extends: ['eslint:recommended'],
rules: {
// 自定义规则
}
}
然后在各子包中引用:
json复制{
"eslintConfig": {
"extends": "@my-project/eslint-config"
}
}
对于大型团队,建议采用以下架构:
code复制.
├── packages/
│ ├── configs/ # 各种共享配置
│ ├── core/ # 核心业务逻辑
│ ├── ui/ # 组件库
│ └── utils/ # 工具函数
├── apps/
│ ├── admin/ # 后台管理系统
│ ├── mobile/ # 移动端H5
│ └── web/ # 官网
└── services/ # BFF层服务
配套的CI/CD流程建议:
在根目录的.vscode/launch.json中添加:
json复制{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Package",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/packages/utils/src/index.ts",
"outFiles": ["${workspaceFolder}/packages/utils/dist/**/*.js"]
}
]
}
使用tmux或VSCode的终端分组功能,同时监控:
生成依赖图谱帮助理解项目结构:
bash复制pnpm add -Dw pnpm-why
pnpm dlx pnpm-why react
或者使用专业的可视化工具:
bash复制pnpm add -Dw dependency-cruiser
npx depcruise --output-type dot packages/* | dot -T svg > deps.svg
在.npmrc中配置私有仓库:
code复制@my-project:registry=https://company.pkgs.visualstudio.com/
//company.pkgs.visualstudio.com/:_authToken=${NPM_TOKEN}
定期运行安全检查:
bash复制pnpm audit
结合Dependabot自动更新:
yaml复制# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "pnpm"
directory: "/"
schedule:
interval: "weekly"
启用lockfile验证:
bash复制pnpm config set verify-store-integrity true
生成安装时间报告:
bash复制PNPM_DEBUG_PERFORMANCE=true pnpm install
使用分析工具找出臃肿的依赖:
bash复制pnpm add -Dw source-map-explorer
npx source-map-explorer 'apps/admin/dist/*.js'
对于SSD空间紧张的情况,可以修改存储路径:
bash复制pnpm config set store-dir /mnt/ssd/.pnpm-store
在根tsconfig.json中设置:
json复制{
"compilerOptions": {
"allowJs": true,
"checkJs": false
}
}
例如同时存在React和Vue项目:
json复制{
"exports": {
".": {
"react": "./dist/react/index.js",
"vue": "./dist/vue/index.js",
"default": "./dist/index.js"
}
}
}
在module federation配置中:
js复制// webpack.config.js
module.exports = {
plugins: [
new ModuleFederationPlugin({
shared: {
react: { singleton: true },
'react-dom': { singleton: true }
}
})
]
}
对于CSS-in-JS库,确保生成一致的hash:
js复制// packages/ui/stitches.config.js
export const { styled } = createStitches({
prefix: 'my-company',
})
使用plop自动生成新包:
js复制// plopfile.js
module.exports = function (plop) {
plop.setGenerator('package', {
description: 'Create a new shared package',
prompts: [...],
actions: [...]
});
}
使用codemod批量更新导入路径:
js复制// packages/codemod/update-imports.js
module.exports = function transformer(file, api) {
return file.source.replace(
/from '..\/..\/utils'/g,
"from '@my-project/utils'"
);
};
用husky+lint-staged确保代码质量:
json复制{
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
]
}
}