📚 全栈开发学习系列

✅ 阶段一 编程基础(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])。要求:

🟡 进阶:用户认证 + 持久化登录

用 Zustand + persist + AsyncStorage 实现登录状态持久化。要求:

🔴 挑战:三层嵌套 + 滑动手势

构建完整的 Drawer → Tabs → Stack 三层嵌套应用。要求:

🏷️ 知识回顾

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 的类型系统会让你感到既熟悉又不同。