1. 多包仓库的痛点与 lerna 的定位前端项目一旦超过三个代码复用就会变成一件让人头疼的事。你可能经历过这样的场景A 项目里写了一个工具函数B 项目想用复制粘贴过去之后A 改了 B 忘了改线上跑出两个版本的行为差异。又或者公司内部有一套组件库、一套工具函数、一套请求封装每个都单独建了 Git 仓库改一个底层依赖要同时发三个包、升三次版本号、通知所有使用方更新光是协调成本就够喝一壶。lerna就是冲着这个场景来的。它是一个基于 Git 和 npm 的多包仓库管理工具核心解决的问题是把多个相互依赖的 npm 包放在同一个仓库里管理让它们可以互相引用、统一发版、统一构建。你可以把它理解成一个“包编排器”——它不负责打包也不负责依赖安装这部分交给 npm、yarn 或 pnpm它负责的是版本管理、依赖链接和发布流程这三件事。我最初接触 lerna 是在一个中台项目里当时仓库里有 12 个包3 个基础工具包、4 个业务组件包、2 个 SDK 包、3 个应用壳。如果用多仓库模式每次改一个基础工具就要手动去 11 个仓库里升级依赖、跑测试、发版本。换成 lerna 之后改完代码一条命令批量发版依赖关系自动处理效率提升非常明显。这篇文章适合三类人看一是正在维护多个相互依赖 npm 包、被版本协调折磨的开发者二是准备搭建公司内部组件库或工具集、在选型阶段的技术负责人三是对 monorepo 模式感兴趣、想搞清楚 lerna 到底能干什么、不能干什么的工程师。我会从整体设计思路讲到具体实操把踩过的坑和验证过的方案都摊开来说。2. 整体架构设计与方案选型思路2.1 为什么是 monorepo 而不是 multirepo在决定用 lerna 之前得先想清楚一个问题你到底需不需要 monorepo。monorepo 不是银弹它解决了一部分问题同时引入了新的复杂度。多仓库模式的优势在于边界清晰、权限独立、CI 互不干扰。但它的代价是跨仓库修改困难、依赖版本容易漂移、本地调试需要npm link或者yalc这类工具辅助。当你的包之间存在频繁的相互依赖时多仓库的协调成本会指数级上升。monorepo 模式把这些问题内部化所有包在一个仓库里改依赖就像改本地文件一样直接lerna会自动把本地包软链到node_modules里不需要发版就能调试。代价是仓库体积变大、CI 需要做增量构建、权限管理变复杂。我的判断标准很简单如果包之间的依赖关系是“树状”的底层包被上层包依赖上层包之间不互相依赖且改动频繁就适合 monorepo如果包之间完全独立、由不同团队维护、发布节奏完全不同就老老实实多仓库。2.2 lerna 的两种工作模式lerna 有两种运行模式这是选型时最容易搞混的地方必须说清楚。Fixed 模式固定模式所有包共用一个版本号发版时统一升级。比如仓库里有utils、ui、sdk三个包当前都是1.2.0你改动了utils执行发版后三个包全部变成1.3.0。这种模式适合包之间耦合紧密、总是一起发布的场景比如一个组件库拆出来的多个子包。Independent 模式独立模式每个包独立维护版本号发版时 lerna 会检测哪些包有改动只给有改动的包升版本。utils可以是1.5.0ui还是0.8.2互不影响。这种模式适合包之间相对独立、各自有发布节奏的场景。选哪种模式取决于你的发布策略。我个人的经验是内部基础库用 Fixed 模式因为使用方通常希望所有包版本对齐对外发布的 SDK 或工具集用 Independent 模式因为不同包的成熟度不一样强行对齐版本号反而会造成困扰。2.3 lerna 与 npm/yarn/pnpm workspaces 的关系很多人会问现在 npm、yarn、pnpm 都支持 workspaces 了还需要 lerna 吗答案是lerna 不替代包管理器它是在包管理器之上的编排层。workspaces 负责的是依赖提升和本地包链接lerna 负责的是版本管理和发布流程。两者是互补关系。从 lerna 6 开始它已经不再自己处理依赖安装和链接而是完全交给底层的包管理器npm/yarn/pnpm。lerna 只保留version和publish这两个核心命令以及run命令来批量执行脚本。这个变化让 lerna 变得更轻、更专注。我现在的组合是pnpm workspaces lerna。pnpm 负责依赖安装和链接它的硬链接机制节省磁盘空间且对 phantom dependencies 处理更严格lerna 负责版本管理和发布。这个组合在多个项目里验证下来很稳。3. 环境搭建与核心配置实操3.1 初始化仓库与目录结构先建一个空仓库初始化 package.json然后安装 lerna。mkdir my-monorepo cd my-monorepo git init npm init -y npm install lerna --save-dev npx lerna init执行lerna init后会生成lerna.json和packages目录。默认是 Fixed 模式如果要改成 Independent 模式加--independent参数。npx lerna init --independent生成的lerna.json长这样{ $schema: node_modules/lerna/schemas/lerna-schema.json, version: 0.0.0, packages: [packages/*] }packages字段定义了包的搜索路径默认是packages/*。你可以改成apps/*和libs/*分开管理比如{ packages: [apps/*, libs/*, tools/*] }目录结构建议这样组织my-monorepo/ ├── packages/ │ ├── utils/ # 基础工具包 │ │ ├── src/ │ │ ├── package.json │ │ └── tsconfig.json │ ├── ui/ # 组件包依赖 utils │ │ ├── src/ │ │ └── package.json │ └── sdk/ # SDK 包依赖 utils │ ├── src/ │ └── package.json ├── lerna.json ├── package.json └── pnpm-workspace.yaml3.2 配置 pnpm workspaces在根目录建pnpm-workspace.yamlpackages: - packages/* - apps/* - libs/*然后在根package.json里加脚本{ scripts: { build: lerna run build, test: lerna run test, clean: lerna run clean, version: lerna version, publish: lerna publish } }这里有个关键点lerna run默认会按照包的拓扑顺序执行脚本。也就是说如果ui依赖utils执行lerna run build时utils的 build 会先跑完再跑ui的 build。这个特性在多包构建时非常重要避免了手动排序的麻烦。3.3 包之间的依赖引用在ui包的package.json里引用utils{ name: my-scope/ui, version: 1.0.0, dependencies: { my-scope/utils: workspace:* } }workspace:*是 pnpm 的协议表示引用工作区内的本地包。发布时 pnpm 会自动替换成实际版本号。如果你用的是 npm 或 yarn需要写具体版本号lerna 在发版时会自动更新。注意包名一定要加 scope比如my-scope/否则发布到公共 registry 时容易和别人的包名冲突。内部包如果不想发布在 package.json 里加private: true。3.4 lerna.json 的完整配置一个生产环境可用的lerna.json配置{ $schema: node_modules/lerna/schemas/lerna-schema.json, version: independent, npmClient: pnpm, packages: [packages/*], command: { version: { allowBranch: [main, release/*], conventionalCommits: true, message: chore(release): publish %s, ignoreChanges: [**/*.md, **/*.test.ts, **/__tests__/**] }, publish: { access: public, registry: https://registry.npmjs.org/ } } }几个关键配置解释一下allowBranch限制只有指定分支才能发版防止在 feature 分支误操作。conventionalCommits根据 commit message 自动决定版本号升级feat 升 minorfix 升 patchBREAKING CHANGE 升 major。ignoreChanges这些文件改动不触发版本升级避免改个 README 就发版。access: public发布 scoped 包时默认是 private需要显式声明 public。4. 版本管理与发布流程详解4.1 版本号升级的三种方式lerna 的version命令支持三种版本升级方式方式一手动指定版本号npx lerna version 1.2.0所有包统一升到1.2.0Fixed 模式或者交互式选择每个包的版本Independent 模式。方式二语义化关键词npx lerna version patch # 1.0.0 - 1.0.1 npx lerna version minor # 1.0.0 - 1.1.0 npx lerna version major # 1.0.0 - 2.0.0方式三根据 commit 自动判断npx lerna version --conventional-commits这是我最推荐的方式。它会分析自上次发版以来的所有 commit根据 conventional commits 规范自动决定每个包的版本升级幅度。feat:开头的 commit 触发 minor 升级fix:触发 patch带BREAKING CHANGE:的触发 major。4.2 发布流程的完整步骤一个标准的发布流程是这样的确保所有改动已提交工作区干净。跑一遍完整测试npx lerna run test。执行版本升级npx lerna version --conventional-commits。lerna 会列出将要升级的包和版本号确认后它会自动修改 package.json、生成 CHANGELOG、打 Git tag、提交。推送到远程git push --follow-tags。执行发布npx lerna publish from-git。from-git表示从 Git tag 读取版本信息发布避免重复计算。也可以用from-package从 package.json 读取。实操心得发布前一定要先跑npx lerna changed看看哪些包有改动。这个命令会列出所有自上次发版后有变更的包避免漏发或误发。我有一次改了一个底层工具包忘了它被三个上层包依赖结果只发了一个包导致线上依赖版本不一致排查了半天。4.3 依赖版本自动更新机制lerna 在发版时有一个很重要的行为当一个包升级时所有依赖它的包如果依赖版本范围不匹配新版本lerna 会自动更新依赖声明并一并发版。举个例子ui依赖utils^1.0.0现在utils升到1.1.0^1.0.0范围匹配ui不需要发版。但如果utils升到2.0.0^1.0.0不匹配lerna 会自动把ui的依赖改成^2.0.0并给ui也升一个 patch 版本一起发布。这个机制保证了依赖链的一致性但也意味着一次底层包的 major 升级可能触发大量上层包发版。所以底层包的 breaking change 要慎重最好提前通知所有使用方。4.4 CHANGELOG 自动生成开启conventionalCommits后lerna 会为每个包生成独立的 CHANGELOG.md内容来自该包相关的 commit。这要求你的 commit message 规范且 commit 时标注了影响的包。如果 commit 没有明确标注包名lerna 会根据文件改动路径来判断。比如改动packages/utils/src/index.ts这个 commit 就会归到utils包的 CHANGELOG 里。注意CHANGELOG 的质量完全取决于 commit message 的质量。建议在项目里配置 commitlint husky强制规范 commit message。我见过太多项目 CHANGELOG 里全是 update、fix bug 这种无意义内容等于白生成。5. 常见问题与排查技巧实录5.1 依赖链接失效问题现象本地改了utils的代码ui里引用时还是旧版本。原因包管理器没有正确建立软链或者node_modules里有缓存的旧版本。排查步骤检查ui/node_modules/my-scope/utils是不是软链到packages/utils。用ls -la看。如果不是软链删掉所有node_modules和 lock 文件重新pnpm install。如果是软链但代码没更新检查utils的main字段指向的是src还是dist。如果指向dist需要先 build。解决方案在开发阶段把utils的main指向src/index.ts配合 tsconfig 的 paths 映射实现源码级热更新。发布时再改成dist。5.2 发版时提示 No changed packages to version现象明明改了代码执行lerna version却说没有包需要升级。原因通常是ignoreChanges配置把改动过滤掉了或者改动没有提交到 Git。排查npx lerna changed --all加--all参数会列出所有包不管有没有改动。对比一下就知道是哪个环节出了问题。还有一种情况改动只在未跟踪的文件里lerna 只检测已提交的改动。先git add再试。5.3 发布权限与 registry 配置现象lerna publish报 403 或 401 错误。原因npm token 没配置或者 scope 的 registry 指向不对。解决npm login npm whoami # 确认登录状态如果用的是私有 registry在.npmrc里配置my-scope:registryhttps://your-registry.com/ //your-registry.com/:_authToken${NPM_TOKEN}CI 环境里通过环境变量注入 token不要硬编码在文件里。5.4 常见问题速查表问题现象可能原因排查命令解决方案本地包引用不到软链未建立ls -la node_modules/scope/重装依赖发版无变更改动未提交或忽略规则lerna changed --all检查 git status 和 ignoreChanges发布 403token 失效或权限不足npm whoami重新登录或更新 token构建顺序错误拓扑依赖未识别lerna run build --graph检查 package.json 依赖声明版本号冲突远程已有同版本npm view scope/pkg versions手动指定更高版本CHANGELOG 为空commit 不规范git log --oneline配置 commitlint5.5 独家避坑技巧技巧一用--no-private跳过私有包发布。如果仓库里有不发布的包比如应用壳在 publish 时加这个参数避免误发。技巧二发版前用--dry-run预演。lerna version --dry-run和lerna publish --dry-run会展示将要执行的操作但不实际执行适合在 CI 里做校验。技巧三CI 里用--yes跳过交互。自动化发布时加--yes参数避免卡在交互确认上。技巧四用lerna run --scope精准执行。只跑某个包及其依赖的脚本lerna run build --scopemy-scope/ui --include-dependencies。技巧五定期清理无用包。仓库里废弃的包要及时删掉否则每次lerna run都会遍历它们拖慢速度。6. 构建优化与 CI 集成实践6.1 增量构建与缓存多包仓库的构建时间会随着包数量增加而线性增长。优化手段有两个方向只构建有改动的包和缓存构建结果。lerna 本身不提供缓存但可以配合nx或turborepo使用。从 lerna 6 开始它内置了对 nx 的支持在lerna.json里加{ useNx: true }然后安装 nxnpm install nx --save-dev。nx 会自动分析包的依赖图和文件改动只重新构建受影响的包并缓存构建产物。实测在 12 个包的仓库里全量构建从 8 分钟降到 1 分半。如果不想引入 nx也可以用lerna run build --sinceorigin/main只构建自 main 分支以来有改动的包。6.2 CI 流水线配置一个典型的 CI 配置以 GitHub Actions 为例name: CI on: push: branches: [main] pull_request: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 with: fetch-depth: 0 - uses: pnpm/action-setupv2 with: version: 8 - uses: actions/setup-nodev4 with: node-version: 18 cache: pnpm - run: pnpm install --frozen-lockfile - run: npx lerna run build --sinceorigin/main - run: npx lerna run test --sinceorigin/main关键点fetch-depth: 0必须加否则 lerna 拿不到完整的 Git 历史无法判断哪些包有改动。6.3 发布流水线发布单独建一个 workflow只在打 tag 时触发name: Publish on: push: tags: - v* jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: pnpm/action-setupv2 - uses: actions/setup-nodev4 with: node-version: 18 registry-url: https://registry.npmjs.org - run: pnpm install --frozen-lockfile - run: npx lerna publish from-git --yes env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}from-git从 tag 读取版本--yes跳过确认NODE_AUTH_TOKEN从 secrets 注入。6.4 性能对比数据在一个 12 包仓库里的实测数据仅供参考具体因项目而异操作无优化加 --since加 nx 缓存全量构建8min3min1.5min全量测试5min2min1min发版流程2min2min2min构建和测试的优化效果明显发版流程本身耗时不多优化空间有限。7. 迁移与扩展的实战建议7.1 从多仓库迁移到 monorepo迁移不是一蹴而就的建议分三步走第一步先合并工具包。把最底层、被依赖最多的工具包先迁进来验证 monorepo 的构建和发布流程。这一步风险最小收益也最直接。第二步迁移中间层包。组件库、SDK 这类依赖工具包的包在工具包稳定后迁入。迁移时注意把dependencies里的版本号改成workspace:*。第三步迁移应用层。应用壳最后迁因为它们通常不发布只是消费方。迁入后可以享受本地调试的便利。每一步之间留出观察期确保线上稳定再继续。7.2 仓库体积控制monorepo 的 Git 仓库会越来越大克隆速度变慢。几个控制手段构建产物dist、lib加进.gitignore不要提交。大文件用 Git LFS 管理。定期用git gc清理。如果历史包袱太重考虑用git filter-repo精简历史或者干脆开新仓库。7.3 权限与代码审查monorepo 的一个挑战是权限管理。所有代码在一个仓库里如何确保不同包的 owner 只审查自己包的改动可以用CODEOWNERS文件packages/utils/ team-utils packages/ui/ team-ui packages/sdk/ team-sdk配合 CI 里的路径过滤只跑受影响包的测试减少无关团队的等待时间。7.4 后续扩展方向lerna 的生态还在演进。几个值得关注的方向与 turborepo 结合turborepo 的远程缓存能力比 nx 更轻量适合中小团队。changesets 替代方案changesets 是另一个版本管理工具交互更友好适合 Independent 模式。lerna 和 changesets 可以共存各取所长。发布到私有 registry如果公司有内部 registrylerna 的registry配置可以指向私有地址配合 CI 的 token 注入实现自动化发布。我在实际使用中的体会是lerna 的价值不在于它有多强大而在于它把多包管理的复杂度收敛到了一套可预测的流程里。你不需要记住每个包的版本号不需要手动协调依赖关系不需要担心发版顺序。这些事交给工具你专注写代码就好。踩过的坑主要集中在依赖链接和发版配置上把这两块搞明白后面基本就是顺水推舟。最后分享一个小技巧在根目录建一个scripts/目录放自定义脚本比如批量检查包名规范、批量更新 license 文件配合lerna run使用能省下不少重复劳动。