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

资讯详情

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

企业级Monorepo工程化模板:pnpm+Turborepo+TypeScript配置共享实战

企业级Monorepo工程化模板:pnpm+Turborepo+TypeScript配置共享实战 团队业务一多仓库就会开始犯难多个应用各自维护一套 TypeScript 配置依赖版本散落各处公共代码全靠复制粘贴每次初始化新项目都要重新经历一遍配置地狱。与其这样不如直接从零搭一套企业级 Monorepo 工程化模板把初始化、应用创建、共享 TypeScript 配置这三件事一次性固化下来。今天我把自己在团队里落地的这套方案完整拆开讲适合前端负责人、全栈工程师以及所有被多仓维护折磨过的人。跟着做你会得到一套能直接跑起来、能提交到 Git 仓库、后续可以继续扩展的工程底座。1. 项目整体设计与思路拆解1.1 为什么企业级项目需要 Monorepo先说一个最常见的场景。团队里有 Web 管理端、H5 端、Node 服务端可能还有内部组件库和工具函数库。一开始每个项目一个仓库看起来挺清爽但业务跑起来之后问题就来了公共组件改一个逻辑要在三个仓库里同步TypeScript 的 strict 开没开、是否检查未使用变量每个项目各写各的新同学入职要接手的不是一个项目而是五六套互不相同的工程配置。时间一长规范就成了嘴上说说。Monorepo 解决的是“代码放一起、依赖统一管、配置共享用”的问题。它不是把代码简单堆到一个仓库里而是用 workspace 机制把多个应用和包组织在同一个仓库让它们既能独立开发、独立构建又能共享公共依赖和配置文件。企业级场景下最看重的是可维护性和一致性。多仓库那种“每个项目都是孤岛”的状态在团队规模变大后基本不可持续。而 Monorepo 恰恰能提供一条清晰的路径新应用可以秒级创建公共配置一键继承依赖版本不再靠人肉同步。当然Monorepo 也不是银弹。它引入的新复杂度主要体现在依赖管理、构建缓存、配置继承三方面。如果这几个坑没填平代码放一起反而更痛苦。所以我做这套模板的时候核心原则很简单所有包必须能被独立开发公共能力必须能一键复用所有魔幻操作必须能在文档里解释清楚。1.2 方案选型为什么是 pnpm Turborepo TypeScript市面上可选的组合不少npm workspaces、Yarn workspaces、Lerna、Nx 都有人用。我最终选了 pnpm 作为包管理器、Turborepo 作为任务编排工具、TypeScript 作为类型体系这个组合是目前我自己实测下来心智负担最低、又能覆盖企业级需求的方案。pnpm 的优势在于它用内容寻址存储来管理依赖同一个版本的包在磁盘上只有一份配合 workspace 协议能非常自然地处理 monorepo 内部的包引用。更重要的是pnpm 默认的 node_modules 结构是隔离的不会把根目录的依赖“提升”到所有包里这能有效避免“幽灵依赖”——一个包能用另一个包没有声明过的依赖这在多仓库时代是老大难问题。Turborepo 负责跑任务和做缓存。dev、build、typecheck 这些脚本如果全部靠人肉在根目录写一串串命令包一多必乱。Turborepo 会根据输入文件和输出产物做内容哈希任务没变化就直接走缓存在本地开发和 CI 里都能省下大量重复构建时间。TypeScript 作为整个模板的类型底座。它不止是语言还是一套可继承的配置体系通过extends机制把公共 compilerOptions 抽出来配合references还能做项目引用。这也是后面共享配置能落地的关键。Nx 功能更重适合已经有完整插件生态诉求的大团队Lerna 现在更多是发布工具任务缓存能力不如 Turborepo 纯粹。对我个人而言Turborepo 这种“只做编排、不绑架架构”的方式更顺手接入成本也低。1.3 模板目录结构设计目录结构是 Monorepo 模板的骨架。我按照“apps 放应用、packages 放可复用包”的惯例来设计monorepo-template/ ├── apps/ │ ├── web/ │ │ ├── src/ │ │ ├── index.html │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── server/ │ ├── src/ │ │ └── index.ts │ ├── package.json │ └── tsconfig.json ├── packages/ │ ├── tsconfig/ │ │ ├── base.json │ │ ├── react.json │ │ ├── node.json │ │ └── package.json │ └── ui/ │ ├── src/ │ │ └── index.ts │ ├── package.json │ └── tsconfig.json ├── .gitignore ├── .nvmrc ├── package.json ├── pnpm-workspace.yaml └── turbo.json这里的核心区分在于apps下的包是可以独立启动、独立部署的业务入口比如 React 前端、Express 后端packages下的包是业务无关的复用单元比如共享 TypeScript 配置、UI 组件、工具函数。在设计模板时我刻意让packages/tsconfig成为第一个被共享的包因为配置文件是所有包的地基它最先被抽出来收益也最大。2. 初始化工程从零开始建仓库2.1 环境准备与版本约定动手之前先把环境约定好这一步看着琐碎但能省掉后面大量问题。我建议初始化阶段就做好三件事约定 Node 版本、锁定包管理器版本、提交 lockfile。我习惯在仓库根目录放一个.nvmrc18.18.0然后根目录package.json里声明packageManager和engines保证任何人拿到仓库都知道该用什么环境{ packageManager: pnpm9.1.0, engines: { node: 18.18.0 } }pnpm 安装很简单如果本机已经有 Node直接npm i -g pnpm即可。需要注意的是Node 版本太旧会导致很多工具链解析失败所以模板里 Node 版本至少 18.18.0推荐用 20 LTS。初始化 Git 仓库时别忘了写.gitignore至少把node_modules、dist、.turbo、*.local这类目录排除掉。node_modules/ dist/ .turbo/ *.local .DS_Store这层准备做扎实了后面所有包才能在一个干净一致的环境里工作。2.2 创建根 package.json 与 workspace 配置初始化工作区只需要两步创建根package.json再创建pnpm-workspace.yaml。根package.json不用依赖任何框架重点是private: true避免误发到 npm以及根级别的 scripts 聚合{ name: monorepo-template, private: true, scripts: { dev: turbo run dev, build: turbo run build, typecheck: turbo run typecheck } }pnpm-workspace.yaml是 pnpm 识别 workspace 的入口它告诉 pnpm 哪些目录是包packages: - apps/* - packages/*写完后在根目录跑一次pnpm install它会在根部生成pnpm-lock.yaml。这个 lockfile 必须提交到 Git它是依赖版本的唯一事实来源。如果没有它不同人安装依赖时可能拉到不同版本Monorepo 的一致性优势就没了。2.3 接入 Turborepo 做任务编排Turborepo 的核心是turbo.json。我安装的时候直接把它放到根级 devDependenciespnpm add -w -D turbo这里的-w表示安装到 workspace 根目录不要装到某个子包里。Turborepo 2.x 的配置文件使用tasks字段结构如下{ $schema: https://turbo.build/schema.json, tasks: { build: { dependsOn: [^build], outputs: [dist/**] }, dev: { cache: false, persistent: true }, typecheck: { dependsOn: [^build] } } }这里解释一下每个配置的意图。build的dependsOn: [^build]表示当前包的 build 执行前它的依赖包先执行 build这样才能保证组件库先编译完应用再引产物。outputs告诉 Turborepo 哪些目录是构建产物缓存会以这些文件是否变化来判断有没有命中。dev是长驻进程所以必须关闭缓存并标记persistent: true否则 turbo 会把 dev 进程也缓存直接把你坑到怀疑人生。typecheck依赖^build是为了保证被引用包的类型声明已经生成。有了 turbo 之后根目录的 scripts 就能一键调度所有子包不需要手写一堆pnpm --filter。3. 应用创建快速添加业务应用3.1 创建 React 前端应用模板里默认放一个 React 前端应用做参考创建方式用的是 Vite 官方脚手架pnpm create vite apps/web --template react-ts创建完成后我会把apps/web/package.json里name改成带 scope 的形式比如apps/web这样后期用--filter指定包时会非常直观。同时把 scripts 收敛成 turbo 能识别的名字{ name: apps/web, private: true, type: module, scripts: { dev: vite, build: tsc -b vite build, preview: vite preview, typecheck: tsc --noEmit }, dependencies: { react: ^18.3.1, react-dom: ^18.3.1 }, devDependencies: { repo/tsconfig: workspace:*, types/react: ^18.3.3, types/react-dom: ^18.3.0, vitejs/plugin-react: ^4.3.1, typescript: ^5.5.4, vite: ^5.4.0 } }注意我在这个包里声明了typescript和vite自身的依赖。很多从多仓库转过来的同学会不习惯明明根目录已经装了 TypeScript为什么子包还要装因为 pnpm 的 node_modules 是隔离的子包默认访问不到根目录的依赖。这看似麻烦实际是保护——它能逼着每个包显式声明自己需要的东西避免幽灵依赖。为了让应用能用别名访问自身src目录需要在vite.config.ts里配置import { defineConfig } from vite import react from vitejs/plugin-react import { fileURLToPath, URL } from node:url export default defineConfig({ plugins: [react()], resolve: { alias: { : fileURLToPath(new URL(./src, import.meta.url)) } } })3.2 创建 Node 后端服务后端服务我选择手动创建不依赖脚手架这样更能看出模板把配置“抽出来”之后长什么样。在项目根目录执行mkdir -p apps/server/src然后创建apps/server/package.json{ name: apps/server, private: true, type: module, scripts: { dev: tsx watch src/index.ts, build: tsc -p tsconfig.json, start: node dist/index.js, typecheck: tsc --noEmit }, dependencies: { express: ^4.19.2 }, devDependencies: { repo/tsconfig: workspace:*, types/express: ^4.17.21, types/node: ^20.14.10, tsx: ^4.16.2, typescript: ^5.5.4 } }后端开发时我用tsx而不是ts-node。tsx基于 esbuild 做转译启动速度快对 ESM 的支持也顺滑在 Monorepo 里配 workspace 协议引用的源码包时基本不用额外折腾。tsx watch自带文件监听开发体验和前端 vite 一样是即改即生效。入口文件先写一个最简单的健康检查接口import express from express const app express() const port Number(process.env.PORT) || 3000 app.get(/api/health, (_req, res) { res.json({ ok: true, ts: Date.now() }) }) app.listen(port, () { console.log(server listening on http://localhost:${port}) })3.3 工作区脚本与本地开发应用创建完之后你不需要记住每个子包单独的命令根目录一条命令就能把它们都拉起来pnpm dev这个命令会交给 Turborepo 去调度。它会根据apps/*和packages/*里各个包的 scripts把所有dev都跑起来。如果你想只启动某一个包用--filter指定即可pnpm dev --filter apps/web pnpm dev --filter apps/server这里有个小细节Turborepo 的参数解析是把它放在双横线后面因为pnpm dev本身是一个 script--filter是要传给 turbo 的参数。如果你直接在根目录跑turbo run dev --filter apps/web也可以但统一用pnpm入口更一致。4. 共享 TypeScript 配置一套配置管所有包4.1 共享配置包的目录与文件设计共享 TypeScript 配置是整个模板里最值得花心思的部分。我把配置做成一个独立包repo/tsconfig它不包含任何逻辑代码只负责发布若干 JSON 配置文件。这样每个应用只需要extends其中的一个就能继承整套严格配置。先看packages/tsconfig/base.json它是所有配置的地基{ $schema: https://json.schemastore.org/tsconfig, compilerOptions: { target: ES2022, lib: [ES2022], module: ESNext, moduleResolution: Bundler, strict: true, noUnusedLocals: true, noUnusedParameters: true, noFallthroughCasesInSwitch: true, esModuleInterop: true, forceConsistentCasingInFileNames: true, resolveJsonModule: true, isolatedModules: true, skipLibCheck: true, declaration: true, declarationMap: true, sourceMap: true } }重点说几个容易被忽略的选项。moduleResolution: Bundler是 TypeScript 5.x 专门为 Vite、webpack 这类打包器设计的解析策略它支持package.json的exports字段这让repo/ui这类包能正确解析到源码入口。strict必须开企业级模板不能允许any到处飞。noUnusedLocals和noUnusedParameters看起来很烦但它能在 CI 阶段就把无用代码挡在门外而不是等 lint 或者 code review 时才发现。isolatedModules是配合 Vite 这类逐文件转译工具的关键它要求每个文件能独立编译避免出现依赖类型推断的跨文件写法。接着是 React 应用的配置{ extends: ./base.json, compilerOptions: { lib: [ES2022, DOM, DOM.Iterable], jsx: react-jsx, useDefineForClassFields: true, noUncheckedIndexedAccess: true } }jsx: react-jsx对应 React 17 的自动运行时不用每个文件手动import React。noUncheckedIndexedAccess是我个人比较偏爱的选项它会让数组索引访问返回T | undefined强制你处理边界情况对前端代码尤其有用。Node 服务的配置单独一份{ extends: ./base.json, compilerOptions: { module: NodeNext, moduleResolution: NodeNext, types: [node], noUncheckedIndexedAccess: true } }后端和前端最大的区别是模块解析策略。前端走打包器那套后端直接跑 Node必须用NodeNext否则 ESM 的导入导出可能产生运行时错误。最后用packages/tsconfig/package.json把 JSON 文件暴露出去{ name: repo/tsconfig, version: 0.0.0, private: true, files: [*.json], exports: { ./base.json: ./base.json, ./react.json: ./react.json, ./node.json: ./node.json } }exports字段在这里很重要它限制了子路径的可见性。如果某个包想引用base.json必须通过repo/tsconfig/base.json这个合法入口而不是直接访问包内部任意文件。4.2 tsconfig 继承的规则与“坑”用extends继承了共享配置之后有几个规则必须刻在脑子里否则排查起问题来会非常痛苦。第一compilerOptions是合并覆盖的关系。子配置里定义的每一项都会覆盖父配置的同名项所以如果你想在某一个应用里临时放松某个规则直接在自身compilerOptions里覆盖即可不会影响其他包。第二files、include、exclude这三个字段不会被继承。它们必须由每个 tsconfig 自己声明。这是大家最容易踩的坑在共享配置里写了include: [src]结果所有应用只能编译src或者反过来根本没生效。我在模板里干脆不在这三个字段上做共享全部由应用自行决定语义最清晰。第三paths和baseUrl是另一个高频坑。TypeScript 5.x 的官方建议已经不再推荐配置baseUrl尤其 TypeScript 7.0 会正式停止运行baseUrl。如果你要配paths应该用相对路径指向 tsconfig 所在目录。比如前端应用想用指向src{ extends: repo/tsconfig/react.json, compilerOptions: { paths: { /*: [./src/*] } }, include: [src] }这里./src/*是相对于当前 tsconfig 文件解析的不需要也不应该依赖baseUrl的目录拼接。第四如果项目用了 TypeScript 的 project referencesreferences字段需要注意 references 也是不会被extends继承的必须在使用方单独配置。模板里为了避免心智负担只在 single-package 维度使用extends没有强制引入 references。4.3 让应用真正用上共享配置配置包建好后应用侧引用非常直接。以apps/web为例把原来的tsconfig.json改成{ extends: repo/tsconfig/react.json, compilerOptions: { paths: { /*: [./src/*] } }, include: [src] }apps/server则用{ extends: repo/tsconfig/node.json, compilerOptions: { outDir: dist, rootDir: src }, include: [src] }这里涉及 package.json 的repo/tsconfig依赖必须先在对应包里声明。以前端为例我在它的 devDependencies 里写了repo/tsconfig: workspace:*然后执行pnpm installworkspace:*是 pnpm 的 workspace 协议它告诉 pnpm 这个包来自当前仓库而不是去 npm registry 下载。安装完成后在任意子包目录跑pnpm typecheck即可验证配置是否生效。如果 IDE 没及时识别新配置重启一下 TypeScript Server 基本都能解决。5. 实操过程与核心环节实现5.1 完整落地步骤这里把整条链路串一遍方便你照着操作。假设你已经 clone 了一个空仓库或者刚执行完git init。写.nvmrc、根package.json、pnpm-workspace.yaml、.gitignore。在根目录执行pnpm install生成pnpm-lock.yaml。安装 Turborepo 到根目录pnpm add -w -D turbo。创建turbo.json配置 build、dev、typecheck 任务。创建packages/tsconfig填入 base/react/node 三份 JSON 和package.json。创建packages/ui作为内部复用包示例。创建apps/web用 Vite 的 react-ts 模板初始化。创建apps/server手动初始化 Express tsx。在各子包 package.json 中声明repo/tsconfig依赖改成workspace:*。根目录执行pnpm install再执行pnpm dev。到这一步两个应用应该能同时跑起来。为了验证共享配置真的生效你可以在apps/web/src里故意写一行声明了但未使用的变量然后跑pnpm typecheck会看到 TS 报错这就说明noUnusedLocals已经从共享的 base 配置一路传到了应用层。Turborepo 的缓存效果可以这样验证先跑一次pnpm build观察耗时完全不做任何改动再跑一次pnpm build会看到来自缓存的结果整个过程往往在零点几秒内完成。这就是它区别于手写 shell 脚本的核心价值。5.2 依赖管理进阶公共依赖与工作区引用依赖管理是 Monorepo 里最容易翻车的环节所以单独拿出来讲。给某个子包安装依赖用--filterpnpm --filter apps/web add axios pnpm --filter apps/server add zod给根目录安装公共开发依赖用-wpnpm add -w -D typescript引用仓库内的其他包必须用 workspace 协议。比如让前端依赖 UI 包pnpm --filter apps/web add repo/uiworkspace:*装完之后apps/web的 dependencies 里会出现repo/ui: workspace:*。pnpm 会把它符号链接到packages/ui这样修改 UI 包后前端应用不需要重新发布下一次构建直接能读到最新源码。我遇到过很多刚用 Monorepo 的团队在根目录安装了一堆依赖然后所有子包都能引用觉得很方便。这其实是错误的用法它会让子包的依赖关系变得不可控。pnpm 默认的严格隔离就是要逼你规范所以我不建议为了图省事加shamefully-hoisttrue之类的配置。宁可每次安装时多写一个--filter也不要以后面对“这个包为什么能编译过”的玄学问题。6. 常见问题与排查技巧实录6.1 高频报错与解决方案我在搭建和给团队推广这套模板的过程中整理了下面这几个被问得最多的问题基本覆盖了从零到能跑的全部阶段。问题现象可能原因解决方案pnpm dev提示 turbo 命令不存在turbo 没装在根目录执行pnpm add -w -D turbo子包里tsc --noEmit报找不到配置文件子包 tsconfig 的extends路径写错或repo/tsconfig没装进子包依赖检查 package.json 中是否声明repo/tsconfig且安装后 node_modules 里能看到符号链接应用能跑但 IDE 里 TS 报错说找不到某模块共享 tsconfig 变更后 IDE 的 TS Server 还持有旧缓存命令面板执行 “TypeScript: Restart TS Server”repo/ui在 vite 环境中导入报错包的exports字段没写对或指向了不存在的入口检查packages/ui/package.json的exports确保入口文件存在且路径正确后端 ESM 下__dirname未定义tsconfig 用了module: ESNext但运行环境期望 Node CJS或反之后端统一用NodeNext文件命名或 package.json 的type声明保持一致Turborepo 缓存命中了但构建产物没更新outputs没配全或缓存 key 没包含真正的输入文件检查turbo.json的outputs把实际产物目录加进去pnpm install后本地明明有依赖子包却报找不到模块依赖被提升到根目录子包没显式声明在子包目录执行pnpm add 依赖把该依赖加入自身 package.jsonTS 报错 “Option baseUrl is deprecated”TypeScript 版本较新baseUrl 已弃用删掉 baseUrlpaths 改成相对路径排查这些问题时我的习惯是先看是编译期报错还是运行期报错。编译期错误 90% 出在 tsconfig 继承或依赖声明上运行期错误大多出在模块解析策略不匹配上。不要一上来就改 tsconfig先把报错信息完整读一遍再决定动哪一层。6.2 让我少踩坑的几条经验最后分享几个我实际踩过之后觉得值得写进文档的细节。第一不要忽略.nvmrc和packageManager。我和同事曾经因为一个 Node 16、一个 Node 20在某次依赖安装后出现了完全不同的解析结果排查了整整一下午。锁定工具版本看似繁琐其实是成本最低的规范。第二lockfile 必须提交。pnpm-lock.yaml 是 monorepo 依赖关系的唯一真相不提交它所有关于“版本一致”的讨论都没有意义。CI 里安装依赖时也建议加上--frozen-lockfile确保和本地安装结果完全一致。第三共享 TypeScript 配置的粒度不宜过细。有人喜欢把 base.json 拆成十几个片段每个应用各取所需。我实践下来觉得分开react.json、node.json、base.json三层已经足够。再细的拆分只会让维护者花费大量时间在“该继承哪个”上而不是写业务代码。第四Turborepo 不是必须从一开始就上。如果你的仓库只有一两个应用pnpm workspace 本身就够用。但既然做的是企业级模板缓存和任务编排能力越早接入后续往 CI 和多人协作方向扩展时就越轻松。这套模板跑通之后再往里面加 ESLint、Prettier、单元测试、发布流程都只是在现有骨架上添砖加瓦不会推倒重来。
返回列表