本节摘要:把前两章的零散知识拼成第一个能跑的完整程序。本节以"取一个用户列表并渲染到页面"为任务,分别用 XMLHttpRequest 与 Fetch 两种 API 实现完整链路——发起请求、判断结果、解析数据、渲染 DOM、处理失败,并把两种写法逐段对照,看清同一任务在两代 API 下的代码形状差异。
不搞抽象演示,做一件具体的事。页面上有一个空的列表容器,页面加载后向接口发起请求,把返回的用户数组渲染成列表;失败时在原位置给出提示。接口约定返回如下 JSON:
{ "code": 0, "data": [ { "id": 1, "name": "李雷", "email": "lilei@example.com" }, { "id": 2, "name": "韩梅梅", "email": "hanmm@example.com" } ] }
页面骨架只有一段:
<ul id="user-list"> <li class="loading">正在加载用户…</li> </ul>
注意初始状态就放了一条"正在加载"——加载态不是锦上添花,是请求代码的一部分。数据没到之前用户看到的不应该是空白。
先写现代写法,二十几行内完成完整链路:
async function loadUsers() { const list = document.getElementById('user-list'); try { const res = await fetch('/api/users'); // 第一步:HTTP 层分流 Fetch 不拒绝 404 500 必须手动查 if (!res.ok) { throw new Error('服务器返回 ' + res.status); } // 第二步:解析 JSON 解析失败会抛错 落进同一个 catch const body = await res.json(); // 第三步:业务层分流 约定的 code 见 2.4 节 if (body.code !== 0) { throw new Error(body.message || '业务处理失败'); } // 第四步:渲染 list.innerHTML = ''; for (const u of body.data) { const li = document.createElement('li'); li.textContent = u.name + '(' + u.email + ')'; list.appendChild(li); } } catch (err) { // 统一收口:网络错误 HTTP 错误 业务错误 解析错误 都到这 list.innerHTML = ''; const li = document.createElement('li'); li.className = 'error'; li.textContent = '加载失败:' + err.message; list.appendChild(li); } } loadUsers();
逐段看这段代码里的设计决策。
为什么用 async 函数。await 让"发请求、查状态、解析体、查业务码"四个异步步骤按同步的形状排列,一行一行读下来就是执行顺序。等价的 Promise 链版本在 3.6 节对照,那里你会看到回调嵌套如何被这个语法消解。
为什么要三步分流。这是实战代码与教程示例的核心差别:教程示例只写"成功路径",实战代码里 HTTP 层(res.ok)、解析层(res.json() 抛错)、业务层(code)三道关卡各有各的失败方式,必须层层设防。三个关卡全部收口到同一个 catch,失败处理只写一处。
为什么用 createElement 而不是拼 HTML。li.textContent = ... 把数据当纯文本插入,天然免疫 XSS——就算接口数据里藏着 <script> 也只会显示成文字。用 innerHTML 拼接字符串当然也能跑,但只要有一处数据没转义,就是注入漏洞(4.2 节展开)。默认用 textContent,确要渲染富文本时再上有白名单的净化方案,这是本教程贯彻的纪律。
为什么先清空再填充。list.innerHTML = '' 先移除加载态占位,避免"加载中"和真实数据同屏。批量重建列表时这个顺序别反。
同样的功能换 XHR 实现,对照着读最能看清两代 API 的形状差异:
function loadUsersXhr() { const list = document.getElementById('user-list'); const xhr = new XMLHttpRequest(); xhr.open('GET', '/api/users', true); xhr.responseType = 'json'; // 让浏览器替我们解析 xhr.onload = function () { if (xhr.status >= 200 && xhr.status < 300) { const body = xhr.response; // 已是对象 if (body.code !== 0) { renderError(list, body.message); return; } renderList(list, body.data); } else { renderError(list, '服务器返回 ' + xhr.status); } }; xhr.onerror = function () { // 网络层失败 renderError(list, '网络错误,请检查连接'); }; xhr.send(); } function renderList(list, users) { list.innerHTML = ''; for (const u of users) { const li = document.createElement('li'); li.textContent = u.name + '(' + u.email + ')'; list.appendChild(li); } } function renderError(list, msg) { list.innerHTML = ''; const li = document.createElement('li'); li.className = 'error'; li.textContent = '加载失败:' + msg; list.appendChild(li); }
两个版本逐项对照:
| 对照点 | Fetch 版 | XHR 版 |
|---|---|---|
| 代码组织 | 顺序直线(async/await) | 事件回调分块 |
| 状态码检查 | !res.ok 手动 |
onload 里判范围 |
| 网络错误 | 走 catch | 独立的 onerror 回调 |
| JSON 解析 | res.json() 返回 Promise |
responseType = 'json' 预设 |
| 错误收口 | 一个 catch 全收 | 每个回调各自处理 |
| 学习重点 | 理解"HTTP 错误不是 reject" | 理解事件挂载时机与状态语义 |
形状差异背后是设计哲学差异:XHR 是事件驱动("响应到了会通知你"),Fetch 是值驱动("调用返回一个将来会有值的 Promise")。后者与函数组合、错误传播的现代 JS 风格更契合,这也是它成为默认推荐的原因。但注意 XHR 版的两个细节仍有价值:onerror 把网络错误从 HTTP 错误里独立出来的语义,以及 responseType 预设让解析失败的路径也走错误事件——这两点在你用 Fetch 时需要自己动手区分,Fetch 不会替你做。
真实接口很少有零参数的。GET 带查询参数(记得编码,2.1 节的坑):
const params = new URLSearchParams({ page: 2, keyword: searchInput.value }); const res = await fetch('/api/users?' + params.toString());
URLSearchParams 自动处理编码,比手工拼串再 encodeURIComponent 更不容易漏。POST 提交 JSON 则是四件套——方法、头、体、序列化,一样不能少:
const res = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '新人', email: 'new@example.com' }) });
漏掉 Content-Type 头,服务端按默认格式解析请求体,轻则 400,重则数据进错字段。"发什么声明什么"(2.2 节)在每次写 POST 时都要默念一遍。
上面的代码能跑,但把"分流三关卡"的逻辑散在每个调用点,第五个接口出现时就会有第五份复制粘贴。实战代码的下一步是提取统一请求函数——这也是理解 axios 这类库在做什么的最好方式:
async function request(url, options = {}) { const res = await fetch(url, { headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options }); if (!res.ok) { throw new Error('HTTP_' + res.status); // 错误码化 便于上层分流 } const body = await res.json(); if (body.code !== 0) { throw new Error(body.message || '业务失败'); } return body.data; // 调用方直接拿业务数据 } // 业务代码从此只有三行 const users = await request('/api/users?page=1'); renderList(list, users);
提取之后,业务代码不再关心 HTTP 状态码、业务码、解析顺序这些协议细节,只表达"我要什么数据、拿到之后干什么"。错误用带结构的异常抛出(这里用前缀演示,工程里通常用自定义 Error 子类携带 status 字段),上层按类型分流提示。
这个函数就是项目里请求层的雏形。后面几节会持续往它身上加东西:3.2 节加表单序列化、3.5 节加错误分类与超时、4.4 节加取消与重试、5.1 节你会看到 axios 把它做成了有拦截器与实例配置的完整产品。自手写一遍再去看库,库的每个配置项都会变得显然。
把本节代码当模板用时,按这张清单自查:
!res.ok 或 2xx 区间,没有只写 === 200。八条全过,这个"简单示例"就已经领先相当一部分线上代码了。
因为 Fetch 的设计只把"请求没能完成 HTTP 交换"(断网、DNS 失败、跨域拦截)当作 rejection;404、500 都是"成功完成的 HTTP 响应",由你检查 res.ok 或 res.status 自行处理。这是 Fetch 被抱怨最多的设计,也是必背的一条。
标准语境不行(顶层 await 仅限模块顶层)。事件回调里用 await,就把回调声明成 async——btn.addEventListener('click', async () => {...}) 是完全合法的写法。
列表类数据放 DOM 就绪后立即发(不必等图片样式全加载完);首屏关键数据甚至可以在入口脚本最前面就发。原则是让网络往返与渲染并行,别让数据请求排队等一个它不依赖的事件。
学完本节最好的巩固方式,是做一个真实有用的小东西。需求:页面上有一个输入框和一个按钮,输入任意接口地址,点击后请求它并把结果以结构化卡片展示——状态码、耗时、响应头摘要、响应体(格式化后)。这个不到百行的小项目,会把本节的每个知识点都逼着用一遍。
拆解一下实现要点。耗时测量用两次时间戳相减,包在 try finally 里保证失败也有记录;更精细的做法用性能接口的条目数据,但 Date 差值对工具场景足够。状态码分流用本节的三关卡:网络层失败显示"无法连接"、非 2xx 显示状态码与状态文本、2xx 再尝试解析。响应体处理分两路:Content-Type 含 json 的格式化展示(解析后缩进两个空格再显示),其余按文本展示——顺带处理解析失败(可能是个长得像 JSON 的错误文本)的兜底。安全展示响应体一律 textContent 放进代码块元素,杜绝把响应当 HTML 渲染的 XSS 面(本工具会被人拿去请求各种地址,这条尤其重要)。按钮防重沿用 3.2 节的状态机。
做完基础版,可以按兴趣加料:加一个历史记录列表(把查过的地址存本地,每次点击复用统一请求函数,练封装);加一个对比模式(同一地址连发三次,展示三次耗时,直观感受网络波动);加一个断网模拟(配合开发者工具的离线模式,观察你的错误分支是否都接住了)。每一项加料都在复用本节的骨架——骨架搭对了,加功能就是抄自己写过的代码。
这个小工具的另一个价值是"排障伙伴":以后怀疑某个接口有问题,先拿它查一下,状态码、头部、耗时、响应体一目了然——比直接看代码猜快得多。给自己造工具,是工程师和操作员的分水岭之一。
基础示例里那段十行的渲染函数,在真实项目里通常还要再打磨一轮,这里把打磨点列出来供对照。第一条,行数据的空值处理:姓名或邮箱缺失时直接拼接会显示"undefined",要么给缺省值要么给占位样式,3.5 节的 normalize 思想提前用上。第二条,长文本截断:邮箱超长会撑破布局,样式层做省略号截断,但更稳的是渲染层控制结构(外套容器限制宽度)。第三条,构建方式的封装:把"一行数据变一个元素"的逻辑抽成独立函数后,列表渲染就变成 map 加 append 的两行主流程——这个抽取出来的行构建函数,正是后续加排序、过滤、虚拟滚动时的复用单元。第四条,XSS 的最后自查:行构建函数里所有来自接口的字符串都经过 textContent 而不是字符串拼接进 HTML,一眼扫过去没有拼角括号的写法,就可以放心提交。
再补一个很多人忽略的行为细节:渲染之前先确认容器存在且可见。轮播里不可见的面板、被条件隐藏的区块,对它们渲染数据虽然不报错,但用户感知不到更新,容易形成"数据到了界面没变"的误报。渲染函数开头加一个存在性检查与必要的显隐切换,是把"能跑"写成"可信"的小动作。这些打磨单看都不难,合起来就是教程代码与生产代码的差距所在——生产代码的本质,是把所有"一般情况下不会出问题"的地方也照顾到。把这段打磨过的渲染函数与最初的十行版本放在一起对比读一遍,差异处就是你与"生产级前端"之间剩余的距离清单;照着清单逐项补齐,本节的目标就全部达成了。