资讯动态

Ajax请求数据全流程实战:从XMLHttpRequest到fetch封装与渲染

发布时间:2026/10/20 5:36:46 来源:尧图企业网站定制
写一篇 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 表示请求对象已创建但未调用 open1 表示已调用 open 但尚未 send2 表示 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只在网络层出错或请求被阻止时才会 rejectHTTP 状态码哪怕是 500fetch 也认为请求完成了。这就是上面代码里为什么要手动判断response.ok的原因。很多人第一次用 fetch 时以为返回 500 会走进 catch结果发现走的是 resolve数据却是废弃的排查半天才发现是这个坑。如果追求代码更简洁可以用 async/awaitasync 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?statusactivepage1pageSize10。直接拼字符串不是不行但用户输入的内容里如果包含特殊字符比如空格、、#、中文就可能导致参数被截断或解析错误。我之前踩过一次很深的坑搜索关键词里带了一个空格直接在 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 请求主要用于提交数据。在前后端分离的架构里最常见的请求体格式是 JSONasync 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;charsetUTF-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 classuser-item span${item.name}/span span${item.age}/span button onclickviewDetail(${item.id})查看/button /div ).join(); }这段代码的隐患集中在模板字符串里的${item.name}。如果 name 字段来自用户输入且包含script或img onerror...之类的内容直接插入 innerHTML 就会产生 XSS 漏洞。所以在做这类拼接前我习惯先做一个 HTML 转义函数function escapeHtml(str) { return String(str) .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, #039;); }然后把所有变量包进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?page1); 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. 常见问题快速排查清单这是一个我在团队里分享的速查表每次有人盯着控制台迷茫时我都会让他对照这张表逐步排查现象可能原因验证方法解决路径控制台报 404URL 拼错、接口未部署打开 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 类型过滤确认一次页面加载发出了哪些请求、耗时分布如何、有没有重复请求。养成这个习惯比看十篇教程都管用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑