📚 全栈开发学习系列
✅ 阶段一 编程基础(01-06)
01 Python基础 / 02 数据结构 / 03 C语言指针 / 04 Git版本控制 / 05 Linux命令行 / 06 Vim编辑器
✅ 阶段二 Web全栈(07-10)
07 HTML+CSS / 08 JavaScript / 09 FastAPI后端 / 10 PostgreSQL数据库
✅ 阶段三 前端深化(17-25)
17-25 React/TypeScript/Next.js/Tailwind/Vite/Vitest/API测试/Zustand
阶段四 跨平台 App(进行中)
26 跨平台开发:React Native 与 Expo ✓
27 React Native 进阶:导航与状态管理 ← 当前
上一篇我们用 Expo 创建了第一个 React Native 应用,学会了 View、Text、Pressable 等核心组件和 Expo Router 的基础路由。但一个真实 App 远不止两个页面——你需要底部标签栏切换功能模块、侧边栏展示用户菜单、页面堆栈管理跳转历史,还需要在移动端做状态管理和本地数据持久化。本篇把 Web 端的状态管理思路迁移到移动端,让你的 App 从"能跑"变成"好用"。
📋 目录
1. Expo Router 三大导航模式概述
2. Stack 导航:页面堆栈与跳转
3. Tabs 导航:底部标签栏
4. Drawer 导航:侧边栏
5. 嵌套导航:组合使用三种模式
6. Zustand 移动端状态管理
7. AsyncStorage 本地持久化
8. Zustand persist + AsyncStorage 联动
9. 手势交互基础
一、Expo Router 三大导航模式概述
Expo Router v7(随 Expo SDK 57 发布)提供三种导航模式,对应移动端最常见的三种交互范式。每种模式在 _layout.tsx 中声明,路由文件放在对应目录下即可自动生效。
三种导航模式对比
| 模式 |
布局函数 |
适用场景 |
| Stack |
Stack() |
页面堆栈跳转,类似浏览器 history |
| Tabs |
Tabs() |
底部标签栏,切换主要功能模块 |
| Drawer |
Drawer() |
侧边栏抽屉,菜单/设置/用户信息 |
三者并非互斥——真实 App 通常嵌套使用:最外层 Drawer 包裹一层 Tabs,每个 Tab 内部再嵌套 Stack 处理子页面跳转。Expo Router 通过目录结构 + _layout.tsx 自动推断嵌套关系,无需手动配置路由表。
💡 Web 思维迁移:Expo Router 的文件路由与 Next.js App Router 几乎一致。app/index.tsx = /,app/about.tsx = /about。区别在于导航容器由 _layout.tsx 中的 Stack/Tabs/Drawer 决定。
二、Stack 导航:页面堆栈与跳转
Stack 是最基础的导航模式。它维护一个页面堆栈:push 入栈(打开新页面),back 出栈(返回上一页)。在 _layout.tsx 中用 Stack() 函数创建:
app/_layout.tsx
|
1
2
3
4
5
6
7
8
9
10 |
import { Stack } from 'expo-router'
export default function Layout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: '首页' }} />
<Stack.Screen name="detail" options={{ title: '详情' }} />
</Stack>
)
}
|
页面跳转使用 router 对象的三个核心方法:
router 方法对比
| 方法 |
行为 |
Web 类比 |
| router.push() |
入栈,可返回上一页 |
history.pushState |
| router.replace() |
替换当前页,无法返回 |
history.replaceState |
| router.back() |
出栈,返回上一页 |
history.back() |
传递参数时,push 接受对象参数,目标页用 useLocalSearchParams 读取:
app/index.tsx
|
1
2
3
4
5
6
7
8 |
import { router, Pressable, Text } from 'expo-router'
export default function Home() {
const goDetail = () => {
router.push({ pathname: '/detail', params: { id: 42 } })
}
return <Pressable onPress={goDetail}><Text>查看 #42</Text></Pressable>
}
|
app/detail.tsx
|
1
2
3
4
5
6 |
import { useLocalSearchParams } from 'expo-router'
export default function Detail() {
const { id } = useLocalSearchParams()
return <Text>文章 ID: {id}</Text>
}
|
三、Tabs 导航:底部标签栏
Tabs 是最常见的主导航模式。底部标签栏让用户在 3-5 个功能模块间快速切换,且切换时保持各 Tab 的内部状态。在 Expo Router 中,只需在 _layout.tsx 中用 Tabs() 替换 Stack(),路由文件放在 (tabs)/ 目录下:
app/(tabs)/_layout.tsx
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 |
import { Tabs } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
export default function TabsLayout() {
return (
<Tabs screenOptions={{ tabBarActiveTintColor: '#c026d3' }}>
<Tabs.Screen
name="index"
options={{
title: '首页',
tabBarIcon: ({ color }) => <Ionicons name="home" size={24} color={color} />
}}
/>
<Tabs.Screen
name="profile"
options={{
title: '我的',
tabBarIcon: ({ color }) => <Ionicons name="person" size={24} color={color} />
}}
/>
</Tabs>
)
}
|
(tabs) 目录用括号包裹,表示路由分组——分组不会出现在 URL 路径中,仅用于组织共享同一布局的页面。目录结构:
目录结构
app/
├── (tabs)/
│ ├── _layout.tsx # Tabs 布局
│ ├── index.tsx # / 首页 Tab
│ └── profile.tsx # /profile 我的 Tab
└── _layout.tsx # 根布局(Stack)
💡 图标库选择:Expo 内置 @expo/vector-icons,无需额外安装。支持 Ionicons、FontAwesome、MaterialIcons 等多套图标族,Tab 图标在 iOS 和 Android 上统一渲染。
四、Drawer 导航:侧边栏
Drawer 提供从屏幕边缘滑出的侧边栏菜单,适合放置导航链接、用户信息、设置入口。使用前需安装三个依赖(@react-navigation/drawer、react-native-reanimated、react-native-gesture-handler):
Terminal
npx expo install @react-navigation/drawer react-native-reanimated react-native-gesture-handler
安装后在 _layout.tsx 中用 Drawer() 创建布局,并通过 drawerContent 自定义侧边栏内容:
app/_layout.tsx
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 |
import { Drawer } from 'expo-router'
import { Text, View } from 'react-native'
function CustomDrawer(props) {
return (
<View style={{ flex: 1, padding: 20 }}>
<Text>用户名</Text>
</View>
)
}
export default function Layout() {
return <Drawer drawerContent={() => <CustomDrawer />}>
<Drawer.Screen name="(tabs)" />
</Drawer>
}
|
注意第 13 行:name="(tabs)" 将整个 Tabs 布局作为 Drawer 的一个 Screen 嵌入——这就是嵌套导航的文件路由写法。
五、嵌套导航:组合使用三种模式
真实 App 的导航结构通常是 Drawer → Tabs → Stack 三层嵌套。Expo Router 通过目录结构自动推断嵌套关系,三层是实际项目的上限——再多会导致导航栈过深、性能下降。
三层嵌套目录结构
app/
├── _layout.tsx # 第1层:Drawer
├── (tabs)/
│ ├── _layout.tsx # 第2层:Tabs
│ ├── index.tsx # 首页 Tab
│ └── feed/
│ ├── index.tsx # /feed 列表
│ ├── [id].tsx # /feed/:id 详情(Stack 自动嵌套)
│ └── _layout.tsx # 第3层:Stack
└── settings/
└── index.tsx # /settings(Drawer 直达)
[id].tsx 是动态路由——文件名用方括号包裹参数名,匹配 /feed/42、/feed/100 等路径,参数通过 useLocalSearchParams() 读取。
💡 嵌套上限:三层(Drawer → Tabs → Stack)是实际项目的舒适上限。超过三层会导致导航栈过深,用户返回路径过长、内存占用增大。如果确实需要更深层级,考虑扁平化部分路由到 Drawer 直达。
六、Zustand 移动端状态管理
好消息:你在第 25 篇学的 Zustand v5 在 React Native 中完全一样——同一套 API、同一个 create 函数、同样的 selector 优化。唯一区别是持久化存储介质从 localStorage 换成 AsyncStorage。
store/useAuth.ts
|
1
2
3
4
5
6
7
8
9
10
11
12
13 |
import { create } from 'zustand'
interface AuthState {
token: string | null
login: (token: string) => void
logout: () => void
}
export const useAuth = create<AuthState>((set) => ({
token: null,
login: (token) => set({ token }),
logout: () => set({ token: null }),
}))
|
组件中使用方式与 Web 完全相同——用 selector 取所需状态,避免不必要的重渲染:
app/(tabs)/profile.tsx
|
1
2
3
4
5
6
7
8
9 |
import { useAuth } from '@/store/useAuth'
import { Text, Pressable } from 'react-native'
export default function Profile() {
const token = useAuth(s => s.token)
const logout = useAuth(s => s.logout)
return token ? <Pressable onPress={logout}><Text>退出</Text></Pressable> : <Text>未登录</Text>
}
|
💡 Web → RN 迁移要点:Zustand store 文件可以直接从 Web 项目复制到 RN 项目,零改动。唯一需要改的是 persist 的 storage 适配器(见第八节)。路径别名 @/ 需要在 tsconfig.json 中配置 paths 映射。
七、AsyncStorage 本地持久化
AsyncStorage v3.1.1 是 React Native 的本地键值存储。它替代 Web 端的 localStorage,数据持久化在设备存储中,App 重启后仍然存在。
⚠️ v3 破坏性变更:v3 引入"Scoped Storage"概念,批量方法 multiGet/multiSet/multiRemove 更名为 getMany/setMany/removeMany,mergeItem/multiMerge 被移除。但默认导出的 singleton 仍兼容 v2 API。
store/storage.ts
|
1
2
3
4
5
6
7
8
9
10
11
12
13 |
import AsyncStorage from '@react-native-async-storage/async-storage'
// 读取
const value = await AsyncStorage.getItem('@theme')
// 写入
await AsyncStorage.setItem('@theme', 'dark')
// 删除
await AsyncStorage.delItem('@theme')
// v3 新增:创建独立存储实例(scoped storage)
const store = createAsyncStorage({ scope: 'user' })
|
注意所有操作都是 async/await——AsyncStorage 是异步的,不像 Web 的 localStorage 同步阻塞。这是移动端存储的固有特征,因为底层文件 I/O 无法在主线程同步执行。
💡 存储方案选择:AsyncStorage 适合小量数据(token、主题偏好、用户设置)。如果需要存储大量结构化数据,考虑 expo-sqlite(SQL 数据库)或 react-native-mmkv(比 AsyncStorage 快 20-30 倍,但需额外原生编译)。学习阶段用 AsyncStorage 足够。
八、Zustand persist + AsyncStorage 联动
Zustand 内置 persist 中间件,可自动将 store 状态序列化到存储介质。在 Web 端用 localStorage,在 RN 端换成 AsyncStorage,只改一行配置:
store/useAuth.ts
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 |
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import AsyncStorage from '@react-native-async-storage/async-storage'
export const useAuth = create<AuthState>()(
persist(
(set) => ({
token: null,
login: (token) => set({ token }),
logout: () => set({ token: null }),
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => AsyncStorage),
}
)
)
|
第 14 行是关键:createJSONStorage(() => AsyncStorage) 将 AsyncStorage 的异步 API 适配为 persist 中间件所需的 StateStorage 接口。之后每次 set() 调用都会自动把新状态序列化为 JSON 存入 AsyncStorage,App 重启后自动恢复。
💡 Hydration 处理:persist 是异步恢复的——App 启动时 store 先用初始值(token: null),AsyncStorage 读取完成后才更新为持久化的值。如果组件在 hydration 完成前渲染了"未登录"界面,之后又跳到"已登录",会产生闪烁。解决方法:用 useAuth.persist.hasHydrated() 判断是否恢复完成,未完成时显示加载动画。
九、手势交互基础
移动端交互的核心是手势——点击、长按、拖拽、滑动。react-native-gesture-handler 提供原生驱动的手势识别,性能远优于 JS 轮询的事件监听。安装 Drawer 时已一并安装。
app/components/SwipeCard.tsx
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 |
import GestureDetector, { Gesture } from 'react-native-gesture-handler'
import { Animated, useSharedValue } from 'react-native-reanimated'
export default function SwipeCard() {
const translateX = useSharedValue(0)
const pan = Gesture.Pan()
.onUpdate((e) => {
translateX.value = e.translationX
})
.onEnd(() => {
translateX.value = 0 // 回弹
})
return (
<GestureDetector gesture={pan}>
<Animated.View style={{ transform: [{ translateX: translateX }] }}>
{/* 卡片内容 */}
</Animated.View>
</GestureDetector>
)
}
|
这段代码实现了一个可左右拖拽并自动回弹的卡片。核心概念:
手势 API 关键概念
| 概念 |
说明 |
| Gesture.Pan() |
创建拖拽手势识别器,还有 Tap/LongPress/Pinch 等 |
| useSharedValue |
Reanimated 共享变量,UI 线程直接读写,无 JS 桥接开销 |
| Animated.View |
支持共享变量驱动 transform 的动画组件 |
💡 原生线程 vs JS 线程:gesture-handler 的手势识别在原生线程执行,Reanimated 的动画也在原生线程驱动——整条链路不经过 JS 桥接,因此 60fps 流畅。这是 RN 新架构 JSI 的核心优势:手势和动画不再受 JS 线程阻塞影响。
常见错误
⚠️ Drawer 闪退 / 白屏
原因:react-native-gesture-handler 未在根布局文件顶部 import。修复:在 app/_layout.tsx 第 1 行添加 import 'react-native-gesture-handler'。
⚠️ AsyncStorage 数据不持久
原因:persist 的 name 属性为空或重复。每个 persist store 的 name 必须全局唯一,否则数据会互相覆盖。修复:确保 name: 'auth-storage'、name: 'settings-storage' 等不重复。
⚠️ Hydration 闪烁:先显示"未登录"再跳"已登录"
原因:persist 异步恢复,组件在 hydration 完成前已渲染。修复:用 useAuth.persist.hasHydrated() 做条件渲染,未完成时显示 ActivityIndicator。
⚠️ 嵌套导航超过三层导致性能卡顿
原因:Drawer → Tabs → Stack → Stack 四层嵌套,导航栈过深。修复:限制在三层以内,将深层页面提升到 Drawer 直达或 Tab 内部用 modal 替代 Stack。
动手练习
🟢 基础:Tabs + Stack 双层导航
创建一个包含 3 个 Tab(首页、列表、我的)的应用,在列表 Tab 内嵌套 Stack,从列表页跳转到详情页(/list/[id])。要求:
- Tabs 底部栏有图标和标题
- 详情页用
useLocalSearchParams 读取 id 参数并显示
- 详情页 header 标题为"详情 #ID"
🟡 进阶:用户认证 + 持久化登录
用 Zustand + persist + AsyncStorage 实现登录状态持久化。要求:
- Store 包含 token、login、logout
- token 持久化到 AsyncStorage(name: 'auth-storage')
- App 启动时用
hasHydrated() 判断恢复状态,未恢复显示加载动画
- 未登录时显示"登录"按钮,已登录显示"退出"按钮
🔴 挑战:三层嵌套 + 滑动手势
构建完整的 Drawer → Tabs → Stack 三层嵌套应用。要求:
- Drawer 侧边栏自定义内容(用户头像 + 导航链接)
- Tabs 包含首页和列表两个 Tab
- 列表 Tab 内有
[id].tsx 动态路由详情页
- 在列表页实现 SwipeCard 组件:左右拖拽卡片并自动回弹
- 用户登录状态持久化,Drawer 显示用户名
🏷️ 知识回顾
Expo Router v7
Stack 导航
Tabs 底部栏
Drawer 侧边栏
嵌套导航
动态路由 [id]
路由分组 (tabs)
Zustand v5
AsyncStorage v3
persist 中间件
Hydration 处理
Gesture.Pan
Reanimated
📖 下一篇预告
下一篇《Flutter 跨平台开发:Dart 语言与 Widget 体系》将切换到全栈路线图的另一套 App 框架——Flutter。从 Dart 语言语法入门,学习 Widget 声明式 UI、Material 与 Cupertino 组件库,理解 Flutter 的渲染管线与 React Native 的本质差异。如果你在 RN 中习惯了 JS/TS,Dart 的类型系统会让你感到既熟悉又不同。