Chapter 05

网络与数据持久化

Axios 请求拦截、React Query 缓存策略、MMKV 高性能本地存储,以及离线优先的设计思路。

RN 网络请求与 Web 的差异

React Native 内置了 fetch API(与浏览器 fetch 相同),也支持 Axios。与 Web 开发最大的不同是:没有 CORS 限制。原生 App 发出的网络请求不受同源策略约束,这既是方便之处,也意味着你需要在服务端做好权限验证(JWT、API Key 等)。

另一个差异是证书验证。RN 会严格验证 HTTPS 证书,自签名证书需要额外配置才能在真机上使用。开发环境推荐用 mkcert 生成受信任的本地证书,或通过 ngrok 暴露本地服务。

还有网络状态问题:手机用户经常在 WiFi 和移动网络之间切换,甚至进入无信号区域。一个好的移动应用必须优雅处理网络中断场景,这是 Web 开发较少面对的挑战。

核心名词解释

fetch
RN 内置的网络请求 API,与浏览器 fetch 兼容。基于 Promise,支持 async/await。适合简单请求,但缺少请求拦截器,复杂项目推荐 Axios。注意:RN 的 fetch 不支持 FormData 的进度事件,上传大文件需要用 XMLHttpRequest。
Axios
功能丰富的 HTTP 客户端库,支持请求/响应拦截器(统一添加 Token、处理 401 刷新)、自动 JSON 转换、超时设置、取消请求(AbortController)。配合 React Query 是生产项目的标准组合。
React Query(TanStack Query)
服务端状态管理库,提供智能缓存(避免重复请求)、后台刷新(Stale-While-Revalidate)、乐观更新、请求去重、分页查询等功能。核心哲学:服务端数据不应该放在 Redux/Zustand 中,而应由 React Query 专门管理。
AsyncStorage
React Native 官方异步键值存储,基于文件系统,存取操作均为异步(需要 await)。存储格式为 JSON 字符串,读写大量数据时解析/序列化开销大。适合存储用户偏好、轻量配置,不适合高频读写。
MMKV
微信团队开源的高性能键值存储,基于内存映射文件(mmap)实现——操作系统将文件直接映射到内存地址空间,读写直接操作内存,省去了 I/O 系统调用。速度比 AsyncStorage 快 30 倍,且支持同步操作。
离线优先(Offline First)
设计模式:先展示本地缓存数据,后台尝试更新;写入操作在网络断开时排队(持久化到本地),恢复后自动同步。让应用在无网络时依然可用,这是移动应用与 Web 应用的重要体验差异。
Optimistic Update(乐观更新)
操作后立即更新 UI(假设成功),同时发送网络请求。若请求失败则回滚 UI 并提示用户。点赞、关注、表单提交等操作的标准实现方式,让用户感受到即时响应。
Stale-While-Revalidate
React Query 的默认缓存策略:数据超过 staleTime 后标记为"陈旧",下次读取时立即返回陈旧缓存(让用户不用等待),同时在后台重新请求最新数据,拿到新数据后更新 UI。

请求链路架构

完整请求链路(组件 → React Query → Axios → API) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ UI 组件 │ │ useQuery({ queryKey: ['posts'], queryFn: ... }) ▼ React Query Cache(内存中的服务端状态快照) │ Cache Hit + 未过期(< staleTime) ──▶ 直接返回缓存 │ Cache Hit + 已过期(> staleTime) ──▶ 返回缓存 + 后台刷新 │ Cache Miss(首次请求) ▼ QueryFn(你定义的 API 调用函数) │ api.getPosts() ▼ Axios Instance │ 请求拦截器:自动附加 Authorization: Bearer {token} │ 请求拦截器:添加 Accept-Language、设备信息 Header ▼ API Server │ 响应 200: 返回 data │ 响应 401: 触发 Token 刷新逻辑并重试 │ 响应 5xx: 触发 retry(最多 2 次,指数退避) ▼ Axios 响应拦截器 │ 统一解包 response.data │ 统一处理错误(Toast 提示) ▼ React Query Cache 更新 │ 通知所有订阅该 queryKey 的组件重渲染 ▼ UI 组件收到最新数据,重渲染

Axios 封装:拦截器与 Token 自动刷新

// lib/api.ts — Axios 实例封装(生产级)
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { useAuthStore } from '../store/authStore';
import { MMKV } from 'react-native-mmkv';

// MMKV 实例:同步读写 Token,无需 await
export const storage = new MMKV();

// 创建 Axios 实例,配置基础 URL 和超时
export const api = axios.create({
  baseURL: 'https://api.myapp.com/v1',
  timeout: 10000,     // 10秒超时(移动网络不稳定,不宜太短)
  headers: { 'Content-Type': 'application/json' },
});

// 请求拦截器:每次请求自动附加最新 Token
// 原理:在请求发出前执行,从 Zustand store 中读取 token
// 使用 getState() 而非 hook,因为拦截器在 React 组件外执行
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
  const token = useAuthStore.getState().token;
  if (token) {
    config.headers.Authorization = `Bearer ${token}`; // 自动附加
  }
  return config;
});

// 标记位:防止 Token 刷新时并发多个刷新请求
// 问题场景:多个 API 同时收到 401,每个都尝试刷新 Token
// 只有第一个请求真正执行刷新,其他等待结果
let isRefreshing = false;
let failedQueue: Array<{ resolve: (token: string) => void; reject: (err: any) => void }> = [];

const processQueue = (error: any, token: string | null = null) => {
  failedQueue.forEach(({ resolve, reject }) => {
    error ? reject(error) : resolve(token!);
  });
  failedQueue = [];
};

// 响应拦截器:统一错误处理 + Token 刷新
api.interceptors.response.use(
  response => response.data, // 成功:自动解包 data
  async (error: AxiosError) => {
    const originalRequest = error.config as any;
    const status = error.response?.status;

    if (status === 401 && !originalRequest._retry) {
      originalRequest._retry = true; // 标记:这个请求已尝试刷新

      if (isRefreshing) {
        // 已有刷新请求在进行中,将此请求加入等待队列
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        }).then(token => {
          originalRequest.headers.Authorization = `Bearer ${token}`;
          return api(originalRequest);
        });
      }

      isRefreshing = true;
      try {
        const refreshToken = storage.getString('refreshToken');
        const { token } = await api.post('/auth/refresh', { refreshToken });
        useAuthStore.getState().setToken(token);
        processQueue(null, token); // 通知所有等待的请求
        originalRequest.headers.Authorization = `Bearer ${token}`;
        return api(originalRequest); // 重试原始请求
      } catch (refreshError) {
        processQueue(refreshError, null); // 通知等待队列刷新失败
        useAuthStore.getState().logout(); // 登出:刷新 Token 也过期了
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);
Token 存储安全 不要将 Access Token 存储在 AsyncStorage 或 MMKV 的普通存储中——这些存储没有加密,在 rooted/jailbroken 设备上可以直接读取。推荐使用 expo-secure-store(iOS 使用 Keychain,Android 使用 Keystore)存储敏感凭证,或将 Token 保存时间缩短为几分钟,用 Refresh Token 不断更新。

React Query 完整配置与高级用法

// App.tsx — QueryClient 配置
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,    // 数据 5 分钟内不重新请求(避免频繁网络请求)
      gcTime: 10 * 60 * 1000,      // 非活跃缓存 10 分钟后垃圾回收(原 cacheTime)
      retry: 2,                       // 请求失败重试 2 次(自动指数退避)
      refetchOnWindowFocus: false,   // App 从后台切回时不自动刷新(移动端默认禁用)
      refetchOnReconnect: true,      // 网络恢复时自动刷新(移动端必须!)
    },
  },
});

// hooks/usePosts.ts — 数据查询与变更
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';

// 基础查询
export function usePost(postId: string) {
  return useQuery({
    queryKey: ['post', postId],              // queryKey 是缓存的唯一标识
    queryFn: () => api.get(`/posts/${postId}`),
    enabled: !!postId,                          // postId 为空时不发请求
  });
}

// 无限分页查询(下拉加载更多)
export function useInfinitePosts() {
  return useInfiniteQuery({
    queryKey: ['posts', 'infinite'],
    queryFn: ({ pageParam }) =>
      api.get(`/posts?cursor=${pageParam ?? ''}&limit=20`),
    initialPageParam: undefined,
    getNextPageParam: (lastPage) =>   // 从响应中提取下一页游标
      lastPage.hasMore ? lastPage.nextCursor : undefined,
  });
}

// 乐观更新:点赞功能
export function useLikePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (postId: string) => api.post(`/posts/${postId}/like`),

    // onMutate:在网络请求发出前立即执行(乐观更新的核心)
    onMutate: async (postId) => {
      // 取消正在进行的相关查询,避免乐观更新被覆盖
      await queryClient.cancelQueries({ queryKey: ['post', postId] });
      // 保存当前数据快照(用于回滚)
      const previousPost = queryClient.getQueryData(['post', postId]);
      // 乐观更新:直接修改缓存中的数据
      queryClient.setQueryData(['post', postId], (old: any) => ({
        ...old,
        likes: old.likes + 1,
        isLiked: true,
      }));
      return { previousPost }; // 返回快照,供 onError 使用
    },

    // onError:请求失败时回滚乐观更新
    onError: (err, postId, context) => {
      if (context?.previousPost) {
        queryClient.setQueryData(['post', postId], context.previousPost);
      }
    },

    // onSettled:无论成功失败,都从服务器获取最新数据(确保一致性)
    onSettled: (data, error, postId) => {
      queryClient.invalidateQueries({ queryKey: ['post', postId] });
    },
  });
}

AsyncStorage vs MMKV 深度对比

AsyncStorage 是 RN 官方的本地存储方案,但它有明显的性能缺陷。MMKV 是目前社区最推荐的替代品,以下是两者的详细对比:

// AsyncStorage — 异步,基于 SQLite 或文件,慢
import AsyncStorage from '@react-native-async-storage/async-storage';

// 每次操作都是异步,需要 await,有 I/O 延迟
await AsyncStorage.setItem('theme', 'dark');
const theme = await AsyncStorage.getItem('theme'); // null | string
// 批量操作(比单次操作高效)
await AsyncStorage.multiSet([['key1', 'val1'], ['key2', 'val2']]);

// MMKV — 同步,基于 mmap,快 30x
// 安装:npx expo install react-native-mmkv
// 注意:需要 Development Build,不支持 Expo Go
import { MMKV } from 'react-native-mmkv';

const storage = new MMKV();                 // 默认实例
const userStorage = new MMKV({ id: 'user' }); // 命名实例(独立命名空间)
const encStorage = new MMKV({              // 加密存储
  id: 'secure',
  encryptionKey: 'my-secret-key',          // 使用 AES 加密
});

// 同步操作,无需 await!(直接从内存映射读取)
storage.set('theme', 'dark');
const theme = storage.getString('theme');  // string | undefined
storage.set('count', 42);
const count = storage.getNumber('count');  // 支持存储 number
storage.set('isVip', true);
const isVip = storage.getBoolean('isVip'); // 支持存储 boolean

// 与 Zustand 集成:持久化全局状态
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

const mmkvStorage = {
  getItem: (name: string) => storage.getString(name) ?? null,
  setItem: (name: string, value: string) => storage.set(name, value),
  removeItem: (name: string) => storage.delete(name),
};

export const useSettingsStore = create()(
  persist(
    (set) => ({ theme: 'dark' as string, setTheme: (t: string) => set({ theme: t }) }),
    { name: 'settings', storage: createJSONStorage(() => mmkvStorage) }
  )
);
MMKV 在 Expo Go 中不工作 react-native-mmkv 包含 JSI 原生代码,无法在 Expo Go 中运行。使用前需要先创建 Development Build(npx expo run:ioseas build --profile development)。如果你的项目必须支持 Expo Go,只能使用 AsyncStorage。

离线优先:网络状态监听与请求队列

// hooks/useNetworkMonitor.ts
// 监听网络状态,在断网时禁用写操作,恢复后自动刷新
import NetInfo from '@react-native-community/netinfo';
import { useEffect } from 'react';
import { onlineManager } from '@tanstack/react-query';

// React Query 的 onlineManager 与 NetInfo 集成
// 网络恢复时,所有标记为 networkMode: 'online' 的查询会自动重试
export function useNetworkSetup() {
  useEffect(() => {
    return NetInfo.addEventListener(state => {
      // 通知 React Query 当前网络状态
      onlineManager.setOnline(
        state.isConnected != null && state.isConnected
      );
    });
  }, []);
}

// 在 App.tsx 顶层使用一次即可
export default function App() {
  useNetworkSetup(); // 全局网络状态同步
  return <QueryClientProvider client={queryClient}>{/* ... */}</QueryClientProvider>;
}
避免的反模式 不要在 useEffect 里直接写 fetch 请求——这种方式没有缓存、没有错误重试、没有加载状态管理,是 React 生态中出了名的"初学者陷阱"。所有服务端数据获取都应该通过 React Query 的 useQuery 进行,服务端数据变更通过 useMutation 进行。
本章小结 React Native 网络与存储层的最佳实践:① 用 Axios 实例封装 Token 自动刷新逻辑(避免每个请求都写 401 处理);② 用 React Query 管理所有服务端状态(不要放到 Zustand);③ 用 MMKV 替代 AsyncStorage(同步、快 30 倍,支持加密);④ 用 onlineManager + NetInfo 让 React Query 自动响应网络状态;⑤ 乐观更新 + 失败回滚是高频交互操作的标准实现方式。