了解如何使用 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 并不适合所有人。它需要对所使用的工具有深入的了解,增加了更多的复杂性,并且通常需要特定的工具配置。只需一个存储库就可以走得很远。
¥Example monorepo
在此示例中,我们将使用 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:
{
"name": "monorepo",
"version": "1.0.0"
}
¥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:
{
"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.
-
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.
¥Modify the Metro config
Expo 的 Metro 配置具有对 Bun、npm 和 Yarn 的 monorepo 支持。如果你使用 expo/metro-config
中的配置,则在使用 monorepos 时无需手动配置 Metro。如果是这种情况,你可以跳过此步骤。
¥Expo's Metro config has monorepo support for bun, npm, 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:
确保 Metro 正在监视 monorepo 中的所有相关代码,而不仅仅是 apps/cool-app。
¥Make sure Metro is watching all relevant code within the monorepo, not just apps/cool-app.
告诉 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:
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.
Metro 在其打包过程中有三个独立的阶段,记录在这里。在第一阶段“解决”期间,Metro 会解决应用所需的文件和依赖。Metro 使用 watchFolders
选项来完成此操作,该选项默认设置为项目目录。此默认设置非常适合不使用 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.
使用 monorepos 时,你的应用依赖会分成不同的目录。这些目录中的每一个都必须在 watchFolders
的范围内。如果更改的文件超出该范围,Metro 将无法找到它。将此路径设置为 monorepo 的根目录将强制 Metro 监视存储库中的所有文件,并可能导致初始启动时间变慢。
¥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.
随着你的 monorepo 大小的增加,查看 monorepo 中的所有文件会变得更慢。你只需查看应用使用的包即可加快速度。通常,这些是在 package.json 中使用星号 (*) 安装的。例如:
¥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:
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;
此选项对于解析正确的 node_modules 目录中的库非常重要。Monorepo 工具(如 Yarn)通常会创建两个不同的 node_modules 目录,用于单个工作区。
¥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.
apps/mobile/node_modules - "project" 目录
¥apps/mobile/node_modules - The "project" directory
node_modules - "root" 目录
¥node_modules - The "root" directory
Yarn 使用根目录来安装多个工作区中使用的包。如果工作区使用不同的包版本,它会在项目目录中安装该不同版本。
¥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.
我们必须告诉 Metro 查看这两个目录。这里的顺序很重要,因为项目目录 node_modules 可以包含我们用于应用的特定版本。当项目目录中不存在包时,它应该尝试共享根目录。
¥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
在 monorepos 中,我们无法再对包的路径进行硬编码,因为我们无法确定它们是安装在根 node_modules 中还是工作区 node_modules 目录中。如果你使用的是托管项目,我们必须更改默认入口点,如 node_modules/expo/AppEntry.js
。
¥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
.
打开我们应用的 package.json,将 main
属性更改为 index.js
,然后在应用目录中创建一个包含以下内容的新 index.js 文件。
¥Open our 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.
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);
这个新的入口点已经存在于裸项目中。仅当你有托管项目时才需要添加此内容。
¥This new entrypoint already exists for bare projects. You only need to add this if you have a managed project.
如果你使用的是 Expo 路由,请不要更改默认入口点。运行 npx expo start
命令时,使用 EXPO_USE_METRO_WORKSPACE_ROOT
。它启用 Metro 的自动服务器根检测。
¥If you are using Expo Router, do not change the default entrypoint. When running npx expo start
command, use the EXPO_USE_METRO_WORKSPACE_ROOT
. It enables the auto server root detection for Metro.
-
EXPO_USE_METRO_WORKSPACE_ROOT=1 npx expo start
该变量也可以在 .env 文件内定义。
¥This variable can also be defined inside a .env file.
¥Create a package
Monorepos 可以帮助我们将代码分组到单个存储库中。这包括应用,但也包括单独的软件包。它们也不需要发布。Expo 资料库 也使用这个。所有 Expo SDK 包都位于我们仓库中的 packages 目录中。它可以帮助我们在发布 apps 目录之一之前测试其中的代码。
¥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.
让我们回到根目录并创建包目录。此目录可以包含你要制作的所有单独的包。进入此目录后,我们需要添加一个新的子目录。子目录是一个单独的包,我们可以在应用内部使用它。在下面的示例中,我们将其命名为 cool-package。
¥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.
# Create our new package directory
-
mkdir -p packages/cool-package
-
cd packages/cool-package
# And create the new package
-
yarn init
我们不会在创建包时讨论太多细节。如果你对此不熟悉,请考虑使用不带 monorepos 的简单应用。但是,为了使示例完整,让我们添加一个包含以下内容的 index.js 文件:
¥We won't go into too much detail in creating a package. If you are not familiar with this, please consider using a simple app without monorepos. But, to make the example complete, let's add an index.js file with the following content:
export const greeting = 'Hello!';
¥Using the package
与标准包一样,我们需要将 cool-package 添加为 cool-app 的依赖。标准包与 monorepo 包之间的主要区别是你始终希望使用 "包的当前状态" 而不是版本。让我们通过将 "cool-package": "*"
添加到我们的应用 package.json 文件中来将 Cool-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:
{
"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.6"
},
"devDependencies": {
"@babel/core": "^7.20.0"
}
}
将包添加为依赖后,运行
yarn install
以安装依赖或将依赖链接到你的应用。¥After adding the package as a dependency, run
yarn install
to install or link the dependency to your app.
现在你应该能够在你的应用中使用该包了!为了测试这一点,让我们在应用中编辑 App.js 并从我们的 Cool-package 中渲染 greeting
文本。
¥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.
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
如前所述,使用 monorepos 并不适合所有人。你面临的复杂性不断增加,需要解决你最有可能遇到的问题。以下是你可能会遇到的一些常见问题。
¥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 50 及更高版本改进了对更完整的 node_modules 模式(例如隔离模块)的支持。不幸的是,在单个 monorepo 中安装多个版本时,React Native 仍然可能会导致问题。因此,建议仅使用单一版本的 React Native。
¥Expo SDK 50 and higher 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.
你可以通过你使用的包管理器检查你的 monorepo 是否有多个 React Native 版本以及为什么安装它们。
¥You can check if your monorepo has multiple React Native versions and why they are installed through the package manager you use.
# 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://classic.yarnpkg.com/en/docs/cli/why
-
yarn why react-native
¥Can I use another monorepo tool instead of Yarn workspaces?
有很多可用的 monorepo 工具,每种工具都有其优点。我们很难跟上最新的工具和方法,因此我们无法正式支持新的 monorepo 工具。话虽这么说,如果该工具遵循这三个规则,它应该可以正常工作。
¥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.
React Native 依赖包含除 JavaScript 之外的许多其他文件,例如 React-native/react.gradle 等 Gradle 文件。这些原生文件是从 Node.js 以外的不同来源引用的,因此,它与 Plug'n'Play 模块等概念从根本上不兼容。
¥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.
每当多个工作区使用单个依赖的相同版本时,它们就可以安装在根 node_modules 目录中。Monorepo 工具通常这样做是为了删除重复的任务,例如在不同的位置安装相同的依赖两次。此规则不是必需的,但确实为我们设置了下一条规则。
¥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.
在 修改 Metro 配置 步骤中,我们指示 Metro 执行以下操作,具体如下:
¥In the Modify the Metro config step, we instructed Metro to do a couple of this, specifically:
#2 - 按照本地 apps/<name>/node_modules 和根 node_modules 目录的顺序解析依赖。
¥#2 - Resolve dependencies in the order of the local apps/<name>/node_modules and root node_modules directories.
#3 - 禁用使用分层查找策略解析依赖。
¥#3 - Disable resolving dependencies using the hierarchical lookup strategy.
如果工作区使用的库版本与根 node_modules 中安装的库版本不同,则必须在工作区 apps/<name>/node_modules 目录中安装该不同库版本。
¥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.
当 Metro 从工作区解析库(例如 react
)时,它应该在 apps/<name>/node_modules 中找到不同的版本,而不是查看根 node_modules 目录。
¥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.
当从根 node_modules 目录导入也导入 react
的依赖时,react
仍应解析为安装在 apps/<name>/node_modules 中的不同版本。这就是禁用的分层查找选项对 Metro 的作用。如果没有这个,某些库可能会导入错误的 react
版本并导致 "发现多个 React 版本" 错误。
¥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.