This documentation is available as Markdown for AI agents and LLMs. See the full Markdown index or append .md to any documentation URL.

使用 monorepo 开发

编辑页面

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


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

搭建 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-57 apps/cool-app

复制或创建第一个应用后,请从 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": "~57.0.0", "expo-status-bar": "~55.0.0", "react": "19.2.3", "react-native": "0.86" } }

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

具有隔离依赖的包管理器

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.3" } }

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

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

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

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

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

带有 TV 项目的 Monorepo

请查看 为 TV 构建 文档中的依赖部分,了解当一个 TV 项目与其他 Expo 项目一起包含在 monorepo 中时,对 react-native 的特殊依赖要求。

脚本“...”不存在

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

Android (来源)

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

iOS (来源)

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

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

Android (来源)

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

iOS (来源)

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 手动修改它,或者向该包的维护者提出来。