使用大仓

了解如何使用 Yarn v1 工作区在 monorepo 中设置 Expo 项目。


Monorepos(或 "整体存储库")是包含多个应用或包的单个存储库。它可以帮助加快大型项目的开发速度,使代码共享变得更容易,并充当单一事实来源。本指南将使用 Expo 项目设置一个简单的 monorepo。目前,我们为 Yarn 1(经典) 工作区提供一流的支持。如果你想使用其他工具,请确保你知道如何配置它。

¥Monorepos, or "monolithic repositories", are single repositories containing multiple apps or packages. It can help speed up development for larger projects, make it easier to share code, and act as a single source of truth. This guide will set up a simple monorepo with an Expo project. We currently have first-class support for Yarn 1 (Classic) workspaces. If you want to use another tool, make sure you know how to configure it.

Monorepos 并不适合所有人。它需要对所使用的工具有深入的了解,增加了更多的复杂性,并且通常需要特定的工具配置。只需一个存储库就可以走得很远。

自动配置

¥Automatic configuration

从 SDK 48 开始,Expo 会自动检测 monorepos 并在使用 bunnpmyarn 时配置添加到 monorepo 的新应用项目。从 SDK 52 开始,Expo 在使用 pnpm 时也会执行此自动配置。检测基于项目中的工作区配置。

¥Starting from SDK 48, Expo automatically detects monorepos and configures new app projects added to a monorepo when using bun, npm, or yarn. From SDK 52, Expo also performs this automatic configuration when using pnpm. The detection is based on the workspaces configuration in your project.

Expo 的自动 monorepo 设置将 monorepo 中的所有工作区作为 watchFolders 添加到 Metro。这可能会减慢 Metro 较大的 monorepos 的速度。你还可以通过更改 metro.config.js 中的 watchFolders 来手动配置 Metro 应监视的目录,请参阅 修改 Metro 配置

手动配置

¥Manual configuration

在此示例中,我们将使用 Yarn 工作区(不带 nohoist 选项)设置 monorepo。我们将采用一些熟悉的名称,但你可以完全自定义它们。完成本指南后,我们的基本结构应如下所示:

¥In this example, we will set up a monorepo using Yarn workspaces without the nohoist option. We will assume some familiar names, but you can fully customize them. After this guide, our basic structure should look like this:

  • apps - 包含多个项目,包括 React Native 应用。

    ¥apps - Contains multiple projects, including React Native apps.

  • packages - 包含我们的应用使用的不同包。

    ¥packages - Contains different packages used by our apps.

  • package.json - 根包文件,包含 Yarn 工作区配置。

    ¥package.json - Root package file, containing Yarn workspaces config.

根包文件

¥Root package file

所有 Yarn monorepos 都应该有一个 "root" package.json 文件。它是我们 monorepo 的主要配置,可能包含为存储库中的所有项目安装的包。你可以运行 yarn init,或手动创建 package.json。它应该看起来像这样:

¥All Yarn monorepos should have a "root" package.json file. It is the main configuration for our monorepo and may contain packages installed for all projects in the repository. You can run yarn init, or create the package.json manually. It should look something like this:

package.json
{
  "name": "monorepo",
  "version": "1.0.0"
}

设置 Yarn 工作区

¥Set up yarn workspaces

Yarn 和其他工具有一个称为 "workspaces" 的概念。我们存储库中的每个包和应用都有自己的工作区。在使用它们之前,我们必须指示 Yarn 在哪里找到这些工作区。我们可以通过在 package.json 中使用 通配符模式 设置 workspaces 属性来做到这一点:

¥Yarn and other tooling have a concept called "workspaces". Every package and app in our repository has its own workspace. Before we can use them, we have to instruct Yarn where to find these workspaces. We can do that by setting the workspaces property using glob patterns, in the package.json:

package.json
{
  "private": true,
  "name": "monorepo",
  "version": "1.0.0",
  "workspaces": ["apps/*", "packages/*"]
}
Yarn 工作区要求根 package.json 是私有的。如果你不设置此项,yarn install 将出错并显示一条提及此信息的消息。

创建我们的第一个应用

¥Create our first app

现在我们已经设置了基本的 monorepo 结构,让我们添加我们的第一个应用。

¥Now that we have the basic monorepo structure set up, let's add our first app.

在创建应用之前,我们必须创建应用目录。此目录包含属于此 monorepo 的所有单独的应用或网站。在这个 apps 目录中,我们可以创建一个包含 React Native 应用的子目录。

¥Before we create our app, we have to create the apps directory. This directory contains all separate apps or websites that belong to this monorepo. Inside this apps directory, we can create a sub-directory that contains the React Native app.

Terminal
yarn create expo apps/cool-app

如果你有现有应用,你可以将所有这些文件复制到应用内的目录中。

¥If you have an existing app, you can copy all those files into a directory inside apps.

复制或创建第一个应用后,运行 yarn 以检查常见警告。

¥After copying or creating the first app, run yarn to check for common warnings.

修改 Metro 配置

¥Modify the Metro config

Expo 的 Metro 配置为 Bun、npm、pnpm 和 Yarn 提供 monorepo 支持。如果你使用 expo/metro-config 中的配置,则在使用 monorepos 时无需手动配置 Metro。如果是这种情况,你可以跳过此步骤。

¥Expo's Metro config has monorepo support for Bun, npm, pnpm and Yarn. You don't have to manually configure Metro when using monorepos if you use the config from expo/metro-config. If that's the case, you can skip this step.

要手动使用 Metro 配置 monorepo,有两个主要更改:

¥To configure a monorepo with Metro manually, there are two main changes:

  1. 确保 Metro 正在监视 monorepo 中的所有相关代码,而不仅仅是 apps/cool-app。

    ¥Make sure Metro is watching all relevant code within the monorepo, not just apps/cool-app.

  2. 告诉 Metro 在哪里可以解析包。它们可能安装在 apps/cool-app/node_modules 或 node_modules 中。

    ¥Tell Metro where it can resolve packages. They might be installed in apps/cool-app/node_modules or node_modules.

我们可以通过 创建 Metro.config.js 来配置它,内容如下:

¥We can configure this by creating a metro.config.js with the following content:

metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');

// Find the project and workspace directories
const projectRoot = __dirname;
// This can be replaced with `find-yarn-workspace-root`
const monorepoRoot = path.resolve(projectRoot, '../..');

const config = getDefaultConfig(projectRoot);

// 1. Watch all files within the monorepo
config.watchFolders = [monorepoRoot];
// 2. Let Metro know where to resolve packages and in what order
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(monorepoRoot, 'node_modules'),
];

module.exports = config;

了解有关 定制 Metro 的更多信息。

¥Learn more about customizing Metro.

1. Why do we need to watch all files with the monorepo?

Metro has three separate stages in its bundling process, documented here. During the first phase, Resolution, Metro resolves your app's required files and dependencies. Metro does that with the watchFolders option, which is set to the project directory by default. This default setting works great for apps that don't use a monorepo structure.

When using monorepos, your app dependencies splits up into different directories. Each of these directories must be within the scope of the watchFolders. If a changed file is outside of that scope, Metro won't be able to find it. Setting this path to the root of your monorepo will force Metro to watch all files within the repository and possibly cause a slow initial startup time.

As your monorepo increases in size, watching all files within the monorepo becomes slower. You can speed things up by only watching the packages your app uses. Typically, these are the ones that are installed with an asterisk (*) in your package.json. For example:

metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');

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

const config = getDefaultConfig(projectRoot);

// Only list the packages within your monorepo that your app uses. No need to add anything else.
// If your monorepo tooling can give you the list of monorepo workspaces linked
// in your app workspace, you can automate this list instead of hardcoding them.
const monorepoPackages = {
  '@acme/api': path.resolve(monorepoRoot, 'packages/api'),
  '@acme/components': path.resolve(monorepoRoot, 'packages/components'),
};

// 1. Watch the local app directory, and only the shared packages (limiting the scope and speeding it up)
// Note how we change this from `monorepoRoot` to `projectRoot`. This is part of the optimization!
config.watchFolders = [projectRoot, ...Object.values(monorepoPackages)];

// Add the monorepo workspaces as `extraNodeModules` to Metro.
// If your monorepo tooling creates workspace symlinks in the `node_modules` directory,
// you can either add symlink support to Metro or set the `extraNodeModules` to avoid the symlinks.
// See: https://metrobundler.dev/docs/configuration/#extranodemodules
config.resolver.extraNodeModules = monorepoPackages;

// 2. Let Metro know where to resolve packages and in what order
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(monorepoRoot, 'node_modules'),
];

module.exports = config;
2. Why do we need to tell Metro how to resolve packages?

This option is important to resolve libraries in the correct node_modules directories. Monorepo tooling, like Yarn, usually creates two different node_modules directories which are used for a single workspace.

  1. apps/mobile/node_modules - The "project" directory
  2. node_modules - The "root" directory

Yarn uses the root directory to install packages used in multiple workspaces. If a workspace uses a different package version, it installs that different version in the project directory.

We have to tell Metro to look in these two directories. The order is important here because the project directory node_modules can contain specific versions we use for our app. When the package does not exist in the project directory, it should try the shared root directory.

Change default entrypoint

In monorepos, we can't hardcode paths to packages anymore since we can't be sure if they are installed in the root node_modules or the workspace node_modules directory. If you are using a managed project, we have to change our default entry point that looks like node_modules/expo/AppEntry.js.

This new entry point already exists for existing React Native (bare) projects. You only need to add this if you have a project generated with npx create-expo-app.

If you are using Expo Router, do not change the entrypoint. By default EXPO_USE_METRO_WORKSPACE_ROOT is activated, which enables the auto server root detection for Metro. You can skip this step.

Otherwise, open your app's package.json, change the main property to index.js, and create this new index.js file in the app directory with the content below.

index.js
import { registerRootComponent } from 'expo';

import App from './App';

// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

Create a package

Monorepos can help us group code in a single repository. That includes apps but also separate packages. They also don't need to be published. The Expo repository uses this as well. All the Expo SDK packages live inside the packages directory in our repo. It helps us test the code inside one of our apps directory before we publish them.

Let's go back to the root and create the packages directory. This directory can contain all the separate packages that you want to make. Once you are inside this directory, we need to add a new sub-directory. The sub-directory is a separate package that we can use inside our app. In the example below, we named it cool-package.

Terminal
# Create our new package directory
mkdir -p packages/cool-package
cd packages/cool-package

# And create the new package
yarn init

We won't go into too much detail in creating a package. If you are not familiar with this, consider using a simple app without monorepos. But, to make the example complete, let's add an index.js file with the following content:

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

Using the package

Like standard packages, we need to add our cool-package as a dependency to our cool-app. The main difference between a standard package, and one from the monorepo, is you'll always want to use the "current state of the package" instead of a version. Let's add cool-package to our app by adding "cool-package": "*" to our app package.json file:

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": "~50.0.0",
    "expo-status-bar": "~1.10.0",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-native": "0.73.0",
    "react-native-web": "~0.19.13"
  },
  "devDependencies": {
    "@babel/core": "^7.20.0"
  }
}

After adding the package as a dependency, run yarn install to install or link the dependency to your app.

Now you should be able to use the package inside your app! To test this, let's edit the App.js in our app and render the greeting text from our cool-package.

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>
  );
}

Common issues

As mentioned earlier, using monorepos is not for everyone. You take on increased complexity and need to solve issues you most likely will run into. Here are a couple of common issues you might encounter.

Multiple React Native versions within the monorepo

Expo SDK has improved support for more complete node_modules patterns, such as isolated modules. Unfortunately, React Native can still cause issues when installing multiple versions inside a single monorepo. Because of that, it's recommended to only use a single version of React Native.

You can check if your monorepo has multiple React Native versions and why they are installed through the package manager you use.

Terminal
# Bun does not support `bun why` yet, but you can use `yarn why`
bun install --yarn && yarn why react-native

# npm supports the `npm explain` command, aliased to `npm why`# https://npm.nodejs.cn/cli/v10/commands/npm-explain
npm why react-native

# pnpm only iterates the full monorepo with the `--recursive` flag# https://pnpm.nodejs.cn/cli/why
pnpm why --recursive react-native

# Yarn supports the `yarn why` command# https://yarn.nodejs.cn/en/docs/cli/why
yarn why react-native

Can I use another monorepo tool instead of Yarn workspaces?

There are a lot of monorepo tools available, and each of these tools has its benefits. It's hard for us to keep up with the latest tools and methods, and because of that, we can't officially support new monorepo tools. That being said, if the tool follows these three rules, it should work fine.

1. All dependencies must be installed in a node_modules directory

React Native dependencies contain many other files besides JavaScript, like Gradle files such as react-native/react.gradle. These native files are referenced from different sources other than Node.js, and because of that, it makes it fundamentally incompatible with concepts like Plug'n'Play modules.

2. Dependencies used in multiple workspaces can be installed in the root node_modules directory

Whenever multiple workspaces use the same version of a single dependency, they can be installed in a root node_modules directory. Monorepo tools usually do this to remove duplicate tasks, like installing the same dependency twice in different places. This rule isn't necessary but does set us up for next rule.

3. Different versions of dependencies must be installed in the app node_modules directory

In the Modify the Metro config step, we instructed Metro to do a couple of this, specifically:

  • #2 - Resolve dependencies in the order of the local apps/<name>/node_modules and root node_modules directories.
  • #3 - Disable resolving dependencies using the hierarchical lookup strategy.

If a workspace uses a different library version than the one installed in the root node_modules, that different library version must be installed in the workspace apps/<name>/node_modules directory.

When Metro resolves a library, for example, react, from the workspace, it should find that different version in apps/<name>/node_modules and not look inside the root node_modules directory.

When importing a dependency from the root node_modules directory that also imports react, react should still resolve to the different version installed in apps/<name>/node_modules. That's what the disabled hierarchical lookup option does for Metro. Without this, some libraries might import the wrong react version and cause "multiple React versions found" errors.

pnpm 等工具的默认设置不遵循这些规则。你可以通过添加带有 node-linker=hoisted (请参阅文档) 的 .npmrc 文件来更改它。该配置选项将更改行为以匹配这些规则。

¥The default settings of tools like pnpm do not follow these rules. You can change that by adding a .npmrc file with node-linker=hoisted (see docs). That config option will change the behavior to match these rules.

脚本 '...' 不存在

¥Script '...' does not exist

React Native 使用包来传送 JavaScript 和原生文件。这些原生文件也需要链接,例如 android/app/build.Gradle 中的 react-native/react.Gradle 文件。通常,该路径被硬编码为:

¥React Native uses packages to ship both JavaScript and native files. These native files also need to be linked, like the react-native/react.Gradle file from android/app/build.Gradle. Usually, this path is hardcoded to something like:

安卓 (source)

¥Android (source)

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

iOS (source)

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

不幸的是,由于 hoisting,这条路径在 monorepos 中可能会有所不同。它也不使用 节点模块解析。你可以通过使用 Node 查找包的位置而不是对其进行硬编码来避免此问题:

¥Unfortunately, this path can be different in monorepos because of hoisting. It also doesn't use the Node module resolution. You can avoid this issue by using Node to find the location of the package instead of hardcoding this:

安卓 (source)

¥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 是因为我们想要找到包的根位置,而不是入口点的位置。有了这个根位置,我们就可以解析出包内预期的相对路径。在此处了解有关这些参考资料的更多信息

¥In the snippets above, you can see that we use Node's own require.resolve() method to find the package location. We explicitly refer to package.json because we want to find the root location of the package, not the location of the entry point. And with that root location, we can resolve to the expected relative path within the package. Learn more about these references here.

所有 Expo SDK 模块和模板都有这些动态引用并与 monorepos 一起使用。但是,偶尔,你可能会遇到仍然使用硬编码路径的包。你可以使用 patch-package 手动编辑它或向软件包维护人员提及这一点。

¥All Expo SDK modules and templates have these dynamic references and work with monorepos. However, occasionally, you might run into packages that still use the hardcoded path. You can manually edit it with patch-package or mention this to the package maintainers.