真实项目里,你不可能在每个页面都 new 一个 XMLHttpRequest——那样代码重复、改个 token 要改 100 个文件。正确做法是把所有 AJAX 逻辑抽到一个工具文件里,页面只负责"调一下、拿数据、渲染"。
核心思路:BASE_URL 统一配置(开发/线上只改一处)、请求拦截器(每个请求自动带 token)、响应拦截器(统一处理错误码、401 跳登录)。这就是大名鼎鼎的 axios 拦截器的思想,现在学会了以后学 axios 直接秒懂。
不封装的话,每个页面都写一遍 new XHR → open → 监听 → send,代码重复一大片。更要命的是:如果后端要求所有请求都要带一个 token,你得去每个页面加一遍——100 个页面就要改 100 次,改漏一个就出 bug。封装就是把这些"所有请求都要做的事"集中到一个地方,改一处全局生效。
// ===== 不封装:每个页面都写一遍重复代码 =====
// 页面 A:
let xhr1 = new XMLHttpRequest();
xhr1.open("get", "http://127.0.0.1:3000/api/user/list", true);
xhr1.setRequestHeader("token", localStorage.getItem("token"));
// ... 监听、判断状态码、解析 JSON、处理错误...
// 页面 B:
let xhr2 = new XMLHttpRequest();
xhr2.open("post", "http://127.0.0.1:3000/api/user/login", true);
xhr2.setRequestHeader("Content-Type", "...");
xhr2.setRequestHeader("token", localStorage.getItem("token"));
// ... 又写一遍监听、判断、解析、错误...
// 问题:
// 1. 代码重复,每个页面几十行
// 2. BASE_URL 改了要改 100 个地方
// 3. token 加请求头要加 100 个地方
// 4. 错误处理逻辑各页面各写各的,乱七八糟
如果你在项目里每个页面都直接 new XHR,以后后端改了接口地址、加了新要求,你就得逐个文件去改——因为逻辑散落在各个页面,没有集中管理。封装到 utils/request.js,改一处全部生效。
// 1. 单独建一个工具文件 utils/request.js
// 所有 AJAX 底层逻辑都放这里,和页面业务分开
// 2. 全局统一配置 BASE_URL
// 直接写后端接口地址就行
// 3. 请求拦截器(发请求前统一做的事)
// - 自动带 token
// - 自动加公共参数(版本号、设备信息)
// - 打开 loading 提示
// 4. 响应拦截器(收到响应后统一做的事)
// - 200 了看业务码 code
// - 401 登录过期 → 清 token → 跳登录页
// - 404/500/403 统一弹窗提示
// 5. 自动区分 GET/POST 传参位置
// GET 参数拼 URL,POST 参数放 send
// 6. 返回 Promise,页面用 async/await 调
// 页面写法:let data = await request("GET", "/user/list")
// 7. 用 export 导出,页面用 import 导入
// ===== 配置 =====
const BASE_URL = "http://127.0.0.1:3000/api"; // 后端接口地址
// ===== 请求拦截器:发请求前做的事 =====
function requestInterceptor(config) { // 每个请求发出前自动执行这个函数
// 自动带上公共参数
config.data = { appVersion: "1.0.0", ...config.data }; // 自动加版本号等公共参数
// 自动从 localStorage 拿 token,塞到请求头
const token = localStorage.getItem("token") || ""; // 从本地读 token,没有就用空串
config.headers = config.headers || {}; // 如果没请求头对象就建一个空的
config.headers.token = token; // 把 token 塞进请求头,每个请求自动带
return config; // 把改好的配置返回,继续往下走
} // 请求拦截器结束
// ===== 响应拦截器:收到响应后做的事 =====
function responseInterceptor(res, status) { // 每个响应回来后自动执行这个函数
if (status === 200) { // 网络通了,再看业务码
// 网络通了,看业务码
if (res.code === 0) return Promise.resolve(res.data); // 业务成功,直接把数据交出去
if (res.code === 401) { // 登录过期了
localStorage.removeItem("token"); // 清掉本地 token
location.href = "/login.html"; // 跳回登录页
return Promise.reject(res.msg); // 把错误传出去
}
return Promise.reject(res.msg); // 其他业务错误,统一传出去
}
// 网络级错误:404/500/403
let errMsg = status === 404 ? "接口地址不存在" : // 404 = 地址写错了
status === 500 ? "服务器内部错误" : // 500 = 后端代码崩了
status === 403 ? "无访问权限" : // 403 = 没权限
"网络异常(HTTP " + status + ")"; // 其他错误
return Promise.reject(errMsg); // 把统一好的错误信息传出去
} // 响应拦截器结束
// ===== 主函数:外部调这个就行 =====
function request(method, url, data) { // 页面只调这一行,三个参数:方式、路径、数据
return new Promise(function (resolve, reject) { // 返回 Promise,页面就能用 await
let fullUrl = BASE_URL + url; // 拼完整地址:BASE_URL + 接口路径
const xhr = new XMLHttpRequest(); // 叫一个送货小哥
// GET 参数拼 URL
if (method.toUpperCase() === "GET" && data) { // 如果是 GET 且有参数——toUpperCase 是方法要加括号
fullUrl += "?" + new URLSearchParams(data).toString(); // 把参数拼成 key=value 拼到 ? 后面
data = null; // GET 的 data 置空,不放 send 里
}
xhr.open(method, fullUrl, true); // 告诉小哥用什么方式、去哪
if (method.toUpperCase() === "POST") { // 如果是 POST——toUpperCase 是方法要加括号
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); // 必写表单请求头
}
xhr.onreadystatechange = function () { // 货到了打电话给你
if (xhr.readyState !== 4) return; // 没传完就跳过,只处理传完的
let res; // 存后端回信
try { res = JSON.parse(xhr.responseText); } catch (e) { res = { code: -1, msg: "服务响应格式异常" }; } // 翻译回信,翻译失败就给个错误对象
// 交给响应拦截器处理
responseInterceptor(res, xhr.status) // 把回信和状态码交给响应拦截器
.then(function (data) { resolve(data); }) // 拦截器说成功 → resolve 出去
.catch(function (err) { reject(err); }); // 拦截器说失败 → reject 出去
}; // 监听结束
xhr.send(data ? new URLSearchParams(data).toString() : null); // 让小哥出发,POST 把参数塞兜里——toString 是方法要加括号
}); // Promise 结束
} // 主函数结束
export default request; // 导出给别的文件用
核心思路:拦截器把 token、错误处理、loading 全部集中在一处,页面只管拿数据和渲染,再也不用每个页面写一遍判断。
// ===== api/user.js —— 接口层:只填路径和方式 =====
import request from "../utils/request.js"; // 引入封装好的 request 工具
export function getUserList(params) { // 导出"获取用户列表"接口
return request("GET", "/user/list", params); // 调底层 request,填路径和方式
}
export function login(formData) { // 导出"登录"接口
return request("POST", "/user/login", formData); // 调底层 request,POST 登录
}
// ===== page/index.js —— 页面层:async/await 调用 =====
import { getUserList, login } from "../api/user.js"; // 从接口层引入两个函数
async function loadPageData() { // 页面加载数据函数
try {
const list = await getUserList({ page: 1, size: 10 }); // 等拿到用户列表
console.log("用户列表:", list); // 渲染列表
const loginData = await login({ username: "admin", password: "123456" }); // 等登录完成
localStorage.setItem("token", loginData.token); // 存 token
} catch (err) {
console.error("页面业务处理:", err); // 拦截器已经弹过提示了
}
}
loadPageData(); // 页面加载时自动执行
三层职责:utils 管底层请求、api 管接口聚合(只填路径和方式)、pages 管页面渲染——各干各的,低耦合,以后学 Vue/React 也是这个套路。
可能在什么地方用:
① 任何正式项目都必须封装,不能页面里直接 new XHR;② 统一带 token、统一错误提示;③ 接口地址统一写在 BASE_URL。
常见的问题:
① 在请求拦截器里就调了 setRequestHeader——但此时 xhr 还没 open,会报错;② GET 参数忘了拼 URL,直接放 send 里;③ 页面里还重复写错误处理逻辑,和拦截器重复;④ 忘了 return Promise,页面拿不到数据。
解决思路:
① setRequestHeader 必须在 xhr.open 之后再调;② GET 拼 URL、POST 放 send,封装函数里自动处理;③ 页面里只管成功逻辑,失败交给拦截器;④ 主函数一定要 return 一个 new Promise。
练习五:通用请求封装——① 按课件思路创建 utils/request.js,包含 BASE_URL、请求拦截器(带 token)、响应拦截器(处理状态码);② 创建 api/user.js,导出 getMemberList 和 login 两个函数;③ 新建页面调用这两个接口,用 async/await 处理。