更新于:2026年6月28日
React Native Monorepo 是指在单一 Git 仓库内并存多个 React Native / Expo 应用与可复用包,通过 pnpm Workspaces(或 Yarn / Bun)管理依赖,再用 Metro 的 watchFolders 与 nodeModulesPaths 让打包器跨包解析模块。在 2026 年,React Native 0.79 与 Expo SDK 53 已原生支持包根 exports 字段、Metro 0.83 默认开启 symlink 解析,搭建一套类型安全、可被 EAS Build 与 Turborepo 缓存的 Monorepo 不再像两年前那样需要十几个补丁。本文给出我在金融科技团队落地的完整配置。
- 2026 年首选 pnpm Workspaces + Turborepo:Metro 0.83 原生支持 symlink,无须再用
metro-symlinked-deps 补丁。
- 每个 Expo / RN 应用放在
apps/*,可复用 TS 包放在 packages/*,原生模块单独走 modules/* 以避开 hoisting。
- Metro 配置必须显式声明
watchFolders 指向 monorepo 根,并把 workspace 根的 node_modules 加入 nodeModulesPaths。
- EAS Build 通过
eas.json 的 cli.appVersionSource 与 build.production.node 字段识别 pnpm,配合 pnpm deploy 输出独立构建上下文。
- 类型安全靠 TypeScript Project References +
tsc --build,共享包独立产物,避免应用直接 import 源码导致 Metro 全量解析。
- Turborepo 远程缓存可把 CI 冷启动从 22 分钟降到 4 分钟,但要把
.expo、ios/Pods 排除出 outputs。
为什么 React Native 应该用 Monorepo
在我所在的金融科技团队里,我们曾经维护过三个独立仓库:客户端 App、内部审批 App、以及一个 React Native Web 营销页。三套 CI、三套 ESLint 配置、三个版本号不一致的 react-native-reanimated,每次升级 RN 都是一场为期两周的迁徙。把它们合并成 Monorepo 之后,共享的 UI 组件库一次发布即可被三个应用消费,react-native 与 react-native-screens 的版本由顶层 package.json 锁定,再也不会出现"客户端能跑、审批端崩溃"的怪事。
Monorepo 真正的杠杆不在"少几个仓库",而在于原子化变更:当我修改一个共享的 @acme/auth 包,同一个 PR 里就能修复消费它的三个 App,CI 会一次性跑完所有受影响应用的类型检查与 E2E。Expo 团队在 SDK 53 的迁移说明中也强调了这一点:模板项目默认就是 monorepo-ready 结构。如果你还在为多仓库的版本漂移头疼,可以先看一下我们的 Expo EAS Build 与 Submit 部署指南,里面对比了单仓库与 Monorepo 的发布流水线差异。
2026 年可选的 Workspace 管理器有四个:pnpm 10、Yarn 4 (Berry, PnP 或 node-modules linker)、Bun 1.2、npm 11。下表给出我在生产环境实测过的关键差异。
| 维度 | pnpm 10 | Yarn 4 (nodeLinker) | Bun 1.2 | npm 11 |
| 冷安装速度 (350 包) | 38s | 52s | 14s | 71s |
| Metro symlink 兼容 | 原生支持 | 需 nodeLinker: node-modules | 需 0.83+ Metro | 原生支持 |
| EAS Build 官方支持 | 稳定 | 稳定 | 实验性 | 稳定 |
| 磁盘占用 | 共享 store,最低 | 中 | 中 | 高 |
| 原生模块 nohoist | public-hoist-pattern | nmHoistingLimits | linker = "isolated" | 不支持 |
| Turborepo 集成 | 一流 | 一流 | 需 1.13+ 适配 | 一流 |
结论很简单:除非你已经全公司锁死 Yarn 4,否则新项目直接选 pnpm 10。Bun 装包很快,但 EAS Build 在 2026 年 5 月才把它移出实验通道,原生模块 prebuild 时偶发 resolution mode 异常,我们暂不在生产关键路径使用。
推荐的目录结构与 package.json 拆分
我们采用三层目录:apps/* 放可发布的最终产物,packages/* 放纯 TS 共享代码,modules/* 放含原生代码的 Expo Modules。这样设计的好处是 Metro 的 blockList 可以精确排除非目标 App 的 node_modules,避免 Symbol 重复注册。
acme-mobile/
├── apps/
│ ├── customer/ # Expo 客户端 App
│ ├── back-office/ # Expo 审批 App
│ └── marketing-web/ # React Native Web
├── packages/
│ ├── ui/ # 共享 UI 组件
│ ├── auth/ # OAuth + Biometrics 封装
│ ├── api-client/ # TanStack Query + tRPC 客户端
│ └── tsconfig/ # 共享 tsconfig 基线
├── modules/
│ └── secure-storage/ # Expo Module: iOS Keychain + Android Keystore
├── pnpm-workspace.yaml
├── package.json
├── turbo.json
└── tsconfig.json
根 pnpm-workspace.yaml 直白声明三层:
packages:
- 'apps/*'
- 'packages/*'
- 'modules/*'
# 2026 起 pnpm 默认 hoist=false,但部分 RN 插件仍依赖 peer hoist
public-hoist-pattern:
- '*eslint*'
- '*prettier*'
- '@types/*'
Metro 配置:symlink、watchFolders 与 blockList
Metro 0.83(随 RN 0.79 / Expo SDK 53 发布)默认开启 resolver.unstable_enableSymlinks: true,所以 2026 年的 Metro 配置远比两年前简洁。不过你仍然需要手动声明 watchFolders,因为 Metro 的文件监听器不会自动越过 App 目录边界。下面是 apps/customer/metro.config.js 的完整范例:
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
// 1. 监听整个 monorepo,否则 packages/* 的改动不会触发热重载
config.watchFolders = [workspaceRoot];
// 2. 模块解析路径:App 本地 → workspace 根
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
// 3. 阻断兄弟 App 的 node_modules,防止 React 被加载两次
config.resolver.blockList = [
new RegExp(`${workspaceRoot}/apps/(?!customer)[^/]+/node_modules/.*`),
];
// 4. 启用 package exports(2026 必开,Expo Router v6 依赖)
config.resolver.unstable_enablePackageExports = true;
module.exports = config;
三条容易踩的坑:第一,watchFolders 必须指向 monorepo 根而非 packages/,否则后续新增的 modules/* 不会被监听;第二,blockList 一定要用 否定前瞻((?!customer))而不是简单白名单,正则错了会导致 Metro 启动后无任何错误但热重载随机失效;第三,如果你还在用 RN 0.74 以下,需要额外加 resolver.disableHierarchicalLookup: true,0.79+ 可以删掉。React Native 官方在 Metro 配置文档给出了 schema 参考。
类型安全共享包:TypeScript Project References
很多团队把共享包写成 "main": "src/index.ts",让 App 直接 import TS 源码。这种"零构建"方案前期省事,但有两个隐患:一是 Metro 必须为每个 App 重复解析整个 packages/*,冷启动慢 30% 以上;二是 IDE 跨包跳转依赖 paths 别名,TS Server 在 1000+ 文件后会明显卡顿。我推荐的做法是TypeScript Project References + tsc --build,每个共享包独立产出 dist/ 与 .d.ts。
// packages/tsconfig/base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-native",
"strict": true,
"composite": true,
"declaration": true,
"declarationMap": true,
"incremental": true,
"skipLibCheck": true
}
}
// packages/auth/tsconfig.json
{
"extends": "@acme/tsconfig/base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"references": [{ "path": "../api-client" }],
"include": ["src"]
}
// packages/auth/package.json
{
"name": "@acme/auth",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"react-native": "./dist/index.js",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc --build",
"dev": "tsc --build --watch"
}
}
根 tsconfig.json 用 references 把所有共享包串起来,开发时跑 pnpm -r --parallel run dev,tsc --watch 会增量重建被改动的包,Metro 在下一次保存时拾取新的 dist/。这一步把我们的全量类型检查从 47s 降到 9s(增量场景)。如果你对类型系统的细节有疑问,可以参考 TypeScript Project References 官方文档。
在 Expo Monorepo 中配置 EAS Build
EAS Build 2026 起原生识别 pnpm Workspaces:当它在 App 的 package.json 上一级发现 pnpm-workspace.yaml 时,会自动把 monorepo 根作为构建上下文上传。但有三处仍需手动配置:
// apps/customer/eas.json
{
"cli": {
"version": ">= 13.0.0",
"appVersionSource": "remote"
},
"build": {
"production": {
"node": "20.18.0",
"pnpm": "10.4.1",
"env": {
"EAS_NO_VCS": "0",
"EXPO_USE_METRO_WORKSPACE_ROOT": "1"
},
"prebuildCommand": "pnpm --filter @acme/customer exec expo prebuild --clean"
}
}
}
关键是 EXPO_USE_METRO_WORKSPACE_ROOT=1。不设这个变量,Expo CLI 会把 App 目录当作根,导致 node_modules hoisting 后的依赖找不到。prebuildCommand 里用 --filter 过滤是为了让 expo prebuild 只为目标 App 生成 ios/ 与 android/,否则 EAS 默认会尝试为所有 App prebuild。我们的 Expo EAS Build 完全指南对 EAS 流水线有更深入的讨论。
用 Turborepo 加速 CI 与本地构建
Turborepo 是目前 React Native monorepo 最契合的任务编排器:它理解 package.json 的 dependsOn 拓扑,能并行跑跨包任务并按内容哈希缓存输出。我们 CI 从 22 分钟降到 4 分钟,主要靠把 tsc --build、eslint、jest 三个任务的产物上传到远程缓存。
// turbo.json
{
"$schema": "https://turborepo.com/schema.json",
"globalDependencies": ["tsconfig.json", ".env"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "!dist/**/*.test.*"]
},
"lint": { "outputs": [] },
"typecheck": {
"dependsOn": ["^build"],
"outputs": ["*.tsbuildinfo"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"expo:prebuild": {
"cache": false
}
}
}
注意 expo:prebuild 必须 cache: false。prebuild 输出 ios/ 与 android/ 含绝对路径,缓存命中后在另一台机器上跑会引用错误的 Pods 路径。CI 上跑 pnpm turbo run typecheck lint test --filter='...[origin/main]' 只会构建受 PR 影响的包,这就是 monorepo 性能起飞的关键。如果你想了解 Turborepo 远程缓存的实现细节,可以参考 Turborepo 缓存官方文档。
原生模块与 nohoist 策略
原生模块(含 .podspec、build.gradle 的包)在 monorepo 下最容易"挂"。原因是 pnpm 默认把所有包用 symlink 链到 node_modules/.pnpm/ 深层目录,而 CocoaPods 与 Gradle 在某些版本下不会跟随 symlink 解析。我们的策略是:
- 第三方含原生代码的包(如
react-native-mmkv、react-native-reanimated)放在每个 App 的直接 dependencies,让 pnpm 在 App 的 node_modules 创建直接 symlink。
- 自研 Expo Modules 放在
modules/*,并在 App 里通过 "@acme/secure-storage": "workspace:*" 引用。Expo Autolinking 会从 App 的 package.json 解析依赖图,无须额外配置。
- 禁止 hoisting 给原生包:在根
.npmrc 加 node-linker=hoisted 是 2024 年的老建议,2026 年应改用 hoist-pattern[]=!react-native-* 精准排除。
关于自研原生模块的 Fabric/TurboModules 接入,可以延伸阅读我们的 React Native 新架构指南,里面有 codegen 在 monorepo 下的额外注意事项。
常见踩坑与排查清单
这是我维护的内部 wiki 摘录,新人接手 monorepo 时先按这个清单自检:
- "Invalid Hook Call":99% 是同一个 App 加载了两个不同路径的 React。先跑
pnpm why react 看引用链;常因 blockList 写错或两个 App 的 React 版本不一致。
- 共享包改动不热重载:检查
watchFolders 是否包含 monorepo 根;如果使用 tsc --build,确认 tsc --watch 进程在跑。
- Android Gradle 找不到 autolinking 模块:跑
cd apps/customer && pnpm expo prebuild --clean,旧的 android/settings.gradle 含绝对路径残留。
- iOS Pod install 极慢:在
apps/*/ios/Podfile 顶部加 install! 'cocoapods', :deterministic_uuids => false,monorepo 下 UUID 重算是性能杀手。
- TypeScript 跨包跳转 404:根
tsconfig.json 必须列全 references;忘记加新包是最常见原因。
- EAS Build 远程失败但本地成功:本地的
node_modules 可能被手动改过,跑 pnpm install --frozen-lockfile 复现 CI 状态。
对于已经在用单仓库的团队,迁移到 monorepo 的最佳时机是下一次大版本升级(如 RN 0.79 → 0.80)。趁着原本就要重新跑一遍依赖图、原生 prebuild,把目录结构一并调整。我们当年是在 RN 0.74 → 0.76 切到 New Architecture 时同步切的 monorepo,关于性能层面的连带收益可以参考 React Native 启动性能优化指南。
常见问题
React Native Monorepo 应该用 pnpm 还是 Yarn?
2026 年新项目推荐 pnpm 10:磁盘占用最低、与 Metro 0.83+ symlink 解析一流、EAS Build 与 Turborepo 都原生支持。已有 Yarn 4 + node-modules linker 的项目无需强行迁移,两者在 Metro 配置上的差异已经被新版 Expo CLI 抹平。
Metro 现在支持 symlink 吗?
支持。Metro 0.83(随 RN 0.79 / Expo SDK 53 发布)默认开启 resolver.unstable_enableSymlinks。这意味着 2026 年起你不再需要 metro-symlinked-deps 之类的补丁,只要正确声明 watchFolders 与 nodeModulesPaths 即可。
如何在 React Native Monorepo 之间共享代码?
把可复用代码放在 packages/*,用 workspace:* 协议引用,并配合 TypeScript Project References + tsc --build 输出独立的 dist/。这样既保留类型安全,又避免 Metro 对源代码全量解析造成的冷启动开销。
Turborepo 和 Nx 哪个更适合 React Native?
对纯 React Native / Expo 项目,Turborepo 更轻量、配置直白、与 pnpm 集成顺滑,足以覆盖 90% 的需求。Nx 在跨技术栈(同仓库混合 Angular、NestJS、RN)或需要插件生态时更强,但学习曲线陡峭、缓存粒度有时与 Expo prebuild 冲突。
EAS Build 怎么识别 Monorepo?
EAS CLI 13+ 会自动向上查找 pnpm-workspace.yaml / package.json 的 workspaces 字段,把 monorepo 根作为构建上下文上传。你只需要在 eas.json 设 EXPO_USE_METRO_WORKSPACE_ROOT=1 并在 prebuildCommand 用 pnpm --filter 过滤目标 App 即可。