写一篇 Ajax 请求数据全流程的实战博文,按你给的"项目标题"来,不绕弯子。
1. 先把核心问题搞清楚:Ajax 到底解决了什么
很多人一提到 Ajax 就条件反射想到"局部刷新""不用跳转页面",但真正上手项目时,却发现光知道这几个词远远不够。实际开发里,我们要面对的是:如何从后端拿到数据,又如何在拿到数据之后高效地把它渲染到页面上。中间涉及请求的构造、参数的传递、回调逻辑的组织、错误情况的处理,每一样都有讲究。
Ajax 全称是 Asynchronous JavaScript And XML,核心思路就是在不阻塞页面其它操作的前提下,与服务器完成数据交换。浏览器里最常见的两个载体是一个老牌的 XMLHttpRequest 对象,以及后来被原生 JavaScript 支持的 fetch 接口。这篇文章会把从创建请求对象,到监听状态变化,再到解析响应数据并完成渲染的完整链路拆开讲清楚,同时给出我实际项目中反复使用的代码范式。
适合阅读的读者分两类:一类是刚接触前后端交互、想在项目里实现"点击按钮后从接口取数据并展示"的新手;另一类是已经写过不少请求、但总被回调嵌套、异常捕获、跨域问题干扰,想系统梳理一遍的中级开发者。这篇文章不会只贴一段能跑的代码就结束,而是把每一行背后的设计思路和容易踩的坑都磨碎了给你看。
2. 一次性看懂请求链路的全貌
2.1 抛开代码先画一条时序线
在动手写任何代码之前,我习惯先在脑子里把整个请求过程过一遍。一次完整的 Ajax 请求,其实由六个阶段构成:
- 构造请求参数。包括请求方法(GET 还是 POST)、请求地址(URL)、请求头(Header)、请求体(Body)。
- 创建一个请求对象。原生环境用 XMLHttpRequest,新代码里更多用 fetch。
- 由对象建立连接并发送数据。
- 等待服务器的响应,期间通过事件监听器观察状态变化。
- 收到响应后,解析状态码和响应体,把字符串转成可用的 JavaScript 对象。
- 把数据交给页面进行局部渲染。
这六个步骤看起来简单,但真正的工程难点集中在第五步和第六步的衔接上——响应返回后,是操作 DOM 还是先去中转给框架?是直接渲染还是先做数据校验?每一步都会影响代码的健壮性。
2.2 为什么局部刷新这么重要
传统的表单提交方式是:用户在页面上填完内容,点击提交,浏览器把整个表单发出去,服务器回传一个新页面。这中间用户看到的是白屏、跳转、然后再重新加载。Ajax 出现后,页面的主体结构可以保持不变,只在某个区块重新拉取数据并替换内容。这就好比你去餐厅吃饭,旧方式是你每次加菜都得换个桌子重新坐下,Ajax 则是服务员直接把新菜端上桌,其它菜还在原位。
这个特性的意义不只在体验层面。对开发者来说,局部刷新意味着我们可以做更细粒度的状态管理:数据变了,只更新对应的视图模块,而不是推倒重来。这对单页应用尤为重要——导航栏、侧边栏、列表区、详情区各自维护各自的数据,互不干扰。
3. 方案选型:原生请求与 fetch 怎么取舍
3.1 老牌方案 XMLHttpRequest 的完整面目
如果你去看早期前端项目的源码,大概率会见到这种写法:
var xhr = new XMLHttpRequest(); xhr.open('GET', '/api/user/list', true); xhr.onreadystatechange = function () { if (xhr.readyState === 4 && xhr.status === 200) { var data = JSON.parse(xhr.responseText); renderList(data); } }; xhr.send();这段代码的关键点是 readyState 和 status。readyState 有五个值,0 表示请求对象已创建但未调用 open,1 表示已调用 open 但尚未 send,2 表示 send 已调用且服务器响应头已接收,3 表示正在接收响应体,4 表示整个响应接收完成。所以放在 readyState === 4 的条件里,是为了确保 responseText 里的内容是完整可用的。
status 则是 HTTP 状态码,200 表示请求成功,404 表示资源不存在,500 表示服务端错误。实际项目里,我很少只判断 200,因为后端偶尔会返回 204 No Content,或者 304 Not Modified,这些同样表示"请求本身是成功的,只是响应里没有新数据或不需要重复取数据"。所以更稳妥的写法是:
if (xhr.readyState === 4) { if (xhr.status >= 200 && xhr.status < 300) { // 按成功处理 } else { // 走异常分支 } }这种状态码范围判断方式,是我在多个项目里反复验证过的,比写死 status === 200 更抗变更。因为后端接口有时会调整状态码,写死具体值就容易被波及。
3.2 fetch 带来了什么改变
XMLHttpRequest 存在一个被前端开发者诟病很多年的问题——回调地狱。如果一次请求完了还要再发一次请求,就得在回调函数里再嵌套一层,嵌套层级一多,代码就很难读了。fetch 在这个方向上做了很大改进,它基于 Promise 设计,天然支持链式调用。
fetch('/api/user/list') .then(function (response) { if (!response.ok) { throw new Error('请求失败,状态码:' + response.status); } return response.json(); }) .then(function (data) { renderList(data); }) .catch(function (error) { console.error(error); });这里有一个容易被忽略的细节:fetch只在网络层出错或请求被阻止时才会 reject,HTTP 状态码哪怕是 500,fetch 也认为请求完成了。这就是上面代码里为什么要手动判断response.ok的原因。很多人第一次用 fetch 时,以为返回 500 会走进 catch,结果发现走的是 resolve,数据却是废弃的,排查半天才发现是这个坑。
如果追求代码更简洁,可以用 async/await:
async function loadData() { try { const response = await fetch('/api/user/list'); if (!response.ok) { throw new Error('HTTP Error: ' + response.status); } const data = await response.json(); renderList(data); } catch (e) { console.error(e); } }从工程实践角度来说,新项目里我能用 fetch 就不用 XMLHttpRequest,除非要做请求进度监控这类 fetch 暂时不方便覆盖的场景。fetch 的 Promise 语义更契合现代前端框架,与 async/await 配合时代码的可读性也更强。
4. 实操过程:一个带参数的 GET 请求是这样写的
4.1 URL 拼接中的编码细节
GET 请求的参数通常放在 URL 的查询字符串里。比如要请求用户列表,条件是按状态筛选,API 约定为/api/user/list?status=active&page=1&pageSize=10。
直接拼字符串不是不行,但用户输入的内容里如果包含特殊字符,比如空格、&、#、中文,就可能导致参数被截断或解析错误。我之前踩过一次很深的坑:搜索关键词里带了一个空格,直接在 URL 里拼进去,服务端拿到的值是截断后的前半个词,结果列表数据对不上,排查了半天才发现是编码问题。
正确做法是使用URLSearchParams来做参数序列化:
const params = new URLSearchParams(); params.append('status', 'active'); params.append('page', 1); params.append('pageSize', 10); params.append('keyword', '需要编码的关键词'); fetch(`/api/user/list?${params.toString()}`) .then(response => response.json()) .then(data => renderList(data));URLSearchParams 会自动帮你完成百分号编码,中文、空格、特殊符号都会被转成安全形式。这样服务端拿到的参数就一定是完整的原始值。
4.2 用 async/await 写一个完整流程
一个真实的项目场景是:进入页面之后,先请求一组筛选条件,再根据筛选条件请求列表数据。这种前后有依赖关系的请求,用 async/await 写起来非常贴合直觉:
async function initPage() { try { // 第一步:获取筛选条件 const conditionResponse = await fetch('/api/filter/options'); const conditionData = await conditionResponse.json(); renderFilters(conditionData); // 第二步:用筛选条件里的默认值请求列表 const listResponse = await fetch(`/api/user/list?status=${conditionData.defaultStatus}`); const listData = await listResponse.json(); renderList(listData); } catch (error) { console.error('页面初始化失败:', error); } }注意,这里每一步都要先await response.json(),因为服务器返回的是文本流,需要先解析成 JavaScript 对象再使用。如果后端返回的是纯文本而不是 JSON 格式,那就改用response.text()。
4.3 收到数据后先做这三件事
响应数据拿到手,很多人直接就开始渲染,但我在实践中发现,拿到数据后至少有三件做在前面:
第一,判断数据结构是否符合预期。比如预期是数组,结果返回的是 null,一渲染就会抛错。
第二,对敏感字段做兜底默认值。
第三,确保渲染操作可以重复执行不变异。举个具体例子,如果接口翻页返回的是分页对象,我会先拆箱:
function handleListData(result) { // 后端统一返回格式:{ code: 0, data: { list: [], total: 100 } } if (result.code !== 0 || !result.data) { throw new Error('接口返回异常'); } const list = result.data.list || []; const total = result.data.total || 0; return { list, total }; }这种统一响应结构在公司的后端项目中很常见,先做一次拆箱校验,后面渲染和分页的逻辑就都清晰了。
5. 实操过程:POST 请求与表单提交
5.1 JSON 格式请求体的写法
POST 请求主要用于提交数据。在前后端分离的架构里,最常见的请求体格式是 JSON:
async function submitForm(formData) { const response = await fetch('/api/user/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: formData.name, age: Number(formData.age), address: formData.address }) }); const result = await response.json(); if (result.code === 0) { message.success('创建成功'); } else { message.error(result.message || '创建失败'); } }这里有个点很多人会问:为什么Content-Type要手动设成application/json?因为 fetch 默认携带的 Content-Type 是text/plain;charset=UTF-8,服务端如果按 JSON 解析,会收到一个字符串而不是对象,接口可能直接报错或者拿到 undefined。后端基于什么格式解析,前端就必须发送对应格式并设置正确的请求头。
5.2 表单上传的另一种写法
如果提交的是 FormData,也就是带有文件上传的场景,情况又不一样了。此时我不用手动设置 Content-Type,浏览器会自动生成带 boundary 的 multipart 格式:
async function uploadFile(fileInput) { const formData = new FormData(); formData.append('file', fileInput.files[0]); formData.append('description', '这是一张图片'); const response = await fetch('/api/upload', { method: 'POST', body: formData // 注意:这里没有手动设置 Content-Type }); const result = await response.json(); return result; }如果你手动设置了Content-Type: application/json,反而会把 FormData 的请求体错串成字符串,服务端就无法正常解析文件了。所以在使用 FormData 时,请求头交给浏览器自动处理是最好的选择。
6. 渲染与回调:数据到手之后怎么做
6.1 拼接字符串与创建 DOM 节点
拿到数据之后,最传统但依然高效的渲染方式,是对数组数据做循环,然后将每一项拼成 HTML 字符串,用innerHTML塞进容器。这种方式的优点是直观,适合页面简单、数据结构固定的场景:
function renderList(list) { const container = document.getElementById('userList'); container.innerHTML = list.map(item => ` <div class="user-item"> <span>${item.name}</span> <span>${item.age}</span> <button onclick="viewDetail('${item.id}')">查看</button> </div> `).join(''); }这段代码的隐患集中在模板字符串里的${item.name}。如果 name 字段来自用户输入,且包含<script>或<img onerror=...>之类的内容,直接插入 innerHTML 就会产生 XSS 漏洞。所以,在做这类拼接前,我习惯先做一个 HTML 转义函数:
function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }然后把所有变量包进escapeHtml(item.name)再拼接。这样既能保留效率,又能防住注入问题。
6.2 createElement 与事件绑定的正确姿势
如果列表里的每一项都有交互事件,我更倾向于用 createElement 配合 addEventListener 来渲染。原因有两个:一是可以避免内联事件里的字符串拼接,二是事件回调可以直接引用闭包里的数据对象,不需要通过 data 属性来回传:
function renderListWithClick(list) { const container = document.getElementById('userList'); container.innerHTML = ''; list.forEach(item => { const div = document.createElement('div'); div.textContent = item.name; div.addEventListener('click', () => { showUserDetail(item); // 直接拿到完整的 item 对象 }); container.appendChild(div); }); }这种写法牺牲了一点性能,但换来的是代码的可维护性。对数据量大、渲染频繁的列表,我会再考虑文档片段 DocumentFragment 来减少回流:
function renderListWithFragment(list) { const fragment = document.createDocumentFragment(); list.forEach(item => { const div = document.createElement('div'); div.textContent = item.name; fragment.appendChild(div); }); document.getElementById('userList').appendChild(fragment); }用 fragment 的目的,是把多次 appendChild 合并成一次 DOM 操作,减少浏览器布局计算的次数。列表项超过一百条时,这种差异在低端设备上会很直观。
7. 请求异常与状态管理实录
7.1 HTTP 状态码对应的排查思路
请求失败时,第一个要看的就是状态码。我的排查经验基本是按状态码分类的:
| 状态码 | 含义 | 排查方向 |
|---|---|---|
| 200 - 299 | 请求成功 | 直接看响应体的业务 code 是否正常 |
| 301 / 302 | 资源临时或永久移动 | 检查接口地址是否变更,是否有跨域重定向 |
| 400 | 请求参数有误 | 核对参数名、参数类型、参数格式 |
| 401 | 未认证 | 检查登录态是否过期,token 是否携带 |
| 403 | 无权限 | 检查当前账号的角色权限 |
| 404 | 接口不存在 | 核对 URL 路径,确认服务是否部署 |
| 500 | 服务端内部错误 | 主要看后端日志,前端能做的有限 |
7.2 网络层面的错误处理
除了服务端返回的状态码,浏览器侧还有一类网络错误,比如断网、域名解析失败、请求被 CORS 拦截等。这类问题在 fetch 里会走进 catch 分支:
async function safeRequest(url, options) { try { const response = await fetch(url, options); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return await response.json(); } catch (error) { console.error('网络异常:', error); handleNetworkError(error); } }有一个经验性的判断逻辑:如果 catch 里的 error 是 TypeError,大概率是网络层问题;如果错误信息里带 HTTP 状态码,一般已经进入了业务层。我习惯在 catch 分支里统一弹 Toast:第一种情况提示"网络连接失败,请检查网络",第二种情况提示后端返回的具体错误信息。
7.3 超时与竞态处理
fetch 原生不提供超时中断的能力,需要结合 AbortController 来实现:
async function fetchWithTimeout(url, timeout = 10000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { signal: controller.signal }); return await response.json(); } catch (error) { if (error.name === 'AbortError') { throw new Error('请求超时'); } throw error; } finally { clearTimeout(timer); } }竞态问题则是另一个高频坑。用户在搜索框里连输几个关键词,每次都触发请求,但响应到达的顺序不一定跟触发顺序一致。后发的请求可能先返回,先发的可能后返回,最终页面渲染的内容反而对应了旧关键词。解决方案是记录"最新请求序号",只有在响应的序号是当前最新时才渲染:
let requestId = 0; async function search(keyword) { const currentId = ++requestId; const data = await fetchData(keyword); if (currentId === requestId) { renderList(data); } }这个模式在联动下拉、搜索建议、Tab 切换等场景里屡试不爽。
8. 数据封装与复用:写一个项目通用的请求模块
8.1 统一封装是最常用的做法
日常项目里,几十个接口不可能每个都裸调 fetch。我习惯在外层做一层轻封装,把请求头统一带上 token,把响应的业务 code 判断逻辑收敛在一个地方:
const request = async (url, options = {}) => { const token = localStorage.getItem('token') || ''; const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) }; if (token) { headers['Authorization'] = `Bearer ${token}`; } const response = await fetch(url, { ...options, headers }); const result = await response.json().catch(() => ({ code: -1, message: '解析响应失败' })); if (result.code === 0) { return result.data; } if (result.code === 401) { // 跳转登录页,这里具体项目具体处理 location.href = '/login'; return null; } throw new Error(result.message || '请求失败'); }; // 使用示例 const list = await request('/api/user/list?page=1'); renderList(list);封装完成后,业务代码里的请求调用就变得非常清爽,异常处理逻辑也统一收敛了。这里有一个细节值得注意:options里的headers如果由调用方传入,需要和默认 headers 合并,而不是覆盖,所以代码里用了对象展开...options.headers。
8.2 防重复提交的实用编排
在实际交互里,用户连续点击提交按钮可能会造成重复请求。我惯用的做法是加一个 isSubmitting 锁:
let isSubmitting = false; async function onSubmit(data) { if (isSubmitting) { return; } isSubmitting = true; try { const result = await request('/api/user/create', { method: 'POST', body: JSON.stringify(data) }); // 处理成功逻辑 } finally { isSubmitting = false; } }这样既防止了重复提交,又保证了异常时锁能释放。比单独做 setTimeOut 解锁的方式鲁棒,也更贴合 async/await 的风格。
9. 四个调优方向:让请求更快更稳
9.1 利用缓存避免重复请求
同一个接口的数据,如果短时间内不会变化,完全可以在浏览器端做内存缓存,而不是每次进入页面都重复走网络请求:
const cacheMap = new Map(); async function getDataWithCache(url) { if (cacheMap.has(url)) { return cacheMap.get(url); } const data = await request(url); cacheMap.set(url, data); return data; }这样做的好处是切页时数据能秒开,用户体验会明显提升。缺点是要注意缓存失效节奏,比如用户操作后调用了更新接口,缓存就应该被手动清理掉。比较好的做法是让调用方在更新成功后调用cacheMap.delete(url)。
9.2 响应较小的数据用 text 解析
有些接口返回的不是 JSON,而是一段 HTML 片段或纯文本。这时如果统一 json() 解析,反而会抛异常。我的原则是根据 Content-Type 来决定解析方式:
async function parseResponse(response) { const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/json')) { return response.json(); } return response.text(); }这样可以避免一部分因为响应格式与预期不符导致的报错。
9.3 请求日志与监控
在项目联调阶段,我会在 request 模块里加一个简单的日志输出,记录每个请求的 url、方法、耗时、状态码。代码不复杂,但排查问题的效率提升非常大:
const startTime = Date.now(); try { const data = await fetchAndParse(url, options); console.debug(`[Request] ${url} 耗时 ${Date.now() - startTime}ms`); return data; } catch (error) { console.warn(`[Request Error] ${url}`, error); throw error; }上线前我会把这部分日志移除或交给专门的埋点组件管理,避免生产环境日志刷屏。
9.4 并发请求的编排
页面初始化时往往需要同时拉取多个无关接口。比如进入一个管理后台首页,需要同时拿用户信息、公告列表、待办数量。此时用 Promise.all 可以并行请求,而不是串行等待:
async function initDashboard() { const [userInfo, noticeList, todoCount] = await Promise.all([ request('/api/user/info'), request('/api/notice/list'), request('/api/todo/count') ]); renderUserInfo(userInfo); renderNotice(noticeList); renderTodoCount(todoCount); }使用 Promise.all 是有前提的:这几个接口彼此没有依赖关系。如果接口 B 的参数需要接口 A 的返回值,那就得用我在前面讲过的串行 await 方式,而不能强行并行。这个判断要放在设计阶段就完成,不要等接口联调时才发现逻辑错位。
10. 原生请求代码的隐藏坑与技巧
10.1 同步请求为什么不应该用
XMLHttpRequest 支持传入 false 来启用同步请求,表示页面线程会阻塞等待响应返回。这种方法在极少数场景(例如页面关闭前的清理上报)看起来有用,但实际开发中我强烈不建议用。因为同步请求会让浏览器冻结,用户无论点哪里都没反应,移动端还容易出现标签卡死。Fetch 接口本身不支持同步请求,也从侧面验证了这条路不该走。
10.2 关于 content-type 的一个细节
很多服务端框架在解析 application/json 时,对请求体格式有严格校验。如果 JSON.stringify 序列化后的内容包含未定义值或循环引用,服务端解析就可能失败。所以我封装 request 时,会在 JSON.stringify 外包一层 try/catch,防止序列化过程直接抛错:
function safeJsonStringify(data) { try { return JSON.stringify(data); } catch (e) { return null; } }如果返回 null,则包含循环引用,请求不会发出,而是直接抛出可读的错误信息。这种边界处理虽然小,但确实帮我避免过线上问题。
11. 常见问题快速排查清单
这是一个我在团队里分享的速查表,每次有人盯着控制台迷茫时,我都会让他对照这张表逐步排查:
| 现象 | 可能原因 | 验证方法 | 解决路径 |
|---|---|---|---|
| 控制台报 404 | URL 拼错、接口未部署 | 打开 Network 面板看请求地址 | 修正路径或部署服务 |
| 控制台报 500 | 后端代码异常 | 看响应体的 error 信息 | 联系后端同学查日志 |
| 请求成功但数据为空 | 参数不对或返回 data 结构变化 | 在 Network 面板里查看 Response 原文 | 和后端核对响应格式 |
| 请求出现 OPTIONS 预检 | 跨域请求且带了自定义头 | 查看请求 Method 列有 OPTIONS | 后端配置 CORS 允许跨域 |
| 入参中文变乱码 | 未做 URL 编码 | 查看请求 URL 里中文字符 | 用 URLSearchParams 编码 |
| 页面白屏且控制台暂无报错 | 响应数据渲染前抛错被吞 | 在渲染函数后面加 debugger | 处理渲染层异常 |
| fetch 请求 404 但走的是 resolve | 对 fetch 语义理解偏差 | 打印 response.ok 的值 | 手动判断 response.ok 再抛错 |
| 连续点击按钮发出多条请求 | 缺少提交锁 | 观察 Network 面板请求总数 | 加 isSubmitting 锁 |
这张表我是从一次次的报错里总结出来的,基本覆盖了项目的绝大多数前端请求异常。对照着做,能省很多排查时间。
12. 个人实操中的一点体会
我做前端技术这些年,最深的感受是请求模块从能跑,到好用,中间隔了封装、边界处理、错误收敛三个台阶。很多人停留在第一步,功能是通的,但代码越来越脆,改一处就崩一处。把请求链路整体梳理清楚后,新增接口对业务的打扰会降到很低,页面渲染与数据获取的边界也能随之清晰起来。
还有一个细节分享给做管理后台的朋友:接口返回的 list 长度真到了几百上千,且每项都有操作按钮时,渲染一定不要用 innerHTML 拼接大段模板,宁可分段渲染或借助虚拟滚动思路。这个问题我早期没注意,数据量一上来,页面卡到怀疑人生。
最后给一个检查习惯:每次写完一个页面模块,打开浏览器的 Network 面板,按 XHR 类型过滤,确认一次页面加载发出了哪些请求、耗时分布如何、有没有重复请求。养成这个习惯,比看十篇教程都管用。