Expo Router 源码级开发指南:文件路由架构、测试体系与工程实践(expo/expo)
【免费下载链接】expoAn open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web.项目地址: https://gitcode.com/GitHub_Trending/ex/expo
导读:Expo Router 是 Expo 官方开源的文件式路由库,面向 React Native 与 Web 应用,提供从文件结构自动生成路由、深层链接(deep linking)、类型化路由与跨平台导航能力。本文以仓库内 packages/expo-router/AGENTS.md 为骨架,结合
packages/expo-router包内真实源码、测试与工程配置,系统梳理其目录架构、路由处理管线、命令式导航 API、测试策略、E2E 流程与验证规范,帮助你在阅读源码、二次开发或为其贡献代码时快速建立完整心智模型。
一、Expo Router 是什么
Expo Router 是一个面向 React Native 和 Web 应用的文件式路由库。它从文件系统结构自动生成路由表,内置深层链接支持、类型化路由与跨平台导航。与直接在代码中手动组装导航器不同,Expo Router 将"路由"这一概念物化为文件系统约定——一个文件即一个路由节点。
值得特别强调的是其依赖策略:React Navigation 的核心代码被直接 vendor/fork 进packages/expo-router/src/react-navigation/ 目录,因此 expo-router 包本身不存在任何外部@react-navigation/*依赖(可对照 packages/expo-router/package.json 中的dependencies与peerDependencies验证)。在此基础上,src/fork/ 目录存放 Expo 对 vendored 代码的定制覆盖,例如自定义的NavigationContainer、URL ↔ 导航状态的互相转换(getStateFromPath.ts/getPathFromState.ts)。
二、源码目录结构速览
packages/expo-router包的主要源码分布在src/、plugin/、ios/、android/四个目录中,整体结构如下(依据 AGENTS.md 并对照真实目录核对):
├── src/ │ ├── index.tsx # 主入口 │ ├── exports.ts # 公共 API 导出 │ ├── ExpoRoot.tsx # 根组件包装器 │ ├── Route.tsx # 路由节点定义与上下文 │ ├── hooks/ # 导航 Hooks(useRouter、usePathname、useSegments、useLocalSearchParams 等) │ ├── imperative-api.tsx # 命令式导航 router 对象 │ ├── types.ts # TypeScript 类型定义 │ │ │ ├── getRoutes.ts # Metro require context → 路由树转换 │ ├── getRoutesCore.ts # 带特异性评分的核心路由解析 │ ├── getReactNavigationConfig.ts # React Navigation 配置生成 │ ├── getLinkingConfig.ts # 深层链接配置 │ ├── matchers.tsx # 路由段模式匹配 │ │ │ ├── global-state/ # 状态管理 │ │ ├── routerConfigContext.ts # 静态路由配置上下文 │ │ ├── navigationRef.ts # 命令式导航 ref │ │ ├── routing.ts # 导航队列与路由函数 │ │ └── getRouteInfoFromState.ts / routeInfoCache.ts / useRouteInfo.ts │ │ │ ├── layouts/ # 导航布局 │ │ ├── Stack.tsx # 原生 Stack 导航器导出(仅用于 RSC 支持) │ │ ├── StackClient.tsx # 客户端 Stack 实现 │ │ ├── Stack.web.tsx # Web 端 Stack 实现 │ │ ├── Tabs.tsx # JavaScript Tab 导航器 │ │ ├── Drawer.tsx # Drawer 导航器 │ │ └── withLayoutContext.tsx # 布局上下文 HOC │ │ │ ├── native-tabs/ # 原生底部标签(iOS UITabBar、Android BottomNav) │ ├── link/ # Link 组件(含 Preview/Menu/Zoom) │ ├── head/ # Web 上为 react-helmet 包装;iOS 上为 ExpoHeadModule 的 JS 层;Android 为 no-op │ ├── ui/ # 无头(headless)Tabs 组件 │ ├── views/ # 内置页面(Navigator、ErrorBoundary、Sitemap、Unmatched 404) │ ├── react-navigation/ # vendored React Navigation 源码 │ ├── fork/ # Expo 定制覆盖 │ ├── split-view/ # 分栏布局 │ ├── toolbar/ # 原生工具栏组件 │ ├── rsc/ # React Server Components 支持 │ ├── static/ # 静态渲染与 SSR 支持 │ └── __tests__/ # Jest 测试 │ ├── plugin/src/index.ts # Expo Router 配置插件入口 ├── ios/ # 原生 iOS 代码(Swift) ├── android/ # 原生 Android 代码(Kotlin) ├── entry.js # 模块入口 └── build/ # 编译后的 JS 产物从 src/hooks/ 目录可以看到,useRouter、usePathname、useSegments、useLocalSearchParams、useGlobalSearchParams、useSearchParams、useNavigationContainerRef、useRootNavigation、useRootNavigationState等常用 Hook 均集中于此,是阅读导航状态读取逻辑的首选入口。
三、路由处理管线:从文件到可导航的树
Expo Router 的路由生成遵循一条清晰的处理管线(源自 AGENTS.md):
- Metro
require.context()在构建期收集所有路由文件; getRoutes()(src/getRoutes.ts)将文件路径转换为RouteNode树;getReactNavigationConfig()(src/getReactNavigationConfig.ts)生成 React Navigation 配置;getLinkingConfig()(src/getLinkingConfig.ts)创建深层链接配置;- 最终 linking 配置被注入到 src/ExpoRoot.tsx 中的
NavigationContainer(即 fork 版的NavigationContainer)。
在核心解析层,src/getRoutesCore.ts 承担"带特异性评分的核心路由解析",其Options类型暴露了丰富的可配置项:ignore(正则忽略列表)、preserveApiRoutes、platformRoutes、redirects(配置插件声明的重定向规则)、rewrites、headers与pageHeaders(全局/按路径响应头)、notFound(是否跳过生成的 404 路由)、unstable_useServerMiddleware(实验性服务端中间件)等。
路由段的模式匹配集中在 src/matchers.tsx,其中用正则定义了各类文件名约定:
// `[page]` → `page`,`[...group]` → `...group` const dynamicNameRe = /^\[([^[\]]+?)\]$/; export function matchDynamicName(name: string): DynamicNameMatch | undefined { const paramName = name.match(dynamicNameRe)?.[1]; if (paramName == null) return undefined; else if (paramName.startsWith('...')) return { name: paramName.slice(3), deep: true }; else return { name: paramName, deep: false }; } // 匹配 `+not-found` 后缀 export function testNotFound(name: string): boolean { return /\+not-found$/.test(name); } // 匹配 `(group)` 分组 export function matchGroupName(name: string): string | undefined { return name.match(/^(?:[^\\()])*?\(([^\\/]+)\)/)?.[1]; }同一文件中的removeSupportedExtensions会同时剥离.js/.ts/.jsx/.tsx扩展名与+api后缀(正则/^(\+api)?\.[jt]sx?$/),这解释了为何+api.ts这样的约定文件能被正确识别为 API 路由。
四、文件路由约定与语义
Expo Router 的文件名即路由声明,核心约定如下(源自 AGENTS.md 的 Key Concepts 章节):
| 文件路径 | 生成路由 | 说明 |
|---|---|---|
page/index.tsx | /page | 静态路由 |
post/[id].tsx | /post/:id | 动态段 |
[...rest].tsx | 兜底路由 | catch-all 路由 |
(group)/_layout.tsx | 布局组 | 组名不出现在 URL 中 |
+not-found.tsx | 404 处理 | 未匹配路由 |
+api.ts | API 路由 | 服务端 API 处理 |
配套的服务端能力由@expo/router-server包(monorepo 内对应 packages/@expo/router-server)提供,其中包含 SSR 与 API 路由处理工具,供expo-router/server使用。
Expo Router 语义要点
在阅读或扩展源码时,AGENTS.md 明确了三条关键语义约束:
- 一切特性均以 Expo Router 视角评估:如果某行为无法通过 Expo Router 触达,那么 React Navigation 对其的支持就不在考虑范围内;
expo-router/react-navigation仅是兼容层:其能力不应被视为 Expo Router 的特性,除非 Expo Router 显式暴露;- 受保护路由(protected routes)通过重定向实现,不依赖
routeNames;且除 HMR 期间外,routeNames在 Expo Router 中是稳定的。
五、状态管理与命令式导航
状态读取的两条路径
AGENTS.md 将状态管理划分为两种读取方式:
- 树内读取(in-tree):使用
RouterConfigContext、NavigationContainerRefContext、RootNavigationStateContext这些 React Context; - 命令式读取:通过
navigationRef(src/global-state/navigationRef.ts)支撑router.*API。
路由队列
src/global-state/routing.ts 实现了导航队列:将导航动作批量入队并按序处理。公开的router对象(src/imperative-api.tsx)本质是对global-state/router内部实现的再导出:
import { router as internalRouter } from './global-state/router'; // 隐藏内部 `goBack` 与 `linkTo`,避免出现在公共 API 与 typedoc 中 export const router: ImperativeRouter = internalRouter;在 src/global-state/router.ts 中可以看到每种命令式导航动作都对应一个显式意图事件:
| 方法 | 底层事件 | 说明 |
|---|---|---|
router.navigate(url) | NAVIGATE | 导航到目标 |
router.push(url) | PUSH | 压入新路由 |
router.replace(url) | REPLACE | 替换当前路由 |
router.dismiss(count) | POP | 按计数弹出栈路由 |
router.dismissTo(href) | POP_TO | 弹回到指定路由 |
router.dismissAll() | POP_TO_TOP | 弹回栈顶 |
router.back() | GO_BACK | 遵循聚焦回退语义 |
router.prefetch(href) | PRELOAD | 预加载目标 |
例如dismiss与back的语义差异在源码注释中写得很清楚:GO_BACK遵循聚焦回退处理;而POP(由dismiss使用)会显式移除栈路由。此外,导航动作在 DOM 环境下会优先通过emitDomDismiss/emitDomGoBack等事件桥接到 Web DOM(见 src/domComponents/emitDomEvent.ts)。所有动作在真正执行前会经过assertIsMounted()校验navigationRef.current是否挂载,否则抛出"Attempted to navigate before mounting the Root Layout component"错误——这正是新手常见报错的出处。
六、测试体系
Expo Router 的测试采用 jest-expo 多平台预设,覆盖 JS、原生(iOS/Android)与 Web 多个运行环境。
运行测试
# 在 packages/expo-router 目录下运行全部测试 pnpm test # 运行指定测试文件 pnpm test src/__tests__/navigation.test.ios.tsx平台化测试文件后缀
不同平台使用不同的文件后缀(AGENTS.md Testing 章节):
.test.ios.tsx— iOS.test.android.tsx— Android.test.native.tsx— iOS + Android.test.web.tsx— Web.test.node.ts— Node.js
从真实测试目录看,src/tests/ 中同时存在smoke.test.ios.tsx、platform-routes.test.android.tsx、initial-url.test.web.tsx等跨平台用例,而getRoutes.test.ios.ts与getRoutes.test.web.ts则验证了同一路由生成逻辑在不同平台下的一致性。
使用 renderRouter 编写路由测试
测试可以借助自定义的renderRouter工具来渲染预定义的路由结构(示例源自 AGENTS.md):
import { renderRouter, screen } from '../testing-library'; import { router } from '../imperative-api'; import Stack from '../layouts/StackClient'; import { act } from '@testing-library/react-native'; it('can navigate between routes', () => { renderRouter({ _layout: () => <Stack />, index: () => <Text testID="index">Index</Text>, 'profile/[id]': () => <Text testID="profile">Profile</Text>, }); expect(screen.getByTestId('index')).toBeVisible(); act(() => router.push('/profile/123')); expect(screen.getByTestId('profile')).toBeVisible(); expect(screen).toHavePathname('/profile/123'); });关键测试工具:
renderRouter(routes, options)— 以 mock 路由配置渲染路由器;renderHook(callback, options)— 在路由器上下文内测试 Hook(从@testing-library/react-native再导出);screen.getPathname()— 获取当前路径名;screen.getSegments()— 获取路由段数组;screen.getSearchParams()— 获取搜索参数;router.navigate/push/replace/back()— 命令式导航(来自imperative-api)。
这些辅助方法实现在 src/testing-library/index.tsx 中,且 src/testing-library/expect.ts 定义了toHavePathname、toHaveSegments、toHaveSearchParams等自定义匹配器——toHavePathname内部正是通过screen.getPathname()与期望值比对。
RSC 测试:新增组件时,应在__rsc_tests__/目录中补充 RSC 测试,验证其在 React Server Components 环境下的正确渲染(src/layouts/rsc_tests/、src/link/rsc_tests/ 均存在此类用例)。
单元测试中原生代码的 Mock
测试原生原语时,用jest.mock()进行替换;新增 mock 时使用typeof import('module-name')保留类型并确保路径正确(示例源自 AGENTS.md):
jest.mock('react-native-screens', () => { const actualScreens = jest.requireActual( 'react-native-screens' ) as typeof import('react-native-screens'); return { ...actualScreens, ScreenStackItem: jest.fn((props) => <actualScreens.ScreenStackItem {...props} />), }; });Spies 与 console mock
使用beforeEach/afterEach配合mockRestore():
let spy: jest.SpyInstance; beforeEach(() => { spy = jest.spyOn(Module, 'fn'); }); // 或 jest.spyOn(console, 'warn').mockImplementation(() => {}) afterEach(() => { spy.mockRestore(); });Mock 调用断言:使用数组索引访问;非零索引需加注释说明:
const props = MockedComponent.mock.calls[0][0]; // [1] 因为第一次调用是 layout,第二次才是 screen const screenProps = MockedComponent.mock.calls[1][0];Swift 原生测试(iOS)
原生 Swift 测试位于ios/Tests/目录,使用 Apple 的 Swift Testing 框架(import Testing)。在packages/expo-router目录下运行:
et native-unit-tests --packages expo-router -p ios前提:需要先在
apps/native-tests/ios执行pod install安装 Pods。
编写约定:
- 使用 Swift Testing 的
@Test/@Suite(而非 XCTest); - 用反引号包裹测试名提升可读性,如
@Test func `converts options correctly`(); - 在
@Suite内部用内嵌 struct 对相关测试分组; - 断言使用
#expect/#require。
七、平台差异化代码
与 React Native 社区惯例一致,Expo Router 通过文件扩展名实现平台变体:
.ios.tsx— iOS 专属.android.tsx— Android 专属.web.tsx— Web 专属.native.tsx— iOS + Android
真实示例包括:head/ExpoHead.ios.tsx(iOS 侧对接ExpoHeadModule)、native-tabs/NativeTabsView.web.tsx(Web 回退实现)、fork/useBackButton.native.ts等。head目录在三个平台的行为各不相同:Web 上是react-helmet的包装,iOS 上是ExpoHeadModule的 JS 层,Android 则是 no-op——这正是"一份代码、多端适配"的典型体现。
iOS 原生侧 ios/ExpoHeadModule.swift 负责通过NSUserActivity对接 Handoff、Spotlight 与 Siri 索引;MetadataOptions结构体展示了其可配置字段:isEligibleForHandoff(默认true)、isEligibleForPrediction(默认true)、isEligibleForPublicIndexing(默认false)、isEligibleForSearch(默认true)、webpageURL、keywords等。
八、E2E 测试(router-e2e)
E2E 测试在 apps/router-e2e 应用中进行:
- 从 CLI 侧运行:在
packages/@expo/cli目录执行pnpm test:e2e <PROJECT_NAME>或pnpm test:playwright <PROJECT_NAME>; - Maestro 测试(原生导航):在
apps/router-e2e目录执行pnpm test:e2e; - 部分应用仅用于手动测试。
Android 手动测试可参考/android-e2e-testing技能,获取在 Android 模拟器上通过 ADB 测试 Expo Router 屏幕的分步指引(启动 E2E 应用、通过 UI dump 导航、与应用交互、验证结果)。
九、开发验证工作流
在packages/expo-router中开发完一个特性后,AGENTS.md 建议按以下顺序验证:
CI=1 pnpm test— 运行全部测试,包括 RSC__rsc_tests__(它们作为rsc/<platform>Jest 项目运行)。开发期间可用pnpm test [test file]提升效率,或用pnpm test --selectProjects rsc/web只跑 RSC 测试;pnpm build— 构建并校验 TypeScript 正确性。若移动或删除了文件,先执行pnpm clean;pnpm lint— 最后执行,发现 lint 问题。
提交前必做:运行et check-packages expo-router,以与 CI 相同的方式完成构建、类型检查、lint 与测试(et即 expotools,用法见仓库根目录 .claude/CLAUDE.md)。
随后在apps/router-e2e/__e2e__/的某个项目上于模拟器中验证特性;Android 使用/android-e2e-testing技能在模拟器上测试。最后,建议生成一个新的资深工程师 Agent 对实现进行挑战性审查,评估其与整体 expo-router 架构的契合度并寻找边界情况。当涉及新增依赖或改动静态/服务端渲染时,还需运行packages/@expo/cli中的 E2E 测试(耗时较长,仅在必要时执行)。
十、文档维护
Expo Router 有两类文档:
- 指南(Guides):monorepo
docs/目录下的 mdx 文件,覆盖概念、教程与 how-to; - API 参考:由 TypeScript 类型经 typedoc 生成。
开发新特性时需同步更新两者。生成 API 参考数据:
# 默认生成 unversioned 数据 et generate-docs-api-data --packageName expo-router # 指定 SDK 版本 et generate-docs-api-data --packageName expo-router --sdk <VERSION>本地预览文档站点:在docs/目录执行pnpm dev,参考文档位于:
http://localhost:3002/versions/unversioned/sdk/router/— 主 routerhttp://localhost:3002/versions/unversioned/sdk/router-native-tabs/— 原生 Tabshttp://localhost:3002/versions/unversioned/sdk/router-split-view/— 分栏视图http://localhost:3002/versions/unversioned/sdk/router-ui/— headless Tabs
十一、编码风格约定
AGENTS.md 对贡献者提出了明确的代码风格要求:
- 优先使用最新的 React 19 Hooks 与模式——用
use代替useContext、useId等; - 确保代码在开启与不开启 React Compiler 时都能正常工作;
- 不要使用
any类型(除非严格必要),改用unknown并尽可能收窄类型; - 绝不直接导入带平台扩展名的文件,始终从基础路径导入并让打包器解析正确文件。正确写法是
import { Component } from './Component',而非import { Component } from './Component.ios'。
结语:维护这份文档的约定
作为仓库内面向开发者的持续维护文档,AGENTS.md 本身也有一条自我演进规则:当开发或规划特性时,应在该文件中记录缺失的行为;当实现变化或新模式出现时,同步更新相应章节。这意味着本文梳理的结构、管线与测试约定并非静态快照,而是随 expo-router 演进持续更新的"活文档"——在阅读源码时,若发现行为与文档不符,以实际源码为准,并可反哺更新该文档。
延伸阅读路径:路由解析核心 src/getRoutesCore.ts、模式匹配 src/matchers.tsx、命令式导航 src/global-state/router.ts、根组件 src/ExpoRoot.tsx、测试工具 src/testing-library/index.tsx。
【免费下载链接】expoAn open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web.项目地址: https://gitcode.com/GitHub_Trending/ex/expo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考