news 2026/9/11 18:57:51

Expo Router 源码级开发指南:文件路由架构、测试体系与工程实践(expo/expo)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Expo Router 源码级开发指南:文件路由架构、测试体系与工程实践(expo/expo)

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 中的dependenciespeerDependencies验证)。在此基础上,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/ 目录可以看到,useRouterusePathnameuseSegmentsuseLocalSearchParamsuseGlobalSearchParamsuseSearchParamsuseNavigationContainerRefuseRootNavigationuseRootNavigationState等常用 Hook 均集中于此,是阅读导航状态读取逻辑的首选入口。

三、路由处理管线:从文件到可导航的树

Expo Router 的路由生成遵循一条清晰的处理管线(源自 AGENTS.md):

  1. Metrorequire.context()在构建期收集所有路由文件;
  2. getRoutes()(src/getRoutes.ts)将文件路径转换为RouteNode树;
  3. getReactNavigationConfig()(src/getReactNavigationConfig.ts)生成 React Navigation 配置;
  4. getLinkingConfig()(src/getLinkingConfig.ts)创建深层链接配置;
  5. 最终 linking 配置被注入到 src/ExpoRoot.tsx 中的NavigationContainer(即 fork 版的NavigationContainer)。

在核心解析层,src/getRoutesCore.ts 承担"带特异性评分的核心路由解析",其Options类型暴露了丰富的可配置项:ignore(正则忽略列表)、preserveApiRoutesplatformRoutesredirects(配置插件声明的重定向规则)、rewritesheaderspageHeaders(全局/按路径响应头)、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.tsx404 处理未匹配路由
+api.tsAPI 路由服务端 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):使用RouterConfigContextNavigationContainerRefContextRootNavigationStateContext这些 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预加载目标

例如dismissback的语义差异在源码注释中写得很清楚: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.tsxplatform-routes.test.android.tsxinitial-url.test.web.tsx等跨平台用例,而getRoutes.test.ios.tsgetRoutes.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 定义了toHavePathnametoHaveSegmentstoHaveSearchParams等自定义匹配器——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)、webpageURLkeywords等。

八、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 建议按以下顺序验证:

  1. CI=1 pnpm test— 运行全部测试,包括 RSC__rsc_tests__(它们作为rsc/<platform>Jest 项目运行)。开发期间可用pnpm test [test file]提升效率,或用pnpm test --selectProjects rsc/web只跑 RSC 测试;
  2. pnpm build— 构建并校验 TypeScript 正确性。若移动或删除了文件,先执行pnpm clean
  3. 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):monorepodocs/目录下的 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/— 主 router
  • http://localhost:3002/versions/unversioned/sdk/router-native-tabs/— 原生 Tabs
  • http://localhost:3002/versions/unversioned/sdk/router-split-view/— 分栏视图
  • http://localhost:3002/versions/unversioned/sdk/router-ui/— headless Tabs

十一、编码风格约定

AGENTS.md 对贡献者提出了明确的代码风格要求:

  • 优先使用最新的 React 19 Hooks 与模式——用use代替useContextuseId等;
  • 确保代码在开启与不开启 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 18:56:59

对标大厂薪资的早9晚6外企:Coupang大模型与后端岗位解析

对标大厂薪资的早9晚6外企&#xff1a;Coupang大模型与后端岗位解析 当早9晚6弹性办公与对标国内大厂薪资同时出现&#xff0c;这种反差足以让脉脉上的开发者驻足。根据近期用户讨论&#xff0c;纳斯达克上市的韩国电商头部企业Coupang正开放多个后端与智能体相关岗位&#xff…

作者头像 李华
网站建设 2026/9/11 18:56:32

会议投屏不再翻车:Windows投屏iOS,只投一个软件窗口的操作方法

在日常工作或生活中&#xff0c;我们常常需要将电脑屏幕投屏到手机、电视或会议大屏上&#xff0c;方便与同事、朋友分享内容。尤其是Windows电脑投屏到iPhone或iPad&#xff0c;很多人都会遇到一个尴尬的问题&#xff1a;一投屏&#xff0c;整个桌面都暴露了。微信消息、私人文…

作者头像 李华
网站建设 2026/9/11 18:55:57

从开题到答辩,毕业论文全流程 AI 助手 —— 汇写平台深度体验

毕业论文是每个大学生的 "毕业大关"&#xff0c;从选题、开题、查文献到写正文、调格式、做答辩&#xff0c;每一个环节都可能让人焦头烂额。有没有一个工具能全程陪伴&#xff1f;答案是汇写&#xff08;[https://www.huixielunwen.com/&#xff09;&#xff0c;一个…

作者头像 李华
网站建设 2026/9/11 18:52:27

20 分钟本地跑通 OpenMetadata:Docker 部署快速上手指南

20 分钟本地跑通 OpenMetadata&#xff1a;Docker 部署快速上手指南 【免费下载链接】OpenMetadata The Open Context Layer for Data and AI , OpenMetadata is the open platform for building trusted data context and business semantics for humans, AI assistants, and …

作者头像 李华
网站建设 2026/9/11 18:52:06

HarmonyOS 7.0 API26 鸿蒙电脑多窗口 日志定位:外接屏拖拽后窗口尺寸和断点状态错乱如何处理,从失败信号定位到修复代码

HarmonyOS 7.0 API26 鸿蒙电脑多窗口 日志定位&#xff1a;外接屏拖拽后窗口尺寸和断点状态错乱如何处理&#xff0c;从失败信号定位到修复代码 这篇只拆一个具体点&#xff1a;HarmonyOS 7.0 API26 鸿蒙电脑多窗口 / 日志定位。版本边界先放前面&#xff1a;下面的写法面向 H…

作者头像 李华