深入了解 Expo Monorepo:架构、结构与整体设计
前置知识
- ›具备基本的 React Native 开发经验
- ›了解 monorepo 的基本概念(workspaces、包链接等)
深入了解 Expo Monorepo:架构、结构与整体设计
expo/expo 是目前规模最大的开源 React Native 代码库之一。它支撑着整个 Expo SDK —— 一个用于构建 iOS、Android 和 Web 跨平台应用的综合性平台。整个仓库包含 117 个以上的包,初次接触难免会感到无从下手。但只要理解了其中的架构逻辑,一切都会豁然开朗。
本文是六篇系列文章的第一篇。我们先从宏观视角入手:monorepo 是如何组织的,各个包之间是什么关系,以及当开发者运行 npx expo start 时究竟发生了什么。读完本文,你将建立起一套清晰的心智模型,为后续每篇文章的深入阅读打好基础。
Monorepo 结构与 Workspace 配置
整个仓库划分为五个顶层目录,各司其职:
| 目录 | 用途 |
|---|---|
packages/ |
核心目录 —— 包含所有 SDK 包、CLI 工具以及 @expo/* 作用域下的工具包 |
apps/ |
集成测试应用、Expo Go 以及沙盒环境 |
docs/ |
docs.expo.dev 文档站点 |
tools/ |
内部构建脚本和开发工具 |
templates/ |
供 create-expo-app 使用的项目模板 |
Workspace 配置定义在 pnpm-workspace.yaml 中,声明了各个包的根路径:
packages:
- apps/*
- packages/*
- packages/@expo/*
- tools
- apps/brownfield-tester/expo-app
- apps/bare-expo/e2e/image-comparison
根目录的 package.json 通过 Volta 锁定了 Node.js 22.14.0 版本,并为 React 19.2.3 和 React Native 0.85.0-rc.7 等关键共享依赖配置了版本覆盖,确保 monorepo 中所有包都解析到完全相同的版本。
graph TD
ROOT["expo/expo (root)"]
ROOT --> PKGS["packages/*"]
ROOT --> SCOPED["packages/@expo/*"]
ROOT --> APPS["apps/*"]
ROOT --> TOOLS["tools/"]
ROOT --> DOCS["docs/"]
PKGS --> SDK["SDK Modules<br/>(expo-camera, expo-router, ...)"]
PKGS --> CORE["expo-modules-core"]
PKGS --> UMBRELLA["expo (umbrella)"]
SCOPED --> CLI["@expo/cli"]
SCOPED --> CONFIG["@expo/config"]
SCOPED --> METRO["@expo/metro-config"]
APPS --> TESTS["test-suite, bare-expo"]
APPS --> GO["expo-go"]
pnpm-workspace.yaml 同时也管理着一些打过补丁的依赖,例如对 react-native-reanimated 和 @react-native/dev-middleware 等第三方包的修改,用于修复兼容性问题。linkWorkspacePackages: deep 配置确保即使在嵌套依赖中,也会优先使用 workspace 内的包,而不是从 registry 拉取已发布的版本。
提示: 如果你想在本地探索这个仓库,在根目录运行
pnpm install即可。ignoreWorkspaceCycles: true配置会抑制部分包之间有意为之的循环引用警告,例如expo依赖expo-asset,而expo-asset又依赖回expo-modules-core。
三个层次:CLI、Runtime 与 Native Bridge
约 117 个包分属三个架构层次,各层职责明确,依赖方向单一:
flowchart TD
subgraph CLI["Layer 1: CLI & Build Tooling"]
EXCLI["@expo/cli"]
EXCONFIG["@expo/config"]
EXMETRO["@expo/metro-config"]
EXCP["@expo/config-plugins"]
EXFP["@expo/fingerprint"]
end
subgraph RUNTIME["Layer 2: Runtime SDK"]
EXPO["expo (umbrella)"]
ROUTER["expo-router"]
CAMERA["expo-camera"]
UPDATES["expo-updates"]
DOTS1["... ~80 more"]
end
subgraph NATIVE["Layer 3: Native Bridge"]
EMC["expo-modules-core"]
AUTOLINK["expo-modules-autolinking"]
end
CLI --> RUNTIME
RUNTIME --> NATIVE
EXCLI --> EXCONFIG
EXCLI --> EXMETRO
EXPO --> EMC
CAMERA --> EMC
ROUTER --> EXPO
第一层 —— CLI 与构建工具(@expo/* 作用域下的包):这些包运行在开发者本地机器上,负责项目配置、Metro 打包、原生项目生成(prebuild)以及代码签名等工作,不会随应用一起发布到用户设备上。
第二层 —— Runtime SDK(如 expo-camera、expo-router 等非作用域包):这些包会随应用一起发布,为开发者提供可直接调用的 JavaScript API,涵盖摄像头访问、基于文件系统的路由、OTA 更新等数十项功能。
第三层 —— Native Bridge(expo-modules-core、expo-modules-autolinking):整个体系的基础。expo-modules-core 提供了用于定义原生模块的 Swift/Kotlin DSL,以及连接 JavaScript 与原生代码的 JSI bridge;expo-modules-autolinking 则负责在构建阶段自动发现并注册模块。
依赖方向始终向下:CLI 工具依赖 Runtime 包获取配置信息,Runtime 包依赖 Native Bridge。Native Bridge 在 monorepo 内部不依赖任何其他包,是整个技术栈的最底层。
伞形包:expo
packages/expo 是每个 Expo 应用都必须依赖的单一入口包。它有意保持轻量,主要由 re-export 和 side effect 组成。其 main 字段指向 src/Expo.ts,该文件做了两件事:
- Side-effect import —— 第一行导入
./Expo.fx,完成 runtime 环境的初始化引导 - Re-export —— 从
expo-modules-core重新导出核心 API,包括EventEmitter、SharedObject、NativeModule、requireNativeModule等
sequenceDiagram
participant App as App Entry
participant Expo as expo/Expo.ts
participant FX as expo/Expo.fx.tsx
participant EMC as expo-modules-core
participant RN as React Native
App->>Expo: import 'expo'
Expo->>FX: import './Expo.fx'
FX->>FX: import './winter' (polyfills)
FX->>FX: import './async-require'
FX->>FX: import 'expo-asset'
FX->>FX: import 'expo/virtual/rsc'
FX->>RN: AppRegistry.registerComponent('main', ...)
Expo->>EMC: re-export { NativeModule, EventEmitter, ... }
真正的引导逻辑发生在 side-effect 模块 Expo.fx.tsx 中:加载 Winter polyfill(如 fetch 等 Web 标准 API)、初始化异步 require 系统、导入 expo-asset 以注册自定义资源源转换器、导入 RSC virtual module,以及在开发模式下连接消息 socket 以协调 HMR。在 Expo Go 中,还会通过 createErrorHandler 安装一个自定义的全局错误处理器。
注意 package.json 中的 sideEffects 字段:["*.fx.tsx", "*.fx.web.tsx", "./src/winter/*.ts", "./src/async-require/*.ts"]。这告诉 Metro 等 bundler 哪些文件存在 side effect、不能被 tree-shaking 掉,同时允许其余未使用的代码被安全移除。
提示:
expo包还通过bin字段注册了三个 CLI 可执行文件:expo(主 CLI)、expo-modules-autolinking(供 CocoaPods 和 Gradle 调用)以及fingerprint(用于检测是否需要重新构建原生代码)。它们分别是三套完全独立系统的入口。
启动流程:从 npx expo start 到应用运行
当开发者运行 npx expo start 时,一系列精心编排的流程随即展开。理解这个流程,是读懂本系列后续每篇文章的关键。
sequenceDiagram
participant Dev as Developer
participant CLI as @expo/cli
participant DSM as DevServerManager
participant Metro as Metro Bundler
participant Device as iOS/Android Device
participant JS as JavaScript Runtime
Dev->>CLI: npx expo start
CLI->>CLI: Parse args, lazy-load start command
CLI->>CLI: getConfig() — resolve app.json/app.config.js
CLI->>DSM: new DevServerManager(projectRoot)
DSM->>Metro: Start Metro dev server
Metro-->>DSM: Server running on port 8081
DSM->>Device: Open on platform (simulator/emulator)
Device->>Metro: Request bundle (/index.bundle)
Metro->>Metro: Transform & serialize JS
Metro-->>Device: JavaScript bundle
Device->>JS: Execute bundle
JS->>JS: import 'expo' → Expo.fx side effects
JS->>JS: registerRootComponent(App)
CLI 入口文件 packages/@expo/cli/bin/cli.ts 通过懒加载 import() 的方式构建了一张命令分发表。运行 npx expo start 时,只有 start 命令对应的代码会被加载:
const commands: { [command: string]: () => Promise<Command> } = {
start: () => import('../src/start/index.js').then((i) => i.expoStart),
prebuild: () => import('../src/prebuild/index.js').then((i) => i.expoPrebuild),
export: () => import('../src/export/index.js').then((i) => i.expoExport),
// ... 15+ more commands
};
start 命令最终会调用 startAsync,由它统筹整个开发服务器的启动过程:解析项目配置、创建 DevServerManager、启动 Metro、打开目标平台、可选地启动一个用于 AI 工具集成的 MCP server,最后启动交互式终端 UI。
关键设计决策
monorepo 中有几处设计选择值得特别关注,因为它们直接影响你阅读和理解代码的方式。
为什么选择 pnpm 而非 Yarn 或 npm? 该仓库此前使用的是 Yarn。pnpm 严格的依赖隔离机制(每个包只能访问它显式声明的依赖)能够暴露出 Yarn 的 hoisting 机制所掩盖的幽灵依赖问题。linkWorkspacePackages: deep 配置确保当 expo-camera 依赖 expo-modules-core 时,拿到的是 workspace 本地版本而非已发布版本,这在开发阶段至关重要。
CLI 为什么使用懒加载 import()? cli.ts 第 36 行的命令分发表采用动态 import 而非静态 import。这意味着运行 npx expo login 时,不会加载任何与 Metro 相关的代码。对于一个可能包含 20 多个命令、每个命令都依赖大量模块的 CLI 来说,这种方式能将启动时间稳定控制在 200ms 以内,无论你运行哪条命令。模式简单,效果显著。
为什么要有一个伞形包? 将 expo 作为唯一必需依赖,极大地简化了开发者的上手体验。开发者不必手动安装 expo-modules-core、expo-asset 以及各种 polyfill 包,只需添加 expo 就能获得一个完整可用的 runtime。伞形包的设计原则是尽量保持轻量 —— 它负责 re-export、负责引导初始化,然后默默退到幕后。
为什么把集成测试放在 apps/ 目录下? apps/ 目录中包含 bare-expo、test-suite、native-component-list 等完整应用。这些并不是演示项目,而是全面的集成测试平台。bare-expo 会对每个 SDK 模块运行端到端测试,test-suite 提供了一个由 UI 驱动的测试运行器。在 monorepo 中内置真实应用,意味着每个 PR 都可以在真实的应用场景下进行验证,而不仅仅是单元测试。
下一步
了解了仓库结构以及三个层次之间的关系,我们就可以深入探究整个体系的基石:expo-modules-core。在第二篇文章中,我们将详细介绍让原生模块开发者能够编写声明式定义的 Swift/Kotlin DSL、从 AppContext 到 ModuleRegistry 再到 ModuleHolder 的所有权链条,以及 JavaScript 如何通过三级回退机制解析原生模块。Expo Native Bridge 真正的魔法,就藏在这里。