Expo Router:文件路由全家桶
Expo Router 是 Expo 官方的路由层:把文件系统当作路由表,每个屏幕自动生成深链(deep link),可选开启编译期类型校验的路由。它是 Expo 新架构下 App 的默认组织方式——create-expo-app 默认模板(截至 2026-10-20 为 expo-template-default@58.0.25)就是 Expo Router 应用。本章按 SDK 58 / expo-router 58.0.x 撰写,关键事实均来自官方文档与迁移指南实测核对。
如果你只需要一句话选型:新项目、需要深链/Web URL/统一导航结构,用 Expo Router;只想手写 React Navigation 路由表,见 react-native/04-导航.md,本章不重复 React Navigation 的基础教学,只讲 Router 这一层,以及它和 React Navigation 的分工边界。
定位:文件系统即路由表
Expo Router 解决的是三个长期痛点:
- 路由表和页面文件脱节。手写 React Navigation 时,
<Stack.Screen name="Feed" component={Feed}/>和src/screens/Feed.tsx是两处要手动保持一致的东西;Router 里文件放对位置,路由就对了。 - 深链要单独配置。Router 中每个路由文件天然对应一个 URL(
myscheme://shop/42),不需要再写 linking 配置。 - 导航结构即目录结构。
app/下一个目录对应一个导航器(stack/tabs),布局文件_layout.tsx声明"这一组屏幕之间是什么关系"。
与 React Navigation 的分工:Router 负责路由生成、URL 语义、深链、布局推导,导航器本体(Stack/Tabs 的转场、手势、头部)在 58 起完全回归上游 React Navigation(fork 已移除,见本章迁移小节)。JS 版 Tabs 就是 React Navigation 的 bottom-tabs,Stack 就是 native-stack——在 React Navigation 学过的 screenOptions 全部原样可用。
入口与目录约定
SDK 58 默认模板实测的三处关键配置:
// package.json(节选)
{
"main": "expo-router/entry",
"dependencies": { "expo": "~58.0.16", "expo-router": "~58.0.24" }
}// app.json(节选)
{
"expo": {
"scheme": "helloworld",
"plugins": ["expo-router"],
"web": { "output": "server" },
"experiments": { "typedRoutes": true }
}
}main必须指向expo-router/entry,这是没有index.js的:入口逻辑全在 Router 包里。- 路由目录:插件配置项
root默认值是"app",但 58 默认模板实际用的是顶层src/布局(src/app/放路由,src/components/放组件),两种都被官方文档认可,文档示例统一以src/app展示。root选项官方明确建议"除非有特定需要否则不要改"。 - 路由目录下的
.tsx文件默认都成为路由;组件、工具函数应放到路由目录外(模板把app-tabs.tsx这类共享组件放src/components/,正是这个意图)。
路由文件语法
58 官方文档(router/basics/notation.md)列出的记法就下面这些,只写文档里真实存在的:
| 记法 | 含义 | URL 示例 |
|---|---|---|
feed.tsx / shop/index.tsx |
静态路由,路径即文件名 | /feed、/shop |
[productId].tsx |
动态路由(一个参数段) | /shop/42 → params.productId === '42' |
[...thread].tsx |
catch-all:捕获一个或多个段,rest 语法返回字符串数组 | /docs/a/b → { thread: ['a','b'] } |
(auth)/ |
路由分组:组织代码、共享布局,不进 URL | (auth)/sign-in.tsx → /sign-in |
index.tsx |
所在目录的默认路由 | (tabs)/index.tsx → / |
_layout.tsx |
布局路由:声明这组屏幕用哪个导航器、怎么导航 | 不直接对应 URL |
+xxx 前缀文件 |
有特殊语义的全局文件 | 见下 |
特殊文件:+ 前缀只有四个
58 文档确认存在的 + 文件:
+not-found.tsx—— 任何未匹配路由的兜底页(Web 上返回 404)。+html.tsx—— Web 的 HTML 骨架(见 SSR 一节,58 行为有大变化)。+native-intent.tsx—— 改写第三方深链的跳转目标(见深链一节)。+middleware.ts—— 路由渲染前跑的中间件,仅根目录一份(见 SSR 一节)。
注意:网上教程里的
+redirect.tsx、+unstable_ignore.js在 58 文档中不存在,不要写。需要"进入某页立即跳走"时用组件式重定向:import { Redirect } from 'expo-router',渲染<Redirect href="/login" />,它在挂载瞬间执行跳转;需要全局静态重定向规则时用 app 配置里 expo-router 插件的redirects数组(source/destination/permanent)。
注意:可选 catch-all
[[...slug]](缺段时回退到父级)出现在旧版教程里,但截至 2026-10-20 的 SDK 58 文档已不再收录该语法,是否仍受支持未经证实——别依赖它。要"某段可缺省"的语义,用普通[...paths]加参数长度判断,或直接配两个路由文件。
一个真实项目的映射示例
电商/社区 App(底部标签 + 商品栈 + 登录分组)的 src/app 目录与 URL 对照:
src/app/
├─ _layout.tsx 根 Stack(声明 Protected 分组、anchor)
├─ +not-found.tsx 未匹配路由 → /anything-typo
├─ +html.tsx Web HTML 骨架(可选,仅 SSR 定制时需要)
├─ +middleware.ts HTTP 请求中间件(可选)
├─ (auth)/
│ ├─ _layout.tsx 登录流程内部 Stack(headerShown: false)
│ ├─ sign-in.tsx /sign-in
│ └─ sign-up.tsx /sign-up
├─ (shop)/
│ ├─ _layout.tsx NativeTabs 标签栏
│ ├─ index.tsx / 推荐流
│ ├─ categories.tsx /categories 分类
│ └─ cart.tsx /cart 购物车
├─ shop/
│ ├─ _layout.tsx 商品 Stack
│ └─ [productId].tsx /shop/42 params.productId = '42'
└─ docs/
└─ [...thread].tsx /docs/a/b/c params.thread = ['a','b','c']分组 (auth)/(shop) 不出现在 URL 里,但各自有独立的 _layout.tsx——这就是"多个导航器"的实现方式:根 Stack 下挂两个分组,各分组自己决定内部是 stack 还是 tabs。
保留路径与平台后缀
- 不要用
/assets(Metro 拦截)、/_expo、/_flight、/inspector、/expo-dev-plugins建路由,完整清单见文档router/reference/reserved-paths.md。 - 平台分文件:
index.web.tsx、index.ios.tsx、index.android.tsx、*.native.tsx均生效(插件项platformRoutes默认true)。58 默认模板就用了这一招:src/components/app-tabs.tsx(原生,NativeTabs)与app-tabs.web.tsx(Web,headlessexpo-router/ui)同名分平台导出。
布局与导航器
Stack:原生堆栈
Stack 包装 React Navigation 的 native-stack。headerShown 默认 true——58 模板里根布局显式关掉 tab 容器的头部是惯例:
// src/app/_layout.tsx
import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useColorScheme } from 'react-native';
SplashScreen.preventAutoHideAsync();
export const unstable_settings = {
anchor: 'index', // 深链直达任何页面都能返回(58 用 anchor 取代 initialRouteName)
};
export default function RootLayout() {
const scheme = useColorScheme();
return (
<ThemeProvider value={scheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack screenOptions={{ headerStyle: { backgroundColor: '#0E1116' }, headerTintColor: '#fff' }}>
<Stack.Screen name="(shop)" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="shop/[productId]" options={{ title: '商品详情' }} />
<Stack.Screen
name="cart"
options={{ presentation: 'formSheet', sheetAllowedDetents: [0.5, 0.95] }}
/>
</Stack>
</ThemeProvider>
);
}要点(均有文档依据):
Stack.Screen的name必须匹配路由文件名(含分组名)。不显式声明的屏幕也会注册,但会走screenOptions默认值。- 常用 options:
title、headerTitle(可传函数渲染自定义标题)、headerLeft/headerRight、headerBackTitle与headerBackButtonDisplayMode: 'minimal'(iOS 返回按钮文案)、headerLargeTitleEnabled(iOS 大标题)、headerSearchBarOptions(原生搜索栏)、animation(Android:slide_from_right/fade等)、presentation(modal/formSheet/transparentModal等)。formSheet配套的sheetAllowedDetents是升序分数数组,Android 最多 3 个停靠位。 - 重复 push 同名屏幕会去重,需要多实例共存(比如同时打开两个商品详情)时给
Stack.Screen配getId:getId: ({ params }) => params.productId。 - iOS 26(Liquid Glass):58 起
headerTransparent默认为{ ios: true, ... },透明头部下滚动内容顶到屏幕边缘,大标题/搜索栏场景要给ScrollView/FlatList加contentInsetAdjustmentBehavior="automatic"。旧观感的逃生门enableLegacyHeaderAppearance()已标记废弃。 - 大标题不折叠的经典坑:滚动视图必须是屏幕组件的第一个直接子节点(必要时给外层加
collapsable={false}),否则headerLargeTitleEnabled只生效一半。
Tabs:JavaScript 标签栏
(tabs) 是文档点名的特殊目录名,默认让 Expo Router 对它使用 Tabs 布局。JS Tabs 即 React Navigation bottom-tabs,API 完全一致:
// src/app/(tabs)/_layout.tsx
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { Tabs } from 'expo-router';
export default function TabLayout() {
return (
<Tabs screenOptions={{ tabBarActiveTintColor: 'blue' }}>
<Tabs.Screen name="index" options={{ title: 'Home', tabBarIcon: ({ color }) => <FontAwesome name="home" size={24} color={color} /> }} />
<Tabs.Screen name="settings" options={{ title: 'Settings', tabBarIcon: ({ color }) => <FontAwesome name="gear" size={24} color={color} /> }} />
</Tabs>
);
}tabBarBadge、tabBarStyle、tabBarButton(可整个替换成自定义组件)等 React Navigation 选项照用。
NativeTabs:平台原生标签栏(58 转正)
SDK 55-57 时代它在 expo-router/unstable-native-tabs,58 起从 expo-router/native-tabs 导入并转为稳定 API。用平台原生控件渲染(iOS 18 的悬浮 tab bar、Android Material You),换得原生动效与外观一致性:
// src/app/(tabs)/_layout.tsx
import { NativeTabs } from 'expo-router/native-tabs';
export default function TabLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="messages">
<NativeTabs.Trigger.Label>Messages</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="envelope.fill" md="mail" />
<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings" hidden={isSettingsHidden} disablePopToTop />
</NativeTabs>
);
}- 与 Stack 不同,屏幕不会自动进 tab bar:每个要显示的 tab 都必须显式写
NativeTabs.Trigger name="文件名"(文档原文强调)。 - 图标三种来源:
sf=(iOS SF Symbols 名)、md=(Material Icons 字体码位,Android 需要安装expo-symbols)、src=(PNG,配renderingMode="template",58 模板实测用法)。sf/md可按平台分别给,也可传{ default, selected }对象分选中态。 Trigger.Badge显示角标;hidden隐藏某 tab(注意:隐藏的 tab 任何方式都导航不进去;且动态增删/隐藏 Trigger 会重挂载整个导航器并重置状态,官方建议只在导航器挂载前或不可见时改可见性);disablePopToTop关闭"点当前 tab 回到栈根"的行为(Android,SDK 55+)。- 58 起
expo-symbols、@expo/ui(Stack.Toolbar依赖)等变成了可选 peer 依赖:用到对应能力才需要安装,模板里预装了expo-symbols@~58.0.4与@expo/ui@~58.0.21。
Link:声明式导航
import { Link } from 'expo-router';
// 字符串简写
<Link href="/shop/42">商品 42</Link>
// 对象形式(动态路由的标准写法,开 typed routes 时有类型校验)
<Link href={{ pathname: '/shop/[productId]', params: { productId: '42' } }}>商品 42</Link>
// 入栈而非替换(tab 间跳转默认是 navigate/replace 语义,push 保留返回栈)
<Link href="/cart" push>查看购物车</Link>
// 关闭当前弹窗后进入目标页
<Link href="/" dismissTo>关闭弹窗</Link>
// 用触发元素自身做外观(给任意组件套上链接行为,可访问性由你负责)
<Link href="/shop/42" asChild><Pressable>...</Pressable></Link>
// 进入新布局时把 anchor 路由垫到目标下方(配合布局的 anchor 设置,制造返回栈)
<Link href="/shop/42" withAnchor>来自推送的链接</Link>
// className:Web 上直接落到 HTML class 属性,原生上配合 NativeWind 等 CSS 互操作工具
<Link href="/about" className="text-blue-700">关于</Link>高频属性对照(均见 link API 文档):
| 属性 | 语义 |
|---|---|
href |
字符串或 { pathname, params } 对象 |
push / replace / dismissTo |
入栈 / 替换当前 / 弹到目标(目标不在栈中则替换当前) |
asChild |
把行为注入 children,而不是渲染默认 Text |
prefetch |
布尔:当该 Link 渲染在获得焦点的屏幕上时预取目标路由(路由代码 + loader 数据),点按时几乎零等待 |
withAnchor |
进入新布局时把 anchor 路由垫到目标下方 |
relativeToDirectory |
相对当前目录解析相对路径(Web 习惯) |
disabled |
禁用导航 |
注意:
displace属性不存在(旧草稿常见笔误)。iOS 26 还有一套Link.Preview/Link.Menu/Link.Trigger(长按预览、上下文菜单)与 Zoom 转场组件族,见文档router/advanced/zoom-transition.md,本章不展开。
useRouter 与命令式导航
import { useRouter } from 'expo-router';
const router = useRouter();
router.push('/shop/42');
router.replace('/sign-in');
router.dismissTo('/'); // 一路弹到目标(弹窗流程收尾常用)
router.back(); // 不能退时返回 false 语义见 canGoBack
if (router.canGoBack()) router.dismiss();
router.setParams({ tab: 'reviews' });
router.prefetch('/shop/99');命令式方法全集:push / replace / dismiss / dismissTo / dismissAll / navigate / back / canGoBack / canDismiss / setParams / prefetch / setTransitionMode。
58 明确建议:组件内用 useRouter(),不要用模块级 import { router }。模块级单例在首次渲染前调用会抛错,而且无法区分多个 Router root(如 Web 标签页场景),仅保留给 root 外部(如错误上报)兜底使用。
58 的确定性路由状态要求导航 href 写完整——{ screen: '...' } 这类隐式魔法参数没了:
// 旧写法(57 前,已失效):靠 screen/params 隐式指定嵌套目标
router.push('/(shop)/shop/[productId]', { params: { productId: '42' }, screen: 'shop/[productId]' } as any);
// 58 写法:pathname 到目标文件,params 一并给全
router.push({ pathname: '/(shop)/shop/[productId]', params: { productId: '42' } });常用 Hooks(58 仍由 expo-router 再导出)
fork 移除后,这些 hooks 依然从 expo-router 直接导入(58 版 API 文档逐个核对过),底层换成了上游 React Navigation 的实现:
import {
useLocalSearchParams, // 当前路由的参数(路由段 + query string)
useGlobalSearchParams, // 整个 URL 的实时参数(含未挂上来的深层路由)
usePathname, // 当前路径(不含 query)
useSegments, // 路径段数组,权限判断常用
useFocusEffect, // 聚焦期间才跑的 effect
useIsFocused,
useNavigation, // 拿导航对象:addListener('focus'|'blur'|...)
usePreventRemove, // 拦截返回/关闭(表单未保存确认等)
useRoute, // { path, name, params }
useRoutePath, // 渲染期防闪烁,比 useSegments 更贴布局匹配
useLoaderData, // 读取本路由 loader 的数据
} from 'expo-router';两个容易写错的点:useFocusEffect 只接受一个参数,依赖要放进内层 useCallback 的依赖数组(58 文档专页如此规定);useNavigation 必须在导航器上下文内调用,否则抛错,传布局路由名可取祖先导航器。
Typed routes:编译期校验 href
状态核实(截至 2026-10-20):文档仍称其 Beta,但它不是"默认全开"也不是"完全手动"——58 默认模板已默认开启(app.json 里 "experiments": { "typedRoutes": true },模板实测),存量项目升级时不强制。
- 开启后
expo start会生成.expo/types/router.d.ts(路由联合类型)与根目录expo-env.d.ts,之后<Link href>与router.push的字符串拼错、动态段缺参数都会在编译期报错;expo-env.d.ts不需要提交,加入.gitignore。 - CI 不跑
expo start也能获得类型:npx expo customize tsconfig.json触发一次生成。 - 动态路由要校验完整 href 时用对象形式(
{ pathname: '/shop/[productId]', params: { productId } })而不是拼好的字符串——字符串模板拼接会退化成宽类型;参数类型用useLocalSearchParams<'/shop/[productId]'>()泛型获取。 - 相对路径 href 在类型校验下不受支持;插件项
partialRouteTypes(默认true)允许部分路由静态可知时先行校验。
URL 参数与查询串
useLocalSearchParams返回值同时含路由段参数和 query string:/shop/42?tab=reviews→{ productId: '42', tab: 'reviews' }。值类型是string | string[]:同名参数重复出现时是数组(?tag=a&tag=b→['a','b']);catch-all rest 段固定是数组。- 哈希片段通过参数名
'#'读取;URL 编码由 Router 处理。58 修复了Linking.parse对百分号编码路径的二次解码 bug(启动 URL 场景,详见05-Expo-Go与开发构建.md)。 - 开发期用设备/模拟器打开的启动 URL 带有一堆
__expo_前缀参数(隧道、dev tools 等)。58 起这些保留参数永远不会进入你的路由参数,业务代码不需要再防御它们。 - 历史保留名
screen、params、initial(以及事件层的state)在 57 及以前是隐式导航魔法参数;58 起魔法被移除、变成普通参数。即便如此,新代码仍应避免用这三个名字做业务参数,防止跨版本行为差异。
注意:
withQueryString/withQueryValue这类辅助函数在 58 文档中不存在,旧教程里常见。拼带参数的 href 直接写对象形式{ pathname, params },让 Router 负责序列化;确需手拼字符串时用URLSearchParams。
Data loaders:导航前取数(58 转正,重点)
Router 的 data loader 解决"点进去再转圈":导航发生时,数据已经取好,屏幕首帧直接带内容渲染;未就绪的目标路由渲染时,Router 用布局里声明的 <Suspense> 边界兜底。
基本形态:loader 导出 + useLoaderData 读取
// src/app/shop/[productId].tsx
import { useLoaderData } from 'expo-router';
export async function loader(request: Request | undefined, params: { productId: string }) {
// 服务端/构建期执行,不会进客户端 bundle
const res = await fetch(`https://api.example.com/products/${params.productId}`);
if (!res.ok) {
throw new Error(`商品 ${params.productId} 加载失败(${res.status})`);
}
const product = await res.json();
return Response.json(product, {
headers: { 'Cache-Control': 'public, max-age=60' },
});
}
export default function ProductScreen() {
const product = useLoaderData<typeof loader>(); // 同步拿到数据,无需 useEffect
return <ProductCard product={product} />;
}签名核实:loader(request, params)——request 是 Web 的 Request,静态渲染时是 Response 语义的来源、构建期静态导出时为 undefined;params 是路由参数对象。loader 返回值必须可 JSON 序列化;直接 export default 页面里隐式关联,也可在 _layout.tsx 或独立处用 createStaticLoader / createServerLoader(从 expo-router/server 导入)显式创建,配 LoaderFunction 类型与 Metadata 导出做 SEO 元数据。
缓存语义(58 新增,核心变化)
SDK 58 起 loader 的响应交给平台 HTTP 缓存,复用行为由你返回的 Cache-Control 决定:
- 上例的
public, max-age=60意味着 60 秒内重复访问该路由不再真正执行 fetch。 - 不写下头的默认值:开发环境与 server 导出默认
no-store(每次真取);静态导出默认private, must-revalidate, max-age=0。 - 没有额外的内存缓存、也没有失效 API——想要"主动刷新",走 HTTP 缓存语义本身(短 max-age + stale-while-revalidate)或在交互层重新请求。
失败与重试
每个路由可以导出 ErrorBoundary 组件(props 为 { error, retry }):loader 抛错时渲染它,retry() 重新触发加载。未写自定义边界时,Web 上 loader 失败会落到 +not-found/错误页语义;原生落在导航器错误处理(配合下面的 screen error boundary)。
预取时机
loader 数据被 Link prefetch 提前拉取:当带 prefetch 的 <Link> 渲染在获得焦点的屏幕上时触发(文档原文),也即"用户看得见这个入口,就顺便把目标数据备好"。命令式 router.prefetch(href) 同理会连同 loader 一起预取。配合 Cache-Control,预取的数据在有效期内可复用。
和 React Query / 状态库的分工
官方文档没有提供 React Query 集成指南或配套 hook(llms 索引核实,截至 2026-10-20 无此页面),loader 与数据缓存库的关系要自己划清:loader 负责进入路由所需的服务器首帧数据(SSR 流、SEO、导航前就绪),TanStack Query / zustand 等负责交互期的客户端缓存、变更与失效(见 tanstack-query/ 手册)。把 loader 当成"路由级的 SSR 数据入口",而不是全量状态方案,两者不冲突也不互相替代。
SSR 与 server output(58 默认值反转,重点)
web.output:不设即 server
SDK 58 的关键变化:app.json 未显式设置 web.output 时,默认值为 "server"(58 默认模板已显式写出 "output": "server",模板实测)。语义:
| output | 行为 | 何时用 |
|---|---|---|
"server"(58 默认) |
每个请求执行 React 渲染,流式 HTML;loader/+middleware/API 路由在服务端运行 |
内容随请求变化、SEO、动态数据 |
"static" |
构建期预渲染(prerender),导出纯静态文件 | 营销页、内容不随请求变 |
注意:这就是 SDK 57 的行为分界——57 及以前"未配置时导出静态站点"的旧路径,在 58 对应
output: "static";旧项目依赖static+ API 路由组合的,注意插件项apiRoutes现在默认跟随 output(server 默认true,static 默认false),不再需要手动开关。static与server两种模式不可混用。
server output 下 expo export 产出 dist/client + dist/server 两部分,用 npx expo serve 在本地跑生产语义验证;部署有现成适配层(EAS Hosting 内置,另有 express/workerd/vercel/netlify/bun 适配器)——部署细节见 10-Web与托管.md,本章不重复。
async routes:Web 默认开启
58 起异步路由(按路由分块懒加载)在 Web 的开发与生产构建中默认开启(插件默认值 { web: true };原生平台仍是实验特性且默认关)。它让首屏 HTML/JS 只带当前路由需要的 chunk,其余按需异步加载。需要旧行为时显式关闭:
{ "expo": { "plugins": [["expo-router", { "asyncRoutes": false }]] } }也可分环境("development"/"production")或分平台({ default, android, ios, web })。两个已知代价:Web 上开启后自定义 Suspense fallback 行为受限(文档明确列出);58 的实验项 experiments.chunking.mode: "granular" 可做更细粒度分块。改过 asyncRoutes 后必须 expo start --clear / expo export --clear 清 Metro 缓存,迁移指南原文要求。
+middleware.ts:路由前的请求拦截
只有根目录一份(src/app/+middleware.ts),默认导出签名为 (request: Request) => Response | void,返回 Response 即短路该请求(重定向、鉴权、限流),返回 void 继续渲染:
// src/app/+middleware.ts
import type { MiddlewareFunction } from 'expo-router/server';
export const unstable_settings = {
matcher: {
methods: ['GET'], // 缺省匹配所有方法
patterns: ['/api', '/admin/[...path]'], // 缺省匹配全部路由
},
};
export default function middleware(request: Request): Response | void {
const token = new URL(request.url).searchParams.get('token');
if (request.url.startsWith('https://example.com/admin') && !token) {
return new Response(null, { status: 302, headers: { Location: '/sign-in' } });
}
}patterns 支持具名参数 [param]、catch-all [...slug](匹配一个或多个段)与正则。
注意:middleware 只处理 HTTP 请求——App 内的
Link/router导航与纯原生环境不经过它。它是 server output 的能力,不是"导航守卫";导航守卫用 Protected routes(见下文)。
+html.tsx 与文档骨架
server output 下自定义根 HTML 要用新的文档上下文钩子(旧的"直接渲染整页 HTML"路径不再工作):
// src/app/+html.tsx
import { useServerDocumentContext } from 'expo-router';
export default function Root() {
const { htmlAttributes, bodyAttributes, headNodes, bodyNodes } = useServerDocumentContext();
return (
<html lang="en" {...htmlAttributes}>
<head>{headNodes}</head>
<body {...bodyAttributes}>{bodyNodes}</body>
</html>
);
}bodyNodes 已包含水合 bundle 与预加载,+html.tsx 里不要再手写 script(重复注入)。同时 58 起导航类 hooks(usePathname、useSegments、useRouter 等)在 +html.tsx 内不再可用,需要它们的逻辑移到 _layout.tsx(迁移指南明列)。页面级 <title>/meta 不再用 expo-router/head,改在各路由 export function generateMetadata({ pathname, params })——SEO 细节见 10-Web与托管.md。
RSC:有实验文档,但不是稳定路线
“Expo 至今不支持 React Server Components"的说法过时了:58 存在 RSC 指南(guides/server-components.md),但通篇标注 experimental/beta——需要开 experiments.reactServerFunctions: true、装 react-server-dom-webpack、仅新架构、'use server' 独立文件承载服务器函数,且 developer preview 阶段对 web.output 仍有额外限制。生产选型请以 data loaders + SSR 为稳定路线,RSC 只用于尝鲜评估。
58 的导航体验增强
React transitions:转场不阻塞当前屏
58 接入 React startTransition 语义的导航转场:默认只用于预取/预渲染路径(preload-only),导航仍会等目标渲染完成。可调:
const router = useRouter();
router.setTransitionMode('always'); // 'always' | 'preload-only'(默认) | 'never'
router.push('/details', { inTransition: true }); // 单次操作显式进 transition
配合 unstable_useIsNavigating() 在导航排队/转场期间渲染进度 UI(dispatchSync 与原生返回手势不排队,该 hook 不报告这两种)。另有底层保证:58 的导航 dispatch 默认推迟到 React commit 之后执行,与 transitions 组合消除"目标组件还没挂载就切状态"的竞态。
React Activity:不活跃路由真正休眠
58 用 <Activity> 语义管理离屏路由的订阅/定时器状态:activityEnabled 可设在导航器或单个屏幕上。Stack 默认在被压到第 2 层以下时隐藏(传数字自定义阈值,1 表示立刻隐藏);Tabs/Drawer 是失焦即隐藏。被隐藏的路径卸载 DOM(Web)但保留 React 状态,回来时"零成本恢复”。它是 57 时代 freezeOnBlur 的替代方案——迁移指南明确:58 中 freezeOnBlur 已无效果(stack 参考页的 options 表里仍残留该条目,不要依赖)。独立组件版 NavigationAwareActivity(hideWhenNestedAtLevel 默认 2)可在屏幕内自定义"哪一块在不活跃时休眠"。
Screen error boundary:一个屏幕崩了不拖垮整栈
58 新增屏幕级错误边界,两种写法任选其一(设在布局导航器上):
// 写法一:布局导出 unstable_settings
export const unstable_settings = { screenErrorBoundary: ScreenErrorBoundary };
// 写法二:导航器 prop(优先级高于写法一)
<Stack unstable_screenErrorBoundary={ScreenErrorBoundary} />边界组件收到 { error },渲染兜底 UI 并提供"返回上一页"动作。优先级:路由自身的 ErrorBoundary(loader 失败等)> 导航器 prop > unstable_settings;嵌套布局会继承上层边界,传 null 可重置。注意:unstable_settings 系列与 async routes 不兼容(文档警告原文,这也是其"unstable"命名的原因),Web server output 项目慎用。
anchor:深链的返回栈
深链直达 /shop/42 时用户没有"返回"可点。布局声明 unstable_settings = { anchor: 'index' } 后,深链历史会自动垫上 anchor 路由;<Link withAnchor> / router.push(href, { withAnchor: true }) 在普通导航中也显式带上 anchor 层。旧属性 initialRouteName 已废弃,能力由 anchor 覆盖。
深链接与发布验证
Router 的核心卖点:每个屏幕自动就是深链目标,不需要 React Navigation 时代的 linking 配置:
- 自定义 scheme 深链:
myscheme://shop/42。scheme在app.json配置(见03-应用配置与插件.md)。 - iOS Universal Links:
app.json配ios.associatedDomains,域名侧托管apple-app-site-association(官方文档/linking/ios-universal-links.md,字段以 docs 为准)。 - Android App Links:
android.intentFilters+ 域名侧assetlinks.json双向校验(/linking/android-app-links.md)。 - 第三方服务的链接(
https://bit.ly/xxx、短信里的裸域名等)落到 App 后想改写到具体路由:用src/app/+native-intent.tsx导出redirectSystemPath(systemPath)返回目标路径。 - Web 部署后的 URL 结构就是路由路径本身(
https://域名/shop/42);server output 下同一 URL 的响应优先级:public 静态文件 → 匹配的路由 → API 路由 →+not-found(404)。/_sitemap自动生成站点地图(插件项sitemap默认开)。
注意:Expo Go 对接收深链支持有限(官方 tip 原文),深链回归必须在 dev build/发布构建里测。平台标准验证命令(非 Expo 文档摘录,通用 adb/simctl 用法):
adb shell am start -a android.intent.action.VIEW -d "myscheme://shop/42"与xcrun simctl openurl booted "myscheme://shop/42"。
鉴权流程实战
官方推荐模式(58 文档 authentication 指南已全面改用 Protected routes 示范):(auth)/(app) 两组 + 根栈的 Stack.Protected,守卫翻转即重定向,不手写跳转 effect:
// src/app/_layout.tsx(鉴权部分)
import { Stack } from 'expo-router';
import { useSession } from '@/hooks/useSession'; // SessionProvider + SecureStore(原生)/ localStorage(Web)
export default function RootLayout() {
const { session, isLoading } = useSession();
if (isLoading) return null; // 配 SplashScreen.preventAutoHideAsync() 避免白屏闪烁
return (
<Stack>
<Stack.Protected guard={!!session} redirectTo="/sign-in">
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={!session}>
<Stack.Screen name="sign-in" />
</Stack.Protected>
</Stack>
);
}guard为假时用户被重定向到 anchor/第一个可达路由;redirectTo(58+)可显式指定(如/sign-in)。- guard 由真变假会清掉相关历史记录,退出登录后返回键不会退回应用内页面。
- 同一屏幕只能属于一个活动 Protected 组(
Stack.Protected/Tabs.Protected/Drawer.Protected均支持)。 - 登录成功:
await signIn(); router.replace('/');退出登录:清 session 即可,guard 翻转自动接管。 - token 读取放哪:放组件/布局(guard),不放 loader。loader 运行在服务端或构建期,读不到设备上的 SecureStore token,也不该承担鉴权 UI 职责;鉴权判定属于导航层(Protected)与请求层(fetch 拦截加 Authorization 头)。
SDK 58 迁移注意
58 是 Router 历史上破坏性最大的一版(导航核心重写),逐项对照(依据 router/migrate/sdk-57-to-58.md):
- 导航状态确定化:同样的操作序列在设备间得到同样的 state 与 route keys;dispatch 推迟到 React commit 后,需要立即生效用
navigation.dispatchSync(action)。 - fork 的 React Navigation API 面大幅移除:
expo-router/react-navigation深导入基本清盘(NavigationContainer、BaseNavigationContainer、getActionFromState、resetRoot、navigateDeprecated、PreventRemoveProvider、React Navigation 版Link/useLinkProps、LinkingOptions.enabled等)。App 本来就该只用expo-router顶层导出;受影响代码改用上文 hooks 或useNavigation。 - 隐式参数移除:
screen/params/initial变普通参数,嵌套目标必须写完整{ pathname, params }(见 Link 一节);祖先布局的参数不再自动下发,目标路由需要的参数显式塞进 href,读取处给默认值。 Stack.Screen的redirect/initialParams移除:用路由文件内<Redirect href>+ 参数默认值替代。navigationKey移除且无直接等价物。- 事件更名:
beforeRemove→removePrevented(被usePreventRemove拦下时)与removed(卸载完成后,注意延迟挂载监听以收到它)。 - 预加载路由改挂在
state.routes尾部,不再有preloadedRoutes字段。 - 测试 API 异步化:
renderRouter()现在返回 Promise(await renderRouter(...)),renderRouterAsync更名为renderRouter,需要@testing-library/react-native≥14;testRouter.canGoBack()等断言仍同步。 - 自定义导航器:
withLayoutContext已废弃(暂可用,第三个参数删掉即可),新写法是integrateWithRouter/createStandardRouterNavigator;自定义 router 用extendRouter,getStateForAction返回{ state, affectedRouteKey }并用注入的nextKey()生成 key。expo-router/js-stack的createStackNavigator移除(createNativeStackNavigator保留)。 - Web 安全区:不再通过
react-native-safe-area-context提供 insets,Web 端 CSS 直接用env(safe-area-inset-*)。 - 默认值变化三连:
web.output不设即server;asyncRoutesWeb 默认开(改配置要--clear);expo-router 的可选 peer(expo-symbols、@expo/ui 等)不再强制安装。
实战建议
目录组织:分组按"导航形态/守卫"划分,不按业务实体滥用分组——(auth)/(app) 服务鉴权,(tabs) 服务标签栏,业务实体(shop/[productId]、orders/[orderId])直接做成目录下的动态路由。共享组件放路由目录外(src/components/),避免被当成路由(路由目录里只放路由文件与 _layout/+ 文件)。
路由深度与性能:每多一层布局就是一次导航器嵌套,深链、返回栈、动画开销随之上涨。常见合理形态是两层(根 Stack + tabs 内 Stack);三到四层要问自己"这层布局真的需要独立导航器吗",多数时候一个 _layout 里两个 Stack.Protected 组就够。Web 上靠默认开启的 async routes 控制 chunk 体积;原生上长列表编辑页等高成本路由考虑 activityEnabled 让离屏休眠。
NativeTabs、Tabs、自定义怎么选:追求平台原生观感(iOS 18 悬浮 tab bar、Material You)选 NativeTabs,代价是自定义程度受平台控件限制;需要完全自定义 tab bar UI(中间凸起按钮等)用 expo-router/ui headless 组件(58 模板的 app-tabs.web.tsx 就是范例);要 React Navigation 熟悉的 tabBarIcon/tabBarBadge API 或过渡旧代码,用 JS Tabs。
什么时候回 React Navigation 原生能力:Router 已经覆盖 95% 的导航需求;剩下 5%(exotic navigator、跨 root 的全局编排)在 58 里也应该通过 integrateWithRouter 在 Router 体系内做自定义导航器,而不是绕回深导入 fork 时代的私有 API——后者在 58 已被移除,写了也跑不起来。
排错入口:升级后行为怪异先跑 expo start --clear;loader 没执行先确认 web.output 语义(static 下 loader 是构建期跑);unstable_settings 失效先查是否开了 async routes。全手册的坑位汇总与上线清单见 12-最佳实践与陷阱.md。