网络请求
这一课你将能做到
- 用 fetch 和 axios 完成 GET / POST 请求,并说清楚 fetch 不抛 HTTP 错误这个坑
- 封装统一请求层:baseURL、超时、token 注入、响应剥壳、401 处理
- 实现标准的 loading / error / 空数据三态界面,完成图片上传和请求取消
为什么要学
没有网络的 App 只是本地玩具。登录、列表、下单、上传头像,全是网络请求。而且「你们项目怎么封装 axios」是面试必问题——这一课就是标准答案。裸写 fetch 也能跑,但项目里这么干,token 注入、错误处理、loading 管理会在几十个页面里复制粘贴,迟早乱套。
人话:每个页面裸写 fetch,就像每家每户自己去河边打水——迟早乱套。封装统一请求层,相当于给小区通自来水:家家拧开龙头就有水(自动带 token),停水了统一通知(统一错误处理),水表统一计费(统一日志)。
先建立图像
fetch('https://jsonplaceholder.typicode.com/posts/1')
.then((res) => res.json())
.then((data) => console.log(data.title));
一次请求的三个阶段,对应界面的三种状态——这个「三态」模型会贯穿你所有的列表页:
发起请求 → loading = true → 界面:转圈
请求成功 → setList(data) → 界面:渲染数据(空数组还要显示「暂无数据」)
请求失败 → setError(true) → 界面:错误提示 + 重试按钮
跟着做
步骤 1:fetch 基本用法与它的天坑
// GET
const res = await fetch('https://api.example.com/users');
const data = await res.json();
// POST JSON
const res2 = await fetch('https://api.example.com/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'tom', password: '123456' }),
});
天坑:fetch 遇到 404 / 500 不会抛错。它只在网络彻底断开时才 reject,HTTP 错误状态码照样走 then。所以用 fetch 必须自己判断:
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`); // 不抛就等着把 404 当成功处理
const data = await res.json();
这一行判断就是大家宁愿用 axios 的核心原因之一——axios 对非 2xx 直接 reject。
步骤 2:封装 axios 实例(面试标准答案)
npx expo install axios @react-native-async-storage/async-storage
// utils/request.js
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
const request = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000, // 10 秒超时,弱网兜底
});
// 请求拦截器:每次请求自动注入 token
request.interceptors.request.use(async (config) => {
const token = await AsyncStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// 响应拦截器:统一剥壳 + 统一错误处理
request.interceptors.response.use(
(res) => res.data, // 业务层直接拿到 data,少写一层 .data
(err) => {
if (err.response?.status === 401) {
// token 失效:清登录态、跳登录页(通过全局事件或 NavigationRef 通知外层)
}
return Promise.reject(err);
}
);
export default request;
封装的价值全在拦截器:token 注入、401 跳转、错误提示、数据剥壳,全部收口在这一处,业务代码干干净净。
步骤 3:页面使用 + 三态界面完整代码
import { useEffect, useState } from 'react';
import { View, Text, ActivityIndicator, Pressable, FlatList } from 'react-native';
import request from './utils/request';
export default function UserList() {
const [list, setList] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const load = async () => {
setLoading(true);
setError(false);
try {
const data = await request.get('/users');
setList(data);
} catch (e) {
setError(true);
} finally {
setLoading(false); // 成功失败都要关 loading
}
};
useEffect(() => { load(); }, []);
if (loading) return <ActivityIndicator style={{ flex: 1 }} size="large" />;
if (error) return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>加载失败,请检查网络</Text>
<Pressable onPress={load}>
<Text style={{ color: '#007bff', marginTop: 12 }}>点我重试</Text>
</Pressable>
</View>
);
return (
<FlatList
data={list}
keyExtractor={(item) => String(item.id)}
renderItem={({ item }) => <Text style={{ padding: 16 }}>{item.name}</Text>}
ListEmptyComponent={<Text style={{ textAlign: 'center', marginTop: 40 }}>暂无数据</Text>}
/>
);
}
背下来这个结构,你以后写的每个列表页都是它:load 函数(try/catch/finally)+ 挂载时调用 + 三态渲染。
步骤 4:图片上传 FormData
配合第 9 课的 ImagePicker 选完图后上传。RN 的 FormData 接受一个特殊的文件描述对象:
const uploadAvatar = async (localUri) => {
const formData = new FormData();
formData.append('file', {
uri: localUri, // ImagePicker 返回的 file:// 路径
name: 'avatar.jpg', // 文件名
type: 'image/jpeg', // MIME 类型
});
await request.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
};
三个字段 uri / name / type 一个不能少。注意不要手动拼 boundary,交给 axios 自动生成。
步骤 5:请求取消 AbortController
搜索联想场景:用户连续输入「re」「rea」「reac」,发出三个请求,如果响应乱序返回,旧结果会覆盖新结果。解法是新请求发起时取消上一个:
useEffect(() => {
const controller = new AbortController();
fetch(`https://api.example.com/search?q=${keyword}`, { signal: controller.signal })
.then(r => r.json())
.then(setResult)
.catch(e => { if (e.name !== 'AbortError') setError(true); });
return () => controller.abort(); // keyword 变了或组件卸载,取消旧请求
}, [keyword]);
讲清楚要点
- fetch 不抛 HTTP 错误:只在断网时 reject,404/500 照样 resolve,必须判断
res.ok。axios 非 2xx 直接 reject,项目级一律用 axios。
- 拦截器的价值:token 注入、401 处理、数据剥壳收口一处。判断封装好坏的标准:业务页面里还看不看得到「token」这两个字。
- finally 里关 loading:漏了它,请求失败后界面永远转圈。
- 超时必设:axios 默认无限等待,弱网下用户体验灾难,
timeout: 10000 是底线。
- RN 没有浏览器跨域限制:CORS 是浏览器安全策略,App 直连后端不受限。但 Android 9+ 默认禁明文 http,后端要上 https。
- localhost 在手机上不通:手机的 localhost 是手机自己。调电脑上的后端,用电脑局域网 IP(如
http://192.168.1.5:3000),并保证同一 Wi-Fi。
你可能会懵的地方
-
安卓真机请求 http 接口直接失败?
Android 9+ 默认禁止明文 http。临时方案在 app.json 配 "android": { "usesCleartextTraffic": true }(需重新构建),正经方案是后端上 https——上架时苹果对明文 http 也会卡审。
-
401 拦截器里怎么跳登录页?拦截器拿不到 navigation 啊?
两种常规做法:创建全局 NavigationRef 暴露给非组件环境调用;或拦截器里发一个全局事件(如 eventBus.emit('logout')),App 根部监听后跳转。进阶做法是拦截器里先用 refreshToken 静默换新 token 并重放原请求。
-
token 过期怎么做到用户无感?
双 token 机制:响应拦截器捕获 401 后,用 refreshToken 调刷新接口,成功后重放原请求。注意并发控制——同时来 5 个 401 只能刷新一次,其余请求排队等新 token。
-
axios 和 fetch 到底用哪个?
项目级一律 axios:拦截器、超时、取消、自动 JSON 转换开箱即用。fetch 留给一次性脚本级调用。
-
GET 请求能带 body 吗?
规范上不建议,很多服务器会忽略。参数走 query:request.get('/users', { params: { page: 1 } })。
错法对照
| 错法 | 问题 | 正解 |
|---|
| fetch 只 catch 不判断 res.ok | 404 被当成功处理 | 检查 res.ok 或用 axios |
| 每个页面手写 token 头 | 漏一个就诡异 401 | 请求拦截器统一注入 |
| 成功才 setLoading(false) | 失败时永远转圈 | 放 finally |
| 手机调 localhost | 连接失败 | 用电脑局域网 IP |
| FormData 手动拼 boundary | 请求体损坏 | 只写 multipart/form-data,边界自动生成 |
| 联想搜索不取消旧请求 | 旧响应覆盖新结果 | AbortController 取消上一次 |
| 响应拦截器里忘 reject | 错误被吞,页面以为成功 | 处理完 return Promise.reject(err) |
| 上传文件少写 type 字段 | 服务端解析失败 | uri / name / type 三件套写全 |
卡点急救
- 安卓 http 请求失败:后端上 https;临时方案 app.json 加
usesCleartextTraffic: true 后重新构建(改原生配置必须重新 build)。
- 请求发不出也没报错:确认手机与电脑同网段、防火墙放行后端端口、URL 用的是局域网 IP 而非 localhost。
- 401 拦截后没法跳页面:用全局 NavigationRef 或事件总线通知外层跳转,别试图在拦截器里 import 组件。
- 上传图片服务端收不到:检查三件套字段名是否与服务端约定一致(file / files / image),用抓包工具看实际请求体。
面试常问
-
你们怎么封装 axios?
创建实例统一 baseURL/timeout;请求拦截器注入 token、按需打日志;响应拦截器剥 data、按业务 code 统一报错提示、401 走 refreshToken 静默刷新(并发排队只刷一次)或清登录态跳登录;最后导出语义化方法(getUser、login)而非让业务层裸调 request。
-
token 过期怎么处理?
响应拦截器捕获 401:有 refreshToken 则调刷新接口,成功后用新 token 重放原请求——关键点是并发请求只触发一次刷新(用一个进行中的 Promise 让其余请求排队等待),失败则清空凭证跳登录;没有 refreshToken 直接跳登录。
-
如何做请求取消?
AbortController:创建 controller,把 signal 传给请求(fetch 原生支持,axios 同样接受 signal),在组件卸载或新请求发起时 controller.abort()。取消产生的 AbortError 要在 catch 里甄别,别当成真实错误提示用户。
自测三问
- fetch 在什么情况下会 reject?404 会吗?
- axios 的两个拦截器各自解决什么重复劳动?
- 手机上为什么访问不到电脑的 localhost 接口?安卓访问 http 接口还有什么额外限制?
小结一句话
网络请求的关键不在「发出去」,而在封装:统一实例、拦截器注入 token、三态界面、超时与取消——做好这层,业务代码只剩一行 await。
延伸