Read OSS

深入了解 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-cameraexpo-router 等非作用域包):这些包会随应用一起发布,为开发者提供可直接调用的 JavaScript API,涵盖摄像头访问、基于文件系统的路由、OTA 更新等数十项功能。

第三层 —— Native Bridgeexpo-modules-coreexpo-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,该文件做了两件事:

  1. Side-effect import —— 第一行导入 ./Expo.fx,完成 runtime 环境的初始化引导
  2. Re-export —— 从 expo-modules-core 重新导出核心 API,包括 EventEmitterSharedObjectNativeModulerequireNativeModule
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-coreexpo-asset 以及各种 polyfill 包,只需添加 expo 就能获得一个完整可用的 runtime。伞形包的设计原则是尽量保持轻量 —— 它负责 re-export、负责引导初始化,然后默默退到幕后。

为什么把集成测试放在 apps/ 目录下? apps/ 目录中包含 bare-expotest-suitenative-component-list 等完整应用。这些并不是演示项目,而是全面的集成测试平台。bare-expo 会对每个 SDK 模块运行端到端测试,test-suite 提供了一个由 UI 驱动的测试运行器。在 monorepo 中内置真实应用,意味着每个 PR 都可以在真实的应用场景下进行验证,而不仅仅是单元测试。

下一步

了解了仓库结构以及三个层次之间的关系,我们就可以深入探究整个体系的基石:expo-modules-core。在第二篇文章中,我们将详细介绍让原生模块开发者能够编写声明式定义的 Swift/Kotlin DSL、从 AppContextModuleRegistry 再到 ModuleHolder 的所有权链条,以及 JavaScript 如何通过三级回退机制解析原生模块。Expo Native Bridge 真正的魔法,就藏在这里。