pnpm+monorepo完整实践:从安装到Electron打包的踩坑指南
2026/9/14 23:40:04 网站建设 项目流程

在npm和yarn轮流折磨了我好几个项目之后,我最终倒向了pnpm,并且把手上能拆的多包项目全部改成了monorepo结构。说实话,这个组合一开始并不好上手,网上资料又乱,我自己前后踩了不止一个星期的坑,从安装pnpm到配置workspace,再到Electron打包时依赖离奇丢失,每一步都有想砸电脑的冲动。但一旦跑通,工作效率的提升是实打实的,磁盘空间也能省出一大截。这篇东西就是我重新梳理后的完整落地记录,按照从零到一、再到进阶的顺序来写,涵盖pnpm安装、基础骨架搭建、依赖管理、版本发布,以及Electron打包这种特殊场景,最后附上我遇到过的报错和排查方法,给正在折腾pnpm+monorepo的人一个能直接抄作业的参考。

1. 先想清楚:monorepo到底解决什么问题

1.1 从npm link的心酸史说起

很多团队一开始都是multirepo,也就是一个Git仓库对应一个项目。我自己早期也是这样干的,把公共组件、工具函数、业务项目各放一个仓库,用的时候发npm包,或者直接npm link一下。发npm包的方式链路太长,改一个公共库的代码要发版、升级、再安装,改完一行代码恨不得等十分钟;npm link则更折磨人,动不动就出现符号链接指向错误、重复安装同一份依赖导致React实例不唯一的问题,害得我排查过好几次hooks报错。

这时候monorepo就显出优势了。简单来说,monorepo是把多个项目/包放进同一个Git仓库里管理,配合workspace机制,让子包之间可以互相引用源码,改动即时生效,不需要发包。前端领域最典型的例子就是Vue和Babel,它们在很早之前就用这种结构来组织源码。现在国内很多团队上到中大型项目也都在往这边迁移。

1.2 pnpm凭什么适合monorepo

提到monorepo,很多人第一反应是lerna,或者是npm/yarn自带的workspaces。但真正用了pnpm之后,我认为pnpm是目前对monorepo支持最彻底的工具,原因有三个:

第一,依赖存储方式完全不同。pnpm使用内容寻址存储,所有依赖统一存放在一个全局store目录里,项目node_modules下只有硬链接和符号链接,而不是完整复制一份文件。这意味着你在电脑上开十个项目,如果都用react,对应文件只在全局store里存一份,项目中通过硬链接引用,磁盘占用大幅降低。我实测过,同样一个几十个依赖的工程,npm安装后node_modules可能超过1GB,pnpm只需要五六百MB。

第二,严格阻止幽灵依赖。什么是幽灵依赖?就是你在项目里明明只声明了A包,但因为npm的扁平化node_modules结构,A包内部依赖的B也被提到了node_modules根目录,于是你的代码能直接import B。这种依赖关系是隐性的,哪天A升级不再依赖B,你这边就莫名其妙挂了。pnpm默认不提升依赖,node_modules下只能看到直接在package.json里声明过的依赖,子依赖都被隔离在各自目录里。这一开始会觉得不习惯,但长期维护项目是真的省心。

第三,workspace支持是一等公民。pnpm内置了对monorepo工作区的支持,底下有filter、workspace协议等一整套玩法,发布流程也可以跟changesets等工具配合。相比lerna还要额外安装一堆插件,pnpm基本开箱即用。

补充一点,很多人卡在第一步的其实是pnpm特有的依赖存储方式看不懂。我用大白话解释一下:pnpm在全局有一个"仓库"叫store,你的项目里node_modules里的真实文件其实都指向这个store里的同一个物理文件,文件系统层面用的是硬链接。项目之间如果依赖同一个版本,就共享同一份物理文件,所以下载快、省空间。

2. 环境准备:把pnpm装好,把坑填平

2.1 安装pnpm的几种方式

在动手搭monorepo之前,第一步是让pnpm命令可用。我见过太多人卡在这一步,网上的安装教程又很乱,这里我把几种方式都梳理一遍,你按自己的实际情况选一种就行。

最常规的方式,是你已经装了Node.js和npm,然后直接执行:

npm install -g pnpm

这种方式最省事,装完重启一下终端,pnpm -v能看到版本号就可以了。但注意一点:如果你本机Node版本很老,比如12以下,不建议这样装最新版pnpm,最好先升级Node,或者装指定版本:

npm install -g pnpm@8

如果你的Node版本在16.13以上,还可以用Node自带的Corepack来管理:

corepack enable corepack prepare pnpm@latest --activate

Corepack是Node官方提供的包管理器管理工具,好处是你可以根据不同项目切换pnpm版本,团队协作时还能通过package.json里的packageManager字段锁定版本,避免"我本地能跑,你本地跑不了"的尴尬。

Windows用户还有两种选择:一是用winget:

winget install pnpm

二是用官方提供的PowerShell脚本:

iwr https://get.pnpm.io/install.ps1 -useb | iex

macOS和Linux则可以用curl脚本或者Homebrew:

curl -fsSL https://get.pnpm.io/install.sh | sh brew install pnpm

我个人建议,如果没有特殊原因,直接走npm全局安装或者corepack,这两个方式最不容易出幺蛾子。

2.2 把pnpm安装到D盘

热词里有个很常见的需求:pnpm安装到D盘。这个需求一般是Windows用户,C盘空间不够,想把依赖和全局包都挪走。记住一个关键概念:把pnpm安装到D盘,不是说你把pnpm这个命令本身装到D盘,而是让pnpm的全局store、全局包、缓存都放到D盘去,这样才真正解决C盘空间问题。

首先要配置pnpm的全局存储路径,执行:

pnpm config set store-dir D:\pnpm-store pnpm config set global-dir D:\pnpm-global pnpm config set global-bin-dir D:\pnpm-global\bin pnpm config set cache-dir D:\pnpm-cache

也可以在项目根目录的.pnpmrc文件里写上这些配置,效果一样:

store-dir=D:\pnpm-store global-dir=D:\pnpm-global global-bin-dir=D:\pnpm-global\bin cache-dir=D:\pnpm-cache

配置完之后要注意,D:\pnpm-global\bin这个目录需要添加到系统环境变量PATH里,否则全局安装的命令就找不到了。另外,如果你用npm全局安装的pnpm本身,那pnpm这个主程序还是存在于npm的全局目录里(通常在C:\Users\xxx\AppData\Roaming\npm),想把这个也挪走,得改npm的配置:

npm config set prefix "D:\npm-global"

然后再把D:\npm-global加进PATH,重新安装pnpm。这一步很多人容易漏,改完prefix一定要重新执行一遍npm install -g pnpm,否则命令找不到。

2.3 命令无法识别怎么救

热词里出现了"pnpm is not recognized as an internal or external command"以及"pnpm命令无法识别",这应该是新手最容易遇到、也最崩溃的问题。实际上问题99%出在环境变量上,跟pnpm本身关系不大。

Windows下排查思路是这样的:

  1. 按Win+R输入sysdm.cpl打开系统属性,进入"高级" -> "环境变量",在用户变量或系统变量里找Path。
  2. 确认pnpm的安装路径是否在Path里。如果你是用npm装的,路径一般是C:\Users\你的用户名\AppData\Roaming\npm;如果你用官方脚本装,路径可能是C:\Users\你的用户名\AppData\Local\pnpm
  3. 如果路径确实在,但命令行还是报错,八成是没重启终端。改完Path后,所有已打开的终端窗口都不会生效,必须全部关闭重新开。
  4. 还有一种情况,你用的终端是PowerShell,执行外部脚本会被执行策略拦下来。可以试试在当前会话临时解除:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

macOS和Linux如果遇到command not found,先检查安装时是否加了export PATH="$HOME/.local/share/pnpm:$PATH"这类路径导出。使用官方脚本安装时会自动帮你在~/.bashrc或~/.zshrc里追加,但如果你的shell不是默认的bash/zsh,有可能没写进去。自己确认一下文件里有没有相关内容即可。

另外一个不常见但确实存在的坑是:你根本就没装上pnpm。执行npm install -g pnpm时如果日志里出现大量ERR!,安装根本就是失败的,但有些终端滚动太快你没扫到。所以遇到"命令无法识别",先执行npm ls -g --depth=0看全局包里是否有pnpm,没有就说明装失败了,重新装。

3. 搭建monorepo基础骨架

3.1 初始化工作区

环境准备好之后,终于可以开始搭工程了。我先说清楚整个目录长什么样,这样后面操作你会有画面感:

my-monorepo/ ├── packages/ │ ├── ui/ # 公共组件库 │ ├── utils/ # 工具函数 │ └── shared/ # 公共类型定义 ├── apps/ │ ├── web/ # 前端应用 │ └── desktop/ # Electron桌面应用 ├── package.json ├── pnpm-workspace.yaml ├── tsconfig.base.json └── .npmrc

pnpm的workspace核心配置在pnpm-workspace.yaml文件里,这个文件就是告诉pnpm哪些目录是monorepo里包。最基础的内容:

packages: - 'apps/*' - 'packages/*'

这样配置后,pnpm会把apps和packages下每个子目录都当成一个独立的包。它还支持排除某些目录,比如:

packages: - 'apps/*' - 'packages/*' - '!packages/legacy'

注意,pnpm-workspace.yaml必须放在仓库根目录。如果你的项目是嵌套在其他目录里的,一定要确认这个文件放在了最顶上那层。

3.2 设计packages目录与子包规范

目录结构确定了,接下来是每个子包自己的package.json。我以一个公共组件库packages/ui为例:

{ "name": "@my/ui", "version": "0.1.0", "description": "基础组件库", "main": "dist/index.js", "module": "dist/index.mjs", "types": "dist/index.d.ts", "files": ["dist"], "scripts": { "build": "vite build", "dev": "vite build --watch" }, "peerDependencies": { "react": "^18.0.0" } }

这里有几个设计上的建议:

  • 包名统一使用@scope/name格式,比如@my/ui@my/utils。scope是npm组织名,自己项目里可以随便起,但建议统一前缀,方便filter筛选。
  • private: true要不要加?如果是应用层的包,比如apps下的web,建议加上,避免被意外发布到npm。公共库则不需要加,因为后面要靠changesets发版。
  • 锁定发布内容:files字段里指定了发布时包含的目录,避免把src、测试代码都发上去。搭配prepublishOnly脚本,发版前先构建:
{ "scripts": { "prepublishOnly": "npm run build" } }
  • 公共库尽量把React、Vue这类框架依赖放到peerDependencies,而不是dependencies。否则在monorepo里很容易出现两个React实例,hooks直接崩溃。

3.3 根工程的统一配置

根目录的package.json也很有讲究。首先它需要标记为private,防止根工程被发布:

{ "name": "my-monorepo", "version": "0.0.0", "private": true, "scripts": { "dev:web": "pnpm --filter @my/web dev", "build": "pnpm -r build", "test": "pnpm -r test" }, "devDependencies": { "typescript": "^5.0.0", "vitest": "^0.34.0" } }

根目录一般只放公共的开发依赖,比如typescript、eslint、prettier等。为什么?因为这些工具通常是一次安装、全仓库共享,放在根目录可以避免每个子包白占一份磁盘。

在pnpm monorepo里跑命令有两个常用参数:

  • -r, --recursive:对所有子包执行某个命令,比如pnpm -r build会按依赖拓扑顺序依次构建所有包。
  • --filter:只对指定包执行命令,pnpm --filter @my/web dev只启动web应用。

按依赖拓扑顺序这一点很重要。pnpm会先构建依赖方,再构建被依赖方,也就是说,如果web依赖ui,那么ui会先构建,web后构建。这比你在shell里手动拼顺序靠谱一百倍。

根目录还需要一个统一的TypeScript配置。常见做法是定义一个tsconfig.base.json,各子包通过extends继承:

{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "jsx": "react-jsx", "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true } }

然后子包里的tsconfig.json写成:

{ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" }, "include": ["src"] }

4. 依赖管理与子包联动实操

4.1 workspace协议的正确用法

monorepo最核心的体验就是子包之间互相引用。pnpm里不需要手动npm link,只要在依赖里写workspace:协议。

比如apps/web要引用packages/ui,在web的package.json里:

{ "dependencies": { "@my/ui": "workspace:^" } }

执行pnpm install之后,node_modules里就会出现@my/ui的符号链接,指向packages/ui目录。你在ui包里改代码,web马上就能感知到(前提是ui包暴露的是源码或通过dev编译输出来)。

workspace:^是pnpm一个比较推荐的写法,含义是"始终引用当前工作区内的这个包"。安装后锁文件里保存的是workspace:^,发布的时候如果忘了转换会出问题。这里建议配置.npmrc

link-workspace-packages=true

然后发布前用pnpm自带的publish流程,它会把workspace协议自动转换成实际版本号。

如果你用Vite或者webpack做构建,本地引用@my/ui时要注意解析路径。推荐在Vite配置里加别名,避免构建时去node_modules找产物:

import path from 'path' export default { resolve: { alias: { '@my/ui': path.resolve(__dirname, '../../packages/ui/src/index.ts') } } }

这样本地开发直接编译源码,热更新更快,也不用每次先build ui包。如果不想引源码,那就确保ui包有dev脚本对外输出到dist,然后web那边正常引用npm包名。

4.2 公共依赖该放哪里

依赖应该放在根目录还是子包,这个问题很多新手会纠结。我给的判断标准很简单:

  • 仅供某个子包使用,就放到那个子包。
  • 多个子包共用,但属于业务性依赖,建议放到公共子包里,再通过workspace协议互相引用。
  • 构建、测试、lint、格式化这些基础设施工具,统一放根目录devDependencies。

举几个具体例子:

  • TypeScript、ESLint、Prettier、Vitest这些,放根目录。
pnpm add -D -w typescript eslint prettier vitest

其中-w表示在workspace root添加,也就是根目录。

  • React、ReactDOM这种,如果所有子包都用,但各包的版本需求不同,那就各自维护,不要强行提根目录。
  • 如果整个仓库只做一个React应用,子包也都是内容模块,那React放根目录也无妨,但要注意packages里的库不能把React当成普通依赖。

还有一类特殊的:有些依赖自带postinstall脚本,比如esbuild、sharp、electron。pnpm默认情况下对依赖包的生命周期脚本有特殊处理机制,如果你发现某些包装完以后报缺少平台二进制,或者编译产物没生成,多半是postinstall没有按预期执行。pnpm 10开始有一个更严格的安全策略,默认不执行依赖的postinstall,需要在pnpm-workspace.yaml或package.json里显式允许。具体做法是,在根目录package.json里配置:

{ "pnpm": { "onlyBuiltDependencies": ["esbuild", "electron"] } }

如果嫌麻烦,也可以在.npmrc里写dangerously-allow-all-builds=true,但这会放开所有依赖脚本的执行权限,存在供应链安全风险,我自己不推荐,团队协作时更不要这么干。

4.3 版本管理与changesets

monorepo里子包一多,版本管理就成了大问题。如果手动改版本号,今天改了a,明天忘了b,发布出去就乱套。业界主流做法是搭配changesets。

先安装:

pnpm add -D -w @changesets/cli npx changeset init

changesets的工作流是:改代码 -> 执行pnpm changeset生成变更描述 -> 合并MR -> 执行pnpm changeset version更新版本号和CHANGELOG -> 然后发布。

配合根目录的pnpm脚本:

{ "scripts": { "changeset": "changeset", "version-packages": "changeset version", "release": "pnpm build -r && changeset publish" } }

这里有个细节:changeset publish会调用pnpm的publish能力,发布时自动转换workspace协议。所以子包之间的依赖在发布后都会变成真实版本号。这一点我前面也提过,用pnpm发monorepo包比lerna省心,不用自己写插件去替换workspace协议。

5. 进阶:pnpm与Electron打包的集成处理

5.1 先认清electron在monorepo里的位置

把Electron应用放进monorepo里打包,是我踩坑最多的地方。热词里"pnpm配置electron打包"能成为一个高频词,说明大家都没少受苦。

先说为什么Electron在pnpm monorepo下这么难搞。Electron的安装分两部分,一是npm包本体,二是安装时通过postinstall脚本下载的二进制文件。而pnpm的node_modules是符号链接结构,electron-builder在打包时默认只处理物理存在的文件,符号链接、链接到全局store的依赖都可能被漏掉。

第二个难点是,Electron应用需要的是生产依赖,而monorepo里应用层常常引用本地workspace包。打包时如果直接引用了../../packages/ui的源码目录,electron-builder不会自动把那个包源码编译并收集进来;如果引用的是@my/ui的构建产物,又依赖dist目录存在且及时更新。这个依赖关系如果没理清楚,打出来的包十有八九运行时报"找不到模块"。

我目前比较顺手的结构是:

apps/desktop/ ├── electron/ │ ├── main.ts │ └── preload.ts ├── src/ │ └── renderer.tsx ├── electron-builder.yml ├── package.json └── vite.config.ts

主进程用electron-vite或Vite的lib模式构建,渲染进程用Vite构建,最终产物都输出到dist或out目录。electron-builder打包时只看构建产物和依赖清单,不再直接引用本地包源码。

5.2 二进制下载与镜像配置

Electron和electron-builder都要下载二进制文件,安装慢、下载失败是高频问题。解决方法是配置镜像环境变量。在项目根目录的.npmrc里:

electron_mirror=https://npmmirror.com/mirrors/electron/ electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/

设置好后,pnpm安装electron时不再访问GitHub Releases,而是走国内镜像,速度完全不同。这不是什么投机取巧的办法,而是社区里大家都在用的标准配置。

另外,Electron和electron-builder版本要匹配。electron-builder版本太老可能不认识新版Electron的安装包格式。我用的组合是electron 27.x + electron-builder 24.x,目前很稳。如果你遇到某种诡异的"下载失败"或"校验失败",先查版本组合,再查镜像配置。

5.3 packaging阶段的几个实际坑

打包阶段几个高频坑我列一下,都是我自己踩过的:

第一个坑是node_modules里依赖不全。electron-builder打包时会根据生产依赖生成应用内部node_modules。如果你的应用用了某个依赖,但package.json没声明,开发阶段可能因为pnpm的符号链接误打误撞能跑,打包后就报"找不到模块"。解决办法是别去赌运行时的侥幸,每个依赖都老老实实写进dependencies。

第二个坑是asar归档后的路径问题。electron内部默认把应用打包进asar,fs操作某些文件时路径要写成process.resourcesPath拼接的模式,不能依赖__dirname一层层往上翻。代码层面要提前处理好。

第三个坑是sqlite等原生模块。原生模块不会被pnpm硬链接正确带入,还因为编译产物是按Node ABI来的,Electron的Node版本和本机Node版本如果不一致,就会出现NODE_MODULE_VERSION不匹配。处理方式是使用electron-rebuild:

pnpm exec electron-rebuild -f -w better-sqlite3

或者用electron-builder的npmRebuild配置自动处理:

npmRebuild: true

关于pnpm符号链接导致electron-builder找不到依赖的问题,我补充一个实际技巧:可以先在应用目录执行pnpm install --prod,把生产依赖裸装一遍,然后再打包。这样electron-builder有机会读到真实的node_modules。这个方案不是最优,但作为应急手段很管用。长期方案是让electron-builder配合pnpm的配置运行,比如electron-builder.yml里加:

nodeGypRebuild: true

或者在打包脚本里先执行:

pnpm --filter @my/desktop exec electron-builder

让打包工具感知到pnpm的工作区环境。

6. 常见问题与排查技巧实录

6.1 经典报错与解决方案

我把这段时间收集到的高频报错整理成了一张速查表,方便你排障时快速定位。

报错或现象可能原因解决办法
pnpm不是内部或外部命令PATH环境变量未配置检查pnpm全局bin目录是否在Path,改完重启终端
ENOSPC / 磁盘空间不足store过大或C盘太小pnpm config set store-dir把store挪到其他盘,pnpm store prune清理
ERR_PNPM_NO_MATCHING_VERSION指定的workspace包版本不存在检查子包的version字段,确认workspace协议写法
幽灵依赖报错之前用npm/yarn迁移过来的项目检查package.json,缺失的依赖显式补上
electron安装后运行报404二进制下载失败配置electron_mirror镜像,重装electron
postinstall脚本没执行pnpm 10+默认禁止依赖脚本package.json中配置onlyBuiltDependencies
子包间版本永远不更新忘了跑changeset version提交前记得生成changeset,发布前执行版本更新

这些报错里,ERR_PNPM_NO_MATCHING_VERSION我特别说一下,它经常出现在你新增了一个本地包、但还没提交到git的时候。pnpm解析workspace协议时按仓库内最新代码来看,理论上应该找得到,但有时候锁文件缓存有问题。执行:

pnpm install

如果还是不行,把node_modules和锁文件删掉重来一遍:

pnpm store prune pnpm install

6.2 缓存与store相关的那些坑

pnpm的store是一把双刃剑,用得好省空间,用得不好就是各种奇怪的"缓存中毒"。

症状一:明明改了npm源的包版本,pnpm install后代码还是旧版。这种情况先看是不是pnpm的cache缓存了元数据,执行:

pnpm cache delete

症状二:store越来越大,C盘空间吃紧。查看store大小:

pnpm store path

然后视情况清理:

pnpm store prune

这会删除那些没有被任何项目引用的孤儿文件。再狠一点,可以pnpm store remove指定包名。

还有一个不算bug但对新手很有误导性的点:node_modules里有些目录是指向全局store的硬链接,看起来像是正常文件,但对它做修改可能会污染全局store。所以千万别手动改node_modules里的依赖文件,就算改了,其他项目也会受影响。要调试依赖代码,用pnpm patch:

pnpm patch react

它会帮你把包解压到临时目录,改完再pnpm patch-commit应用修改。这个机制我非常推荐,比粗暴改node_modules干净得多。

6.3 Node版本管理

热词里有个"pnpm下载node版本",其实pnpm本身并不负责下载Node,它只负责装npm依赖。但这个热词能出现,说明很多人在monorepo环境里被Node版本折腾过。

正确的做法是,Node版本用nvm、fnm或Volta管理,pnpm这时只做一个事情:检查你的Node版本是否符合项目要求。你可以在根目录package.json加上:

{ "engines": { "node": ">=18.0.0", "pnpm": ">=8.0.0" } }

再配合.npmrc:

engine-strict=true

这样Node版本不对时,pnpm会直接报错并中断安装,提示你升级或切换。Windows上我建议用nvm-windows,macOS/Linux用nvm。

如果你确实需要快速获取某个Node版本,可以用pnpm env系列命令。比如pnpm 8的pnpm env use --global 18可以帮你下载并切换到Node 18。这是pnpm官方提供的Node版本管理能力,虽然不是主推功能,但应急时很实用。下载慢的话先给pnpm配好registry镜像,env命令同样会走镜像。

结尾:几点实在的体会

搞完这一整套pnpm + monorepo之后,我最大的感受是:Monorepo解决的是工程统筹问题,但如果不选择一个真正理解工作区的包管理器,前面省下来的时间会在各种奇怪问题里加倍赔回去。pnpm不是没有学习成本,光是符号链接、内容寻址存储、依赖隔离这几个概念就够琢磨一阵,但一旦适应了它的"规矩",反而会觉得npm和yarn那种松散的结构更让人提心吊胆。

最后再分享一个小习惯:我会在根目录放一个.nvmrc文件,里面写上Node版本号,配合.npmrc的engine-strict,团队任何成员clone下来,一步安装、一步运行,基本不会再有人因为"我本地环境不一样"干瞪眼。如果你正在从npm或yarn迁移到pnpm+monorepo,建议先从一个两个包的规模开始练手,别一上来就搬十几个包,那只会让你在头一个星期就想放弃。跑通一个小规模案例之后,再逐步扩大,整个过程会顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询