在 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(); 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
| <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"; 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
| <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" }} />
<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", "slug": "my-expo-app", } }
|
处理深链接跳转:在根布局中使用useEffect监听深链接,分为两种情况:
- 程序未打开(应用由深链接打开)
- 应用运行中或者挂在后台,比如拼多多的砍一刀、淘宝的分享
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) => { 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结合路由状态进行监听
主要操作:
- 监听路由变化
- 在跳转前进行条件判断
- 条件不满足使用
.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
| 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();
const publicPaths = ["/login", "/register"];
useEffect(() => { const isPublic = publicPaths.includes(pathname); if (!isLoggedIn && !isPublic) { 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" } } } }
|
history模式需要服务端支持(额外的 nginx 配置),否侧会因为刷新后找不到资源而跳转到404页面,相关介绍
history模式404页面的 nginx 转发配置
1 2 3
| location / { try_files $uri $uri/ /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
| 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
| 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()的返回值大概是会变成false(window.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(); const [initialPath, setInitialPath] = useState(null);
useEffect(() => { if (!initialPath) { setInitialPath(pathname); } }, [pathname, initialPath]);
const canGoBackInApp = router.canGoBack() && pathname !== initialPath;
return ( <Button title="返回" onPress={() => router.back()} disabled={!canGoBackInApp} // 仅允许应用内返回 /> ); }
|