【翻译】基于 Yarn Workspaces 的 React Native 单体仓库(Monorepo)实践指南

8 阅读10分钟

原文链接:www.callstack.com/blog/a-prac…

本文将逐步引导你搭建一个支持移动端Web 端的 React Native 单体仓库(Monorepo)。

注意:本指南适用于基于 React Native Community CLI 的单体仓库搭建。若你使用的是 Expo 项目,请参考 Expo 官方单体仓库文档

什么是单体仓库(Monorepo)?

单体仓库是一个包含多个项目及其所有代码和资源的单一代码仓库。这些项目通常相互关联,但仍可由不同团队独立开发、测试和发布。

单体仓库在开发大型复杂产品(如超级应用)时尤为实用。它能简化跨平台代码共享(例如移动端应用与 Web 端应用之间),避免逻辑重复或在多个地方重复开发相同解决方案。

为什么在 React Native 中使用单体仓库?

随着 React Native 应用规模扩大,将代码拆分为多个应用和共享模块是常见做法:例如一个移动端应用、一个 Web 端应用,以及包含 UI 组件、Hooks 或业务逻辑的共享包。在多个独立仓库中管理这些模块很快会变得繁琐:代码重复、依赖版本不匹配、工具链碎片化等问题层出不穷。

单体仓库通过将所有应用和共享包集中在一个代码仓库中解决了这些问题。这让依赖管理更可预测,减少了持续集成(CI)的开销,并允许团队协作开发共享代码,无需发布中间包或在多个仓库间同步更改。

此外,React Native 支持平台特定的文件扩展名(如 .web.tsx.native.tsx)。这使我们能够共享包的 API,同时在需要时提供平台优化的实现,无需额外的运行时层。因此,我们可以在移动端和 Web 端复用逻辑和 UI 契约,同时保持每个平台的代码简洁高效。

工作区策略

重组项目结构

要搭建 Yarn Workspaces,首先需要重组项目结构。单体仓库要求清晰分离应用和共享代码,仓库根目录需有一个 package.json 文件,用于管理依赖和工作区配置。

本文将使用一种已被验证适用于多平台产品扩展的结构,这也是React Native TV 开发终极指南中推荐的通用应用构建方案之一。

your-project/
	├── apps/
	│   ├── mobile/        # React Native 移动端应用
	│   └── web/           # Next.js Web端应用
	│
	├── packages/
	│   ├── api/            # API 客户端、SDK
	│   ├── config/         # 共享配置/环境变量
	│   ├── hooks/          # 共享 React Hooks
	│   ├── ui/             # 跨平台 UI 组件
	│   └── utils/          # 纯 JavaScript 工具函数

为什么要分离应用和包?

这种分离有助于维护清晰的所有权边界:应用依赖于包,但包从不依赖于应用。这让依赖关系图更可预测,并允许单体仓库随着更多应用或共享模块的添加而灵活扩展。

搭建 Yarn Workspaces

准备代码仓库

要将代码仓库转换为 Yarn 驱动的单体仓库,需在根目录执行以下一系列命令:

  1. corepack enable启用 Corepack,使项目能够控制使用的 Yarn 版本,不受全局安装版本的影响。

  2. yarn set version stable在代码仓库中直接安装并固定一个稳定的 Yarn 版本,确保不同设备上的行为一致。

  3. yarn config set nodeLinker node-modules现代版本的 Yarn 默认使用 Plug’n’Play(PnP)模式。虽然 PnP 适用于许多项目,但 React Native 工具链仍高度优化于传统的 node_modules 目录结构,因此我们选择常规的 node-modules 链接器。

  4. yarn init -w -p初始化项目为 Yarn 工作区:

    • w--workspace):将根目录标记为工作区根目录。
    • p--private):在 package.json 中设置 "private": true,这是必需的,因为工作区根目录不应被发布。

完成以上步骤后,你将获得一个根目录级别的 package.json 文件,用于管理整个单体仓库的依赖。

配置工作区

打开生成的 package.json 文件,将 appspackages 添加到工作区数组中。最终版本如下:

{
  "name": "example-monorepo",
  "packageManager": "yarn@4.12.0",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ]
}

workspaces 字段定义了工作树,我们可以传入一个全局匹配模式(glob patterns)数组来定位工作区。在上述示例中,packages 目录下的每个文件夹都被定义为一个工作区。

定义依赖解析规则

在单体仓库中,核心依赖(如 react)在所有工作区中解析为单一版本至关重要。即使是微小的版本不匹配(例如 React 的不同补丁版本),都可能导致运行时错误和意外崩溃。

我们可以在根目录的 package.json 中明确定义这些依赖的解析规则,添加 resolutions 字段:

{
  "resolutions": {
    "react": "19.2.3",
    "react-dom": "19.2.0"
  }
}

上述版本反映了本文撰写时 Next.js 和 React Native 生成的默认版本,请根据你的项目实际情况调整。

创建 React Native 应用

工作区配置完成后,我们可以在 apps/mobile 目录下创建 React Native 应用。

在工作区中初始化 React Native

从仓库根目录运行以下命令:

npx @react-native-community/cli init mobile \
  --directory apps/mobile \
  --skip-git-init \
  --skip-install
  • --directory apps/mobile:在 apps/ 工作区中创建应用。
  • --skip-git-init:防止 React Native 创建嵌套的 Git 仓库。
  • --skip-install:将依赖安装推迟到 Yarn Workspaces 统一处理。

从工作区根目录安装依赖

项目生成后,从仓库根目录执行一次依赖安装:

yarn install

从此时起,yarn install 应始终从工作区根目录运行。在单个应用或包的目录中运行安装命令可能会破坏工作区依赖关系。

单体仓库的 React Native 配置

默认情况下,React Native 假设是单项目结构,所有依赖都位于应用自身的 node_modules 目录中。在单体仓库中,这一假设不再成立,因此需要调整配置。

更新 Metro 配置

此时,我们需要让 Metro 打包器能够从共享包导入代码,同时兼容提升到根目录的依赖。打开 apps/mobile/metro.config.js,按以下方式更新:

const path = require('path');
const { getDefaultConfig } = require('@react-native/metro-config');

const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');

const config = getDefaultConfig(projectRoot);

config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(workspaceRoot, 'node_modules'),
];

module.exports = config;
  • watchFolders:告知 Metro 监听整个工作区的变化,包括共享包。
  • nodeModulesPaths:确保 Metro 能解析提升到工作区根目录的依赖。

iOS 配置

要安装 CocoaPods,可以从仓库根目录使用工作区快捷命令:

yarn workspace mobile exec bash -c "cd ios && pod install"
  • exec:在工作区环境中运行命令,可访问其依赖和二进制文件。
  • bash -c:允许我们切换目录并在一步中执行多个 shell 命令。

或者,你也可以直接导航到 apps/mobile/ios 目录并运行 pod install

Android 配置

更新 settings.gradle 文件,正确引用工作区根目录中的 React Native Gradle 插件:

- pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
+ pluginManagement { includeBuild("../../../node_modules/@react-native/gradle-plugin") }  

- includeBuild('../node_modules/@react-native/gradle-plugin')
+ includeBuild('../../../node_modules/@react-native/gradle-plugin')

并在 android/build.gradlebuildscript 部分添加以下内容:

allprojects {
   project.pluginManager.withPlugin("com.facebook.react") {
        react {
           reactNativeDir = rootProject.file("../../../node_modules/react-native/")
           codegenDir = rootProject.file("../../../node_modules/@react-native/codegen/")
       }
    }
}

单体仓库的 TypeScript 配置

在单体仓库中,在应用和共享包之间共享一组精简的 TypeScript 默认配置非常实用。无需在每个 tsconfig.json 中重复相同的选项,我们可以创建一个最小化的基础配置,供其他模块扩展。

首先,将 TypeScript 添加到根目录 package.json 的开发依赖中:

yarn add -D typescript

创建共享基础配置

在项目根目录创建 tsconfig.base.json,内容如下:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "node",
    "jsx": "react-jsx",

    "strict": true,
    "strictNullChecks": true,

    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "exclude": ["node_modules"]
}

该文件定义了一组适用于 React Native 和 Web 项目的语言级默认配置,避免了对运行时、打包器或输出格式的假设,因此可以安全地在整个单体仓库中扩展使用。

搭建共享包

代码共享是使用单体仓库的主要原因之一。我们无需在多个应用中重复逻辑或组件,而是可以将它们提取到共享包中,供移动端和 Web 端应用共同使用。

创建 UI 包

我们首先创建一个共享 ui 包,其中包含一个跨平台 UI 组件 —— 简单的 Button 组件。

准备以下目录结构:

packages/ui/
  ├── src/
  │   ├── Button.tsx           # 默认实现(Web端)
  │   ├── Button.native.tsx    # 移动端实现(覆盖默认)
  │   ├── Button.types.ts      # 共享属性类型
  │   └── index.ts             # 包入口文件
  ├── package.json
  ├── tsconfig.json            # 开发环境 TypeScript 配置
  └── tsconfig.build.json      # 构建环境 TypeScript 配置

接下来,创建 packages/ui/package.json,配置如下:

{
  "name": "@example/ui",
  "version": "1.0.0",
  "private": true,
  "main": "src/index.ts",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc -p tsconfig.build.json"
  }
}

这里我们配置包从 dist 目录暴露编译输出和 TypeScript 类型。

创建共享 UI 组件

packages/ui/src/Button.tsx 中,创建 Web 端使用的组件:

import { ButtonProps } from './Button.types'

export const Button = ({ label }: ButtonProps) => (
  <button style={{padding: '16px', cursor: 'pointer'}}>
    {label}
  </button>
);

packages/ui/src/Button.native.tsx 中,添加 React Native 组件:

import { TouchableOpacity, Text } from 'react-native';
import { ButtonProps } from './Button.types'

export const Button = ({ label }: ButtonProps) => (
  <TouchableOpacity>
    <Text>{label}</Text>
  </TouchableOpacity>
);

packages/ui/src/Button.types.ts 中添加共享的 ButtonProps 类型:

export type ButtonProps = { label: string };

packages/ui/src/index.ts 中导出包中的 Button 组件:

export { Button } from './Button';

共享包的 TypeScript 配置

共享包扩展根目录的 tsconfig.base.json 配置,并定义自己的开发和构建设置。

创建 packages/ui/tsconfig.json

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "noEmit": true
  },
  "include": ["src"],
  "exclude": ["dist"]
}

该配置用于开发阶段,作用范围限制在 src 目录。noEmit: true 确保 TypeScript 仅执行类型检查,不生成输出文件。

接下来创建 packages/ui/tsconfig.build.json

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": false,
    "emitDeclarationOnly": true,
    "outDir": "dist",
    "declaration": true,
    "declarationMap": false
  },
  "include": ["src"]
}

该配置仅在打包时使用,仅将 TypeScript 声明文件输出到 dist 目录。有意跳过 JavaScript 输出,因为运行时打包由使用该包的应用负责。设置 "noEmit": false 确保 TypeScript 为该包生成构建产物。

现在需要为该包添加 TypeScript 开发依赖:

yarn workspace @example/ui add -D typescript

构建共享包

共享包已配置完成。从仓库根目录运行以下命令构建该包:

yarn workspace @example/ui build

别忘了将 dist/ 目录添加到 .gitignore 文件中,因为构建产物不应提交到代码仓库。

搭建 Web 项目

有了共享包后,我们可以添加一个 Web 应用,它将与 React Native 应用使用相同的代码。本文将使用 Next.js—— 构建现代 Web 应用的理想选择。

创建 Next.js 应用并完成设置步骤:

npx create-next-app@latest apps/web --ts --use-yarn

将共享的 @example/ui 包添加到 Web 应用中:

yarn workspace web add @example/ui

启动 Web 服务器:

yarn workspace web dev

跨平台使用共享 UI

现在我们可以验证同一个共享 UI 组件是否能在 Web 端和移动端应用中正常工作。

在 Web 端使用共享组件

打开 apps/web/src/pages/index.tsx,导入共享的 Button 组件:

import { Button } from '@example/ui';

export default function Home() {
  return (
    <main className="container">
      <div className="bg-gray-100">
        <Button label="Hello from Web" />
      </div>
    </main>
  );
}

在移动端使用共享组件

我们已经将 @example/ui 包添加到了 Web 应用,现在为移动端应用执行相同操作:

yarn workspace mobile add @example/ui

打开 apps/mobile/App.tsx,按以下方式更新:

import { View, StyleSheet } from 'react-native';
import { Button } from '@example/ui';

export default function App() {
  return (
    <View style={styles.container}>
      <Button label="Hello from Mobile" />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
});

总结与后续步骤

我们已经搭建了一个支持移动端和 Web 端应用的 React Native 单体仓库基础,包含共享的 UI 和逻辑。在此基础上,你可以从多个方向扩展单体仓库:添加更多共享包、支持更多平台,或集成适合多应用仓库的持续集成(CI)工作流。

如果你想了解这种方案在实际场景中的扩展情况,以下相关文章可能对你有用:

祝你使用愉快!