Expo Router 是 React Native 的文件路由方案,建在 React Navigation 之上。它把 Next.js App Router 的”文件系统 = 路由结构”思路带到了移动端,解决了 React Navigation 手动配置路由时的发现性差、类型不安全、deep linking 配置繁琐三个问题。
概述
移动端导航为什么比 Web 复杂
Web 的导航本质是 URL → 页面的映射,浏览器管理历史栈,<a href> 就够了。移动端多出三层复杂度:
- 页面生命周期:页面在导航后仍保留在内存中(iOS 的 UINavigationController),返回时状态完好。导航框架要管理一棵活的页面树。
- 多范式并存:Stack(堆叠)、Tabs(底部切换)、Drawer(侧滑)、Modal(弹窗)同时存在且可嵌套。
- 原生过渡动画:iOS 从右滑入,Android 从底部渐入,JS 导航框架要模拟或桥接这些原生行为。
RN 导航方案演变
| 时期 | 方案 | 解决的问题 | 引入的新问题 |
|---|---|---|---|
| 2015 | Navigator (RN 内置) | 基础导航能力 | iOS/Android API 不统一,纯 JS 动画卡顿 |
| 2017 | React Navigation | 统一跨平台 API,声明式配置 | 手动维护路由树,导航目标是”魔法字符串” |
| 2023 | Expo Router | 文件路由 + Typed Routes + 自动 deep linking | 受文件系统约束,灵活性低于直接使用 React Navigation |
Expo Router 不是 React Navigation 的替代品——底层的 Stack/Tab/Drawer 容器、手势处理、过渡动画全部由 React Navigation 提供。Expo Router 只解决上层的开发体验:路由发现、类型安全、deep linking 配置。
工作方式
文件路由映射规则
app/ 目录下的文件结构就是路由结构:
| 文件路径 | URL 路径 | 说明 |
|---|---|---|
app/index.tsx | / | 根路由 |
app/(tabs)/index.tsx | / | (tabs) 是分组,不影响 URL |
app/(tabs)/history/index.tsx | /history | 嵌套目录的 index |
app/train/[module].tsx | /train/schulte | 动态路由,方括号里是参数名 |
app/train/result.tsx | /train/result | 静态路由 |
app/_layout.tsx | (不映射) | Layout 文件,不是路由 |
核心规则:
index.tsx= 该目录的默认路由[param].tsx= 动态路由,参数通过useLocalSearchParams()读取_layout.tsx= 布局包装层,定义导航容器(Stack/Tabs)和 Provider(name)/= 路由分组,只组织文件不影响 URL- 以
_开头的文件(除_layout.tsx)被忽略
_layout.tsx:包装层而非路由
_layout.tsx 定义”这个目录下的路由应该被包装在什么容器里”。两个职责:
- 定义导航容器:Stack(页面堆叠)、Tabs(底部切换)、Drawer(侧滑)
- 注入 Provider/全局 UI:语言 Context、主题 Provider、StatusBar 等
嵌套 layout 形成组件树:
RootLayout (Stack)
├── TabLayout (Tabs)
│ ├── 首页
│ ├── 历史
│ └── 设置
└── TrainLayout (Stack)
├── 训练页 [module]
└── 结果页
和 Next.js App Router 的 layout 概念一致。区别:Next.js 用 {children} 渲染子路由,Expo Router 用 <Stack>/<Tabs> 导航组件——因为移动端需要导航容器管理页面堆栈和过渡动画。
(group) 分组语法
括号目录名不出现在 URL 路径里,纯粹用于文件组织。典型用途:
(tabs)— 归组 Tab 页面(auth)— 归组登录/注册流程(app)— 归组登录后的主页面
用 (auth) + (app) 可以实现登录态路由拆分,而 URL 都从根开始,没有多余的 /auth/ 前缀。
Typed Routes
在 app.json 中开启 "typedRoutes": true,Expo 自动扫描 app/ 目录生成 .expo/types/router.d.ts。原理:通过 TypeScript 的 module augmentation 扩展 expo-router 模块,把实际路由路径注入 href 类型联合。
router.push('/train/schulte') // OK — 匹配 /train/${string}
router.push('/trian/schulte') // 类型错误 — 拼写错误在编译期发现
实现层面:
- 编译时:Metro 启动时扫描
app/目录,通过require.context收集路由文件引用,生成类型文件 - 运行时:编译时收集的路由树转换成 React Navigation 的 Navigator/Screen 配置
router.push vs router.replace
判断标准:用户按返回按钮时,回到上一个页面是否有意义?
- push:新页面压入栈顶,可返回。如:首页 → 训练页。
- replace:新页面替换栈顶,不可返回到被替换页。如:训练页 → 结果页(训练已结束,返回无意义)。
useLocalSearchParams vs useGlobalSearchParams
useLocalSearchParams:只读当前路由的参数。推荐默认使用,不会因其他路由参数变化导致不必要的重渲染。useGlobalSearchParams:读取整个 URL 的所有参数。外层组件需要读子路由参数时才用。
路由参数本质是 URL query string,只能传 string | number,不能传对象或数组。
与 React Navigation 对比
| 维度 | React Navigation | Expo Router |
|---|---|---|
| 路由发现 | 需要读配置代码 | 看 app/ 目录结构 |
| 类型安全 | 手动维护 ParamList 类型 | 自动生成 Typed Routes |
| Deep Linking | 需手动配置 linking 对象 | 文件结构即 URL,自动支持 |
| 灵活性 | 任意组合 Navigator | 受文件系统约束 |
| Web 支持 | 有限 | 一等公民 |
| 底层 | 自己就是底层 | 建在 React Navigation 之上 |
React Navigation 更好的场景
- 极复杂的动态路由树:根据用户角色动态插入不同子路由,文件路由的约定很别扭
- 不使用 Expo:Expo Router 依赖 Expo 构建管道
- 渐进式迁移:大型项目迁移需要一次性重组
app/目录,React Navigation 可以逐步改 - 非标准导航模式:同时显示两个 Stack、Tab 间拖拽切换等
参见
- React Navigation — Expo Router 的底层引擎
- Expo 与裸 RN — 理解 Expo Router 的 Expo 依赖
- NativeWind 与 Tailwind 设计哲学 — 路由页面中的样式方案
参考
- Expo Router 官方文档 — 最权威参考,特别是 File-based Routing 和 Layouts 两节
- Evan Bacon: Why Expo Router — 作者解释设计动机
- React Navigation 官方文档 — 理解 Expo Router 的底层,特别是 Native Stack Navigator 和 Bottom Tabs Navigator