使用 monorepo 开发

编辑页面

了解如何在 monorepo 中使用 workspaces 设置 Expo 项目。


For the complete documentation index, see llms.txt. Use this Use this file to discover all available pages.

Monorepo,或 “单体仓库”,是包含多个应用或包的单个仓库。它们可以帮助加快大型项目的开发速度,让代码共享更容易,并作为单一事实来源。本指南将使用一个 Expo 项目搭建一个简单的 monorepo。Expo 对由支持工作区的包管理器管理的 monorepo 提供一流支持:BunnpmpnpmYarn(v1 Classic 和 Berry)。Expo 会自动检测 monorepo,并为添加到 monorepo 中的新应用项目进行配置。检测基于你项目中的工作区配置。

Monorepo 并不适合每个项目。若多个应用位于同一个仓库中并共享代码,它会很有用;或者将原生模块与应用放在一起也会很方便。代价是在设置和配置工具链时复杂度会增加。在搭建 monorepo 之前,请先检查你的工具和库是否能在 monorepo 中良好工作。
自动配置(迁移到 SDK 52+)

Expo 会为 monorepo 自动配置 Metro。在使用 monorepo 时,如果你使用的是 expo/metro-config,就不需要手动配置 Metro。

如果你之前为 monorepo 手动配置过 Metro,并且你的 metro.config.js 修改了以下属性之一,请将它们从配置中删除:

  • watchFolders
  • resolver.nodeModulesPath
  • resolver.extraNodeModules
  • resolver.disableHierarchicalLookup

删除这些选项后,你需要使用 npx expo start --clear 运行一次 Expo,以清除过时的 Metro 缓存。如果之后你的应用仍能按预期运行,那么它就是一个普通的 Node monorepo,今后不需要任何特殊配置。

手动配置(在 SDK 52 之前)

Expo 的 Metro 配置内置支持 Bun、npm、pnpm 和 Yarn 的 monorepo。如果你使用的是来自 expo/metro-config 的配置,在使用 monorepo 时就不需要手动配置 Metro。

在 SDK 52 之前,使用 Metro 配置 monorepo 需要两项手动更改:

  1. 必须手动配置 Metro 去监视 monorepo 内的代码(例如,不仅仅是 apps/cool-app。)
  2. 需要调整 Metro 的解析方式,以便查找其他工作区中的包以及多个 node_modules 文件夹(例如,apps/cool-app/node_modulesnode_modules。)

配置是通过创建一个 metro.config.js 并使用以下内容来完成的:

metro.config.js
const { getDefaultConfig } = require('expo/metro-config'); const path = require('path'); // 这可以替换为 `find-yarn-workspace-root` const monorepoRoot = path.resolve(__dirname, '../..'); const config = getDefaultConfig(__dirname); // 1. 监视 monorepo 中的所有文件 config.watchFolders = [monorepoRoot]; // 2. 让 Metro 知道去哪里以及按什么顺序解析包 config.resolver.nodeModulesPaths = [ path.resolve(projectRoot, 'node_modules'), path.resolve(monorepoRoot, 'node_modules'), ]; module.exports = config;

了解更多关于自定义 Metro的信息。

搭建 monorepo

在 monorepo 中,你的应用通常会位于仓库的一个子目录中,而你的包管理器会配置为允许你在 monorepo 内为其他包添加依赖。 例如,一个包含 Expo 应用的 monorepo 的基本结构可能如下所示:

  • apps:包含多个项目,包括 Expo 应用。
  • packages:包含应用使用的不同包。
  • package.json:根目录包文件。

所有 monorepo 都应该有一个“根” package.json 文件。它是 monorepo 的主要配置,并且可能包含为仓库中所有项目安装的工具。根据你使用的包管理器不同,设置工作区的步骤可能会有所不同,但对于 BunnpmYarn,应在根 package.json 文件中添加一个 workspaces 属性,用来为 monorepo 中的所有工作区指定 glob 模式

package.json
{ "name": "monorepo", "private": true, "version": "0.0.0", "workspaces": ["apps/*", "packages/*"] }

对于 pnpm,你需要改为创建一个 pnpm-workspace.yaml

pnpm-workspace.yaml
packages: - 'apps/*' - 'packages/*'

创建你的第一个应用

现在你已经完成了基本的 monorepo 结构设置,接下来添加你的第一个应用。

在创建应用之前,你需要先创建 apps 目录。这个目录包含属于这个 monorepo 的所有独立应用或网站。在这个 apps 目录中,你可以创建一个包含 Expo 应用的子目录。

Terminal
npx create-expo-app@latest --template default@sdk-55 apps/cool-app

如果你已经有一个现有应用,可以把所有这些文件复制到 apps 内的一个目录中。

复制或创建第一个应用后,请从 monorepo 的根目录使用你的包管理器安装依赖,以检查常见警告。

创建一个包

Monorepo 可以帮助我们将代码分组到一个仓库中。这包括应用,也包括独立的包。它们也不一定需要发布。Expo 仓库 也使用了这种方式。所有 Expo SDK 包都位于我们仓库中的 packages 目录内。它有助于我们在发布这些包之前,先在我们 apps 目录中的某个应用里测试代码。

让我们回到根目录并创建 packages 目录。这个目录可以包含你想创建的所有独立包。进入这个目录后,我们需要添加一个新的子目录。这个子目录是一个可以在应用中使用的独立包。在下面的示例中,我们将其命名为 cool-package

Terminal
mkdir -p packages/cool-package && cd packages/cool-package && npm init

我们不会过多展开如何创建一个包。如果你对此不熟悉,建议使用一个不带 monorepo 的简单应用。不过,为了让示例完整,我们来添加一个 index.js 文件,并包含以下内容:

index.js
export const greeting = 'Hello!';

使用该包

像标准包一样,我们需要将 cool-package 作为依赖添加到我们的 cool-app 中。标准包和 monorepo 中的包之间的主要区别是,你通常总是想使用 “包的当前状态” 而不是某个版本。让我们通过在应用的 package.json 文件中添加 "cool-package": "*" 来把 cool-package 添加到应用中:

package.json
{ "name": "cool-app", "version": "1.0.0", "scripts": { "start": "expo start", "android": "expo start --android", "ios": "expo start --ios", "web": "expo start --web" }, "dependencies": { "cool-package": "*", "expo": "~55.0.0", "expo-status-bar": "~55.0.0", "react": "19.2.0", "react-native": "0.83" } }

Bun、npm 和 pnpm 支持使用 "workspace:*" 而不是 "*" 来指定工作区依赖。这可以确保工作区包不会从 npm registry 解析到同名的已发布包,但这是可选的。

添加包之后,请再次从 monorepo 的根目录使用你的包管理器安装依赖,以再次检查常见警告。

现在你应该能够在应用中使用这个包了!为了测试这一点,让我们编辑应用中的 App.js,并渲染来自 cool-packagegreeting 文本。

App.js
import { greeting } from 'cool-package'; import { StatusBar } from 'expo-status-bar'; import React from 'react'; import { Text, View } from 'react-native'; export default function App() { return ( <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}> <Text>{greeting}</Text> <StatusBar style="auto" /> </View> ); }

常见问题

Monorepo 可能会带来普通项目不会遇到的解析和依赖问题。它们需要更深入的知识,并且需要特定的工具配置。你需要接受更高的复杂度,并解决一些在没有工作区时不会遇到的问题。以下是你可能会遇到的一些常见问题。

具有隔离依赖的包管理器

SDK 54 开始,Expo 支持隔离依赖和隔离安装。
SDK 53 中,建议禁用隔离依赖,否则你可能会遇到原生构建错误和依赖冲突。

Bunpnpm 对隔离安装提供一流支持。对于 pnpm,除非被禁用,否则这就是默认安装策略。

使用隔离依赖时,包管理器不会将嵌套 node_modules 目录中的包提升到更高层级。相反,它们会创建一个包含你的 Node 模块的中心目录,并创建指向该目录的链接。这种依赖结构强制要求包只能访问它们显式声明的依赖。这比传统的 hoisted 安装策略严格得多,而 npm 和 Yarn 的默认策略就是使用扁平化结构来安装依赖。

hoisted 安装的一个副作用是,你可能会意外依赖于自己 package.jsondependenciespeerDependencies 中并未指定的 Node 模块。相反,更多其他包所依赖的模块会被提升并变得可访问。这可能导致非确定性行为,并使你拥有有问题的依赖链;这些链更脆弱,并且在更新或升级包时可能引发解析错误。这在 monorepo 中尤其常见。

从 SDK 54 开始,Expo 支持隔离依赖。不幸的是,并非你安装的所有包都能正常工作,有些 React Native 库在与隔离依赖一起使用时可能会导致构建或解析错误。如果你在使用 pnpm 的隔离安装时遇到问题,可以通过修改仓库根目录下 pnpm-workspace.yaml 文件中的 nodeLinker 设置,切换为 hoisted 安装策略:

pnpm-workspace.yaml
nodeLinker: hoisted

monorepo 中重复的原生包

Expo 已改进对更完整的 node_modules 模式的支持,例如隔离模块。不幸的是,如果你的应用包含重复依赖,问题仍然可能出现:

  • 单个 monorepo 中不支持重复的 React Native 版本
  • 单个应用中重复的 React 版本会导致运行时错误
  • Turbo 和 Expo 模块的重复版本可能导致运行时或构建错误

你可以检查 monorepo 中是否存在某个包的多个版本,例如 react-native,以及它们是通过你使用的包管理器为什么会被安装的。

Terminal
npm why react-native

这些命令的输出在不同包管理器之间会有很大差异,但你可以通过查找该包的多个版本来识别输出中的重复包,例如 react-native@0.79.5react-native@0.81.0npm

为 peer 依赖添加依赖解析

如果重复依赖无法通过你修改依赖来解决,那么你可能需要添加一个解析。例如,并非所有包都已更新其 peerDependencies 以支持 React 19。为了解决这个问题,你可以创建一个解析,强制安装单一版本的 react

package.json
{ "name": "monorepo", "private": true, "version": "0.0.0", "workspaces": ["apps/*", "packages/*"], "resolutions": { "react": "^19.2.0" } }

对于 npm,你必须使用名为 overrides 的属性,而不是 resolutions

为自动链接的原生模块去重

很多时候,重复依赖不会造成任何问题。然而,原生模块绝不应该重复,因为一次应用构建中只能编译一个版本的原生模块。与 JavaScript 依赖不同,原生构建不能包含同一个原生模块的两个冲突版本。

SDK 54 开始,你可以在 app.json 中将 experiments.autolinkingModuleResolution 设为 true,以将自动链接自动应用于 Expo CLI 和 Metro bundler。这将强制让 Metro 解析到的依赖与 autolinking 为你的原生构建所链接的原生模块保持一致。

SDK 55 开始,这在 monorepo 中的应用里会自动启用。

Script '...' does not exist

React Native 使用包来同时提供 JavaScript 和原生文件。这些原生文件也需要被链接,就像 android/app/build.Gradle 中的 react-native/react.Gradle 文件一样。通常,这个路径会被硬编码为类似下面这样的内容:

Android (source)

apply from: "../../node_modules/react-native/react.gradle"

iOS (source)

require_relative '../node_modules/react-native/scripts/react_native_pods'

不幸的是,由于 hoisting,在 monorepo 中这个路径可能不同。它也没有使用 Node 模块解析。你可以通过使用 Node 来查找包的位置,而不是把这个路径硬编码,从而避免这个问题:

Android (source)

apply from: new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), "../react.gradle")

iOS (source)

require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")

在上面的代码片段中,你可以看到我们使用了 Node 自身的 require.resolve() 方法来查找包的位置。我们明确引用 package.json,因为我们想找到包的根位置,而不是入口点的位置。有了这个根位置,我们就可以解析到包内预期的相对路径。在这里了解更多关于这些引用的信息

所有 Expo SDK 模块和模板都使用这些动态引用,因此可以在 monorepo 中正常工作。不过,偶尔你可能会遇到仍然使用硬编码路径的包。你可以使用 patch-package 手动修改它,或者向该包的维护者提出来。