Ajax 这个词,做 Web 开发的人几乎没有不知道的。但有意思的是,我面试过不少工作两三年的前端,问他们"Ajax 到底是什么",十有八九会回答"就是发请求的那个东西",再追问"它和 fetch、axios 有什么区别",就开始含糊了。这个现象很普遍,因为大多数人接触 Ajax 是从 jQuery 的$.ajax()或者某个封装库开始的,用得很顺手,却从没认真想过它背后到底发生了什么。这篇内容我想把 Ajax 和异步数据传输这件事从头到尾捋一遍,不堆概念,重点讲清楚它在真实项目里怎么用、哪些地方容易翻车、编码格式和参数传递这些细节到底该怎么处理。不管你是刚入门的前端新手,还是想回头补一补基础的开发者,应该都能从中找到对自己有用的部分。
1. Ajax 到底解决了什么问题
1.1 从"整页刷新"到"局部更新"的转变
要理解 Ajax 的价值,得先回到没有它的年代。早期的 Web 页面交互模式非常原始:用户在页面上填个表单,点提交,浏览器把整个页面数据打包发给服务器,服务器处理完返回一个全新的 HTML 页面,浏览器再把整个页面重新渲染一遍。这个过程里,哪怕你只是想验证一下用户名有没有被注册,页面也会白屏一下,然后整个刷新。用户体验很差,服务器压力也大,因为每次都要重新生成完整的页面。
Ajax 的出现改变了这个模式。它的核心思路是:让浏览器在后台悄悄地和服务器通信,拿到数据之后,由 JavaScript 动态地更新页面上的某一部分,而不是刷新整个页面。这个"悄悄通信"的能力,就是异步数据传输。所谓异步,是指请求发出去之后,浏览器不会傻等着结果回来,用户还可以继续操作页面,等数据回来了再通过回调函数处理。这种模式让 Web 应用第一次有了接近桌面软件的流畅感。
我印象很深的一个例子是搜索框的自动补全。你在输入框里敲几个字,下面立刻弹出相关的搜索建议,这个交互背后就是典型的 Ajax 场景:每次输入变化触发一次异步请求,拿到建议列表后局部渲染。如果没有 Ajax,这个功能根本没法做,你总不能让用户每敲一个字就刷新一次页面。
1.2 XMLHttpRequest:Ajax 的底层引擎
很多人以为 Ajax 是一个技术或者框架,其实不是。Ajax 是 Asynchronous JavaScript and XML 的缩写,它描述的是一种"用 JavaScript 异步发请求"的开发模式,而真正干活的底层对象叫XMLHttpRequest,简称 XHR。这个对象是浏览器提供的原生 API,所有上层封装(jQuery 的$.ajax、axios 等)最终都是通过它来发请求的。
XHR 最早由微软在 IE5 里引入,后来被各大浏览器跟进,逐渐成为事实标准。它的使用方式大致是这样的:创建一个 XHR 实例,调用open()方法配置请求方法和地址,设置回调监听状态变化,最后调用send()发出请求。整个过程是事件驱动的,请求状态每变化一次就会触发readystatechange事件,你需要在回调里判断当前状态,等状态变成完成时再读取响应数据。
这里有个细节值得说:虽然 Ajax 名字里带 XML,但现在实际项目中绝大多数场景传的都是 JSON,XML 反而很少见了。名字是历史遗留,不用纠结。理解 XHR 的工作机制很重要,因为后面讲到的编码格式、参数传递、跨域等问题,本质上都是在和 XHR 打交道,哪怕你用的是封装库,底层逻辑是一样的。
1.3 同步请求为什么基本不能用
XHR 支持同步和异步两种模式,通过open()的第三个参数控制,传false就是同步。同步请求的意思是,请求发出去之后,JavaScript 会停在这里等结果,直到服务器返回才继续往下执行。听起来好像更"直观",但实际项目中几乎不能用。
原因很简单:JavaScript 是单线程的,同步请求会阻塞整个主线程。请求期间页面完全卡死,用户点什么都没反应,滚动也滚不动。如果服务器响应慢,或者网络不好,页面就会长时间假死。更麻烦的是,现代浏览器已经明确在主线程上废弃了同步 XHR,控制台会直接警告。所以除非是在 Web Worker 这种独立线程里,否则不要用同步请求。这一点新手特别容易踩,看到文档里有同步模式就想试试,结果页面卡住还找不到原因。
2. 一次 Ajax 请求的完整生命周期
2.1 从创建实例到发送请求
我们拿原生 XHR 走一遍完整流程,把每一步都拆开看。第一步是创建实例:
const xhr = new XMLHttpRequest();第二步是配置请求,调用open():
xhr.open('POST', '/api/user/login', true);open()的三个参数分别是请求方法、请求地址、是否异步。注意这个方法只是配置,并不会真正发请求。第三步是设置请求头,比如告诉服务器我发的是 JSON:
xhr.setRequestHeader('Content-Type', 'application/json');第四步是监听状态变化:
xhr.onreadystatechange = function () { if (xhr.readyState === 4) { if (xhr.status >= 200 && xhr.status < 300) { console.log('成功', xhr.responseText); } else { console.log('失败', xhr.status); } } };最后一步才是真正发送:
xhr.send(JSON.stringify({ username: 'test', password: '123456' }));这套流程看起来繁琐,但每一步都有明确的目的。readyState有五个值,从 0 到 4,分别代表未初始化、已打开、已发送、接收中、已完成。实际开发中我们基本只关心 4 这个状态,也就是请求完成。不过理解这五个状态有助于排查问题,比如请求一直停在 1,说明send()没执行;停在 2 或 3,可能是网络卡住了。
2.2 readyState 与 HTTP 状态码的配合判断
这里有个新手常犯的错误:只判断readyState === 4就认为请求成功了。实际上readyState变成 4 只代表请求流程走完了,不代表服务器返回的是成功结果。服务器可能返回 404、500 等各种错误状态,这些都需要通过 HTTP 状态码来判断。
正确的做法是两层判断:先看readyState是不是 4,再看status是不是在 200 到 299 之间。只有两个条件都满足,才算真正成功。我见过有人的代码里只判断了readyState,结果接口报 500 的时候,页面还傻乎乎地拿着错误信息去渲染,用户看到一堆乱码。
另外,status为 0 的情况也要注意。这通常意味着请求根本没发出去,可能是跨域被拦截了,也可能是网络断了,或者请求被浏览器插件拦截了。遇到status === 0,先检查网络和跨域配置,别急着怀疑后端。
2.3 超时与中断处理
真实项目里,请求不一定都能顺利返回。服务器可能挂了,网络可能断了,用户可能等不及关掉页面了。这些情况都需要处理,否则请求会一直挂着,占用资源。
XHR 提供了timeout属性来设置超时时间,单位是毫秒:
xhr.timeout = 10000; xhr.ontimeout = function () { console.log('请求超时了'); };超过设定时间还没返回,就会触发ontimeout回调。超时时间设多少合适?这个没有标准答案,要看接口的实际响应速度。一般查询类接口设 5 到 10 秒,上传下载类接口可以设长一点。设太短会误杀正常请求,设太长又起不到保护作用。
中断请求用abort()方法:
xhr.abort();调用之后请求会立即终止,同时触发onabort回调。这个在搜索建议这种场景特别有用:用户快速输入时,前一个请求可能还没返回,新请求就来了,这时候应该把旧请求中断掉,避免旧数据覆盖新数据。我踩过这个坑,用户输入"abc","ab"的请求先返回,"abc"的请求后返回,结果页面上显示的是"ab"的结果,看起来就像卡住了。
3. 编码格式与参数传递的那些坑
3.1 Content-Type 到底该设什么
Content-Type是请求头里最容易出错的一个字段,它告诉服务器"我发过来的数据是什么格式"。设错了,服务器可能解析不出来,或者解析成乱码。
最常见的几种取值:
| Content-Type | 适用场景 | 数据格式 |
|---|---|---|
| application/json | 传 JSON 对象 | {"name":"test"} |
| application/x-www-form-urlencoded | 表单提交 | name=test&age=18 |
| multipart/form-data | 文件上传 | 二进制分块 |
| text/plain | 纯文本 | 原始字符串 |
用application/json的时候,send()里要传 JSON 字符串,不能直接传对象。很多人写成xhr.send({name:'test'}),结果发出去的是[object Object],服务器一脸懵。正确写法是xhr.send(JSON.stringify({name:'test'}))。
用application/x-www-form-urlencoded的时候,数据要拼成key=value&key2=value2的形式,而且值需要做 URL 编码,否则特殊字符会出问题。比如值里有&或者=,不编码的话服务器会解析错位。
3.2 中文乱码的根因与解决
中文乱码是 Ajax 里非常经典的问题,尤其是用x-www-form-urlencoded格式的时候。根本原因是编码方式不一致:前端用 UTF-8 编码发出去,服务器却用别的编码(比如 GBK)来解析,或者反过来。
解决思路有两个方向。第一个方向是在请求头里明确指定字符集:
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');第二个方向是对参数值做encodeURIComponent编码:
const params = 'name=' + encodeURIComponent('张三') + '&city=' + encodeURIComponent('北京'); xhr.send(params);encodeURIComponent会把中文转成%E5%BC%A0%E4%B8%89这种形式,服务器收到后按 UTF-8 解码就能还原。我建议两个方向一起做,双保险。另外要注意,encodeURIComponent和encodeURI不一样,前者会编码更多字符,传参数用前者更安全。
如果用的是 JSON 格式,中文乱码的情况会少很多,因为 JSON 默认就是 UTF-8。但也不是绝对,如果服务器端读取请求体时用了错误的编码,照样会乱。这种情况就得前后端一起排查,确认双方都用 UTF-8。
3.3 GET 与 POST 传参的差异
GET 和 POST 传参的方式完全不同,这点必须搞清楚。
GET 请求的参数是拼在 URL 后面的,用?开头,多个参数用&连接:
const url = '/api/search?keyword=' + encodeURIComponent('手机') + '&page=1'; xhr.open('GET', url, true); xhr.send();注意 GET 请求的send()里不传数据,传了也会被忽略。参数必须拼在 URL 上,而且同样要做编码。
POST 请求的参数放在请求体里,通过send()传:
xhr.open('POST', '/api/user', true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.send(JSON.stringify({ name: 'test' }));GET 和 POST 的选择不只是"传参位置不同"这么简单。GET 请求有长度限制(不同浏览器不一样,一般 2KB 到 8KB),参数会暴露在 URL 里,会被浏览器缓存,也会留在历史记录里。所以涉及敏感信息、大量数据、需要修改服务器状态的场景,都应该用 POST。查询类、幂等的场景可以用 GET。
3.4 参数赋值的几种常见写法
实际项目里给请求参数赋值有好几种写法,各有适用场景。
第一种是直接拼字符串,适合参数少的情况:
const params = 'id=' + id + '&type=' + type;第二种是用数组收集再拼接,适合参数多或者动态的情况:
const arr = []; arr.push('id=' + encodeURIComponent(id)); arr.push('type=' + encodeURIComponent(type)); const params = arr.join('&');第三种是借助URLSearchParams,这是现代浏览器提供的 API,用起来最省心:
const params = new URLSearchParams(); params.append('id', id); params.append('type', type); xhr.send(params.toString());URLSearchParams会自动处理编码,不用手动encodeURIComponent,减少出错概率。不过要注意兼容性,老版本浏览器可能不支持,如果需要兼容 IE 就得用前两种方式。
4. 封装一个能用的 Ajax 工具
4.1 为什么原生写法不适合直接用
原生 XHR 的写法在真实项目里几乎没法直接用,原因有几个。一是代码冗长,一个简单的请求要写十几行。二是回调嵌套深,多个请求有依赖关系的时候,代码会变成"回调地狱",可读性极差。三是错误处理分散,每个请求都要重复写状态判断。四是参数处理繁琐,编码、拼接、请求头设置都要手动来。
所以实际项目里都会做一层封装,把重复的逻辑抽出来,对外暴露简洁的接口。封装的目标是:调用方只需要关心"请求哪个地址、传什么参数、拿到结果做什么",其他细节都藏起来。
4.2 基于 Promise 的封装思路
用 Promise 封装是目前最主流的做法,它能很好地解决回调地狱问题。核心思路是把 XHR 的各个回调映射到 Promise 的 resolve 和 reject 上。
function request(options) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const method = (options.method || 'GET').toUpperCase(); const url = options.url; const data = options.data || {}; let queryString = ''; if (method === 'GET') { const params = new URLSearchParams(); Object.keys(data).forEach(key => params.append(key, data[key])); queryString = '?' + params.toString(); } xhr.open(method, url + queryString, true); xhr.timeout = options.timeout || 10000; if (method === 'POST') { xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); } xhr.onreadystatechange = function () { if (xhr.readyState !== 4) return; if (xhr.status >= 200 && xhr.status < 300) { let result; try { result = JSON.parse(xhr.responseText); } catch (e) { result = xhr.responseText; } resolve(result); } else { reject(new Error('请求失败,状态码:' + xhr.status)); } }; xhr.ontimeout = function () { reject(new Error('请求超时')); }; xhr.onerror = function () { reject(new Error('网络错误')); }; if (method === 'POST') { xhr.send(JSON.stringify(data)); } else { xhr.send(); } }); }这个封装虽然简单,但已经覆盖了大部分日常场景。调用的时候就很清爽了:
request({ url: '/api/user/list', method: 'GET', data: { page: 1, size: 10 } }).then(res => { console.log(res); }).catch(err => { console.error(err); });4.3 封装中容易忽略的细节
封装的时候有几个细节特别容易漏,我一个个说。
第一个是响应数据的解析。服务器返回的不一定是 JSON,可能是纯文本、HTML 或者二进制。直接JSON.parse会抛异常,所以要用 try-catch 包起来,解析失败就返回原始文本。更完善的做法是根据响应头的Content-Type来判断该怎么解析。
第二个是请求头的合并。实际项目里经常需要传自定义请求头,比如 token。封装的时候要支持传入 headers 参数,并且和默认的请求头合并,而不是直接覆盖。
第三个是重复请求的处理。同一个接口短时间内被触发多次,比如用户狂点按钮,会产生多个重复请求。好的封装应该支持取消重复请求,或者做防抖处理。这个在搜索建议、提交表单等场景特别重要。
第四个是错误信息的友好化。直接把状态码抛给用户看是很糟糕的体验,应该根据状态码映射成用户能看懂的话。比如 401 提示"登录已过期",500 提示"服务器开小差了",404 提示"请求的资源不存在"。
5. 异步流程控制与常见陷阱
5.1 回调地狱是怎么形成的
假设有个需求:先登录拿到 token,再用 token 获取用户信息,再根据用户信息获取订单列表。用原生回调写出来是这样的:
login(function (token) { getUserInfo(token, function (userInfo) { getOrderList(userInfo.id, function (orders) { renderOrders(orders); }); }); });三层还能忍,如果业务再复杂点,嵌套五六层,代码就会变成向右的三角形,这就是所谓的"回调地狱"。这种代码难读、难维护、难调试,改一个地方要顺着缩进找半天。
Promise 的出现就是为了解决这个问题。同样的逻辑用 Promise 写:
login() .then(token => getUserInfo(token)) .then(userInfo => getOrderList(userInfo.id)) .then(orders => renderOrders(orders)) .catch(err => console.error(err));链式调用,从上到下读,逻辑清晰多了。再进一步,用 async/await 会更接近同步代码的写法:
async function loadData() { try { const token = await login(); const userInfo = await getUserInfo(token); const orders = await getOrderList(userInfo.id); renderOrders(orders); } catch (err) { console.error(err); } }这就是异步流程控制的演进路线:回调到 Promise 到 async/await。每一步都在让异步代码更好写、更好读。
5.2 并发请求的正确处理方式
有时候多个请求之间没有依赖关系,可以同时发出去,等全部返回再统一处理。这种场景用Promise.all:
Promise.all([ request({ url: '/api/user' }), request({ url: '/api/orders' }), request({ url: '/api/messages' }) ]).then(([user, orders, messages]) => { renderUser(user); renderOrders(orders); renderMessages(messages); }).catch(err => { console.error('有请求失败了', err); });Promise.all的特点是"一荣俱荣,一损俱损":只要有一个请求失败,整个 Promise 就 reject,其他请求的结果也拿不到了。如果希望即使有失败也能拿到成功的结果,可以用Promise.allSettled:
Promise.allSettled([req1, req2, req3]).then(results => { results.forEach(result => { if (result.status === 'fulfilled') { console.log('成功', result.value); } else { console.log('失败', result.reason); } }); });还有一种场景是"谁先返回用谁",比如同时请求两个镜像地址,哪个快用哪个,这时候用Promise.race。
5.3 请求竞态的排查与解决
请求竞态是异步场景里非常隐蔽的 bug,表现是页面显示的数据和预期不符,但又不是每次都出现,很难复现。典型场景是搜索建议:用户输入"a",发请求 A;接着输入"ab",发请求 B。如果 B 先返回,A 后返回,页面上最终显示的是 A 的结果,也就是"a"的建议,但输入框里明明是"ab"。
解决思路有三种。第一种是中断旧请求,每次发新请求前把上一个请求abort()掉。第二种是给请求打标记,返回时检查标记是不是最新的,不是就丢弃。第三种是用防抖,等用户停止输入一段时间后再发请求,从源头减少请求数量。
我一般推荐防抖加标记的组合。防抖减少请求量,标记保证即使有请求乱序返回也不会渲染错误数据。具体实现是维护一个自增的请求 ID,每次发请求时记录当前 ID,回调里比对 ID,只有 ID 匹配才处理结果。
6. 跨域与安全相关的实际问题
6.1 同源策略限制了什么
浏览器有个安全机制叫同源策略,它规定:只有协议、域名、端口三者完全相同,才算同源。不同源的页面之间,默认不能互相读取数据。这个策略是为了防止恶意网站窃取用户在其他网站的数据。
同源策略对 Ajax 的影响是:你从http://a.com的页面里,用 Ajax 请求http://b.com的接口,浏览器会拦截响应,控制台报跨域错误。注意,请求实际上是发出去了的,服务器也处理了,只是浏览器不让你的 JavaScript 读取响应内容。
这里有个容易混淆的点:跨域拦截的是"读取响应",不是"发送请求"。所以像 CSRF 这种攻击,虽然攻击者读不到响应,但请求已经生效了。这也是为什么涉及数据修改的接口要做 CSRF 防护。
6.2 CORS 的协商过程
解决跨域最正规的方式是 CORS,也就是跨域资源共享。它的原理是服务器在响应头里声明"我允许哪些源来访问我"。浏览器看到这个声明,就会放行。
CORS 请求分两种:简单请求和预检请求。简单请求直接发出去,浏览器检查响应头里的Access-Control-Allow-Origin是否包含当前源。预检请求会先发一个 OPTIONS 请求问服务器"我要用 POST 方法、带这些头来请求,你允许吗",服务器同意后才发真正的请求。
触发预检的条件包括:用了 PUT、DELETE 等方法,或者设置了自定义请求头,或者 Content-Type 是application/json。也就是说,现在大部分前后端分离的项目,用的都是application/json,都会触发预检。预检本身不复杂,但会增加一次往返,稍微影响性能。服务器端要正确处理 OPTIONS 请求,返回正确的 CORS 头,否则预检失败,真正的请求就发不出去。
6.3 带 Cookie 的跨域请求
默认情况下,跨域请求不会带上 Cookie。如果接口需要登录态,就得显式开启:
xhr.withCredentials = true;同时服务器端也要配合,响应头里要设置Access-Control-Allow-Credentials: true,而且Access-Control-Allow-Origin不能是*,必须是具体的源。这两个条件缺一不可,少一个浏览器就会拦截。
这里有个坑:很多人开了withCredentials之后发现还是不行,排查半天,最后发现是服务器端Access-Control-Allow-Origin写成了*。因为安全原因,带凭证的跨域请求不允许用通配符。这个规则要记牢。
7. 从 XHR 到 fetch 再到 axios
7.1 fetch 相比 XHR 的改进
fetch 是浏览器后来提供的新的请求 API,基于 Promise 设计,用起来比 XHR 简洁很多:
fetch('/api/user', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'test' }) }) .then(res => res.json()) .then(data => console.log(data)) .catch(err => console.error(err));fetch 的优点是 API 简洁、基于 Promise、语义清晰。但它也有几个让人头疼的地方。第一,fetch 只在网络错误时 reject,HTTP 状态码是 404 或 500 时,Promise 仍然是 resolve 的,需要手动检查res.ok。第二,fetch 默认不带 Cookie,要手动设置credentials。第三,fetch 没有超时机制,需要配合AbortController实现。第四,fetch 不支持上传进度监听,这个在文件上传场景很致命。
所以 fetch 虽然新,但并不是所有场景都比 XHR 好用。文件上传、需要进度条的场景,XHR 反而更合适。
7.2 axios 为什么成为主流选择
axios 是目前最流行的请求库,它本质上是对 XHR 的封装,但补齐了很多能力。它自动把响应 JSON 化,自动处理 HTTP 错误状态码,支持请求和响应拦截器,支持超时设置,支持取消请求,支持上传下载进度,浏览器和 Node.js 都能用。
拦截器是 axios 最实用的特性之一。你可以在请求拦截器里统一加 token,在响应拦截器里统一处理错误码:
axios.interceptors.request.use(config => { config.headers.Authorization = 'Bearer ' + getToken(); return config; }); axios.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { redirectToLogin(); } return Promise.reject(error); } );这样业务代码里就不用重复写这些逻辑了,非常省心。不过要注意,拦截器用多了也会让问题排查变复杂,因为请求和响应的实际内容被改过了。调试的时候如果发现数据不对,记得先看看拦截器里做了什么。
7.3 选型建议与迁移成本
那到底该用哪个?我的建议是:新项目直接用 axios,生态成熟、文档完善、社区活跃,遇到问题好找答案。如果项目对体积敏感,或者不想引入第三方库,可以用 fetch 加一层轻量封装。只有在需要上传进度、需要兼容很老的浏览器时,才考虑直接用 XHR。
从 XHR 迁移到 axios 的成本其实不高,因为核心概念是相通的:请求方法、请求头、请求体、响应状态码,这些都是一样的。主要的变化是写法从回调变成了 Promise,以及一些配置项的命名不同。花半天时间熟悉一下 API,基本就能上手。
8. 真实项目中的调试经验
8.1 用浏览器开发者工具定位问题
调试 Ajax 问题,浏览器开发者工具的 Network 面板是主战场。打开面板,触发请求,你能看到请求的方法、地址、状态码、耗时、请求头、请求体、响应头、响应体。几乎所有问题都能从这里找到线索。
几个常用的排查思路:请求没发出去,看 Network 里有没有记录,没有的话检查代码是不是没执行到send();请求发出去了但报错,看状态码,4xx 是客户端问题,5xx 是服务端问题;状态码正常但数据不对,对比请求体和响应体,看是发错了还是收错了;请求很慢,看 Timing 标签,是 DNS 慢、连接慢还是服务器处理慢。
我特别推荐用"Copy as cURL"功能。在 Network 面板里右键某个请求,选择复制为 cURL 命令,然后拿到终端里执行。如果终端里能正常返回,说明问题在前端代码;如果终端里也失败,说明问题在请求本身或者服务端。这个技巧能快速缩小排查范围。
8.2 常见报错的对照排查
| 报错信息 | 常见原因 | 排查方向 |
|---|---|---|
| CORS policy 拦截 | 跨域未配置 | 检查服务端 CORS 头 |
| 404 Not Found | 地址写错 | 核对接口路径和代理配置 |
| 400 Bad Request | 参数格式错 | 检查 Content-Type 和请求体 |
| 401 Unauthorized | 未登录或 token 失效 | 检查请求头里的凭证 |
| 500 Internal Error | 服务端异常 | 看服务端日志 |
| net::ERR_CONNECTION_REFUSED | 服务没启动 | 检查后端服务状态 |
| status 为 0 | 请求被拦截或网络断 | 检查跨域、插件、网络 |
这张表基本覆盖了日常遇到的大部分情况。遇到报错先对号入座,能省不少时间。
8.3 接口联调时的沟通技巧
前后端联调是 Ajax 问题的高发期,很多问题其实不是技术问题,而是沟通问题。我的经验是,联调前一定要把接口文档对齐:请求地址、请求方法、请求头、参数格式、响应格式、错误码,每一项都要确认清楚。文档里写application/json,实际发的是表单格式,这种低级错误经常发生。
遇到问题的时候,不要只说"接口不通",要把 Network 面板里的请求详情截图发出来,包括请求头、请求体、响应。这样后端一眼就能看出问题在哪。反过来,后端说"我这边没问题"的时候,让他提供一下服务端收到的请求日志,对比一下前端发的内容和服务端收的内容是否一致。很多时候问题就出在中间某个环节改了数据。
还有个小技巧:联调阶段可以在请求拦截器里加日志,把每个请求的完整信息打印出来。这样出问题的时候有据可查,不用靠猜。
9. 性能优化与最佳实践
9.1 减少请求数量的几种手段
请求数量直接影响页面加载速度,能合并的就合并。常见的手段有:合并接口,把多个小接口合并成一个大接口,一次返回所有需要的数据;用雪碧图或者字体图标减少图片请求;用缓存,对不常变的数据设置合理的缓存策略;用防抖节流,减少高频操作触发的请求。
合并接口是最有效的,但也要注意粒度。把所有接口合成一个巨无霸接口,会导致任何一个小改动都要重新请求全部数据,反而浪费。合理的做法是按页面或者按模块合并,一个页面初始化时发一到两个请求拿全数据。
9.2 请求缓存的正确使用
GET 请求默认会被浏览器缓存,这有时候是好事,有时候是坑。如果接口数据变化频繁,缓存会导致用户看到旧数据。解决办法是在 URL 后面加时间戳或者随机数:
const url = '/api/data?_t=' + Date.now();这样每次 URL 都不一样,浏览器就不会用缓存了。但这样也失去了缓存的好处。更好的做法是通过响应头控制缓存策略,让服务器明确告诉浏览器这个资源能缓存多久。对于确实需要实时的接口,用 POST 代替 GET 也能绕过缓存。
9.3 加载状态与用户体验
异步请求有个绕不开的问题:数据没回来之前,页面显示什么?如果什么都不显示,用户会以为页面坏了。所以加载状态很重要。
常见的处理方式有三种:一是显示 loading 动画,简单直接;二是用骨架屏,提前占位,视觉上更平滑;三是乐观更新,先假设请求成功,直接更新界面,失败了再回滚。第三种体验最好,但实现复杂,适合点赞、收藏这种成功率高的操作。
不管用哪种方式,都要记得在请求结束(成功或失败)后把 loading 状态关掉。我见过不少 bug 是请求失败了但 loading 没关,页面一直转圈,用户只能刷新。
10. 我踩过的几个真实坑
第一个坑是Content-Type和实际数据格式不匹配。有次我设置了application/json,但send()里传的是表单格式的字符串,后端按 JSON 解析直接报错。排查了半天才发现是这里的问题。教训是:设了什么 Content-Type,就要传对应格式的数据,两者必须一致。
第二个坑是 GET 请求传数组。后端要求传ids=[1,2,3],我直接JSON.stringify拼到 URL 上,结果方括号和引号在 URL 里是特殊字符,被截断了。正确做法是用ids=1&ids=2&ids=3这种重复 key 的形式,或者用逗号分隔ids=1,2,3,具体看后端怎么约定。
第三个坑是并发请求共享变量。我在循环里发请求,回调里用了循环变量,结果所有回调拿到的都是最后一个值。这是 JavaScript 闭包的经典问题,解决办法是用let声明循环变量,或者把变量通过参数传进回调。
第四个坑是请求没做错误处理。有次接口挂了,前端没做 catch,整个页面白屏。用户反馈过来才发现。从那以后我养成了习惯:每个请求都要有错误处理,哪怕只是弹个提示。
第五个坑是忽略了请求的取消。用户在页面 A 发了个请求,还没返回就跳到了页面 B,请求返回后回调里操作了已经不存在的 DOM,报了一堆错。解决办法是在页面卸载时取消所有未完成的请求,或者用标志位判断页面是否还在。
这些坑说起来都不复杂,但真遇到的时候往往要花不少时间排查。希望看到这里的你能少走点弯路。Ajax 这东西,用起来简单,用好却需要对细节有足够的理解。编码格式、参数传递、异步流程、错误处理,每一块都有讲究。把这些都摸透了,你写出来的代码才会真正稳定可靠。