普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月1日首页

我的 Monorepo 实践经验:从基础概念到最佳实践

作者 Dilettante258
2026年3月1日 14:35

本文将系统地整理我在 Monorepo 和前端工程化方面的一些实践经验,先简单介绍几个概念。

Monorepo(单一代码仓库):Monorepo 是将多个应用和共享库放在同一个仓库中进行管理。与多仓库模式相比,Monorepo 更加高效,尤其在代码共享、依赖升级和跨项目协作方面。

工程化:通过规范、工具链和流程,把“能跑”提升到“可维护、可协作、可持续交付”。它覆盖的不只是开发,还包括构建、测试、发布、质量保障等完整生命周期。

Monorepo 里最关键的抽象:应用包与库包

应用包(App)

应用包是最终会被部署的项目,通常放在 apps/*,例如 Web、Admin、Docs、BFF 等。

库包(Package)

库包用于复用能力,通常放在 packages/*,本身一般不直接部署,而是被应用包消费。

在 Monorepo 里,库包常见有三种策略:

  1. 可发布包(Publishable Package)
  2. 预构建包(Compiled Package)
  3. 源码引用包(Source Package)

选择哪种策略取决于具体场景,没有绝对的优劣之分。

三种库包策略介绍

1) 可发布包:面向仓库外复用

如果这个包是要提供给外部团队或开源用户使用,那么应该采用可发布包的方式。

优点:

  • 对外分发标准清晰,边界明确
  • 可独立版本化,兼容性管理更规范

代价:

  • package.json 字段配置更复杂(nameexportstypesfilespublishConfig 等)
  • 可能需要考虑 CJS/ESM 的导出兼容与消费方式(如 import/require、不同 bundler 解析差异)
  • 需要维护发版流程、版本语义和变更记录
  • 在仓库内频繁迭代时,版本与锁文件更新会增加心智负担

2) 预构建包:面向仓库内复用、兼顾稳定与性能

预构建包首先会构建出 dist 文件,应用包再消费这些构建结果。

优点:

  • 应用包构建更加稳定,模块之间的边界更加清晰
  • 减少重复转译,尤其在大仓库中

代价:

  • 需要维护 build 步骤与产物一致性
  • 调试链路比直接使用源码更长

一个典型配置:

{
  "name": "@workspace/utils",
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "default": "./dist/index.js"
    }
  },
  "scripts": {
    "build": "tsc"
  }
}

在工作区里引用通常用:

{
  "dependencies": {
    "@workspace/utils": "workspace:*"
  }
}

3) 源码引用包:开发体验优先

源码引用包直接导出 src/*.ts(x),由应用包的构建工具(如 Vite、Webpack等)完成转译。

优点:

  • 配置简单,改完即生效,开发体验好
  • 少一层“先打包再消费”的步骤

代价:

  • 应用包需要承担类型检查和转译的成本
  • 对 TypeScript 配置一致性要求更高

示例:

{
  "name": "@workspace/utils",
  "exports": {
    "./tool": "./src/tool.ts"
  }
}

这种模式通常不需要 build 脚本,但建议保留独立的类型检查脚本(例如 typecheck)。

很多团队最终会混用这三种策略:

  • UI 组件用预构建或源码引用
  • 配置类包(eslint、tsconfig)走源码引用
  • SDK 或公共能力包走可发布流程

如何初始化一个靠谱的 Monorepo

最好的学习方法就是模仿。可以参考 Turborepo Getting Started 页面。提供的示例

例如,Kitchen Sink中的 @repo/ui 采用了预构建包的方式。如果你想使用预构建包,可以重点查看 apps/admin 是如何消费它的。示例中还展示了 eslint-config 和 tsconfig 这类公共配置包的使用。不同包通过继承 base 配置,既能保证代码风格一致,也能针对不同项目做细化适配(比如规则微调或插件加载)。

如果你更倾向于使用源码引用包,可以参考Vite + React 示例。这个例子里的 @repo/ui 采用的就是源码引用包。

另外,Turborepo 还有一个面向 AI 的 best-practices/RULE.md,也可以读一读:链接

因为 Turborepo 本身就是 Monorepo 的任务编排工具,所以它的文档和示例质量都很高,值得反复参考。

共享 tsconfig.json:把配置做成一个包

在 monorepo 中创建一个共享的 tsconfig 配置包(比如放在 packages/typescript-config 里)是一个常见的做法:把一些通用的 TypeScript 配置写在基础配置(base.json)里,然后让各个项目(比如 Next.js 应用、库项目等)通过 extends 引用这个基础配置。这样整个仓库能有一致的 TS 设置。

packages/typescript-config 可以声明一个 package.json

{
  "name": "@repo/typescript-config"
}

关于 tsconfig references

在 Monorepo 中,很多人都会接触到 TypeScript 的 references 配置。Turborepo 官方建议,大多数情况下不需要使用 TypeScript 项目引用。

参考: You likely don’t need TypeScript Project References

它会引入额外的配置和缓存层,这可能会在使用 Turborepo 时带来问题,且很少能带来实际的好处。

具体来说:

  • 额外的配置:使用 TypeScript 项目引用时,你需要在不同的项目之间配置相应的 tsconfig.json 文件,这增加了配置的复杂度。

  • 额外的缓存层:TypeScript 项目引用为每个项目生成独立的构建输出,需要将缓存目录配置到.gitignore中,turbo.json中。

但在某些特定场景下,如 Hono RPC 的前后端类型联动时,项目引用非常必要。 参考:

如果你使用了源码引用包,建议统一关键编译选项(如 modulemoduleResolution),避免跨包解析不一致。

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler"
  }
}

references 示例:

{
  "references": [{ "path": "../packages/utils/tsconfig.json" }]
}

关于 package imports(子路径导入)

在编写子包代码时,可以在 tsconfig 中使用 compilerOptions.paths 来创建别名。然而,这个别名只在当前 TypeScript 配置上下文中有效,不会被其他包自动读取。

如果你使用 TypeScript 5.4+,推荐使用 Node.js 的子路径导入(imports)来替代 TypeScript 的路径别名,在 package.json 内编写:

{
  "imports": {
    "#*": "./src/*"
  }
}

那源码中可以这样引用自己的文件:

import { MY_STRING } from "#utils.ts"; // Uses .ts extension
export const Button = () => {
  return <button>{MY_STRING}</button>;
};

通过这种方式,可以在模块内部使用子路径导入,不会受 TypeScript 配置的限制。这里的imports 主要解决包内部如何引用,exports 主要解决包对外暴露什么。也就是说,跨包消费还是走包名和 exports,而不是把 imports 当成跨包 alias。

这种模式下要注意导入路径和产物格式保持一致(例如编译包需要使用 .js 后缀)。

关于跨包“跳转到定义”的实现

在 Monorepo 项目中,多个包通常是彼此依赖的。如果你希望在 IDE(如 VSCode)中通过“跳转到定义”功能,在不同包之间轻松导航(例如,从 ui 包跳转到 utils 包中的代码),需要进行一些配置,以确保不同包之间的 TypeScript 类型信息能够正确链接和识别。

对于预构建包,当包已经编译后,跳转到定义的功能通常不会直接跳转到源代码。例如,点击一个 A.js 的导出,编辑器将跳转到 dist 文件夹中的生成代码,而不是源代码。为了确保跳转功能正常工作,需要在 TypeScript 配置文件中启用 declarationdeclarationMap 选项。这样生成的 .d.ts(类型声明文件)和 .d.ts.map(源映射文件)就能帮助编辑器找到原始的 TypeScript 源代码。

配置示例:

{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true
  }
}

我个人对这个问题还有另一种替代方法,可以通过配置 allowImportingTsExtensionsrewriteRelativeImportExtensions 来解决,同时改成源码引用包。启用这两个选项后,编辑器会识别并允许在代码中显式地使用 .ts 扩展名进行模块导入,这样跳转功能会直接指向原始的 TypeScript 源代码。代码里原有的import ’./A.js' 也可以改成import ’./A.ts'了。

相关配置如下:

{
  "compilerOptions": {
    "allowImportingTsExtensions": true,
    "rewriteRelativeImportExtensions": true
  }
}

配置项解释:

  • allowImportingTsExtensions:此配置项允许你在导入模块时显式使用 .ts 扩展名。默认情况下,TypeScript 会自动忽略文件扩展名,但启用此选项后,你可以在 import 语句中明确指定 .ts 扩展名。

  • rewriteRelativeImportExtensions:此配置项使得 TypeScript 在生成 JavaScript 代码时,会自动将相对路径导入的 .ts.tsx 扩展名重写为 .js 扩展名。这样,在 TypeScript 代码中使用 .ts 扩展名导入文件时,最终生成的 JavaScript 代码会使用 .js 扩展名,从而确保路径的兼容性。

通过这些配置,开发者可以更方便地在不同包之间进行跳转,提升开发效率。

用Turborepo 管理Package Graph 和 Task Graph

在一个大型项目中,或者跨语言项目中,可能会有很多命令。不同的包可能有自己的build命令,dev命令,lint命令,test命令,类型检查命令等等。这些包之间,可能还有依赖关系。一个命令以另一个命令完成为前提,而Turborepo可以很好的完成,这一节我们展示如何用 Turborepo 进行管理。

Package Graph(包图)

Turborepo 自动从你的 Monorepo 结构和各个子包的 package.json 里找出来的依赖关系图。比如你有一个 apps/web 应用,它依赖两个库 packages/uipackages/utils,Turborepo 就会把这些关联“连成线”,构成一个图,形成所有包之间的依赖网络。这个图是 Task Graph 的基础。

Task Graph(任务图)

任务图是 Turborepo 通过你的 turbo.json 配置和上面那个 Package Graph 从你的任务定义里构建出来的一个 有向无环图。节点(node)是任务(比如 build、lint、test),边(edge)表示任务之间的依赖关系——也就是 “这个任务要等另一个任务跑完才能运行”。

如果一个任务(比如 build)在 turbo.json 里写了 dependsOn: ["^build"],这就表示“在当前包的 build 任务之前,先跑掉所有它依赖的包的 build 任务”。这种依赖关系会被表示成一条从依赖任务指向当前任务的边。

例如,执行apps/web 应用的build命令之前,会先运行 packages/uipackages/utilsbuild命令。Turborepo 还有自己的缓存策略,通过指定任务的inputsoutputs,它可以观察文件是否改动,如果没有改动,就可以直接跳过这个步骤。同时,这两个任务还可以最大程度地并行化执行,提升构建效率。

此外,任务还有分类,如持续任务,可以将任务声明为"persistent": true。一些持续任务可能还需要另一个任务始终同时运行,如后端服务器,亦或者是路由库的router-cli,通过with字段可以设置自动启动。一些任务即使非TS语言仓库,也支持加入到Turborepo的任务流中。

Turborepo还支持使用tui在一个终端内同时查看所有日志并与任务进行交互。

Turborepo 能够提高构建与任务运行的效率,通过并行执行、缓存命中等优化手段加速你的 Monorepo 工作流。更多相关内容,请参考Crafting your repository - Configuring tasks

Catalog 目录协议

在 Monorepo 中,使用相同的依赖项版本非常常见。通过 pnpm-workspace.yaml 中的 Catalog 协议,我们可以减少依赖的重复并保持一致性:

  • 维护唯一版本 - 我们通常希望在工作空间中共同的依赖项版本一致。 Catalog 让工作区内共同依赖项的版本更容易维护。 重复的依赖关系可能会在运行时冲突并导致错误。 当使用打包器时,不同版本的重复依赖项也会增大项目体积。
  • 易于更新 — 升级或者更新依赖项版本时,只需编辑 pnpm-workspace.yaml 中的目录,而不需要更改所有用到该依赖项的 package.json 文件。
  • 减少合并冲突 — 由于在升级依赖项时不需要编辑 package.json 文件,所以这些依赖项版本更新时就不会发生 git 冲突。

如果你使用的pnpm,可以参考这个文档,如果是bun,则可以参考这个文档

以 pnpm 管理的 workspace 为例,它是这么使用的:

pnpm-workspace.yaml 中定义:

packages:
  - packages/*

# 定义目录和依赖版本号
catalog:
  react: ^18.3.1
  redux: ^5.0.1
{
  "name": "@example/app",
  "dependencies": {
    "react": "catalog:",
    "redux": "catalog:"
  }
}

Enginue 和包管理器配置

在 Monorepo 中使用合适的包管理器配置是至关重要的。尤其是在使用 pnpm 时,可以指定运行的 Node 版本以及 pnpm 的版本,确保包管理器和 Node.js 的版本一致,避免版本不兼容的问题。

{
  "engines": {
    "node": ">=10",
    "pnpm": ">=9"
  },
  "packageManager": "pnpm@9.3.0"
}

在本地开发时, 如果其版本与 engines 字段中指定的版本不匹配,pnpm 将始终失败并报错。

与之相对应的,还有一个pnpm-workspace.yaml的配置字段nodeVersion,当同时设置了 engine-strict=true 时,npm 会在安装包时检查你的 Node.js 版本是否大于或等于设置的版本范围(应当填精确的语义化版本号),如果不符合,安装会被拒绝。例如,当开发公共包时,设置这个选项可以保证不安装不支持特定node版本的依赖。参见链接

nodeVersion: 22.22.0
engineStrict: true

还有一个就是如果你使用了nvm配置,有时候项目根部会有一个.nvmrc文件来指定版本,这样在该目录下唤起Node时,会自动启动相应版本的Node。 例如可以设置useNodeVersion: 16.16.0。pnpm 将自动安装指定的 Node.js 版本,并使用它来运行pnpm run命令pnpm node。参见链接

Monorepo 的 hoist

Hoisting(提升)是指在安装依赖时,某些依赖会被提升到 node_modules 的顶层(根目录)。这种行为确保了在整个项目中可以共享某些常用的依赖包,而不是每个子包都单独安装一份。这有助于避免重复安装相同版本的依赖,减少磁盘空间的占用。

在 npm 和 yarn 中,依赖项的 hoisting 行为通常是自动的。当你安装依赖时,它们会根据包的依赖关系被扁平化,并被提升到 node_modules 根目录中。在 pnpm 中,依赖不会像在 npm 或 yarn 中那样自动扁平化,而是根据每个包的依赖结构创建嵌套的 node_modules 目录。

转存失败,建议直接上传图片文件

默认情况下,pnpm 创建一个半严格的 node_modules,所有依赖项都会被提升到 node_modules/.pnpm/node_modules。这使得 node_modules 中的所有包都可以访问未列出的依赖项,而 node_modules 之外的模块不行。通过这种布局,大多数的包都可以正常工作。

但是也会有一些不能正常工作例外,但你可以通过配置来控制。这种情况下,可能需要设置publicHoistPattern属性。命中的模块,会安装到根模块目录node_modules中。例如,之前版本的pnpm的默认配置['types', 'eslint', '@prettier/plugin-*', 'prettier-plugin-'],项目如果依赖了 eslintbabel,可以看到根模块目录中如下所示。一般来说,我们不需要关心这个,如果需要配置,依赖的文档会讲这些。

> tree node_modules -L 1
node_modules
  ├── @babel
  ├── @eslint
  ├── @types
  ├── @typescript-eslint
  ├── eslint
  ├── eslint-config-ali
  ├── eslint-import-resolver-node
  ├── eslint-module-utils
  ├── eslint-plugin-import
  ├── eslint-plugin-jsx-plus

关于 hoist 的更多知识,可以参考这个文章:A diagram to show how pnpm works

一些值得设置的 npmrc 配置或者 pnpm-workspace 设置

npmrcpnpm-workspace 中设置适当的配置项,能有效提高项目管理效率。

npmrc

  • registry:指定 npm 使用的默认注册表 URL。

  • save-exact:确保依赖项以精确版本安装,而不是使用版本范围,例如^1.2.3。

pnpm

  • prefer-frozen-lockfile:强制使用锁定文件中的依赖版本。如果设置为 true,即使 package.json 中的依赖有更新,也会优先使用锁定文件(pnpm-lock.yaml)中的版本,避免自动升级。

  • overrides:强制指定某些依赖包的版本,无论这些包在其他依赖包中是否有版本冲突。例如,假设你有两个依赖包 AB,它们依赖于同一个包 C,但它们的版本不同。通过 overrides,你可以强制这两个包都使用 C 的同一版本。


参考资料

  1. TypeScript 5.4: Auto-import support for subpath imports
  2. Turborepo TypeScript 指南
  3. 设置(pnpm-workspace.yaml)
  4. A diagram to show how pnpm works 5.Crafting your repository - Configuring tasks

这一招让 Node 后端服务启动速度提升 75%!

作者 Dilettante258
2026年2月28日 21:58

一个Node 后端项目的启动方式可以分类为三种:

  1. 由源代码直接启动,如tsx src/server.ts
  2. 由tsc简单转译,如tsc 编译后 node dist/server.js
  3. 使用一些bundler进行打包,将其打包为单个文件,如esbuild --bundlenode bundle.mjs

很多人其实并不知道这几种方法之间的区别,今天我想通过具体的测试来区分每种方法的不同。

测试目的

把测试拆成两个维度:

  • 启动阶段性能
  • 服务运行阶段性能

因为,我们可以测试三类数据:

  1. 冷启动会差多少?
  2. 稳态吞吐/延迟会差多少?
  3. 资源占用是否存在显著差异?

测试方法

环境

  • macOS arm64
  • Apple M5 / 10 cores
  • 24GB RAM
  • Node v22.22.0
  • Express 5.1(TypeScript)

三种模式使用同一份 src/server.ts,业务逻辑完全一致,包含基础路由和“mock业务形态”路由:

  • 基础路由(baseline)(参考了一个比较Express4/5版本速度差异的测试方法)

    • GET /ping:返回 "pong",用于测最小路径开销
    • GET /middlewares:挂 50 层 no-op middleware 后返回 { ok: true }
    • GET /json:返回预生成的约 50KB JSON(固定内容,避免每次动态生成噪声)
    • GET /payload:返回预生成的 100KB 文本
  • 业务形态路由(realistic)

    • GET /route1/info
    • GET /route2/stats
    • GET /route3/catalog
    • GET /route4/summary(聚合 route1~3 的服务输出)
    • GET /orm/users(走 Drizzle ORM 查询路径)

ORM 使用 drizzle-orm/sqlite-proxy + 内存数据模拟,不依赖外部数据库,尽量隔离网络与 DB 抖动对对比的干扰。

压测与采样口径

  • 压测命令:ab -k -n 200000 -c 100
  • 每个场景重复 5 次,取均值
  • 冷启动定义:从进程 spawn/ping 首个 200
  • 资源采样:压测期间每秒采样 RSSCPU%

总体结果

1)冷启动

mode cold(ms)
esbuild 104
tsc 308
tsx 399

冷启动的差异非常明显,esbuild 在冷启动上的表现优于 tsctsx,差距达到 75%。

2)平均吞吐(9 场景)

mode avg req/s
tsc 22114
esbuild 21959
tsx 21928

吞吐量差异小于 1%,可见三者在稳态性能上几乎一致。

3)P95 延迟

三种方式的 P95 延迟几乎完全相同,均为 5-6ms。

4)RSS 内存

三者的内存使用几乎一致,均约为 62MB。

测试数据报告:链接


关键问题 1:为什么冷启动差这么多?

冷启动时间的差异可以拆解为以下几个部分:

  1. 模块图加载与文件 IO

    a. 解析 import

    • 静态分析:Node.js 会分析你的 JavaScript 文件中的 import 语句,确定需要加载的模块。这是一个静态分析过程,Node.js 会在执行之前构建模块的依赖关系图(import 图)。这有助于了解哪些模块需要加载,并准备好这些模块的依赖。

    b. 读取文件

    • 加载文件:当 Node.js 发现一个 import 语句,它会根据静态分析结果读取相应的文件内容。如果该模块是一个 JavaScript 文件(.js.mjs),Node.js 会读取文件的内容并将其解析为 JavaScript 代码。
    • 查找模块:Node.js 会查找模块文件的位置,如果模块没有缓存,它会从磁盘读取相应文件。

    c. 构建模块缓存

    • 模块缓存:Node.js 会缓存已加载的模块,这样在多次加载同一个模块时,Node.js 不需要重新执行该模块的代码。这样可以提高性能,避免重复加载和执行相同的模块。
    • 导出模块:在加载并执行完模块后,Node.js 会将模块的导出结果(module.exportsexport)存入缓存中,以便后续调用。

在不同的启动方式下,加载的模块数量和方式有所不同:

  • tsc:编译多个 JS 文件。
  • tsx:编译多个 TS 文件。
  • esbuild:生成单一的打包文件。

esbuild 通过打包将多个模块合并为一个文件,减少了模块解析和文件 I/O 的开销,因此冷启动时间显著较短。

  1. 运行时转译成本(仅适用于 tsx)

tsx 使用 esbuild 编译 TypeScript 和 ESM,还会生成 source map 并内联到代码中。每次启动时,tsx 需要额外进行源代码映射,导致启动速度较慢。而 tsc 编译的是纯 JavaScript,Node.js 不需要做任何 TypeScript 转换,启动速度较快。

冷启动的结论

边缘计算、Serverless、短生命周期容器、CLI 工具,这种场景下,冷启动的速度至关重要,那使用esbuild等打包工具提前bundler而带来的冷启动优势是实打实的。

如果是常驻 API 服务,冷启动只发生一次,意义有限。

但是天下毕竟没有免费的午餐。使用第三方bundle工具提前bundle是不是也有一些坏处呢?是的。

第一点,不支持一些 TypeScript 特性。如esbuild不支持保留如eval()语法,还有就是不支持某些tsconfig.json属性,如emitDecoratorMetadata

第二点,调试难度加大。esbuild 生成的代码通常会做大量的代码压缩、优化和打包,这使得调试变得比较困难。因为调试时的代码结构与原始源代码有很大的差异。如线上报错,开发人员可能需要额外的源映射(source maps)和调试工具来简化调试过程。

第三点,就是启动时,会有更大的cpu运行开销,请看下一节。

关键问题 2:为什么 CPU 峰值差异大?

CPU 峰值均值:

mode peak CPU%
tsx 5.84
tsc 9.13
esbuild 11.89

这看起来 esbuild 更“耗 CPU”。但吞吐几乎一样。这说明什么?

一个可能解释是:在使用 esbuild 打包后的代码中,代码结构变得更加紧凑,启动时可能会大量导入很多原来零散的js模块等,某些常见的函数或代码路径可能会执行得更加频繁。

由于 JIT 编译机制,V8 可能会更快地识别出这些频繁执行的代码,并对其进行优化。这个优化过程又叫热点编译。

V8 JIT(即时编译)

  • V8 是 Chrome 和 Node.js 中使用的 JavaScript 引擎,它使用 JIT(即时编译,Just-In-Time Compilation) 技术将 JavaScript 代码在运行时编译成机器代码,来提高执行效率。
  • JIT 编译的目的是将频繁执行的代码(即“热点代码”)优化成更高效的机器代码,从而提升性能。

热点编译(Hotspot Compilation)

  • 当你执行一段 JavaScript 代码时,V8 会在开始时使用 解释执行(即不进行优化的方式)来快速运行代码。
  • 如果某段代码被执行得非常频繁(即“热点代码”),V8 会将它标记为热点代码,并对其进行优化。
  • 这时,V8 会在后台将热点代码编译为更高效的机器码,称为 热点编译。这通常会提高执行速度,但也可能带来一些额外的 CPU 开销。

在 V8 进行热点编译时,它需要使用 CPU 来分析和优化这些热点代码。这通常会导致短时间内 CPU 使用率升高,表现为 CPU 使用率的“抬高”。

另外很重要的一点,可以看到上面的统计图表中,重要的一点是,吞吐量几乎一致,这意味着无论是 tsxtsc 还是 esbuild,在处理请求时的效率差异都很小。如果 esbuild 确实比其他模式更高效,它的吞吐量应该显著超过其他模式。然而,实际数据表明,差距微乎其微,这表明 CPU 峰值差异 主要来源于 短期的计算开销,而非整体的运行效率差异。

最终性能,尤其是吞吐量,在底层上受 V8 引擎优化和 I/O 处理 等因素的影响更大,在运行层面上应该受到业务逻辑、IO、JSON 序列化、数据库等因素决定。

小结

现在可以回答问题Node 后端服务启动方式的问题了:

在开发环境,生产环境(常驻 API 服务),还是推荐 tsx。性能差别不大,带来了更好的体验。

在如云函数等,冷启动敏感场景,推荐用如 esbuild来提前bundle,本文中的案例,esbuild的冷启动时长比普通tsx快了75%!

❌
❌