网络请求详解
前后端通信是前端开发的日常。从最早的 XMLHttpRequest 到现代 fetch,再到工程化的 axios,本文系统梳理三者的用法、差异与取舍,并讲透跨域与 JSONP 这两个绕不开的话题。
一、XMLHttpRequest:老牌请求对象
XHR 是浏览器最早的异步请求 API,流程固定为"创建 → 配置 → 监听 → 发送":
const xhr = new XMLHttpRequest();
// 1. 配置:GET 请求 /api/users
xhr.open("GET", "/api/users");
// 2. 监听状态变化
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) { // 4 = 请求完成
if (xhr.status >= 200 && xhr.status < 300) {
console.log("成功:", xhr.responseText);
} else {
console.error("失败,状态码:", xhr.status);
}
}
};
// 3. 发送
xhr.send();1.1 readyState 状态码
| readyState | 含义 |
|---|---|
| 0 | 未初始化(open 未调用) |
| 1 | 已建立连接(open 已调用) |
| 2 | 已收到响应头 |
| 3 | 下载中(部分响应体) |
| 4 | 请求完成 |
1.2 配置与错误处理
function request(url, options = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(options.method || "GET", url);
// 设置请求头
if (options.headers) {
Object.entries(options.headers).forEach(([key, value]) => {
xhr.setRequestHeader(key, value);
});
}
// 响应类型
xhr.responseType = options.responseType || ""; // text/json/blob/arraybuffer
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response);
} else {
reject(new Error(`HTTP ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error("网络错误"));
xhr.ontimeout = () => reject(new Error("请求超时"));
xhr.timeout = options.timeout || 0; // 超时毫秒数
xhr.send(options.body);
});
}
request("/api/users", { responseType: "json" })
.then((data) => console.log(data))
.catch((error) => console.error(error));| 成员 | 作用 |
|---|---|
open(method, url) | 配置请求 |
setRequestHeader | 设置请求头 |
responseType | 响应类型(json、blob 等) |
timeout | 超时时间 |
onload / onerror / ontimeout | 成功/失败/超时回调 |
upload.onprogress | 上传进度监听 |
XHR 缺点明显:API 冗长、回调嵌套、基于事件模型,因此有了更现代的 fetch。
二、Fetch API
fetch 基于 Promise,语法简洁,几乎取代了 XHR 的日常使用:
// 基本 GET
const response = await fetch("/api/users");
// 请求配置
const response = await fetch("/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer token123",
},
body: JSON.stringify({ username: "zhangsan", password: "123456" }),
});2.1 响应处理
const response = await fetch("/api/users");
response.ok; // 布尔,2xx 为 true
response.status; // 数字状态码
response.statusText; // 状态文本
const text = await response.text(); // 文本
const json = await response.json(); // JSON
const blob = await response.blob(); // 二进制
const formData = await response.formData(); // 表单数据
const arrayBuffer = await response.arrayBuffer(); // ArrayBuffer| 方法 | 用途 |
|---|---|
response.json() | 解析 JSON 响应体 |
response.text() | 解析文本 |
response.blob() | 解析二进制 |
response.headers.get("x-total") | 读取响应头 |
注意:响应体只能读一次——json() 调用后不能再用 text() 读同一份响应,需要时先 clone()。
2.2 错误处理:fetch 的"陷阱"
fetch 只在网络层失败时 reject(断网、DNS 失败),HTTP 错误状态码(404、500)不会 reject:
const response = await fetch("/api/nonexistent");
try {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`); // 必须手动抛出
}
const data = await response.json();
console.log(data);
} catch (error) {
console.error("请求失败:", error.message);
}| 情况 | fetch 行为 |
|---|---|
| 网络断开 | reject,抛 TypeError: Failed to fetch |
| HTTP 404 / 500 | resolve,response.ok 为 false |
| JSON 解析失败 | json() 时 reject |
2.3 取消请求:AbortController
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000); // 3 秒超时
try {
const response = await fetch("/api/slow", { signal: controller.signal });
console.log(await response.json());
} catch (error) {
if (error.name === "AbortError") {
console.log("请求已被取消");
} else {
console.error(error);
}
} finally {
clearTimeout(timer);
}AbortController 还能同时取消多个请求(共用同一个 signal),适合"离开页面时清理全部请求"。
三、fetch 与 XHR 对比
| 对比项 | fetch | XMLHttpRequest |
|---|---|---|
| 编程模型 | Promise / async-await | 事件回调 |
| 代码简洁度 | 简洁 | 冗长 |
| HTTP 错误处理 | 不自动 reject,需手动判断 | 同样需手动判断状态码 |
| 请求取消 | AbortController | xhr.abort() |
| 上传进度 | 原生不支持,需流式 API | upload.onprogress |
| 超时控制 | 需 AbortController 模拟 | xhr.timeout |
| 兼容性 | 现代浏览器 | 老版本兼容好 |
需要上传进度条等场景,XHR 仍有用武之地;日常请求优先 fetch。
四、axios:工程化请求库
axios 是基于 XHR/Promise 封装的第三方库,靠"拦截器"和"实例"解决重复配置问题。
4.1 基本用法与实例
// 创建实例:统一 baseURL 与超时
const api = axios.create({
baseURL: "https://api.example.com",
timeout: 10000,
headers: { "Content-Type": "application/json" },
});
// 实例上发起请求
const { data } = await api.get("/users", { params: { page: 1 } });
const res = await api.post("/login", { username: "zhangsan" });4.2 拦截器:统一处理请求与响应
// 请求拦截:统一带 token
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// 响应拦截:统一解包与错误提示
api.interceptors.response.use(
(response) => response.data, // 直接拿到业务数据
(error) => {
if (error.response?.status === 401) {
console.log("登录已过期,请重新登录");
}
return Promise.reject(error);
}
);| 拦截器 | 时机 | 典型用途 |
|---|---|---|
| 请求拦截 | 发出前 | 加 token、加公共参数 |
| 响应拦截 | 返回后 | 解包、统一错误处理、刷新 token |
| 响应拦截(错误分支) | 失败时 | 401 跳登录、错误提示 |
4.3 请求/响应转换
const { data } = await api.get("/users");
// 请求转换:默认自动 JSON 序列化对象 body
api.post("/users", { name: "张三" }); // 自动 JSON.stringify
// 响应转换:默认尝试 JSON.parse| 能力 | axios | fetch |
|---|---|---|
| 拦截器 | 内置 | 需手动封装 |
| 统一 baseURL | 实例配置 | 手动拼接 |
| 自动 JSON | 自动 | 手动 |
| 上传进度 | onUploadProgress | 需流式 |
| 取消 | AbortController / CancelToken | AbortController |
| 体积 | 约 30KB+ | 原生 0 |
五、JSONP:老式跨域方案
JSONP 利用 <script> 标签不受同源限制的特性,通过"动态插入 script + 回调函数名"实现跨域 GET 请求:
<!-- 服务端返回:callbackName({"name":"张三"}) -->
<script>
function handleData(data) {
console.log("拿到数据:", data.name);
}
</script>
<script src="https://api.example.com/data?callback=handleData"></script>// 动态封装 JSONP
function jsonp(url, callbackName = "callback") {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
const fnName = "jsonp_" + Date.now();
window[fnName] = (data) => {
resolve(data);
delete window[fnName];
script.remove();
};
script.onerror = () => {
reject(new Error("JSONP 请求失败"));
delete window[fnName];
script.remove();
};
script.src = `${url}${url.includes("?") ? "&" : "?"}${callbackName}=${fnName}`;
document.body.appendChild(script);
});
}
jsonp("https://api.example.com/data").then((data) => console.log(data));| 对比项 | JSONP | CORS |
|---|---|---|
| 原理 | <script> 标签不受同源限制 | 服务端显式声明允许 |
| 请求方法 | 仅 GET | 任意方法 |
| 依赖 | 服务端配合包一层回调 | 服务端配置响应头 |
| 错误处理 | 弱(无状态码) | 完善 |
| 现状 | 历史遗留方案 | 现代标准做法 |
JSONP 局限明显:只支持 GET、依赖服务端配合、存在安全风险(脚本来源不可控),新项目一律用 CORS。
六、跨域问题与 CORS
6.1 同源策略
同源指"协议 + 域名 + 端口"三者一致。页面发起的跨源请求默认被浏览器拦截(注意:是响应被拦截,请求可能已发出):
// 页面位于 https://a.com,以下都属于跨源
fetch("https://b.com/api"); // 域名不同
fetch("http://a.com/api"); // 协议不同
fetch("https://a.com:8080/api"); // 端口不同6.2 CORS 与简单请求
CORS(跨源资源共享)是标准解法:服务端在响应中声明允许哪些来源访问,浏览器据此放行:
// 响应头(服务端设置)
Access-Control-Allow-Origin: https://a.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true // 允许携带 cookie简单请求(条件同时满足)不发预检,直接请求:
- 方法为
GET、HEAD、POST - 自定义头只有
Accept、Content-Type(且仅限text/plain、multipart/form-data、application/x-www-form-urlencoded)等 - 不使用
ReadableStream
6.3 预检请求(Preflight)
带自定义头、非简单方法(PUT/DELETE)、或 Content-Type: application/json 时,浏览器先发一个 OPTIONS 预检:
// 预检请求
OPTIONS /api/users
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
// 服务端回应
Access-Control-Allow-Origin: https://a.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: content-type| 对比项 | 简单请求 | 复杂请求 |
|---|---|---|
| 是否预检 | 否 | 是(先 OPTIONS) |
| 触发条件 | 满足简单请求条件 | 自定义头、非简单方法、application/json |
| 失败表现 | 响应被拦截 | 预检失败直接报错 |
// 常见报错与排查
fetch("https://b.com/api") // CORS 错误:No 'Access-Control-Allow-Origin' header排查 CORS 的步骤:先看服务端是否返回了正确的 Access-Control-Allow-* 头;开发环境用代理服务器转发,避免被预检问题卡住。
七、防抖与节流在网络请求中的应用
高频触发请求(搜索联想、滚动加载)需要频率控制,防抖与节流的具体实现见定时器专题,这里看典型应用:
function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 搜索联想:停止输入 300ms 后才请求
const searchInput = document.querySelector("#search");
const doSearch = debounce(async () => {
const { data } = await api.get("/search", { params: { q: searchInput.value } });
renderSuggestions(data);
}, 300);
searchInput.addEventListener("input", doSearch);function throttle(fn, interval = 500) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn.apply(this, args);
}
};
}
// 滚动加载更多:最多每 500ms 触发一次
const loadMore = throttle(async () => {
page++;
const { data } = await api.get("/list", { params: { page } });
appendItems(data);
}, 500);
window.addEventListener("scroll", loadMore);| 场景 | 手段 | 目的 |
|---|---|---|
| 搜索联想 | 防抖 300ms | 减少无效请求 |
| 滚动分页 | 节流 500ms | 控制请求频率 |
| 按钮提交 | 提交后禁用按钮 | 防止重复提交 |
另外,"响应乱序"问题(先发的请求后返回)可以用请求序号或 AbortController 取消旧请求:只采纳最新一次的结果。
八、综合示例:封装一个带超时与取消的请求模块
// 统一封装:超时、取消、错误标准化
async function request(url, options = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), options.timeout || 10000);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: { "Content-Type": "application/json", ...options.headers },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
if (error.name === "AbortError") {
throw new Error("请求超时或已取消");
}
throw error;
} finally {
clearTimeout(timer);
}
}
// 用法
try {
const users = await request("/api/users", { method: "GET" });
console.log(users);
} catch (error) {
console.error(error.message);
}网络请求的完整知识链路:XHR 理解历史与上传场景,fetch 作为日常首选,axios 负责工程化(拦截器、实例、统一错误);跨域用 CORS 标准方案,JSONP 仅作历史认知;高频场景配合防抖节流与取消机制,请求代码就能稳定高效。