十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

pnpm+monorepo完整实践:从安装到Electron打包的踩坑指南

pnpm+monorepo完整实践:从安装到Electron打包的踩坑指南 在npm和yarn轮流折磨了我好几个项目之后我最终倒向了pnpm并且把手上能拆的多包项目全部改成了monorepo结构。说实话这个组合一开始并不好上手网上资料又乱我自己前后踩了不止一个星期的坑从安装pnpm到配置workspace再到Electron打包时依赖离奇丢失每一步都有想砸电脑的冲动。但一旦跑通工作效率的提升是实打实的磁盘空间也能省出一大截。这篇东西就是我重新梳理后的完整落地记录按照从零到一、再到进阶的顺序来写涵盖pnpm安装、基础骨架搭建、依赖管理、版本发布以及Electron打包这种特殊场景最后附上我遇到过的报错和排查方法给正在折腾pnpmmonorepo的人一个能直接抄作业的参考。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可能超过1GBpnpm只需要五六百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 pnpm8如果你的Node版本在16.13以上还可以用Node自带的Corepack来管理corepack enable corepack prepare pnpmlatest --activateCorepack是Node官方提供的包管理器管理工具好处是你可以根据不同项目切换pnpm版本团队协作时还能通过package.json里的packageManager字段锁定版本避免我本地能跑你本地跑不了的尴尬。Windows用户还有两种选择一是用wingetwinget install pnpm二是用官方提供的PowerShell脚本iwr https://get.pnpm.io/install.ps1 -useb | iexmacOS和Linux则可以用curl脚本或者Homebrewcurl -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-dirD:\pnpm-store global-dirD:\pnpm-global global-bin-dirD:\pnpm-global\bin cache-dirD:\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下排查思路是这样的按WinR输入sysdm.cpl打开系统属性进入高级 - 环境变量在用户变量或系统变量里找Path。确认pnpm的安装路径是否在Path里。如果你是用npm装的路径一般是C:\Users\你的用户名\AppData\Roaming\npm如果你用官方脚本装路径可能是C:\Users\你的用户名\AppData\Local\pnpm。如果路径确实在但命令行还是报错八成是没重启终端。改完Path后所有已打开的终端窗口都不会生效必须全部关闭重新开。还有一种情况你用的终端是PowerShell执行外部脚本会被执行策略拦下来。可以试试在当前会话临时解除Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUsermacOS和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 --depth0看全局包里是否有pnpm没有就说明装失败了重新装。3. 搭建monorepo基础骨架3.1 初始化工作区环境准备好之后终于可以开始搭工程了。我先说清楚整个目录长什么样这样后面操作你会有画面感my-monorepo/ ├── packages/ │ ├── ui/ # 公共组件库 │ ├── utils/ # 工具函数 │ └── shared/ # 公共类型定义 ├── apps/ │ ├── web/ # 前端应用 │ └── desktop/ # Electron桌面应用 ├── package.json ├── pnpm-workspace.yaml ├── tsconfig.base.json └── .npmrcpnpm的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:^发布的时候如果忘了转换会出问题。这里建议配置.npmrclink-workspace-packagestrue然后发布前用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-buildstrue但这会放开所有依赖脚本的执行权限存在供应链安全风险我自己不推荐团队协作时更不要这么干。4.3 版本管理与changesetsmonorepo里子包一多版本管理就成了大问题。如果手动改版本号今天改了a明天忘了b发布出去就乱套。业界主流做法是搭配changesets。先安装pnpm add -D -w changesets/cli npx changeset initchangesets的工作流是改代码 - 执行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_mirrorhttps://npmmirror.com/mirrors/electron/ electron_builder_binaries_mirrorhttps://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内部默认把应用打包进asarfs操作某些文件时路径要写成process.resourcesPath拼接的模式不能依赖__dirname一层层往上翻。代码层面要提前处理好。第三个坑是sqlite等原生模块。原生模块不会被pnpm硬链接正确带入还因为编译产物是按Node ABI来的Electron的Node版本和本机Node版本如果不一致就会出现NODE_MODULE_VERSION不匹配。处理方式是使用electron-rebuildpnpm 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镜像重装electronpostinstall脚本没执行pnpm 10默认禁止依赖脚本package.json中配置onlyBuiltDependencies子包间版本永远不更新忘了跑changeset version提交前记得生成changeset发布前执行版本更新这些报错里ERR_PNPM_NO_MATCHING_VERSION我特别说一下它经常出现在你新增了一个本地包、但还没提交到git的时候。pnpm解析workspace协议时按仓库内最新代码来看理论上应该找得到但有时候锁文件缓存有问题。执行pnpm install如果还是不行把node_modules和锁文件删掉重来一遍pnpm store prune pnpm install6.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 patchpnpm 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 } }再配合.npmrcengine-stricttrue这样Node版本不对时pnpm会直接报错并中断安装提示你升级或切换。Windows上我建议用nvm-windowsmacOS/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迁移到pnpmmonorepo建议先从一个两个包的规模开始练手别一上来就搬十几个包那只会让你在头一个星期就想放弃。跑通一个小规模案例之后再逐步扩大整个过程会顺畅很多。
返回列表