ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

从XMLHttpRequest到Fetch:前端异步请求的机制、封装与实战避坑指南

从XMLHttpRequest到Fetch:前端异步请求的机制、封装与实战避坑指南 干前端这些年我有个很深的感受很多同学 JS 基础背得滚瓜烂熟但一碰到数据交互就露怯。项目里十个报错至少有五个和 JS AJAX 这条链路有关。AJAX 不是一个库也不是某个框架独有的能力它是浏览器提供的一套异步通信方案。理解它你才能理解网络面板里那些请求为什么会有 preflight、为什么 GET 会被缓存、为什么接口明明返回 200 却拿不到数据。这篇文章不讲空泛的 API 罗列我会从 AJAX 的核心机制讲到原生手写封装再聊到 fetch、取消请求、跨域、竞态和安全问题最后用一个带 loading 和错误处理的用户列表案例把整个流程串起来。无论你是刚入门的前端新人还是被各种请求问题折磨过的“野生程序员”这篇都值得花十分钟细读。1. 先把 AJAX 这条线捋清楚1.1 从整页刷新到局部更新在 AJAX 出现之前网页的数据交互模式非常原始用户在页面上点击提交浏览器把整个表单打包发给服务器服务器返回一整个新的 HTML 页面浏览器再重新渲染。这个过程中用户看到的是白屏、转圈、页面跳转。如果只是点赞、翻页、提交一条备注也要整个页面刷新体验相当糟糕。AJAX 的全称是 Asynchronous JavaScript And XML核心思想是“页面不跳转也能和服务器交换数据”。它让浏览器在后台发请求拿到数据后通过 JavaScript 动态更新页面局部内容。比如微博点赞你点击后立刻变红这个操作背后就是一个异步请求服务器返回“已点赞”状态前端据此更新按钮样式。整个过程用户无感知不用等页面重新加载。之所以能做到这一点依赖的是浏览器内置的 XMLHttpRequest 对象。注意它名字里带着 XML但实际传输什么格式都行现在主流是 JSON。你不用管名字只要知道它是一套底层的 HTTP 通信接口就行。1.2 浏览器里的一次完整请求长什么样一个完整的 AJAX 请求本质上就是一个普通 HTTP 请求只不过由 JavaScript 发起、由 JavaScript 接收。整个流程可以概括为创建 XMLHttpRequest 实例。调用 open 方法设置请求方法、URL、是否异步。绑定事件监听比如 onreadystatechange、onload、onerror。调用 send 方法把请求发出去。浏览器在后台完成网络通信触发回调函数。这里最关键的概念是 readyState 和 status。readyState 表示请求当前处于哪个阶段0 是未初始化1 是已建立连接2 是已收到响应头3 是正在接收响应体4 是响应体接收完毕。千万不要一看到 readyState 变成 4 就以为成功还需要再看 status也就是 HTTP 状态码。status 是 200 到 299 才表示请求成功304 表示走缓存404、500 这些都属于失败。新手最容易犯的错是只判断 readyState不判断 status导致接口报错时前端毫无感知。所以学习 AJAX第一步就是把这两个状态的关系刻在脑子里。2. 手写 AJAX 请求从零开始理解每一步2.1 最基本的 GET 请求很多人工作第一天就用第三方请求库反而不会写原生的 XMLHttpRequest。但原生写法才是理解一切请求库的基础。一个最简单的 GET 请求是这样const xhr new XMLHttpRequest() xhr.open(GET, /api/users?id1, true) xhr.onreadystatechange function () { if (xhr.readyState 4) { if (xhr.status 200 xhr.status 300) { console.log(xhr.responseText) } else { console.error(请求失败, xhr.status) } } } xhr.send(null)这段代码有几个点值得琢磨。open 方法的第三个参数是 async必须传 true。如果传 false请求会变成同步阻塞浏览器主线程卡死页面假死现代浏览器甚至会直接报废这个用法。服务端响应慢的时候用户会看到页面像冻住了一样点哪里都没反应。另外GET 请求没有请求体所以 send 的参数传 null 或不传都行。我习惯显式写 null意思是“我没有 body”看起来更清晰。等到 readyState 变成 4responseText 里面就是服务器返回的字符串。如果接口返回的是 JSON你还得手动 JSON.parse 一下。这一步经常被忽略因为很多请求库自动做了原生不会。2.2 POST 请求、请求头和参数序列化POST 请求和 GET 最大的区别是参数放在请求体里同时要正确设置 Content-Type 请求头。否则服务器可能解析不出你的参数。看下面的例子const xhr new XMLHttpRequest() xhr.open(POST, /api/users, true) xhr.setRequestHeader(Content-Type, application/x-www-form-urlencoded) xhr.onload function () { if (xhr.status 200 xhr.status 300) { const data JSON.parse(xhr.responseText) console.log(data) } } xhr.send(namezhangsanage18)这里我们把请求体拼成了namezhangsanage18这种查询字符串格式对应的 Content-Type 是application/x-www-form-urlencoded。如果你改成application/json就必须先JSON.stringify再发送比如xhr.setRequestHeader(Content-Type, application/json) xhr.send(JSON.stringify({ name: zhangsan, age: 18 }))很多后端框架接收 JSON 和表单参数的解析方式完全不同。前端如果设置错了 Content-Type后端会一脸茫然地返回 400。实际开发中先和后端确认接口约定的提交格式比啥都重要。还有一个容易踩的坑是中文编码。直接用name张三这种字符串发出去浏览器可能发出 UTF-8 编码但服务端解析时按错误编码解码就乱码了。表单格式下最保险的做法是用encodeURIComponent编码每个参数值const name encodeURIComponent(张三) xhr.send(name name age18)GET 请求的 URL 参数同样需要 encodeURIComponent尤其是关键词搜索这种场景不然特殊字符会把 URL 搞坏。2.3 封装一个够用的 ajax 函数理解了原生写法之后推荐自己封装一层不是为了造轮子而是为了真正理解 Promise 是怎么把回调地狱变成扁平链式的。一个够用的封装大概长这样function ajax(options) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest() const method options.method || GET const url options.url const timeout options.timeout || 10000 xhr.open(method, url, true) xhr.timeout timeout xhr.onreadystatechange function () { if (xhr.readyState ! 4) return if (xhr.status 200 xhr.status 300) { let result xhr.responseText try { result JSON.parse(result) } catch (e) { // 保留原始字符串 } resolve(result) } else { reject(new Error(请求失败 xhr.status xhr.statusText)) } } xhr.onerror function () { reject(new Error(网络异常)) } xhr.ontimeout function () { reject(new Error(请求超时)) } const headers options.headers || {} Object.keys(headers).forEach(function (key) { xhr.setRequestHeader(key, headers[key]) }) let body null if (options.data) { body JSON.stringify(options.data) xhr.setRequestHeader(Content-Type, application/json) } xhr.send(body) }) } // 使用 ajax({ url: /api/users, method: GET }) .then(function (data) { console.log(data) }) .catch(function (err) { console.error(err) })为什么要把 onreadystatechange 包在 Promise 里因为 Promise 的状态只能从 pending 变为 fulfilled 或 rejected而且只会变一次。这正好符合我们对一次请求的预期要么成功要么失败绝对不会回调两次。这也倒逼你把“请求中”的状态管理放到 Promise 外部用变量或状态机来控制。不过要注意这个封装为了简单没有处理 GET 参数拼接也没有做请求取消。真实项目里取消请求非常常用后面我会单独讲。3. Fetch 和第三方请求库它们和 AJAX 是什么关系3.1 Fetch 到底是不是 AJAX 的替代品很多新人会问现在不都用 fetch 吗为什么还要学 XMLHttpRequest准确地说fetch 是浏览器提供的另一种异步请求 API和 XMLHttpRequest 是平级关系都是原生能力。但由于 fetch 基于 Promise写法更现代所以被很多人当作 AJAX 的“下一代”。一个基本 fetch 请求长这样fetch(/api/users, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ name: zhangsan }) }) .then(function (response) { if (!response.ok) { throw new Error(HTTP response.status) } return response.json() }) .then(function (data) { console.log(data) })看起来比 XMLHttpRequest 清爽不少。但有三个坑特别容易踩第一fetch 默认不会携带同源请求的 cookie。在 XMLHttpRequest 里同源请求默认带 cookie而 fetch 需要通过credentials: same-origin或credentials: include显式声明。如果接口依赖登录态你会发现用 fetch 登录之后下一次请求依然拿不到 session。第二fetch 只在网络错误或请求被取消时 reject。HTTP 404、500 这些状态码response.ok 是 false但 fetch 本身不会抛异常只会正常 resolve。所以必须在 then 里面手动判断 response.ok。第三response.json() 也是异步操作如果返回的不是合法 JSON会在这里抛异常。实际调试时我喜欢先用 response.text() 把原始字符串打出来确认服务端到底返回了什么再决定怎么解析。3.2 取消请求与超时AbortController原生 XMLHttpRequest 有 abort 方法也有 timeout 属性和 ontimeout 事件。fetch 本身没有 timeout 概念但可以通过 AbortController 来取消请求。const controller new AbortController() fetch(/api/users, { signal: controller.signal }) .then(function (response) { return response.json() }) .catch(function (err) { if (err.name AbortError) { console.log(请求被主动取消) } else { console.error(err) } }) // 5 秒超时 setTimeout(function () { controller.abort() }, 5000)这个模式很实用。比如用户在搜索框里输入快速敲击的时候会触发很多请求。如果每次输入都发一个请求上一条还没返回下一条就已经发出去了。这时候用 AbortController 把旧请求取消掉就能避免无谓的网络消耗和状态覆盖。注意 catch 里要判断 err.name 是不是 AbortError。因为用户主动取消和网络错误是两回事不区分的话页面上可能会错误地弹出“网络异常”提示。3.3 第三方请求库到底帮你做了什么事市面上流行的 HTTP 请求库在浏览器环境里的底层能力大多还是建立在 XMLHttpRequest 或 fetch 之上。它们并没有发明新的网络协议只是帮你把很多繁琐细节封装好了。比如自动拼接 URL 参数并且处理数组、嵌套对象这些复杂结构。自动序列化请求体和解析响应体。根据状态码自动决定 resolve 还是 reject。提供拦截器让你在请求发出前统一加 token在响应回来后统一处理错误码。提供更完善的取消机制和超时控制。所以如果你已经理解了原生 AJAX再用第三方库会非常顺手如果没理解出了问题只会觉得是库有 bug然后到处查配置。我的建议是项目里可以用封装好的库提高效率但至少要能手写原生请求。面试考 AJAX 底层时这几乎是必问题。4. 真实项目中常见的 AJAX 坑与排查思路4.1 跨域问题为什么接口调不通前端开发中几乎天天遇到跨域。浏览器出于安全考虑默认禁止页面脚本访问不同源协议、域名、端口任意一个不同的资源。你用 AJAX 请求一个不同域的接口控制台会报 CORS 错误。解决办法有几类。最直接的是后端在响应头里加允许跨域的字段Access-Control-Allow-Origin: https://your-domain.com如果请求带自定义请求头或者用了 PUT、DELETE 这些方法浏览器会先发一个 OPTIONS 预检请求询问服务器允不允许。服务器如果没正确响应预检正式请求就不会发出。这也是为什么有些人发现网络面板里只有一个 OPTIONS 请求却没有真正的业务请求。开发环境下更好的做法是通过本地开发服务器做代理。配置一个代理规则把/api开头的请求转发到目标服务器。这样浏览器看到的请求是同源的就不会有跨域问题。生产环境则可以用服务器反向代理或网关统一处理。跨域不是前端能单方面解决的它需要前后端配合。4.2 返回数据是字符串还是对象这也是高频问题。接口明明返回了 JSON你打印出来却是字符串。原因通常有两个一是服务端返回的 Content-Type 不是 application/json而是 text/plain二是前端没有做 JSON.parse。使用原生 XMLHttpRequest 时responseText 永远是字符串必须自己 parse。使用 fetch 时你调用了 response.json() 它会自己 parse但如果服务端返回的是空内容或错误页面解析会报错。遇到这种情况我建议先在 network 面板里看 Response 标签确认原始返回内容再去检查代码。很多时候错误不在前端而是后端把错误堆栈直接当成响应体返回了。还有一类问题是 JSON.parse 成功了但数据结构跟预期不一样。比如你期待一个数组结果拿到一个对象。这时候不要瞎猜把数据打印出来对照接口文档逐一核对字段。4.3 请求竞态最后发出的不一定最后到达请求竞态是很容易被忽视的坑。假设页面上有两个并行的请求第二个请求比第一个快后发先至界面就可能显示旧数据。更常见的是搜索建议用户输入“a”发送请求 A又输入“ab”发送请求 B。如果 A 比 B 慢A 的响应后到会把 B 的结果覆盖掉。解决办法有两种思路。一是用请求序号拿到响应后比对序号如果当前响应不是最新一次请求就直接丢弃let requestSeq 0 async function search(keyword) { const current requestSeq const data await ajax({ url: /api/search?q encodeURIComponent(keyword) }) if (current ! requestSeq) { // 过期响应丢弃 return } renderResult(data) }二是直接取消上一次请求。用 XMLHttpRequest 就调 abort用 fetch 就调 AbortController 的 abort。两种方式都可以实际项目中我更喜欢“序号法”因为逻辑直观、不依赖取消机制而且不会因为取消请求产生不必要的错误日志。4.4 缓存、乱码与状态码误判GET 请求容易被浏览器缓存。明明后端数据改了前端拿到的却是旧数据。两种最常用的规避办法加时间戳参数比如/api/users?t${Date.now()}或者在后端响应头里设置 Cache-Control: no-cache。我通常只在排查问题时手动加时间戳生产环境应该由后端统一控制缓存策略。乱码问题常见于服务端返回的编码和前端声明的编码不一致。页面用的是 UTF-8服务端返回 GBK浏览器就会显示乱码。这个不推荐在前端硬解应该让后端改响应头统一用 UTF-8。如果实在改不了后端再考虑在请求时用responseType arraybuffer然后手动解码但这是下策。状态码方面不要只判断 status 200。201 表示创建成功304 表示未修改走缓存这些都应该算成功范围。所以前面封装里用status 200 status 300就是避免漏掉这些合法状态。另外302 重定向在 AJAX 场景下通常会被浏览器透明处理前端拿到的可能是最终资源的响应这个特性有时候会让人困惑。5. 完整案例用原生 JS 实现一个带 loading 的用户列表5.1 页面结构与接口约定用一个实际场景把上面的知识点串起来。假设我们要做一个用户列表页面进入页面后请求/api/users实时显示加载状态加载完成渲染用户名称。接口约定如下请求方法GET响应格式JSON成功返回{ code: 0, data: [{ id: 1, name: 张三 }, ...] }失败返回{ code: 500, message: 服务器开小差了 }页面结构很简单div idapp div idloading加载中.../div ul iduser-list/ul button idload-btn重新加载/button /div5.2 加载并渲染数据渲染用户列表时最关键的一条不要用 innerHTML 去拼name字段。用户昵称什么内容都可能包含比如script标签或者img onerror一旦拼进 HTML就可能变成 XSS 漏洞。用 textContent 最安全function renderUsers(users) { const list document.getElementById(user-list) list.innerHTML users.forEach(function (user) { const li document.createElement(li) li.textContent user.name list.appendChild(li) }) }加载数据部分我会把 loading、错误、成功三种状态拆分开处理async function loadUsers() { const loading document.getElementById(loading) const list document.getElementById(user-list) loading.style.display block list.innerHTML try { const result await ajax({ url: /api/users, method: GET, timeout: 8000 }) if (result.code ! 0) { throw new Error(result.message || 未知错误) } renderUsers(result.data) } catch (err) { const item document.createElement(li) item.textContent 加载失败 err.message list.appendChild(item) } finally { loading.style.display none } } document.getElementById(load-btn).addEventListener(click, function () { loadUsers() }) loadUsers()这个案例虽然简单但体现了几个关键思路状态分离、错误处理、结果校验。很多人写 AJAX 只处理成功路径失败路径全靠浏览器默认报错这在正式项目里是没法用的。5.3 给案例加上取消和竞态保护再进一步。如果用户快速点击“重新加载”多次就会发出多个并发请求响应顺序不确定页面可能被旧数据覆盖。我加一个简单的序号保护let loadSeq 0 async function loadUsers() { const current loadSeq // ... 其他代码不变 const result await ajax({ url: /api/users, method: GET, timeout: 8000 }) if (current ! loadSeq) { return // 已经有更新的请求发出了丢弃本次结果 } renderUsers(result.data) }这样做的好处是不需要真的取消请求旧的请求依然会返回但不会再触发渲染。页面始终只展示最后一次请求的结果。6. 安全底线AJAX 请求需要注意什么6.1 渲染数据时如何防 XSS前面提到的 textContent 是一种最直接的防护方式。凡是把外部数据插入 DOM 的地方都要默认不可信。如果业务确实需要渲染富文本比如后台返回一段带有标签的公告不建议自己写正则过滤更不能用正则把script去掉就完事。更稳妥的做法是使用成熟的 HTML 清洗库或者让后端直接返回安全的 JSON 结构由前端用创建节点的方式来构建。XSS 的危害不只是弹个窗。攻击者可以窃取 cookie、模拟用户操作、篡改页面内容。所以在 AJAX 相关代码里所有从服务端来的字符串在进入 innerHTML、document.write、eval 之前都要多问一句这东西能不能直接用。6.2 请求伪造与基础防护CSRF 攻击的原理是用户登录了你的网站浏览器里留有登录状态此时攻击者在另一个网站诱导用户发起请求浏览器会带着 cookie 自动发送服务器没有能力区分这是用户主动操作还是被诱导的操作。常见的防护手段包括在请求头里加入自定义字段并让后端校验使用 CSRF Token把随机字符串放到页面和 cookie 中提交请求时带上后端比对一致性设置 cookie 的 SameSite 属性限制跨站请求携带 cookie。作为前端我们能做的是配合后端在封装 AJAX 时统一为请求添加必要的 token 头同时避免在 URL 参数里携带敏感信息因为 URL 会被记录到访问日志中安全性更低。AJAX 的安全问题很容易被忽视因为平时开发环境没有攻击者但一旦上线各种扫描和恶意请求就会涌过来。写代码的时候养成好习惯能省去以后很多麻烦。老实说JS AJAX 这套东西表面上看是“浏览器发请求、收响应”八个字真正吃透却需要把协议、状态、异步、安全这些问题串起来。我自己带过不少新人发现他们最常卡住的不是语法而是遇到问题不知道从哪下手分析。每次接口调不通我都建议先打开网络面板看请求体、响应体和状态码再对照代码逐步排查。这个方法听起来原始但比盲目改代码有效得多。如果你也想真正掌握 AJAX不妨抽个下午把原生 XMLHttpRequest 的封装手写一遍再在项目里试着自己加请求取消和竞态保护。做完这些很多看似玄学的 bug 都会变得有迹可循。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表