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

发布时间:2026/9/11 18:57:58
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 中的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/ # 导航 HooksuseRouter、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/ # 无头headlessTabs 组件 │ ├── 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.mdMetrorequire.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.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使用RouterConfigContext、NavigationContainerRefContext、RootNavigationStateContext这些 React Context命令式读取通过navigationRefsrc/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.mdimport { 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 testIDindexIndex/Text, profile/[id]: () Text testIDprofileProfile/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.mdjest.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.tsxiOS 侧对接ExpoHeadModule、native-tabs/NativeTabsView.web.tsxWeb 回退实现、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-e2eE2E 测试在 apps/router-e2e 应用中进行从 CLI 侧运行在packages/expo/cli目录执行pnpm test:e2e PROJECT_NAME或pnpm test:playwright PROJECT_NAMEMaestro 测试原生导航在apps/router-e2e目录执行pnpm test:e2e部分应用仅用于手动测试。Android 手动测试可参考/android-e2e-testing技能获取在 Android 模拟器上通过 ADB 测试 Expo Router 屏幕的分步指引启动 E2E 应用、通过 UI dump 导航、与应用交互、验证结果。九、开发验证工作流在packages/expo-router中开发完一个特性后AGENTS.md 建议按以下顺序验证CI1 pnpm test— 运行全部测试包括 RSC__rsc_tests__它们作为rsc/platformJest 项目运行。开发期间可用pnpm test [test file]提升效率或用pnpm test --selectProjects rsc/web只跑 RSC 测试pnpm build— 构建并校验 TypeScript 正确性。若移动或删除了文件先执行pnpm cleanpnpm 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 有两类文档指南Guidesmonorepodocs/目录下的 mdx 文件覆盖概念、教程与 how-toAPI 参考由 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),仅供参考