expo路由

expo路由

在 expo 中也可以使用 react navigation(component-based),不过在 expo 项目中是更加推荐 expo router(file-based)

文件结构

项目结构

1
2
3
4
5
6
7
app/
_layout.js # 根布局(必选,定义导航容器)
index.js # 首页(对应路径:/)
profile.js # 个人页(对应路径:/profile)
users/
[id].js # 动态路由(对应路径:/users/123)
_layout.js # users 目录的子布局
  • _layout.js:每个目录下所有页面的导航容器(如栈导航、标签导航)。相当于 react navigation 导航时每个页面常用的index.js
  • index.js当前目录的默认页面

页面结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";

export default function RootLayout() {
return (
<>
{/* 栈导航容器:管理页面跳转,类似浏览器的历史栈 */}
<Stack>
{/* 定义页面路由:name 对应文件名(不含 .js),可选配置导航栏标题 */}
<Stack.Screen name="index" options={{ title: "首页" }} />
<Stack.Screen name="profile" options={{ title: "个人中心" }} />
</Stack>
<StatusBar style="auto" />
</>
);
}

页面导航

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { View, Text, Button, StyleSheet } from "react-native";
import { useRouter } from "expo-router";

export default function HomePage() {
// 获取路由实例,用于导航跳转
const router = useRouter();

return (
<View style={styles.container}>
<Text style={styles.title}>首页</Text>
{/* 跳转到 /profile 页面 */}
<Button
title="去个人中心"
onPress={() => router.push("/profile")}
/>
</View>
);
}

const styles = StyleSheet.create({
// ...
});

动态路由

动态参数通过[xx]文件名实现:app/users/[id].js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { View, Text, Button, StyleSheet } from "react-native";
import { useRouter, useLocalSearchParams } from "expo-router";

export default function UserDetailPage() {
const router = useRouter();
// 获取动态参数(从路径中提取 id)
const { id } = useLocalSearchParams();

return (
<View style={styles.container}>
<Text style={styles.title}>用户详情页</Text>
<Text style={styles.id}>用户 ID:{id}</Text>
<Button title="返回首页" onPress={() => router.push("/")} />
</View>
);
}

const styles = StyleSheet.create({
// ...
});

在其他页面中进行跳转

1
2
3
4
5
<Button
title="查看用户 123"
onPress={() => router.push("/users/123")}
style={styles.button}
/>

嵌套路由

在 file-based 中,路由嵌套实际上就是文件夹的嵌套,然后路由结构在对应文件夹的_layout.js中定义

1
2
3
4
5
6
7
8
9
10
app/                  # 根路由层级
_layout.js # 根布局(如 Tabs 导航)
index.js # 根路由页面:/
dashboard/ # 子路由层级:/dashboard
_layout.js # 子布局(如 Stack 导航)
index.js # 子路由页面:/dashboard
settings.js # 子路由页面:/dashboard/settings
users/ # 另一子路由层级:/users
_layout.js # 子布局
[id].js # 动态子路由:/users/123

根路由添加子路由执行同级添加:

1
2
// app/_layout.js(新增一行)
<Stack.Screen name="dashboard" options={{ title: "仪表盘" }} />

标签导航

标签导航常用于同级目录(同级文件夹)中的切换,比如程序底部的:首页、我的。标签导航使用的是Tabs,比如在app/_layout.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { Tabs } from "expo-router"; // 替换 Stack 为 Tabs
import { StatusBar } from "expo-status-bar";
import { Ionicons } from "@expo/vector-icons";

export default function RootLayout() {
return (
<>
{/* 标签导航容器:底部显示标签栏 */}
<Tabs>
{/* 首页标签 */}
<Tabs.Screen
name="index"
options={{
title: "首页",
// 可设置图标需安装 @expo/vector-icons
tabBarIcon: ({ color }) => <Ionicons name="home" color={color} size={24} />,
}}
/>
{/* 个人中心标签 */}
<Tabs.Screen
name="profile"
options={{
title: "我的",
tabBarIcon: ({ color }) => <Ionicons name="person" color={color} size={24} />,
}}
/>
{/* 仪表盘标签(嵌套路由也可作为标签) */}
<Tabs.Screen
name="dashboard"
options={{
title: "仪表盘",
tabBarIcon: ({ color }) => <Ionicons name="bar-chart" color={color} size={24} />,
}}
/>
</Tabs>
<StatusBar style="auto" />
</>
);
}

标签页的切换会保持每个页面的状态,比如在 首页 和 我的 之间来回切换,首页 的内容不会重新加载

此外还有实验性的包expo-router/unstable-native-tabs,在布局的时候类似于普通的 tab(纯JS控制层),但是在性能等方面有所差别:

  • 接近原生体验、性能更好
  • 定制性不高

如想实现iOS的 liquid glass ,可使用

路由分组

将相关的路由归到一个文件夹中,但不会影响路由的路径

需要分组的路由,文件夹名称使用()包裹,比如:(auth)(admin)

1
2
3
4
5
6
7
8
app/
(auth)/ # 路由分组:存放登录/注册相关页面
login.js # 路由路径:/login(而非 /(auth)/login)
register.js # 路由路径:/register
(tabs)/ # 路由分组:存放标签页相关页面
home.js # 路由路径:/home
profile.js # 路由路径:/profile
_layout.js # 根布局

分组路由不会影响 url,上面的路径不会出现auth或者admin,但是在声明的时候需要写上

1
2
3
4
5
// app/_layout.js(根布局)
<Stack>
<Stack.Screen name="(auth)/login" options={{ title: "登录" }} />
<Stack.Screen name="(auth)/register" options={{ title: "注册" }} />
</Stack>

模态路由(弹窗)

在 web 路由中没有这个概念,所以弹窗打开时,左滑退出(关闭弹窗)需要进行的是路由拦截,然后判断弹窗的显示状态再决定是否放行 router 导航

弹窗仍属于页面栈,所以在打开的时候也可使用router.push()、或者使用<Link>

配置和普通的页面一致,主要区别在于presentation: "modal"(这和 react navigation 一样 )

1
2
3
4
5
6
7
8
// 模态路由配置
<Stack.Screen
name="modal"
options={{ presentation: "modal" }} // 关键声明
/>

// 普通路由配置(默认无需声明,或隐含 presentation: "card")
<Stack.Screen name="profile" />

行为上和普通路由的区别:

  • 样式为弹窗
  • 关闭方式.dismiss()(推荐,语义明确)、.back(),iOS点击弹窗外部会自动关闭

深链接

DeepLink,由外部跳转到应用内页面;如浏览器、其他app、短信链接直接跳到用户详情(myapp://user/123)

配置深链接:在app.json中添加scheme(应用的专属协议)

1
2
3
4
5
6
7
{
"expo": {
"scheme": "myapp", // 自定义协议名,用于深链接(如 myapp://...)
"slug": "my-expo-app", // 可选,用于 Web 端深链接
// 其他配置...
}
}

处理深链接跳转:在根布局中使用useEffect监听深链接,分为两种情况:

  1. 程序未打开(应用由深链接打开)
  2. 应用运行中或者挂在后台,比如拼多多的砍一刀、淘宝的分享
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { useEffect } from "react";
import { Stack } from "expo-router";
import * as Linking from "expo-linking";

export default function RootLayout() {
// 监听深链接
useEffect(() => {
const handleUrl = (url) => {
// 解析链接(如 myapp://user/123 → 提取路径 /user/123)
const parsed = Linking.parse(url);
if (parsed.path) {
router.push(parsed.path); // 跳转到对应页面
}
};

// 监听初始链接(应用从深链接打开时)
Linking.getInitialURL().then((url) => {
if (url) handleUrl(url);
});

// 监听应用运行中收到的深链接
const subscription = Linking.addEventListener("url", (event) => {
handleUrl(event.url);
});

return () => subscription.remove(); // 清理监听
}, []);

return <Stack>{/* 页面配置... */}</Stack>;
}

路由拦截

主要是使用useEffect结合路由状态进行监听

主要操作:

  1. 监听路由变化
  2. 在跳转前进行条件判断
  3. 条件不满足使用.replace()强制跳转等操作(不用back(),比如不让用户再次回到权限还生效前的页面)

如进行非登录拦截

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// app/_layout.js(根布局中实现全局拦截)
import { useEffect } from "react";
import { Stack, useRouter, usePathname } from "expo-router";
import { useAuthStore } from "../stores/authStore"; // 假设的登录状态管理(可替换为你的实现)

export default function RootLayout() {
const router = useRouter();
const pathname = usePathname(); // 当前路径
const { isLoggedIn } = useAuthStore(); // 登录状态(示例:true/false)

// 定义不需要登录的白名单路径(如登录页、注册页)
const publicPaths = ["/login", "/register"];

useEffect(() => {
// 路由变化时触发拦截逻辑
const isPublic = publicPaths.includes(pathname);
// 未登录且访问的不是白名单页面 → 拦截并跳转到登录页
if (!isLoggedIn && !isPublic) {
// 使用 replace 而非 push,避免用户通过 back() 返回受保护页面
router.replace("/login");
}

// 确保路由变化、登录状态变化时能触发监听
}, [pathname, isLoggedIn, router]);

return (
<Stack>
{/* 页面配置 */}
<Stack.Screen name="login" options={{ title: "登录" }} />
<Stack.Screen name="index" options={{ title: "首页" }} />
<Stack.Screen name="profile" options={{ title: "个人中心" }} />
</Stack>
);
}

web端路由适配

app.json中添加web配置

1
2
3
4
5
6
7
8
9
10
{
"expo": {
"web": {
"router": {
"mode": "history" // 推荐:使用 HTML5 History 模式(URL 无 # 号)
// 或 "mode": "hash"(URL 带 # 号,兼容旧浏览器)
}
}
}
}
  • history模式需要服务端支持(额外的 nginx 配置),否侧会因为刷新后找不到资源而跳转到404页面,相关介绍
  • history模式404页面的 nginx 转发配置
1
2
3
location / {
try_files $uri $uri/ /index.html; # 所有请求转发到 index.html
}

在web中显示自定义的404页面:创建文件app/[...unmatched].js...标识匹配所有未定义的路由):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// app/[...unmatched].js
import { View, Text, Button, StyleSheet } from "react-native";
import { useRouter } from "expo-router";

export default function NotFoundPage() {
const router = useRouter();
return (
<View style={styles.container}>
<Text style={styles.title}>404 - 页面不存在</Text>
<Button title="返回首页" onPress={() => router.push("/")} />
</View>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
},
title: {
fontSize: 24,
marginBottom: 20,
},
});

路由元数据

配置和 html 标签相似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// app/products/[id].js
import { Head } from "expo-router";
import { useLocalSearchParams } from "expo-router";

export default function ProductPage() {
const { id } = useLocalSearchParams();
// 假设从接口获取商品信息
const product = { name: `商品 ${id}`, description: `这是商品 ${id} 的详情` };

return (
<>
<Head>
<title>{product.name} - 我的商店</title>
<meta name="description" content={product.description} />
</Head>
{/* 页面内容 */}
</>
);
}

其他

优化项

在 web 中有一个用户体验的优化项:router.canGoBack()在App中路由完成由应用控制,但在web中是和window.history共享,如果当前调用页面返回值为true,证明在当前页面是可以返回上一层页面的

情况1:无论是从外部页面外链而来还是应用内路由跳转)刷新浏览器后,router.canGoBack()的返回值大概是会变成falsewindow.histort清空了,无处返回),此时就可以以此来显示是否显示返回按钮(刷新前显示,刷新后不显示了)

情况2:如果通过外链而来,在应用内进行一次或者多次导航后希望后退(某个返回按钮)不会再退到外链来源(如百度),只希望不管怎么back都还是在程序内(首页),可以有以下操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { useRouter, usePathname } from "expo-router";
import { useEffect, useState } from "react";

export default function Page() {
const router = useRouter();
const pathname = usePathname(); // 获取当前应用内路径(如 /home、/profile)
const [initialPath, setInitialPath] = useState(null);

// 记录用户首次进入应用时的路径(初始路径)
useEffect(() => {
if (!initialPath) {
setInitialPath(pathname);
}
}, [pathname, initialPath]);

// 核心判断:
// 1. router.canGoBack():是否有历史记录
// 2. pathname !== initialPath:当前路径是否不是初始路径(即存在应用内跳转)
const canGoBackInApp = router.canGoBack() && pathname !== initialPath;

return (
<Button
title="返回"
onPress={() => router.back()}
disabled={!canGoBackInApp} // 仅允许应用内返回
/>
);
}
作者

dsjerry

发布于

2025-11-17

更新于

2025-11-17

许可协议

评论