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:ios 或 eas 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 自动响应网络状态;⑤ 乐观更新 + 失败回滚是高频交互操作的标准实现方式。