Expo Router v6 完全指南:文件路由、类型安全导航与 Liquid Glass 实战

从零掌握 Expo Router v6:文件系统路由、类型安全导航、Stack.Protected 保护路由、Liquid Glass 原生标签栏、Link.Preview 和服务端中间件,附完整代码示例。

为什么 2026 年你必须掌握 Expo Router v6?

如果你正在用 React Native 做移动端开发,导航方案的选择可能是你最先要面对的架构决策之一。说实话,2026 年的格局已经很明朗了——Expo Router 已经成为 React Native 导航的事实标准。随着 Expo SDK 55 的发布和 Expo Router v6 的正式落地,文件系统路由、类型安全导航、保护路由、原生 Liquid Glass 标签栏……这些能力全部开箱即用。

对于经历过 React Navigation 手动配置路由那个时代的开发者来说,这简直是质的飞跃。

想想以前的日子:手动配置每一条路由、手写导航类型声明、自己维护深度链接映射……维护成本不是一般的高。Expo Router 把这些全部自动化了——你只需要在 app/ 目录下创建文件,路由就自动生成,深度链接自动映射,TypeScript 类型自动推断。就是这么简单粗暴。

这篇文章会从零开始,带你全面掌握 Expo Router v6 的核心能力。不管你是从 React Navigation 迁移过来的,还是新项目直接起步,都能快速上手。那就开始吧。

环境准备与安装

前置要求

  • Expo SDK 54+(推荐 SDK 55,已强制启用新架构)
  • React Native 0.81+(SDK 55 用的是 React Native 0.83)
  • Node.js 18+
  • TypeScript 5.0+(类型安全路由需要这个)

创建新项目

最快的方式就是用 create-expo-app,它会帮你自动配置好 Expo Router:

npx create-expo-app@latest my-app
cd my-app
npx expo start

如果你是在现有项目中集成,手动装一下依赖就行:

npx expo install expo-router react-native-safe-area-context react-native-screens expo-linking expo-constants expo-status-bar

然后在 package.json 中设置入口:

{
  "main": "expo-router/entry"
}

最后别忘了在 app.json 里配上 URI scheme,深度链接要用到这个:

{
  "expo": {
    "scheme": "myapp"
  }
}

文件系统路由核心概念

基本原理

Expo Router 的核心思想其实一句话就能说清——文件即路由。你在 app/ 目录下创建的每一个文件,都会自动变成一条导航路由。目录结构直接对应 URL 结构,不管是 Web 还是原生端,逻辑都一样。

app/
├── _layout.tsx          → 根布局
├── index.tsx            → / (首页)
├── about.tsx            → /about
├── settings/
│   ├── _layout.tsx      → /settings 布局
│   ├── index.tsx        → /settings
│   └── profile.tsx      → /settings/profile
└── user/
    └── [id].tsx         → /user/123, /user/456 (动态路由)

有没有觉得跟 Next.js 的 App Router 很像?没错,Expo Router 的灵感就是来自那里。

路由文件命名规则

Expo Router 的文件命名有一套明确的约定。理解这些规则是用好它的前提,所以值得花一分钟过一遍:

  • index.tsx——目录的默认路由,对应该目录的根路径
  • _layout.tsx——布局文件,定义子路由的导航结构(Stack、Tabs 等)
  • [param].tsx——动态路由,方括号中的名字就是参数名
  • [...slug].tsx——通配路由(Catch-all),匹配任意深度的路径
  • (group)/——路由分组,圆括号目录不影响 URL 路径
  • +not-found.tsx——404 页面
  • +middleware.ts——服务端中间件(v6 新增的)

布局文件(_layout.tsx)

布局文件可以说是 Expo Router 的灵魂所在。它决定了子路由以什么方式呈现——Stack 堆栈导航、Tab 标签导航,还是 Drawer 抽屉导航。

// app/_layout.tsx
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: '首页' }} />
      <Stack.Screen name="about" options={{ title: '关于' }} />
      <Stack.Screen
        name="modal"
        options={{ presentation: 'modal' }}
      />
    </Stack>
  );
}

路由分组的实际用途

路由分组是组织代码结构的利器,我个人特别喜欢这个设计。比如你想把"登录后可见的页面"和"未登录页面"分开管理,但又不希望 URL 里出现分组名:

app/
├── (auth)/
│   ├── _layout.tsx
│   ├── login.tsx        → /login
│   └── register.tsx     → /register
├── (main)/
│   ├── _layout.tsx
│   ├── index.tsx        → /
│   ├── feed.tsx         → /feed
│   └── profile.tsx      → /profile
└── _layout.tsx

注意看,(auth)(main) 这两个圆括号目录不会出现在最终的 URL 中。(auth)/login.tsx 对应的路径就是 /login,而不是 /auth/login。这样代码组织清晰了,URL 结构也保持干净。

类型安全路由

启用 TypedRoutes

这是我觉得 Expo Router 最值得吹一下的特性之一。类型安全路由能在编译时就捕获无效的导航路径,帮你省去大量运行时调试的麻烦。

app.json 中一行配置就能开启:

{
  "expo": {
    "experiments": {
      "typedRoutes": true
    }
  }
}

开启后,Expo CLI 会在项目根目录自动生成 expo-env.d.ts 类型声明文件(已经自动加入 .gitignore 了),并修改 tsconfig.json 来包含这些类型。你基本不需要操心任何额外配置。

类型安全的 Link 和 router

启用之后,<Link> 组件和 router 对象都会获得完整的自动补全和类型检查。来看看实际效果:

import { Link, useRouter } from 'expo-router';

export default function Home() {
  const router = useRouter();

  return (
    <View>
      {/* ✅ 类型安全——自动补全可用路由 */}
      <Link href="/about">关于我们</Link>

      {/* ✅ 动态路由——自动检查参数 */}
      <Link href={{ pathname: '/user/[id]', params: { id: '123' } }}>
        用户详情
      </Link>

      {/* ❌ 编译时报错——路径不存在 */}
      {/* <Link href="/usser/1">错误路径</Link> */}

      {/* 命令式导航同样类型安全 */}
      <Button title="跳转" onPress={() => router.push('/about')} />
    </View>
  );
}

试想一下,如果你在重构的时候删掉了某个路由文件,所有引用它的地方会立刻飘红。这种体验在以前的 React Navigation 中是不可能有的。

useLocalSearchParams 的类型用法

获取路由参数的时候,可以通过泛型来获得类型推断:

// app/user/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import { View, Text } from 'react-native';

export default function UserProfile() {
  // 基础用法——手动指定参数类型
  const { id } = useLocalSearchParams<{ id: string }>();

  // 高级用法——同时获取路由参数和查询参数
  const { id: userId, tab } = useLocalSearchParams<
    '/user/[id]',
    { tab?: string }
  >();

  return (
    <View>
      <Text>用户 ID:{userId}</Text>
      <Text>当前标签:{tab ?? '默认'}</Text>
    </View>
  );
}

顺便提一嘴:在 Stack 导航中,优先用 useLocalSearchParams 而不是 useGlobalSearchParams。前者只在当前路由聚焦时更新,能避免后台页面不必要的重渲染。这个坑我之前踩过,后台页面莫名其妙地重渲染,排查了半天才发现是用错了 hook。

保护路由与认证

Stack.Protected 组件

从 Expo SDK 53 开始,Expo Router 引入了 Stack.ProtectedTabs.Protected。坦白说,这是很多人(包括我)期待已久的功能。终于不用在每个页面手动做 redirect 判断了。

// app/_layout.tsx
import { Stack } from 'expo-router';
import { useAuth } from '@/contexts/AuthContext';

export default function RootLayout() {
  const { isLoggedIn } = useAuth();

  return (
    <Stack>
      {/* 未登录时可见的路由 */}
      <Stack.Protected guard={!isLoggedIn}>
        <Stack.Screen name="login" />
        <Stack.Screen name="register" />
      </Stack.Protected>

      {/* 登录后可见的路由 */}
      <Stack.Protected guard={isLoggedIn}>
        <Stack.Screen name="(main)" />
        <Stack.Screen name="settings" />
      </Stack.Protected>
    </Stack>
  );
}

用法很直觉:guardtrue 时路由可访问,为 false 时不可访问。如果用户当前在一个受保护页面上,而 guard 条件突然变为 false(比如 token 过期),会自动重定向到锚点路由,并且该路由的所有历史记录会被清除。不需要你写一行额外的代码。

嵌套保护与角色控制

保护路由还可以嵌套使用,轻松实现细粒度的角色控制:

export default function AppLayout() {
  const { isLoggedIn, isAdmin } = useAuth();

  return (
    <Stack>
      <Stack.Protected guard={isLoggedIn}>
        <Stack.Screen name="dashboard" />

        {/* 管理员专属页面 */}
        <Stack.Protected guard={isAdmin}>
          <Stack.Screen name="admin-panel" />
          <Stack.Screen name="user-management" />
        </Stack.Protected>
      </Stack.Protected>
    </Stack>
  );
}

外层检查登录状态,内层检查管理员权限——逻辑一目了然。

深度链接与认证的配合

这是 Stack.Protected 最让我惊喜的地方——即使通过深度链接直接进入受保护页面,守卫依然生效

以前我们经常遇到这种情况:用户点了一个深度链接,直接跳到了需要登录才能看的页面,因为 useEffect 里的 redirect 逻辑还没来得及执行,页面内容就闪了一下。现在有了 Stack.Protected,未认证用户通过深度链接访问 /dashboard,会被直接拦截并重定向到登录页。比之前可靠太多了。

加载状态与闪屏控制

在认证状态加载期间(比如从 AsyncStorage 读取 token),有一个很常见的体验问题:闪屏消失后会"闪"一下未授权页面。解决方法是配合 SplashScreen 使用:

import { SplashScreen, Stack } from 'expo-router';
import { useAuth } from '@/contexts/AuthContext';
import { useEffect } from 'react';

SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const { isLoggedIn, isLoading } = useAuth();

  useEffect(() => {
    if (!isLoading) {
      SplashScreen.hideAsync();
    }
  }, [isLoading]);

  if (isLoading) return null;

  return (
    <Stack>
      <Stack.Protected guard={!isLoggedIn}>
        <Stack.Screen name="(auth)" />
      </Stack.Protected>
      <Stack.Protected guard={isLoggedIn}>
        <Stack.Screen name="(main)" />
      </Stack.Protected>
    </Stack>
  );
}

这样在认证状态确定之前,用户看到的一直是闪屏,不会有任何闪烁。

深度链接

零配置的自动深度链接

Expo Router 的另一个杀手级特性——零配置深度链接。只要你在 app.json 中配好了 scheme,所有路由都会自动生成对应的深度链接。不需要像以前在 React Navigation 里那样手动维护一个又臭又长的 linking.ts 配置文件。

// 文件 app/product/[id].tsx 自动对应以下 URL:
// - myapp://product/42       (自定义 scheme)
// - https://myapp.com/product/42  (Universal Link)
// - exp://localhost:8081/product/42  (开发环境)

少维护一个配置文件,少一个出 bug 的地方。

Universal Links 和 App Links

在 2026 年的生产环境中,强烈推荐使用 Universal Links(iOS)和 App Links(Android),而不是自定义 URL scheme。它们用标准的 https:// URL,应用未安装时还能优雅地回退到网页:

// app.json
{
  "expo": {
    "scheme": "myapp",
    "ios": {
      "associatedDomains": ["applinks:myapp.com"]
    },
    "android": {
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [
            { "scheme": "https", "host": "myapp.com", "pathPrefix": "/" }
          ],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}

模态框呈现

基本模态框

模态框的使用很简单,在布局文件中把 presentation 设为 modal 就搞定了:

// app/_layout.tsx
import { Stack } from 'expo-router';

export default function Layout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen
        name="create-post"
        options={{
          presentation: 'modal',
          title: '发布动态',
        }}
      />
    </Stack>
  );
}

表单底部弹窗(Form Sheet)

Form Sheet 是我在 v6 中最喜欢的呈现方式之一。它以底部弹窗形式展示内容,用户可以在不同高度之间拖拽,体验非常流畅:

<Stack.Screen
  name="filter"
  options={{
    presentation: 'formSheet',
    sheetGrabberVisible: true,
    sheetInitialDetentIndex: 0,
    sheetAllowedDetents: [0.25, 0.5, 1.0],
  }}
/>

Web 端模态框(v6 新增)

Expo Router v6 还把原生模态框的体验带到了 Web 端,用的是 Radix 和 Vaul 来实现 iPhone/iPad 风格的动画、手势和布局,支持下拉关闭。目前需要设置一个环境变量来启用:

EXPO_UNSTABLE_WEB_MODAL=1 npx expo start

你还可以通过 CSS 自定义变量来全局调整 Web 模态框的样式(比如宽度、圆角什么的):

:root {
  --expo-router-modal-width: 700px;
  --expo-router-modal-max-width: 95vw;
  --expo-router-modal-height: 640px;
  --expo-router-modal-border-radius: 16px;
  --expo-router-modal-overlay-background: rgba(0, 0, 0, 0.5);
}

Liquid Glass 原生标签栏

NativeTabs 介绍

这可能是 Expo Router v6 最抓眼球的新功能了。NativeTabs 不是用 JavaScript 模拟出来的标签栏,而是直接调用了 iOS 的 UITabBarController 和 Android 的原生标签组件。在 iOS 26 上,你能看到那个超好看的 Liquid Glass 毛玻璃效果。

// app/(tabs)/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';

export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index">
        <NativeTabs.Trigger.Icon
          sf={{ default: 'house', selected: 'house.fill' }}
        />
        <NativeTabs.Trigger.Label>首页</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>

      <NativeTabs.Trigger name="search">
        <NativeTabs.Trigger.Icon
          sf={{ default: 'magnifyingglass' }}
        />
        <NativeTabs.Trigger.Label>搜索</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>

      <NativeTabs.Trigger name="profile">
        <NativeTabs.Trigger.Icon
          sf={{ default: 'person', selected: 'person.fill' }}
        />
        <NativeTabs.Trigger.Label>我的</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}

暗色模式适配

用 NativeTabs 的时候有一个比较容易踩的坑——暗色模式下标签栏可能会闪烁。原因是 React Navigation 的默认主题和系统暗色模式不匹配。修复很简单,用 ThemeProvider 包一下:

import { ThemeProvider, DarkTheme, DefaultTheme } from '@react-navigation/native';
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import { useColorScheme } from 'react-native';

export default function TabLayout() {
  const colorScheme = useColorScheme();

  return (
    <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
      <NativeTabs>
        <NativeTabs.Trigger name="index">
          <NativeTabs.Trigger.Label>首页</NativeTabs.Trigger.Label>
        </NativeTabs.Trigger>
        <NativeTabs.Trigger name="settings">
          <NativeTabs.Trigger.Label>设置</NativeTabs.Trigger.Label>
        </NativeTabs.Trigger>
      </NativeTabs>
    </ThemeProvider>
  );
}

使用注意事项

在你兴冲冲地把 NativeTabs 用到项目里之前,有几点需要了解:

  • NativeTabs 目前还是实验性 API(从 expo-router/unstable-native-tabs 导入),在稳定版之前 API 可能会变动
  • 不支持自定义 tabBar 渲染——你用的就是系统原生标签栏,没有自定义的余地(这也是它快的原因)
  • 要看到 Liquid Glass 效果,需要 iOS 26+ 和 Xcode 26;低版本 iOS 会自动回退到经典样式
  • SDK 55 中,Icon、Label、Badge 改为了复合组件 API:NativeTabs.Trigger.IconNativeTabs.Trigger.LabelNativeTabs.Trigger.Badge

Link.Preview 与 Link.Menu(iOS 专属)

Expo Router v6 为 Apple 平台带来了长按预览和上下文菜单的能力——就像 Safari 里长按链接弹出预览的那种体验。

import { Link } from 'expo-router';

export default function ArticleCard({ article }) {
  return (
    <Link href={`/article/${article.id}`} asChild>
      <Pressable>
        <Link.Trigger>
          <Text>{article.title}</Text>

          {/* 长按时弹出预览 */}
          <Link.Preview />

          {/* 长按菜单选项 */}
          <Link.Menu>
            <Link.Menu.Item label="收藏" onPress={handleSave} />
            <Link.Menu.Item label="分享" onPress={handleShare} />
          </Link.Menu>
        </Link.Trigger>
      </Pressable>
    </Link>
  );
}

这个功能只在 Apple 平台上生效,Android 和 Web 上会被安全忽略,不会报错。配合 Link.Preview,你可以在内容列表页做到长按查看详情——跟系统原生应用体验一模一样,用户完全感知不到你用的是 React Native。

服务端中间件(Alpha)

Expo Router v6 还引入了 +middleware.ts,让你能在服务端处理请求之前执行一些逻辑——比如认证检查、日志记录、请求重定向之类的。不过这个功能目前还是 Alpha 状态,用的时候需要心里有数。

配置

首先在 app.json 中启用:

{
  "expo": {
    "web": { "output": "server" },
    "plugins": [
      ["expo-router", { "unstable_useServerMiddleware": true }]
    ]
  }
}

基本用法

// app/+middleware.ts
import type { MiddlewareFunction } from 'expo-server';

const middleware: MiddlewareFunction = async (request) => {
  const url = new URL(request.url);
  console.log(`[${request.method}] ${url.pathname}`);

  // 认证检查示例
  if (url.pathname.startsWith('/api/admin')) {
    const token = request.headers.get('authorization');
    if (!token) {
      return new Response('未授权', { status: 401 });
    }
  }

  // 不返回 Response 则继续到下一个路由处理
};

export default middleware;

路由匹配器

你可以通过 unstable_settings 限制中间件只作用于特定路由,不用每次都对所有请求做处理:

import type { MiddlewareSettings } from 'expo-server';

export const unstable_settings: MiddlewareSettings = {
  matcher: {
    methods: ['GET', 'POST'],
    patterns: [
      '/api',                // 精确匹配
      '/api/users/[userId]', // 动态参数
      '/blog/[...slug]',     // 通配路由
    ],
  },
};

几个需要注意的限制:

  • 客户端导航(原生端或 Web 端的 <Link /> 跳转)不会经过服务端中间件
  • 请求对象是不可变的——不能修改 headers 或消费 request body
  • 整个应用只能有一个根级别的 +middleware.ts,不支持嵌套
  • 该功能处于 Alpha 阶段,生产环境使用需要部署服务器,谨慎评估

从 React Navigation 迁移

如果你的项目之前用的是 React Navigation,迁移到 Expo Router 并不像你想象的那么复杂。但有几个关键点要提前了解,免得踩坑。

迁移清单

  1. 拆分屏幕组件——确保每个 Screen 组件都在独立文件中,然后按路由结构移入 app/ 目录
  2. 替换导航容器——删掉 NavigationContainer,Expo Router 的 _layout.tsx 会自动处理这些
  3. 更新参数传递——这个是最容易出问题的地方。React Navigation 允许传 Function、Object 等复杂类型作为参数,但 Expo Router 只支持可序列化的顶层值(stringnumberboolean
  4. 处理加载状态——不要在根组件返回 null(React Navigation 项目中常见的字体加载模式),改用 SplashScreen.preventAutoHideAsync()
  5. 更新深度链接——删掉手动配置的 linking 对象,文件系统路由会自动帮你处理

导航 API 对照

下面这个对照表值得收藏一下:

// React Navigation
navigation.navigate('Profile', { userId: '123' });
navigation.goBack();
navigation.reset({ routes: [{ name: 'Home' }] });

// Expo Router
router.push({ pathname: '/profile/[id]', params: { id: '123' } });
router.back();
router.replace('/');

// Expo Router v6 新增
router.dismissTo('/home');  // 退栈到指定路由

性能优化技巧

最后聊几个实用的性能优化建议:

  • 懒加载路由——生产环境下路由默认就是懒加载的,开发环境启用 deferred bundling 可以加快热更新
  • 异步路由(Bundle Splitting)——大型项目中每个路由可以独立打包,能有效减少首屏加载时间
  • 优先用 useLocalSearchParams——前面提过了,避免 useGlobalSearchParams 带来的不必要重渲染
  • router.dismissTo() 代替多次 back()——v6 新增的 API,一次性退栈到目标路由,比连续调用 back() 性能好得多
  • 平台特定布局——用 _layout.ios.tsx_layout.web.tsx 为不同平台提供最优的导航体验,而不是在一个文件里用 Platform.OS 做大量条件判断

常见问题

Expo Router 和 React Navigation 有什么区别?我该选哪个?

Expo Router 是建立在 React Navigation 之上的封装层——底层用的是同一套导航组件,性能基本没有差异。Expo Router 在此基础上增加了文件系统路由、自动深度链接、类型安全路由、Web 静态渲染等能力。如果你在 2026 年用 Expo 开发新项目,官方明确推荐使用 Expo Router。已有的 React Navigation 项目也可以逐步迁移过来。

保护路由和手动 redirect 有什么区别?

Stack.Protected 把访问控制集中在导航树中声明式管理,不需要在每个页面写重复的认证检查逻辑。更重要的是,它能可靠地处理深度链接进入受保护页面的场景——手动 redirect 有时候会被深度链接、浏览器导航或竞态条件绕过。不过要记住,保护路由只在客户端生效,服务端认证还是得另外做。

NativeTabs 的 Liquid Glass 效果在旧版 iOS 上会怎样?

在 iOS 26 以下版本,NativeTabs 会自动回退到经典的 iOS 标签栏样式,你不需要做任何额外处理。Android 上则使用原生 Android 标签组件,还支持长按显示标签名等平台特性。完全不用担心兼容性问题。

如何在 Expo Router 中实现角色权限控制?

利用 Stack.Protected 的嵌套能力就行。外层 guard 检查登录状态,内层 guard 检查角色权限(比如 isAdmin)。当权限条件发生变化时,路由会自动重定向并清理历史记录。前面的代码示例已经演示过了。

服务端中间件可以用在生产环境吗?

技术上是可以的(需要部署服务器),但中间件功能目前还处于 Alpha 阶段,API 随时可能变化。所以在正式项目中建议谨慎使用,等稳定版出来再大规模铺开。目前的建议是:认证等关键逻辑优先用客户端的 Stack.Protected 配合后端 API 验证,更稳妥。

关于作者 Editorial Team

Our team of expert writers and editors.