React Native Monorepo 完全指南:pnpm Workspaces、Metro 配置与类型安全共享代码(2026)

2026 年 React Native Monorepo 实战:pnpm 10 + Metro 0.83 symlink、Turborepo 缓存、EAS Build 集成、类型安全共享包与原生模块 nohoist 策略,附完整目录结构与避坑清单。

React Native Monorepo pnpm 配置指南 2026

更新于:2026年6月28日

React Native Monorepo 是指在单一 Git 仓库内并存多个 React Native / Expo 应用与可复用包,通过 pnpm Workspaces(或 Yarn / Bun)管理依赖,再用 Metro 的 watchFoldersnodeModulesPaths 让打包器跨包解析模块。在 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.jsoncli.appVersionSourcebuild.production.node 字段识别 pnpm,配合 pnpm deploy 输出独立构建上下文。
  • 类型安全靠 TypeScript Project References + tsc --build,共享包独立产物,避免应用直接 import 源码导致 Metro 全量解析。
  • Turborepo 远程缓存可把 CI 冷启动从 22 分钟降到 4 分钟,但要把 .expoios/Pods 排除出 outputs。

为什么 React Native 应该用 Monorepo

在我所在的金融科技团队里,我们曾经维护过三个独立仓库:客户端 App、内部审批 App、以及一个 React Native Web 营销页。三套 CI、三套 ESLint 配置、三个版本号不一致的 react-native-reanimated,每次升级 RN 都是一场为期两周的迁徙。把它们合并成 Monorepo 之后,共享的 UI 组件库一次发布即可被三个应用消费,react-nativereact-native-screens 的版本由顶层 package.json 锁定,再也不会出现"客户端能跑、审批端崩溃"的怪事。

Monorepo 真正的杠杆不在"少几个仓库",而在于原子化变更:当我修改一个共享的 @acme/auth 包,同一个 PR 里就能修复消费它的三个 App,CI 会一次性跑完所有受影响应用的类型检查与 E2E。Expo 团队在 SDK 53 的迁移说明中也强调了这一点:模板项目默认就是 monorepo-ready 结构。如果你还在为多仓库的版本漂移头疼,可以先看一下我们的 Expo EAS Build 与 Submit 部署指南,里面对比了单仓库与 Monorepo 的发布流水线差异。

pnpm vs Yarn vs Bun:2026 工具链对比

2026 年可选的 Workspace 管理器有四个:pnpm 10、Yarn 4 (Berry, PnP 或 node-modules linker)、Bun 1.2、npm 11。下表给出我在生产环境实测过的关键差异。

维度pnpm 10Yarn 4 (nodeLinker)Bun 1.2npm 11
冷安装速度 (350 包)38s52s14s71s
Metro symlink 兼容原生支持需 nodeLinker: node-modules需 0.83+ Metro原生支持
EAS Build 官方支持稳定稳定实验性稳定
磁盘占用共享 store,最低
原生模块 nohoistpublic-hoist-patternnmHoistingLimitslinker = "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.jsonreferences 把所有共享包串起来,开发时跑 pnpm -r --parallel run devtsc --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 --buildeslintjest 三个任务的产物上传到远程缓存。

// 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 策略

原生模块(含 .podspecbuild.gradle 的包)在 monorepo 下最容易"挂"。原因是 pnpm 默认把所有包用 symlink 链到 node_modules/.pnpm/ 深层目录,而 CocoaPods 与 Gradle 在某些版本下不会跟随 symlink 解析。我们的策略是:

  1. 第三方含原生代码的包(如 react-native-mmkvreact-native-reanimated)放在每个 App 的直接 dependencies,让 pnpm 在 App 的 node_modules 创建直接 symlink。
  2. 自研 Expo Modules 放在 modules/*,并在 App 里通过 "@acme/secure-storage": "workspace:*" 引用。Expo Autolinking 会从 App 的 package.json 解析依赖图,无须额外配置。
  3. 禁止 hoisting 给原生包:在根 .npmrcnode-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 之类的补丁,只要正确声明 watchFoldersnodeModulesPaths 即可。

如何在 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.jsonworkspaces 字段,把 monorepo 根作为构建上下文上传。你只需要在 eas.jsonEXPO_USE_METRO_WORKSPACE_ROOT=1 并在 prebuildCommandpnpm --filter 过滤目标 App 即可。

Yelena Petrov
关于作者 Yelena Petrov

React Native architect at a fintech. Builds platform teams, type-safe bridges, and runs the upgrade playbook so others don't have to.