Skip to content
Expo Router:文件路由全家桶

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,headless expo-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):

  1. 导航状态确定化:同样的操作序列在设备间得到同样的 state 与 route keys;dispatch 推迟到 React commit 后,需要立即生效用 navigation.dispatchSync(action)。
  2. 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。
  3. 隐式参数移除:screen/params/initial 变普通参数,嵌套目标必须写完整 { pathname, params }(见 Link 一节);祖先布局的参数不再自动下发,目标路由需要的参数显式塞进 href,读取处给默认值。
  4. Stack.Screen 的 redirect/initialParams 移除:用路由文件内 <Redirect href> + 参数默认值替代。navigationKey 移除且无直接等价物。
  5. 事件更名:beforeRemove → removePrevented(被 usePreventRemove 拦下时)与 removed(卸载完成后,注意延迟挂载监听以收到它)。
  6. 预加载路由改挂在 state.routes 尾部,不再有 preloadedRoutes 字段。
  7. 测试 API 异步化:renderRouter() 现在返回 Promise(await renderRouter(...)),renderRouterAsync 更名为 renderRouter,需要 @testing-library/react-native ≥14;testRouter.canGoBack() 等断言仍同步。
  8. 自定义导航器:withLayoutContext 已废弃(暂可用,第三个参数删掉即可),新写法是 integrateWithRouter / createStandardRouterNavigator;自定义 router 用 extendRouter,getStateForAction 返回 { state, affectedRouteKey } 并用注入的 nextKey() 生成 key。expo-router/js-stack 的 createStackNavigator 移除(createNativeStackNavigator 保留)。
  9. Web 安全区:不再通过 react-native-safe-area-context 提供 insets,Web 端 CSS 直接用 env(safe-area-inset-*)。
  10. 默认值变化三连:web.output 不设即 server;asyncRoutes Web 默认开(改配置要 --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。