导读:本文从 XMLHttpRequest 的进阶特性 (响应类型、超时、进度事件)出发,系统讲透 Web 开发绕不开的 同源策略与跨域 ,对比 CORS (官方方案)与 JSONP (历史方案)的原理与取舍,再把请求逻辑沉淀为一个可复用的
ajax()封装 ,最后用一个 RESTful 记账本 把「前端发请求 → 后端响应 → 渲染列表」的全链路串起来。每个知识点都配有可直接保存为.html运行的示例,所有结论锚定 MDN 与 W3C 规范。适合已掌握 XHR 基本用法、希望进入工程实战的前端学习者。权威参考:MDN XMLHttpRequest | MDN 同源策略 | MDN CORS | MDN FormData | Fetch 标准
目录
- 零、导读与学习价值
- [0.1 示例覆盖清单](#0.1 示例覆盖清单)
- [0.2 核心名词速查](#0.2 核心名词速查)
- [0.3 为什么要学本篇](#0.3 为什么要学本篇)
- [一、XMLHttpRequest 核心机制回顾](#一、XMLHttpRequest 核心机制回顾)
- 名词解释
- 概念与底层原理
- [入门示例:
responseType与基础事件](#入门示例:responseType 与基础事件) - 实战示例:超时控制与进度事件
- 二、同源策略与跨域
- [三、CORS 跨域资源共享](#三、CORS 跨域资源共享)
- [四、JSONP 跨域实现](#四、JSONP 跨域实现)
- 名词解释
- 概念与底层原理
- [入门示例:手写 JSONP 四步](#入门示例:手写 JSONP 四步)
- 实战示例:搜索框输入联想
- [五、Ajax 函数封装](#五、Ajax 函数封装)
- [六、记账本 REST 实战](#六、记账本 REST 实战)
- 名词解释
- 概念与底层原理
- [入门示例:REST API 的服务端实现](#入门示例:REST API 的服务端实现)
- 实战示例:完整可运行的记账本
- 七、错误处理与调试
- 八、性能优化与生产实践
- 总结
零、导读与学习价值
0.1 示例覆盖清单
本文每个知识点都配有可运行示例,下表列出全部示例与对应章节,确保「读完即能动手」:
| 示例 | 练习要点 | 本文章节 |
|---|---|---|
| XHR 进阶特性演示 | responseType:'json'、timeout、onprogress 进度事件、大 JSON 响应 |
§1 |
| 同源判断与跨域复现 | URL.origin、isSameOrigin、跨域报错现场 |
§2 |
| CORS 联调示例 | 服务端 Access-Control-Allow-Origin、按 Origin 白名单放行 |
§3 |
| HTTPS 服务示例 | https.createServer、协议不同即跨域 |
§3 |
| 手写 JSONP 示例 | 动态 <script>、回调函数名、服务端拼 cb(JSON) |
§4 |
| 搜索联想 JSONP 示例 | 输入联想、<datalist>、第三方 sugrec 接口 |
§4 |
ajax(options) 封装示例 |
统一配置项、responseType、success/error 回调 |
§5 |
| Promise 版封装示例 | async/await、拦截器、统一错误出口 |
§5 |
| 记账本 REST 示例 | GET/POST/DELETE /api/account、FormData、事件委托、{code,msg,data} |
§6 |
| 错误分层处理示例 | HTTP 状态码分支、超时重试、统一错误提示 | §7 |
| 防抖搜索 + 缓存示例 | 输入防抖、请求缓存、escapeHtml 防 XSS |
§8 |
0.2 核心名词速查
| 术语 | 一句话解释 |
|---|---|
| 源(Origin) | 协议 + 域名 + 端口三者构成的身份,三者全同才是同源 |
| 同源策略 | 浏览器的安全机制:限制脚本读取「非同源」响应 |
| 跨域 | 页面源与请求 URL 的源不一致;请求能发出,但响应被浏览器拦截 |
| CORS | 跨域资源共享,服务端用响应头声明「允许哪些源读取响应」 |
| 简单请求 / 预检请求 | 满足特定条件的请求直接发;否则先发 OPTIONS 预检 |
| JSONP | 借 <script src="?callback=fn"> 跨域,仅支持 GET |
responseType |
声明响应体解析格式,设为 'json' 后 xhr.response 是对象 |
| 进度事件 | onprogress 等事件,按字节汇报下载/上传进度 |
FormData |
以「表单」形式组织请求体,自动带 multipart 边界 |
| 统一响应体 | 后端约定的 { code, msg, data } 结构,code 表业务结果 |
| RESTful | 用 HTTP 方法(GET/POST/DELETE/PATCH)表达对资源的操作语义 |
0.3 为什么要学本篇
- 岗位刚需:前后端分离已是行业标配,「页面在一个源、接口在另一个源」是每天都要面对的场景;不懂跨域,连本地联调都跑不通。
- 工程化起点 :把零散的
XMLHttpRequest调用收敛成一个ajax()封装,是理解 axios、fetch封装乃至前端「请求层」架构的第一步。 - 承上启下 :本篇上承
XMLHttpRequest基础用法,下接 Promise、async/await与fetch/axios------封装里的回调正是后续用 Promise 重写的对象。 - 面试高频:「同源策略三要素」「CORS 简单请求与预检」「JSONP 为什么只能 GET」「业务 code 与 HTTP 状态码如何分层」是前端面试的必考题。
一、XMLHttpRequest 核心机制回顾
在进入跨域之前,先把 XMLHttpRequest(下称 XHR)容易被忽略的几个进阶能力补齐------它们是后面封装 ajax() 时每一个配置项的来历。
名词解释
XMLHttpRequest(XHR):浏览器提供的、用脚本发起 HTTP 请求的内置对象,是 Ajax 技术的底层引擎。responseType:一个字符串属性,声明「希望浏览器把响应体解析成什么类型」,可取''/'text'/'json'/'blob'/'arraybuffer'/'document'。response与responseText:responseText永远是字符串;response的类型由responseType决定。- 进度事件(Progress Events) :一组描述请求生命周期的事件------
loadstart、progress、load、error、timeout、loadend。 timeout:毫秒数,超过该时长请求自动中止并触发ontimeout。- 同步请求 / 异步请求 :
xhr.open()第三个参数为false时为同步,会冻结页面;默认true为异步。
概念与底层原理
XHR 的一次请求是一个有限状态机 。readyState 从 0 到 4 依次推进,每次推进都触发一次 readystatechange:
readyState |
常量 | 含义 |
|---|---|---|
| 0 | UNSENT |
已创建 xhr,还没 open |
| 1 | OPENED |
已 open,还没 send |
| 2 | HEADERS_RECEIVED |
已收到响应头 |
| 3 | LOADING |
正在下载响应体 |
| 4 | DONE |
响应体接收完毕 |
现代代码不再手动判断 readyState,而是直接监听语义化的进度事件 。它们的触发顺序是固定的:loadstart → progress(可能多次)→ load / error / timeout / abort → loadend。其中 load 表示「响应成功收完」,error 表示「请求根本没发成功」(断网、DNS 失败、被同源策略拦截),loadend 则无论成败都会触发,是放收尾逻辑(关 loading 动画)的最佳位置。
responseType 的价值在于「把解析工作交给浏览器」。设为 'json' 后,浏览器在 C++ 层直接把响应体解析成对象挂到 xhr.response------这比拿到 responseText 再 JSON.parse() 更省事,也避免了「忘了 try/catch 导致解析异常崩溃」。代价是:一旦设了 responseType='json',responseText 就不可用(访问会抛错),二者只能选其一。
关于同步请求 :xhr.open(method, url, false) 会让 JS 主线程阻塞 到响应返回为止,期间页面无法滚动、无法响应点击。XHR 规范 已明确将「主线程上的同步 XHR」列为废弃特性,浏览器控制台会打印警告。结论很简单:永远用异步。
#mermaid-svg-4nhRmmxL1lb87OLy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4nhRmmxL1lb87OLy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4nhRmmxL1lb87OLy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4nhRmmxL1lb87OLy .error-icon{fill:#552222;}#mermaid-svg-4nhRmmxL1lb87OLy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4nhRmmxL1lb87OLy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4nhRmmxL1lb87OLy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4nhRmmxL1lb87OLy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4nhRmmxL1lb87OLy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4nhRmmxL1lb87OLy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4nhRmmxL1lb87OLy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4nhRmmxL1lb87OLy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4nhRmmxL1lb87OLy .marker.cross{stroke:#333333;}#mermaid-svg-4nhRmmxL1lb87OLy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4nhRmmxL1lb87OLy p{margin:0;}#mermaid-svg-4nhRmmxL1lb87OLy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-4nhRmmxL1lb87OLy .cluster-label text{fill:#333;}#mermaid-svg-4nhRmmxL1lb87OLy .cluster-label span{color:#333;}#mermaid-svg-4nhRmmxL1lb87OLy .cluster-label span p{background-color:transparent;}#mermaid-svg-4nhRmmxL1lb87OLy .label text,#mermaid-svg-4nhRmmxL1lb87OLy span{fill:#333;color:#333;}#mermaid-svg-4nhRmmxL1lb87OLy .node rect,#mermaid-svg-4nhRmmxL1lb87OLy .node circle,#mermaid-svg-4nhRmmxL1lb87OLy .node ellipse,#mermaid-svg-4nhRmmxL1lb87OLy .node polygon,#mermaid-svg-4nhRmmxL1lb87OLy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-4nhRmmxL1lb87OLy .rough-node .label text,#mermaid-svg-4nhRmmxL1lb87OLy .node .label text,#mermaid-svg-4nhRmmxL1lb87OLy .image-shape .label,#mermaid-svg-4nhRmmxL1lb87OLy .icon-shape .label{text-anchor:middle;}#mermaid-svg-4nhRmmxL1lb87OLy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-4nhRmmxL1lb87OLy .rough-node .label,#mermaid-svg-4nhRmmxL1lb87OLy .node .label,#mermaid-svg-4nhRmmxL1lb87OLy .image-shape .label,#mermaid-svg-4nhRmmxL1lb87OLy .icon-shape .label{text-align:center;}#mermaid-svg-4nhRmmxL1lb87OLy .node.clickable{cursor:pointer;}#mermaid-svg-4nhRmmxL1lb87OLy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-4nhRmmxL1lb87OLy .arrowheadPath{fill:#333333;}#mermaid-svg-4nhRmmxL1lb87OLy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-4nhRmmxL1lb87OLy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-4nhRmmxL1lb87OLy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4nhRmmxL1lb87OLy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-4nhRmmxL1lb87OLy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4nhRmmxL1lb87OLy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-4nhRmmxL1lb87OLy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-4nhRmmxL1lb87OLy .cluster text{fill:#333;}#mermaid-svg-4nhRmmxL1lb87OLy .cluster span{color:#333;}#mermaid-svg-4nhRmmxL1lb87OLy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-4nhRmmxL1lb87OLy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-4nhRmmxL1lb87OLy rect.text{fill:none;stroke-width:0;}#mermaid-svg-4nhRmmxL1lb87OLy .icon-shape,#mermaid-svg-4nhRmmxL1lb87OLy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4nhRmmxL1lb87OLy .icon-shape p,#mermaid-svg-4nhRmmxL1lb87OLy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-4nhRmmxL1lb87OLy .icon-shape .label rect,#mermaid-svg-4nhRmmxL1lb87OLy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4nhRmmxL1lb87OLy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-4nhRmmxL1lb87OLy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-4nhRmmxL1lb87OLy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 成功
网络失败
超时
new XMLHttpRequest
open 初始化
send 发送
loadstart
progress 多次
结果
load
error
timeout
loadend 收尾
【代码注释】(XHR 生命周期图)这张图描述的是一次 XHR 请求从创建到结束的事件流。
open只是「初始化」请求行,并不发送;send才真正把请求交给网络层。progress在下载过程中可能触发很多次 ,每次带来最新的loaded(已下载字节)和total(总字节)。load、error、timeout三者互斥 ,一次请求只会走其中一条分支;loadend一定在最后兜底。- 市面应用 :文件上传进度条、大列表加载的骨架屏与「加载中」遮罩,都是靠
progress+loadend这两个事件驱动的。
入门示例:responseType 与基础事件
下面的示例演示 responseType:'json' 如何省掉手动解析。保存为 xhr-basic.html 用浏览器打开,点击按钮即可在页面看到结果。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>XHR responseType 演示</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 560px; margin: 2rem auto; padding: 0 1rem; }
button { padding: 8px 14px; cursor: pointer; }
pre { background: #f4f4f4; padding: 12px; border-radius: 6px; white-space: pre-wrap; }
</style>
</head>
<body>
<h1>responseType = 'json'</h1>
<button id="btn">请求一条用户数据</button>
<pre id="out">点击按钮发起请求...</pre>
<script>
const out = document.getElementById('out');
document.getElementById('btn').onclick = () => {
const xhr = new XMLHttpRequest();
xhr.responseType = 'json'; // 关键:声明响应是 JSON
xhr.onload = () => {
// xhr.response 已经是对象,无需 JSON.parse
const user = xhr.response;
out.textContent = '类型:' + typeof user + '\n' +
'内容:' + JSON.stringify(user, null, 2);
};
xhr.onerror = () => { out.textContent = '请求失败(检查网络)'; };
xhr.open('GET', 'https://api.github.com/users/octocat');
xhr.send();
};
</script>
</body>
</html>
【代码注释】这段代码的核心是 xhr.responseType = 'json' 这一行。
- 设置后,浏览器会自动把响应体解析成对象,
xhr.onload里拿到的xhr.response直接就是{...}------typeof输出object,证明无需再JSON.parse。 - 为什么这样写更好:手动
JSON.parse(xhr.responseText)一旦响应不是合法 JSON 就会抛异常,必须包 try/catch;交给responseType则由浏览器底层处理,更稳。 - 此处用了一个公开的 GitHub 接口(恰好支持跨域),所以本示例单独打开也能跑通。
- 市面应用 :所有现代请求库(axios、
fetch)默认都帮你解析 JSON,本质就是替你设置了responseType或调用了response.json()。
实战示例:超时控制与进度事件
下面的示例请求一个较大的响应体 ,完整演示 timeout、onprogress、onloadstart、onloadend 的协作。它需要一个本地服务返回大数据,服务端代码如下:
javascript
const path = require('path');
const express = require('express');
const app = express();
// 返回一个「页面」
app.get('/page', (req, res) => {
res.sendFile(path.join(__dirname, 'xhr-progress.html'));
});
// 返回一个超大的 JSON 数组,便于观察 progress 事件
app.get('/getData', (req, res) => {
res.set('Content-Type', 'application/json');
// 把同一段字符串重复 1000 次,凑出一个体积可观的响应
const chunk = `{"name":"张三","token":${Math.random()}},`;
res.send('[' + chunk.repeat(1000) + '{}]');
});
app.listen(8080, () => console.log('服务已启动:http://127.0.0.1:8080/page'));
【代码注释】这段是配套服务端,关键在 /getData 故意构造了一个「大响应」。
chunk.repeat(1000)把一小段 JSON 重复 1000 次,拼成一个上百 KB 的数组------只有响应体足够大,浏览器才会分多次触发progress事件,效果才看得见。res.set('Content-Type', 'application/json')声明响应是 JSON,配合前端的responseType='json'。- 末尾补一个
{}是为了让「逗号结尾的重复串 + 收尾」拼成合法的 JSON 数组。 - 市面应用:真实项目中「大响应」来自导出报表、加载长列表、下载文件等场景,进度事件正是为它们设计的。
配套页面保存为 xhr-progress.html,与服务端放在同一目录,启动服务后访问 http://127.0.0.1:8080/page:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>XHR 进度与超时</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 560px; margin: 2rem auto; padding: 0 1rem; }
progress { width: 100%; height: 18px; }
#log { background: #f4f4f4; padding: 12px; border-radius: 6px; height: 180px; overflow: auto; }
</style>
</head>
<body>
<h1>进度事件与超时控制</h1>
<progress id="bar" value="0" max="100"></progress>
<div id="log"></div>
<script>
const log = m => document.getElementById('log').innerHTML += m + '<br>';
const bar = document.getElementById('bar');
const xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.timeout = 5000; // 超过 5 秒自动中止
xhr.onloadstart = () => log('① 请求开始');
xhr.onprogress = e => { // 下载过程中多次触发
if (e.lengthComputable) {
const percent = Math.floor(e.loaded / e.total * 100);
bar.value = percent;
log(`② 进度 ${e.loaded}/${e.total} 字节(${percent}%)`);
}
};
xhr.onload = () => log('③ 成功,共 ' + xhr.response.length + ' 条');
xhr.ontimeout = () => log('③ 请求超时(超过 5 秒)');
xhr.onerror = () => log('③ 网络错误,请求未发出');
xhr.onloadend = () => log('④ 请求结束(无论成败都执行)');
xhr.open('GET', '/getData');
xhr.send();
</script>
</body>
</html>
【代码注释】这段把 XHR 的五个生命周期事件全部用上,是一个「会动」的完整演示。
xhr.timeout = 5000给请求设了 5 秒上限;若服务端迟迟不返回,会触发ontimeout而不是onerror------二者要分开处理,因为「超时」往往可以重试,「网络错误」则可能是断网。onprogress的事件对象里,e.loaded是已接收字节、e.total是总字节、e.lengthComputable表示「总量是否已知」(服务端没返回Content-Length时它为false)。三者配合就能算出百分比驱动<progress>进度条。onloadstart在请求发出瞬间触发一次,onloadend在请求彻底结束时触发一次------把「显示/隐藏 loading」分别放进这两个回调,逻辑最干净。- 市面应用:网盘上传进度、在线播放器的缓冲进度、后台导出大文件的「已下载 XX MB」提示,本质都是这套事件。
【实战要点】
- 经典应用场景 :文件上传组件用
xhr.upload.onprogress(注意是xhr.upload,不是xhr本身)做上传进度条;列表页用onloadstart/onloadend控制骨架屏的显隐。 - 常见坑 :① 设了
responseType='json'后又去读xhr.responseText,会直接抛InvalidStateError------两者只能用一个。② 把收尾逻辑(关 loading)只写在onload里,结果请求失败时 loading 永远不消失------收尾逻辑应放onloadend。 - 性能与最佳实践 :
timeout一定要设,否则弱网下请求会「永远挂起」,用户既看不到结果也等不到报错;一般接口设 10~15 秒,上传类设得更长或不设。
【本章小结】
| 维度 | 关键点 | 记忆锚点 |
|---|---|---|
| 解析 | responseType='json' → xhr.response 直接是对象 |
「声明类型,免去 parse」 |
| 事件 | loadstart→progress→load/error/timeout→loadend |
「开始、进度、三选一、收尾」 |
| 超时 | timeout + ontimeout,与 onerror 区分 |
「超时可重试,错误要排查」 |
| 同步 | 同步 XHR 阻塞主线程,已废弃 | 「永远用异步」 |
记忆口诀 :类型先声明、进度看 loaded、收尾用 loadend、超时单独管。
【面试考点】
Q1:xhr.response 和 xhr.responseText 有什么区别?
A:responseText 永远是字符串;response 的类型由 responseType 决定------默认('' 或 'text')时它也是字符串,设为 'json' 时它是已解析的对象,设为 'blob'/'arraybuffer' 时是二进制。一旦把 responseType 设成非文本类型,再访问 responseText 会抛 InvalidStateError,所以二者只能选其一。实践中请求 JSON 接口就直接用 responseType='json',省掉手动 JSON.parse 和它的 try/catch。
Q2:XHR 有哪些进度事件?onload 和 onloadend 区别是什么?
A:进度事件按顺序是 loadstart → progress(可多次)→ load / error / timeout / abort → loadend。onload 只在「响应成功收完」时触发,onerror/ontimeout 分别对应失败和超时,三者互斥;onloadend 则无论成功失败都会触发 ,是请求的统一终点。所以「显示 loading」放 onloadstart、「隐藏 loading」放 onloadend,能保证任何结局下 loading 都会消失。
二、同源策略与跨域
名词解释
- 源(Origin) :由 协议(protocol)+ 域名(host)+ 端口(port) 三部分组成的元组。
- 同源(Same-Origin) :两个 URL 的协议、域名、端口三者完全相同。
- 同源策略(Same-Origin Policy):浏览器的核心安全机制,限制一个源的文档/脚本与另一个源的资源交互。
- 跨域(Cross-Origin):违反同源策略的访问;不是错误,而是一种需要被「显式允许」的正常需求。
- 跨域请求:发往「非同源 URL」的 Ajax 请求。
概念与底层原理
同源策略是浏览器内置的一道安全墙。它要解决的核心威胁是:如果没有它,你在 A 银行登录后,随手打开的恶意网站 B 就能用脚本读取 A 银行页面里的余额、转账记录。同源策略通过「限制脚本读取非同源资源」把不同站点的数据彼此隔离。
判断同源的规则非常机械------协议、域名、端口三者全同 才同源,差一个都算跨域。以 https://shop.com 为基准:
| 待比较 URL | 是否同源 | 原因 |
|---|---|---|
https://shop.com/cart |
✅ 同源 | 协议、域名、端口都相同 |
http://shop.com |
❌ 跨域 | 协议不同(https vs http) |
https://api.shop.com |
❌ 跨域 | 域名不同(子域名也算不同) |
https://shop.com:8080 |
❌ 跨域 | 端口不同 |
这里有一个最关键、也最容易被误解的点 :跨域时,请求其实已经成功发出、服务端也确实收到并返回了响应 ------被同源策略拦住的,是「让 JavaScript 读取这个响应 」这一步。所以你会在控制台看到红色的 CORS 报错,但在 Network 面板里那个请求往往是 200。
为什么是「拦读取」而不是「拦发送」?因为浏览器要兼容历史:<img src>、<script src>、<link href>、表单提交从 Web 诞生起就能跨域发请求。同源策略不可能禁止「发」,只能禁止「脚本拿到响应内容」------这样既不破坏老页面,又挡住了「恶意脚本窃取数据」。
同源策略的限制范围也要分清:它管的是 Ajax 读响应、读跨源 iframe 的 DOM、读 Canvas 里的跨源图片像素 ;它不管 <img>/<script>/<link>/<video> 这类标签加载资源------这正是 JSONP 能成立的根基(详见第四章)。
服务器(源 B) 浏览器 页面(源 A) 服务器(源 B) 浏览器 页面(源 A) #mermaid-svg-KYdpQJgBEE0gd72Q{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KYdpQJgBEE0gd72Q .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KYdpQJgBEE0gd72Q .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KYdpQJgBEE0gd72Q .error-icon{fill:#552222;}#mermaid-svg-KYdpQJgBEE0gd72Q .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KYdpQJgBEE0gd72Q .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KYdpQJgBEE0gd72Q .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KYdpQJgBEE0gd72Q .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KYdpQJgBEE0gd72Q .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KYdpQJgBEE0gd72Q .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KYdpQJgBEE0gd72Q .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KYdpQJgBEE0gd72Q .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KYdpQJgBEE0gd72Q .marker.cross{stroke:#333333;}#mermaid-svg-KYdpQJgBEE0gd72Q svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KYdpQJgBEE0gd72Q p{margin:0;}#mermaid-svg-KYdpQJgBEE0gd72Q .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KYdpQJgBEE0gd72Q text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-KYdpQJgBEE0gd72Q .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-KYdpQJgBEE0gd72Q .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-KYdpQJgBEE0gd72Q .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-KYdpQJgBEE0gd72Q .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-KYdpQJgBEE0gd72Q #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-KYdpQJgBEE0gd72Q .sequenceNumber{fill:white;}#mermaid-svg-KYdpQJgBEE0gd72Q #sequencenumber{fill:#333;}#mermaid-svg-KYdpQJgBEE0gd72Q #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-KYdpQJgBEE0gd72Q .messageText{fill:#333;stroke:none;}#mermaid-svg-KYdpQJgBEE0gd72Q .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KYdpQJgBEE0gd72Q .labelText,#mermaid-svg-KYdpQJgBEE0gd72Q .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-KYdpQJgBEE0gd72Q .loopText,#mermaid-svg-KYdpQJgBEE0gd72Q .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-KYdpQJgBEE0gd72Q .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-KYdpQJgBEE0gd72Q .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-KYdpQJgBEE0gd72Q .noteText,#mermaid-svg-KYdpQJgBEE0gd72Q .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-KYdpQJgBEE0gd72Q .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KYdpQJgBEE0gd72Q .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KYdpQJgBEE0gd72Q .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KYdpQJgBEE0gd72Q .actorPopupMenu{position:absolute;}#mermaid-svg-KYdpQJgBEE0gd72Q .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-KYdpQJgBEE0gd72Q .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KYdpQJgBEE0gd72Q .actor-man circle,#mermaid-svg-KYdpQJgBEE0gd72Q line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-KYdpQJgBEE0gd72Q :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 没有 CORS 头 有合法 CORS 头 xhr.open + send 跨域请求 请求照常发出 服务端正常返回 200 + 数据 检查响应是否带 CORS 头 拦截:JS 读不到响应,控制台报错 放行:xhr.onload 拿到数据
【代码注释】(跨域拦截时序图)这张图回答了「为什么请求 200 了前端还报错」。
- 浏览器照常 把跨域请求发给服务器,服务器也照常处理并返回 200------这一段没有任何阻碍。
- 真正的「关卡」在浏览器收到响应之后:它检查响应头里有没有合法的 CORS 声明,没有就拦在浏览器内部,不交给 JS。
- 所以排查跨域问题要看两处:Network 面板看请求是否真的到达服务端(多半到了),控制台看红字说明被拦的原因。
- 市面应用:理解这张图,就能解释开发中最常见的困惑------「我后端明明返回数据了,前端怎么拿不到」,答案永远是「响应缺 CORS 头」。
入门示例:同源判断器
下面的示例用 URL 对象拆解任意网址的源,并判断两个网址是否同源。保存为 origin-check.html 打开即可。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>同源判断</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
input { width: 100%; padding: 8px; margin: 4px 0; box-sizing: border-box; }
pre { background: #f4f4f4; padding: 12px; border-radius: 6px; }
.yes { color: #2a8; font-weight: bold; }
.no { color: #d33; font-weight: bold; }
</style>
</head>
<body>
<h1>两个 URL 是否同源?</h1>
<input id="u1" value="https://shop.com/cart">
<input id="u2" value="https://api.shop.com/list">
<button id="btn">判断</button>
<pre id="out"></pre>
<script>
// 把一个 URL 拆成「协议 + 域名 + 端口」
function originOf(url) {
const u = new URL(url);
return { protocol: u.protocol, host: u.hostname, port: u.port || '(默认)', origin: u.origin };
}
// 同源 = 三要素完全相同,URL.origin 已经把三者拼好
function isSameOrigin(a, b) {
return new URL(a).origin === new URL(b).origin;
}
document.getElementById('btn').onclick = () => {
const a = document.getElementById('u1').value;
const b = document.getElementById('u2').value;
const same = isSameOrigin(a, b);
document.getElementById('out').innerHTML =
'URL1 源信息:' + JSON.stringify(originOf(a), null, 2) + '\n\n' +
'URL2 源信息:' + JSON.stringify(originOf(b), null, 2) + '\n\n' +
'结论:<span class="' + (same ? 'yes">同源' : 'no">跨域') + '</span>';
};
</script>
</body>
</html>
【代码注释】这段示例把「同源判断」这个抽象规则变成了可交互的工具。
new URL(url)是浏览器内置的 URL 解析器,.protocol/.hostname/.port分别取出三要素,.origin则直接给出「协议+域名+端口」拼成的源字符串。isSameOrigin的实现只有一行:比较两个 URL 的.origin是否相等------因为origin已经把三要素打包好了,不必逐个比。- 默认端口(http 的 80、https 的 443)在
.port里是空字符串,所以https://shop.com和https://shop.com:443其实同源。 - 市面应用 :微前端框架在加载子应用前会做同源校验,CDN 防盗链会校验
Referer的源,原理都是这套三要素比对。
实战示例:复现一次真实的跨域报错
下面这个页面会故意 向一个非同源接口发请求,让你在控制台亲眼看到 CORS 报错。保存为 cross-origin-fail.html 打开。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>跨域报错复现</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
button { padding: 8px 14px; cursor: pointer; }
#box { margin-top: 16px; padding: 12px; border: 2px dashed #999; min-height: 60px; }
</style>
</head>
<body>
<h1>跨域请求会发生什么?</h1>
<p>点击按钮后,请同时打开「控制台」和「Network 面板」对照观察。</p>
<button id="btn">向非同源接口发请求</button>
<div id="box">结果会显示在这里...</div>
<script>
document.getElementById('btn').onclick = () => {
const box = document.getElementById('box');
const xhr = new XMLHttpRequest();
// example.com 不会返回 CORS 头,因此这个跨域请求的响应读不到
xhr.onload = () => { box.textContent = '成功读到响应:' + xhr.responseText.slice(0, 80); };
xhr.onerror = () => {
box.innerHTML = '请求失败:响应被同源策略拦截。<br>' +
'请看 Network 面板------这个请求很可能是 200,说明服务端收到了;<br>' +
'但控制台会有 CORS 红字,说明浏览器不让 JS 读这个响应。';
};
xhr.open('GET', 'https://example.com/');
xhr.send();
};
</script>
</body>
</html>
【代码注释】这个示例的目的是「把抽象的跨域规则变成一次可观察的现场」。
https://example.com/是一个真实存在但不返回 CORS 头 的地址,所以从任何别的源向它发 Ajax 请求,响应都会被浏览器拦下,onerror被触发。- 关键的学习动作是「对照看两个面板」:Network 面板里请求状态多半是
200(证明请求发出去了、服务端也回了),控制台却有has been blocked by CORS policy的红字(证明是浏览器在「读取」这一步拦的)。 onerror里拿不到任何状态码------跨域被拦时,前端连xhr.status都读不到,这也是跨域问题难排查的原因。- 市面应用:前后端分离项目第一次本地联调,几乎必然撞上这个报错;看懂它,就知道下一步该去服务端配 CORS(第三章)。
【实战要点】
- 经典应用场景 :前后端分离(页面
:8080、接口:3000,端口不同即跨域)、主站调子域名 API(shop.com调api.shop.com)、静态页调第三方开放接口------这些都是每天都会遇到的跨域场景。 - 常见坑 :① 用
file://协议双击打开 HTML 去联调,此时页面的源是null,行为和线上https://完全不同------练习时务必用本地服务(如node起一个端口)提供页面。② 看到控制台报错就以为「请求没发出去」,于是去查前端代码,方向错了------请求其实发出去了,要去服务端加响应头。 - 性能与最佳实践 :开发阶段最省心的方案是用构建工具的
devServer.proxy,把/api代理到后端,浏览器视为同源、零跨域;上线则靠服务端 CORS 或同域部署/Nginx 反代。
【本章小结】
| 维度 | 关键点 | 记忆锚点 |
|---|---|---|
| 同源定义 | 协议 + 域名 + 端口,三者全同 | 「三要素,缺一不可」 |
| 拦的是什么 | 拦「JS 读响应」,不拦「请求发出」 | 「能发出,读不到」 |
| 为何存在 | 隔离站点数据,防恶意脚本窃取 | 「银行页面不被别站读」 |
| 不管什么 | <img>/<script>/<link> 标签加载 |
「标签跨域自由,这是 JSONP 的根」 |
记忆口诀 :协议域名端口,三同才同源;拦的是读响应,不是发请求。
【面试考点】
Q1:什么是同源策略?它限制的到底是什么?
A:同源策略是浏览器的安全机制,要求「协议、域名、端口」三者完全相同才算同源。它限制的不是「请求能不能发出」,而是「脚本能不能读取非同源的响应」------跨域请求照样会发到服务端、服务端也会返回,只是浏览器在收到响应后、交给 JS 之前把它拦住了。所以跨域时 Network 面板里请求常是 200,控制台却报 CORS 错。它的目的是隔离不同站点的数据,防止你登录 A 网站后被恶意网站 B 用脚本偷读数据。
Q2:为什么 <img>、<script> 可以跨域,Ajax 却不行?
A:同源策略管的是「脚本读取响应内容」,而 <img>/<script>/<link> 这类标签只是「加载并使用资源」,并不把响应内容暴露给 JS 去读------<img> 加载完你拿不到它的二进制、<script> 加载完只是执行了代码。从 Web 诞生起这些标签就能跨域,同源策略为了兼容必须放它们一马。正是「<script> 能跨域加载、且加载的内容会被当作 JS 执行」这两点,催生了 JSONP 这个跨域方案。
三、CORS 跨域资源共享
名词解释
- CORS(Cross-Origin Resource Sharing):跨域资源共享,W3C 标准的官方跨域方案。
Access-Control-Allow-Origin:核心 CORS 响应头,声明「允许哪个源读取本响应」。- 简单请求(Simple Request):满足特定条件的跨域请求,浏览器直接发送、不预检。
- 预检请求(Preflight Request) :非简单请求前,浏览器自动先发的一个
OPTIONS请求,用来「问服务器允不允许」。 - 凭证(Credentials):跨域请求时是否携带 Cookie 等身份信息。
withCredentials:XHR 上的布尔属性,设为true才会在跨域请求里带 Cookie。
概念与底层原理
CORS 是解决跨域的官方方案 ,它的设计哲学是「把决定权交给被请求的服务器 」。浏览器依然拦截跨域响应,但如果服务器在响应头里明确说「我允许 https://shop.com 这个源读我」,浏览器就放行。整个机制对前端几乎透明------前端代码不用为 CORS 做任何特殊处理,配置全在服务端。
CORS 把跨域请求分成两类,区别在于「要不要先预检」:
简单请求------同时满足以下条件,浏览器直接发送:
- 方法是
GET、HEAD、POST之一; - 请求头只用了安全头部(
Accept、Accept-Language、Content-Language、Content-Type); Content-Type只能是text/plain、multipart/form-data、application/x-www-form-urlencoded三者之一。
预检请求 ------只要有一条不满足(比如方法是 PUT/DELETE/PATCH、带了自定义头 Authorization、Content-Type 是 application/json),浏览器就会在真实请求之前 ,自动先发一个 OPTIONS 请求去「问路」。这个 OPTIONS 不带业务数据,只带几个询问头:Access-Control-Request-Method(我接下来想用什么方法)、Access-Control-Request-Headers(我想带哪些头)。服务器用 Access-Control-Allow-Methods、Access-Control-Allow-Headers 回答。预检通过,浏览器才发真实请求;预检失败,真实请求根本不会发。
为什么要有预检这一步?因为 PUT/DELETE 这类方法可能有副作用 (删数据、改数据)。简单请求里的 GET/POST 本来用 <form>、<img> 就能跨域发,预检它们没意义;但 DELETE 这种「新能力」必须先征得服务器同意,否则恶意页面就能跨域删别人数据。预检就是这道「先问后做」的保险。
关于带 Cookie 的跨域 有一个必须记牢的约束:当请求需要带 Cookie(前端设 xhr.withCredentials = true),服务端的 Access-Control-Allow-Origin 绝不能是通配符 * ,必须回填具体的源 ,并且要额外返回 Access-Control-Allow-Credentials: true。三者缺一,浏览器都会拦掉响应。这是规范有意的限制------「带身份 + 对所有源开放」是危险组合,规范从根上禁止它。
服务器 浏览器 服务器 浏览器 #mermaid-svg-73wKp5W4QMYAmWOA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-73wKp5W4QMYAmWOA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-73wKp5W4QMYAmWOA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-73wKp5W4QMYAmWOA .error-icon{fill:#552222;}#mermaid-svg-73wKp5W4QMYAmWOA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-73wKp5W4QMYAmWOA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-73wKp5W4QMYAmWOA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-73wKp5W4QMYAmWOA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-73wKp5W4QMYAmWOA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-73wKp5W4QMYAmWOA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-73wKp5W4QMYAmWOA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-73wKp5W4QMYAmWOA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-73wKp5W4QMYAmWOA .marker.cross{stroke:#333333;}#mermaid-svg-73wKp5W4QMYAmWOA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-73wKp5W4QMYAmWOA p{margin:0;}#mermaid-svg-73wKp5W4QMYAmWOA .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-73wKp5W4QMYAmWOA text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-73wKp5W4QMYAmWOA .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-73wKp5W4QMYAmWOA .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-73wKp5W4QMYAmWOA .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-73wKp5W4QMYAmWOA .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-73wKp5W4QMYAmWOA #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-73wKp5W4QMYAmWOA .sequenceNumber{fill:white;}#mermaid-svg-73wKp5W4QMYAmWOA #sequencenumber{fill:#333;}#mermaid-svg-73wKp5W4QMYAmWOA #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-73wKp5W4QMYAmWOA .messageText{fill:#333;stroke:none;}#mermaid-svg-73wKp5W4QMYAmWOA .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-73wKp5W4QMYAmWOA .labelText,#mermaid-svg-73wKp5W4QMYAmWOA .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-73wKp5W4QMYAmWOA .loopText,#mermaid-svg-73wKp5W4QMYAmWOA .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-73wKp5W4QMYAmWOA .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-73wKp5W4QMYAmWOA .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-73wKp5W4QMYAmWOA .noteText,#mermaid-svg-73wKp5W4QMYAmWOA .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-73wKp5W4QMYAmWOA .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-73wKp5W4QMYAmWOA .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-73wKp5W4QMYAmWOA .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-73wKp5W4QMYAmWOA .actorPopupMenu{position:absolute;}#mermaid-svg-73wKp5W4QMYAmWOA .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-73wKp5W4QMYAmWOA .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-73wKp5W4QMYAmWOA .actor-man circle,#mermaid-svg-73wKp5W4QMYAmWOA line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-73wKp5W4QMYAmWOA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 非简单请求(如 DELETE / application/json) 预检通过才继续 校验通过,交给 JS OPTIONS 预检(询问方法与头部) Allow-Origin / Allow-Methods / Allow-Headers 真实请求(DELETE ...) 真实响应 + Allow-Origin
【代码注释】(CORS 预检时序图)这张图刻画的是「非简单请求」多出来的一次往返。
- 第一次
OPTIONS是浏览器自动 发的,前端代码里看不到它------但在 Network 面板里能看到一条OPTIONS记录。 - 服务器对
OPTIONS的回答(Allow-Methods/Allow-Headers)决定预检是否通过;不通过,下面的真实请求直接不发。 - 真实响应里仍然要带
Access-Control-Allow-Origin------预检通过不代表真实响应能被读,每个响应都要自己「亮证」。 - 市面应用 :用
Authorization头传 token 的接口、用application/json提交的接口,全都会触发预检;这也是「为什么我的接口被请求了两次」的标准答案。
入门示例:服务端开启 CORS
CORS 的配置全在服务端。下面是用 Express 手动设置 CORS 响应头的最小实现:
javascript
const path = require('path');
const express = require('express');
const app = express();
// 返回一个用于联调的页面
app.get('/page', (req, res) => {
res.sendFile(path.join(__dirname, 'cors-page.html'));
});
// 一个允许跨域读取的接口
app.get('/getData', (req, res) => {
// 核心:声明「允许哪个源读取本响应」
// 开发阶段可用 '*' 放行所有源;生产应回填具体源
res.set('Access-Control-Allow-Origin', '*');
res.send('hello cors');
});
app.listen(8080, () => console.log('服务已启动:http://127.0.0.1:8080/page'));
【代码注释】这段代码用一行 res.set 就打通了跨域。
res.set('Access-Control-Allow-Origin', '*')是 CORS 的最小配置:它告诉浏览器「任何源都可以读这个响应」。把这一行注释掉再请求,前端就会复现第二章的跨域报错------这是验证 CORS 是否生效的最快办法。'*'通配符方便但宽松:它意味着全世界任何网站都能读你的接口,且不能配合 Cookie。生产环境应改成按白名单回填具体源(见下方实战示例)。- CORS 是「服务端的事」------这段代码里没有任何针对前端的特殊处理,前端照常用 XHR 即可。
- 市面应用 :开放平台的公共接口(天气、汇率、地图 SDK 的数据接口)常年返回
Access-Control-Allow-Origin: *,因为它们本就希望被任意网站调用。
实战示例:按 Origin 白名单放行 + 联调页面
生产环境不该长期用 *。下面是「按白名单动态回填 Origin」的服务端写法,并能正确处理预检:
javascript
const express = require('express');
const app = express();
// 允许跨域的源白名单
const allowOrigins = ['http://127.0.0.1:5500', 'https://shop.com'];
// 通用 CORS 中间件
app.use((req, res, next) => {
const origin = req.get('Origin');
// 只有在白名单里的源,才把它回填进 Allow-Origin
if (allowOrigins.includes(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Credentials', 'true'); // 允许带 Cookie
res.set('Access-Control-Allow-Methods', 'GET, POST, DELETE, PATCH, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.set('Access-Control-Max-Age', '600'); // 预检结果缓存 10 分钟
}
// 预检请求:直接回 204,不进入业务逻辑
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
app.get('/getData', (req, res) => res.json({ msg: '白名单放行成功' }));
app.delete('/item/:id', (req, res) => res.json({ msg: '已删除 ' + req.params.id }));
app.listen(8080, () => console.log('服务已启动:http://127.0.0.1:8080'));
【代码注释】这段是「生产可用」的 CORS 配置,比通配符 * 安全得多。
- 核心思路是动态回填 :读取请求头里的
Origin,只有它在白名单allowOrigins里,才把这个具体源写进Access-Control-Allow-Origin。这样既精确控制了「谁能调」,又满足了「带 Cookie 时不能用*」的规范要求。 Access-Control-Max-Age: 600让浏览器把预检结果缓存 10 分钟------这段时间内对同一接口的非简单请求不再重复预检,能显著减少OPTIONS往返。if (req.method === 'OPTIONS') return res.sendStatus(204)单独拦截预检请求:预检不需要业务数据,回一个204 No Content即可,不能让它继续走到业务逻辑。- 实际项目中很少手写这一套,直接用官方的
cors中间件(npm i cors,app.use(cors({ origin: allowOrigins, credentials: true })))更省事------但手写一遍能让你真正理解它在做什么。 - 市面应用:所有需要登录态的跨域接口(带 Cookie / token)都用这种「白名单 + 具体 Origin」的写法;电商、SaaS 后台的网关层 CORS 配置就是它的工程化版本。
配套联调页面保存为 cors-page.html。它先后请求一个简单接口和一个会触发预检的 DELETE 接口:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>CORS 联调</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
button { padding: 8px 14px; margin-right: 8px; cursor: pointer; }
#box { margin-top: 16px; padding: 12px; border: 2px dashed #999; min-height: 60px; }
</style>
</head>
<body>
<h1>CORS 跨域联调</h1>
<button id="simple">简单请求 GET</button>
<button id="preflight">预检请求 DELETE</button>
<div id="box"></div>
<script>
const box = document.getElementById('box');
const API = 'http://127.0.0.1:8080';
// 简单请求:GET,不会触发预检
document.getElementById('simple').onclick = () => {
const xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.onload = () => box.textContent = '简单请求成功:' + JSON.stringify(xhr.response);
xhr.onerror = () => box.textContent = '简单请求失败:检查服务端 CORS 头';
xhr.open('GET', API + '/getData');
xhr.send();
};
// 预检请求:DELETE 会先发一个 OPTIONS
document.getElementById('preflight').onclick = () => {
const xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.onload = () => box.textContent = '预检请求成功:' + JSON.stringify(xhr.response);
xhr.onerror = () => box.textContent = '预检请求失败:检查 Allow-Methods / Allow-Headers';
xhr.open('DELETE', API + '/item/101');
xhr.send();
};
</script>
</body>
</html>
【代码注释】这个页面用两个按钮,把「简单请求」和「预检请求」的差别直观地摆出来。
- 点「简单请求」按钮:发的是
GET,Network 面板里只有一条请求。 - 点「预检请求」按钮:发的是
DELETE,Network 面板里会出现两条 记录------先一条OPTIONS(浏览器自动的预检),再一条DELETE(真实请求)。这是肉眼区分两类请求最直接的方式。 - 若把服务端的
Access-Control-Allow-Methods去掉DELETE,预检就会失败,真实的DELETE不会发出,onerror被触发------可以亲手试一下加深印象。 - 市面应用 :联调时遇到「接口被请求两次」不要慌,那条
OPTIONS是正常的预检;真正要排查的是OPTIONS的响应头对不对。
CORS 还和协议绑定。把上面的服务换成 HTTPS,就是另一个源:
javascript
const https = require('https');
const fs = require('fs');
const path = require('path');
const express = require('express');
const app = express();
app.get('/getData', (req, res) => {
res.set('Access-Control-Allow-Origin', '*');
res.send('hello https');
});
// 用证书文件创建 HTTPS 服务,监听 8081
const options = {
key: fs.readFileSync(path.join(__dirname, 'keys', 'private.key')),
cert: fs.readFileSync(path.join(__dirname, 'keys', 'cert.crt'))
};
https.createServer(options, app).listen(8081, () => {
console.log('HTTPS 服务已启动:https://127.0.0.1:8081');
});
【代码注释】这段把同一套路由跑在 HTTPS 上,用来印证「协议不同也算跨域」。
https.createServer(options, app)用本地证书把 Express 应用包成 HTTPS 服务。options里的key/cert是一对证书文件,本地开发用自签证书即可。- 关键结论:
http://127.0.0.1:8080与https://127.0.0.1:8081是两个不同的源------既因为协议不同(http vs https),也因为端口不同。从前者的页面请求后者的接口,照样要配 CORS。 - 自签证书会让浏览器弹「不安全」警告,本地点「继续访问」即可;生产环境必须用受信任 CA 签发的正式证书。
- 市面应用:网站全站升级 HTTPS 后,若接口仍是 HTTP,会同时撞上「跨域」和「混合内容(Mixed Content)被拦截」两个问题------所以页面和接口要一起上 HTTPS。
【实战要点】
- 经典应用场景 :开放平台对外接口(
Allow-Origin: *);带登录态的业务接口(白名单回填具体 Origin +Allow-Credentials: true);微服务网关统一在入口层加 CORS 头。 - 常见坑 :① 带 Cookie 跨域时
Allow-Origin还写*------浏览器直接拦,必须回填具体源。② 只配了真实请求的 CORS 头,忘了处理OPTIONS预检,导致DELETE/PATCH全部失败。③ 自定义请求头(如token)没写进Access-Control-Allow-Headers,预检不通过。 - 性能与最佳实践 :用
Access-Control-Max-Age缓存预检结果,避免每个非简单请求都多一次OPTIONS往返;能用简单请求就别触发预检(例如登录态用 Cookie 而非自定义头,能省掉一次预检)。
【本章小结】
| 维度 | 关键点 | 记忆锚点 |
|---|---|---|
| 谁负责 | 服务端设响应头,浏览器强制执行 | 「CORS 是后端的活」 |
| 核心头 | Access-Control-Allow-Origin |
「亮出你允许谁」 |
| 两类请求 | 简单请求直发;非简单先 OPTIONS 预检 |
「危险方法先问路」 |
| 带 Cookie | Allow-Origin 不能 * + Allow-Credentials:true |
「带身份必须实名」 |
记忆口诀 :跨域配 CORS、首选具体源、危险方法先预检、带 Cookie 别用星。
【面试考点】
Q1:CORS 的简单请求和预检请求有什么区别?什么情况会触发预检?
A:简单请求同时满足三个条件------方法是 GET/HEAD/POST、只用安全请求头、Content-Type 是 text/plain/multipart/form-data/application/x-www-form-urlencoded 之一------这种请求浏览器直接发。只要有一条不满足(用了 PUT/DELETE/PATCH、带了 Authorization 等自定义头、Content-Type 是 application/json),就触发预检:浏览器先自动发一个 OPTIONS 请求问服务器「允不允许这个方法、这些头」,服务器用 Access-Control-Allow-Methods/Allow-Headers 回答,通过了才发真实请求。预检的意义是:DELETE 这类有副作用的「新能力」必须先征得服务器同意。
Q2:Access-Control-Allow-Origin: * 有什么限制?带 Cookie 的跨域该怎么配?
A:* 表示允许所有源读取响应,方便但有两个限制:一是过于宽松,等于接口对全网开放;二是不能配合凭证 ------当前端设了 withCredentials = true(要带 Cookie),服务端的 Allow-Origin 绝不能是 *,必须回填请求头里的具体 Origin,同时返回 Access-Control-Allow-Credentials: true。这是规范的硬性规定,因为「带身份 + 对所有源开放」是危险组合。正确做法是服务端维护一个源白名单,命中才把那个具体源回填进 Allow-Origin。
四、JSONP 跨域实现
名词解释
- JSONP(JSON with Padding):一种非官方的跨域方案,「Padding」指把 JSON 数据「填充」进一个函数调用里。
- 回调函数(Callback):前端预先定义好的全局函数,服务端返回的代码会调用它并把数据传进去。
- 动态
<script>:用 JS 创建并插入页面的<script>标签,插入瞬间即发起请求。
概念与底层原理
JSONP 不是标准,而是程序员利用浏览器特性「凑」出来的方案。它的全部聪明之处建立在第二章那个结论上------<script> 标签可以跨域加载资源,且加载到的内容会被当作 JavaScript 直接执行。
JSONP 的链路是这样的:
- 前端定义一个全局函数,比如
function parseData(data) { ... }; - 前端动态创建一个
<script>,把src指向跨域接口,并在 URL 里用查询参数告诉服务端这个函数名:?cb=parseData; - 服务端不返回 JSON ,而是返回一段 JS 代码字符串 :
parseData([{...},{...}]); - 浏览器加载完这段
<script>,把内容当 JS 执行------于是parseData被调用,数据就「送」进了前端。
本质上,JSONP 是「服务端帮你写好一行调用代码,前端通过 <script> 把它执行掉」。数据没有经过 XHR,自然不受同源策略对「读响应」的限制。
这种「巧妙」也带来了 JSONP 写死的几个硬限制:
- 只能 GET :
<script src>只会发 GET 请求,没有任何办法让它发 POST。 - 错误处理弱 :拿不到 HTTP 状态码。
<script>只有onload/onerror两个粗粒度信号------服务端返回 404 还是 200、数据对不对,前端区分不了。 - 安全风险 :服务端返回的是「可执行代码」,前端等于无条件信任并执行它。如果接口被劫持,返回的就是恶意脚本。因此 JSONP 只适合调用自己可信的或知名大厂的接口。
- 需要服务端配合:服务端必须专门写「拼回调」的逻辑,一个返回纯 JSON 的接口无法直接当 JSONP 用。
在 CORS 已被所有现代浏览器支持的今天,JSONP 基本退役。它现在的价值是:理解「标签跨域」这一原理,以及看懂一些历史遗留的第三方接口(如某些搜索联想、统计脚本)。
服务器 动态 script 页面 服务器 动态 script 页面 #mermaid-svg-b7TRAwtwf0gXfjz8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-b7TRAwtwf0gXfjz8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-b7TRAwtwf0gXfjz8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-b7TRAwtwf0gXfjz8 .error-icon{fill:#552222;}#mermaid-svg-b7TRAwtwf0gXfjz8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-b7TRAwtwf0gXfjz8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-b7TRAwtwf0gXfjz8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-b7TRAwtwf0gXfjz8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-b7TRAwtwf0gXfjz8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-b7TRAwtwf0gXfjz8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-b7TRAwtwf0gXfjz8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-b7TRAwtwf0gXfjz8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-b7TRAwtwf0gXfjz8 .marker.cross{stroke:#333333;}#mermaid-svg-b7TRAwtwf0gXfjz8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-b7TRAwtwf0gXfjz8 p{margin:0;}#mermaid-svg-b7TRAwtwf0gXfjz8 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-b7TRAwtwf0gXfjz8 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-b7TRAwtwf0gXfjz8 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-b7TRAwtwf0gXfjz8 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-b7TRAwtwf0gXfjz8 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-b7TRAwtwf0gXfjz8 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-b7TRAwtwf0gXfjz8 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-b7TRAwtwf0gXfjz8 .sequenceNumber{fill:white;}#mermaid-svg-b7TRAwtwf0gXfjz8 #sequencenumber{fill:#333;}#mermaid-svg-b7TRAwtwf0gXfjz8 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-b7TRAwtwf0gXfjz8 .messageText{fill:#333;stroke:none;}#mermaid-svg-b7TRAwtwf0gXfjz8 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-b7TRAwtwf0gXfjz8 .labelText,#mermaid-svg-b7TRAwtwf0gXfjz8 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-b7TRAwtwf0gXfjz8 .loopText,#mermaid-svg-b7TRAwtwf0gXfjz8 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-b7TRAwtwf0gXfjz8 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-b7TRAwtwf0gXfjz8 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-b7TRAwtwf0gXfjz8 .noteText,#mermaid-svg-b7TRAwtwf0gXfjz8 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-b7TRAwtwf0gXfjz8 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-b7TRAwtwf0gXfjz8 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-b7TRAwtwf0gXfjz8 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-b7TRAwtwf0gXfjz8 .actorPopupMenu{position:absolute;}#mermaid-svg-b7TRAwtwf0gXfjz8 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-b7TRAwtwf0gXfjz8 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-b7TRAwtwf0gXfjz8 .actor-man circle,#mermaid-svg-b7TRAwtwf0gXfjz8 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-b7TRAwtwf0gXfjz8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 定义全局函数 parseData 创建 script,src=接口?cb=parseData 发起 GET 请求 返回字符串 parseData(...) 浏览器执行该 JS,调用 parseData parseData 拿到数据,更新页面
【代码注释】(JSONP 流程图)这张图把 JSONP「绕过同源策略」的全过程拆开了。
- 注意第一步:必须先定义好全局函数 ,再去创建
<script>------否则服务端代码执行时找不到这个函数会报错。 - 服务器返回的不是数据,是一句「函数调用代码」;浏览器把
<script>内容当 JS 执行,调用就发生了。 - 数据全程没碰 XHR,所以同源策略对「读响应」的限制完全管不到它------这就是 JSONP 能跨域的根本原因。
- 市面应用 :早期没有 CORS 的年代,几乎所有第三方数据接口(地图、天气、搜索联想)都靠 JSONP;今天看到接口 URL 里带
callback=/cb=参数,基本就是 JSONP。
入门示例:手写 JSONP 四步
下面把 JSONP 的四个步骤完整写出来。它请求一个本地 JSONP 接口,服务端代码在示例下方。先看前端,保存为 jsonp-basic.html:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>手写 JSONP</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
button { padding: 8px 14px; cursor: pointer; }
#box { margin-top: 16px; padding: 12px; border: 2px dashed #999; min-height: 60px; }
</style>
</head>
<body>
<h1>JSONP 四步走</h1>
<button id="btn">JSONP 请求</button>
<div id="box"></div>
<script>
const box = document.getElementById('box');
// 第 3 步(需先定义):全局回调函数,服务端返回的代码会调用它
function parseData(list) {
box.innerHTML = list.map(u => `${u.name} · ${u.age}岁 · ${u.city}`).join('<br>');
}
document.getElementById('btn').onclick = () => {
// 第 1 步:创建 script 标签
const script = document.createElement('script');
// 第 2 步:设置 src,用 cb 参数把回调函数名告诉服务端
script.src = 'http://127.0.0.1:8080/getData?cb=parseData';
// 第 4 步:插入文档------插入瞬间请求就发出去了
document.body.appendChild(script);
// 收尾:请求已发出,标签可以立即移除(不影响请求)
document.body.removeChild(script);
};
</script>
</body>
</html>
【代码注释】这段代码把 JSONP 的四个步骤逐行标了出来。
- 顺序很重要 :
parseData必须在<script>加载完成前就已存在,所以它写在最外层、先于点击逻辑定义好。 script.src里的?cb=parseData是前后端的约定------前端把回调名通过 URL 传给服务端,服务端才知道该用哪个函数名来「包」数据。- 「插入即发送」:
appendChild的那一刻请求就发出去了。之后removeChild把标签移除只是清理 DOM,请求已经在路上,不受影响------所以每次请求都能 append/remove 一次,避免页面残留一堆<script>。 - 市面应用 :所有 JSONP 库(如老版 jQuery 的
$.ajax({dataType:'jsonp'}))内部都是这套「建标签、拼 callback、插入、清理」逻辑,只是额外做了「自动生成不重名的回调函数名」。
配套服务端:JSONP 接口的关键是「返回 JS 代码字符串,而不是 JSON」:
javascript
const express = require('express');
const app = express();
app.get('/getData', (req, res) => {
// 业务数据
const users = [
{ name: '张三', age: 28, city: '上海' },
{ name: '李四', age: 32, city: '北京' },
{ name: '王五', age: 25, city: '深圳' }
];
// 从 URL 取出前端约定的回调函数名
const cb = req.query.cb;
// 白名单校验函数名,只允许字母数字下划线------防止注入恶意代码
const safeCb = /^[\w$]+$/.test(cb) ? cb : 'callback';
// 关键:返回的是「函数调用代码」字符串,不是 JSON
res.send(`${safeCb}(${JSON.stringify(users)})`);
});
app.listen(8080, () => console.log('JSONP 服务已启动:http://127.0.0.1:8080'));
【代码注释】这段服务端代码和普通 JSON 接口最大的不同,是 res.send 发出去的内容。
- 普通接口发
res.json(users),body 是[{...}];JSONP 接口发的是res.send('parseData([{...}])'),body 是一段能直接执行的 JS。 req.query.cb取出前端通过 URL 传来的回调名;JSON.stringify(users)把数据序列化成字符串,拼进函数调用的括号里。/^[\w$]+$/.test(cb)是必须做的安全校验 :如果不校验,攻击者构造?cb=alert(document.cookie);//,服务端就会原样返回恶意代码并被前端执行。只允许字母、数字、下划线、$的函数名,能挡住这类注入。- 市面应用:成熟的 JSONP 服务端都会做回调名白名单校验;这也是面试「JSONP 有什么安全问题、怎么防」的标准答案。
实战示例:搜索框输入联想
JSONP 最经典的真实应用是「搜索联想」------很多搜索引擎的联想接口至今仍以 JSONP 形式开放。下面用 <datalist> 实现一个输入联想框,保存为 jsonp-search.html 直接用浏览器打开(它请求公开的搜索联想接口,无需本地服务):
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>搜索联想(JSONP)</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 3rem auto; padding: 0 1rem; }
input { width: 100%; height: 42px; padding: 0 12px; box-sizing: border-box;
border: 1px solid #ccc; border-radius: 6px; font-size: 15px; }
</style>
</head>
<body>
<h1>搜索联想</h1>
<input type="text" id="kw" list="tips" placeholder="输入关键词试试..." autocomplete="off">
<datalist id="tips"></datalist>
<script>
const kw = document.getElementById('kw');
const tips = document.getElementById('tips');
// 全局回调:搜索接口返回后会调用它,res.g 是联想词数组
function parseSearch(res) {
tips.innerHTML = '';
(res.g || []).forEach(item => {
// <option> 的 value 就是一条联想词
tips.appendChild(new Option('', item.q));
});
}
// 输入即联想:每次输入都发一次 JSONP 请求
kw.oninput = () => {
if (!kw.value.trim()) return;
const script = document.createElement('script');
// encodeURIComponent 处理关键词里的空格、中文,避免破坏 URL
script.src = `https://www.baidu.com/sugrec?prod=pc&wd=${encodeURIComponent(kw.value)}&cb=parseSearch`;
document.body.appendChild(script);
document.body.removeChild(script);
};
</script>
</body>
</html>
【代码注释】这个示例是 JSONP 在真实场景里的样子------一个输入联想框。
- 接口地址形如
https://www.baidu.com/sugrec?prod=pc&wd=关键字&cb=函数名:wd是用户输入的关键词,cb是回调函数名。 - 服务端返回的数据结构里,联想词在
res.g数组中,每个元素的q字段是一条联想词;parseSearch把它们逐个塞进<datalist>。 <input list="tips">配<datalist id="tips">是 HTML 原生的「输入建议」能力,不用写任何下拉框样式,浏览器自动渲染候选列表。encodeURIComponent(kw.value)必不可少:用户输入里若有空格、&、中文,不编码就会破坏 URL 的查询字符串结构。- 常见坑 :
oninput每敲一个字就发一次请求,打字快时会瞬间发出大量请求且响应可能乱序。生产中要加「防抖」(停止输入若干毫秒才发),第八章会讲。 - 市面应用 :搜索引擎、电商站的搜索框联想、地图的地点输入联想,都是这个模式;现代实现多已换成 CORS +
fetch,但交互形态完全一致。
【实战要点】
- 经典应用场景:调用只提供 JSONP 形式的历史第三方接口(部分搜索联想、老的统计/广告脚本);除此之外,新项目一律用 CORS。
- 常见坑 :① 回调函数定义晚于
<script>加载完成,导致「函数未定义」报错------回调必须先定义。②oninput高频触发 JSONP 且无防抖,请求乱序、联想结果跳动。③ 服务端不校验cb参数,被构造成cb=恶意代码形成 XSS 注入。 - 性能与最佳实践 :JSONP 没有原生的「取消请求」能力(不像 XHR 有
abort()),高频场景只能靠防抖减少请求数;回调名服务端务必白名单校验;返回内容只信任可信源。
【本章小结】
| 维度 | JSONP 的特性 | 对比 CORS |
|---|---|---|
| 原理 | 动态 <script> 加载并执行 JS |
CORS 走 XHR + 响应头 |
| 请求方式 | 仅 GET | 支持所有方法 |
| 错误处理 | 拿不到状态码,只有粗粒度 onerror | 可读完整状态码 |
| 安全性 | 执行服务端返回的代码,风险高 | 只读数据,风险低 |
| 现状 | 历史方案,基本退役 | 官方标准,首选 |
记忆口诀 :JSONP 借 script、回调拼 URL、只能发 GET、能用 CORS 就别用它。
【面试考点】
Q1:JSONP 的原理是什么?为什么它只能发 GET 请求?
A:JSONP 利用了「<script> 标签可以跨域加载资源,且加载到的内容会被当作 JS 执行」这一点。前端动态创建 <script>,把跨域接口地址放进 src、并用查询参数传一个回调函数名;服务端不返回 JSON,而是返回一段「调用该回调函数并把数据作为参数」的 JS 代码字符串;浏览器加载完就执行它,回调函数被调用,数据就到了前端。它只能 GET,是因为 <script src> 这种标签发起的请求天生就只有 GET,没有任何途径让它带请求体、用 POST。
Q2:JSONP 和 CORS 相比有哪些缺点?
A:三个主要缺点。一是只支持 GET,无法做增删改;二是错误处理弱------它走 <script> 加载,拿不到 HTTP 状态码,无法区分 404、500 还是数据格式错,只有一个粗粒度的 onerror;三是安全性差------服务端返回的是「会被执行的代码」,前端等于无条件信任,接口一旦被劫持就是 XSS。相比之下 CORS 是官方标准、支持所有方法、能读完整状态码、只传输数据不传输代码。所以现代项目一律用 CORS,JSONP 只用于个别历史遗留的第三方接口。
五、Ajax 函数封装
名词解释
- 封装(Encapsulation):把重复的 XHR 流程收进一个函数,对外只暴露简洁的配置项。
- 配置对象(Options) :调用封装函数时传入的参数对象,含
url、method、headers、body等。 - 回调(Callback) :请求结束后被调用的函数,分
success(成功)和error(失败)。 - 拦截器(Interceptor):在请求发出前、响应返回后自动执行的「钩子」函数。
概念与底层原理
每写一个 Ajax 请求,都要重复「创建 xhr → 绑事件 → open → 设头 → send」这五步。同样的样板代码散落在几十个地方,一旦要统一加个 token 头、统一处理错误,就得改几十处。封装就是把这五步收进一个函数,调用方只关心「请求什么、成功后做什么」。
设计一个 ajax() 封装,要想清楚两件事:对外暴露哪些配置项 、默认值给什么。一个最小但够用的配置约定是:
url:请求地址(必填);method:请求方法,默认'GET';headers:请求头对象,默认{};body:请求体,GET 时省略;dataType:响应体类型,传'json'时内部设xhr.responseType;success/error:成功/失败回调,默认空函数。
「默认值」是封装好不好用的关键。用 ES6 的解构赋值默认值 ,调用方只传 url 和 success 就能用,其余自动补齐------这就是「约定优于配置」。
封装也分层次。回调式封装 最简单,但多个请求有先后依赖时会陷入「回调地狱」。再进一步是 Promise 版封装 ------把 success/error 换成 resolve/reject,调用方就能用 .then() 链式写法甚至 async/await。最后是带拦截器的封装:在请求发出前统一塞 token、在响应返回后统一判断登录态------这正是 axios 的核心能力。理解这三层,就理解了「为什么前端项目都要有一个自己的请求层」。
#mermaid-svg-vTvFXU9oNMoE0mzo{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vTvFXU9oNMoE0mzo .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vTvFXU9oNMoE0mzo .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vTvFXU9oNMoE0mzo .error-icon{fill:#552222;}#mermaid-svg-vTvFXU9oNMoE0mzo .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vTvFXU9oNMoE0mzo .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vTvFXU9oNMoE0mzo .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vTvFXU9oNMoE0mzo .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vTvFXU9oNMoE0mzo .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vTvFXU9oNMoE0mzo .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vTvFXU9oNMoE0mzo .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vTvFXU9oNMoE0mzo .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vTvFXU9oNMoE0mzo .marker.cross{stroke:#333333;}#mermaid-svg-vTvFXU9oNMoE0mzo svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vTvFXU9oNMoE0mzo p{margin:0;}#mermaid-svg-vTvFXU9oNMoE0mzo .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-vTvFXU9oNMoE0mzo .cluster-label text{fill:#333;}#mermaid-svg-vTvFXU9oNMoE0mzo .cluster-label span{color:#333;}#mermaid-svg-vTvFXU9oNMoE0mzo .cluster-label span p{background-color:transparent;}#mermaid-svg-vTvFXU9oNMoE0mzo .label text,#mermaid-svg-vTvFXU9oNMoE0mzo span{fill:#333;color:#333;}#mermaid-svg-vTvFXU9oNMoE0mzo .node rect,#mermaid-svg-vTvFXU9oNMoE0mzo .node circle,#mermaid-svg-vTvFXU9oNMoE0mzo .node ellipse,#mermaid-svg-vTvFXU9oNMoE0mzo .node polygon,#mermaid-svg-vTvFXU9oNMoE0mzo .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vTvFXU9oNMoE0mzo .rough-node .label text,#mermaid-svg-vTvFXU9oNMoE0mzo .node .label text,#mermaid-svg-vTvFXU9oNMoE0mzo .image-shape .label,#mermaid-svg-vTvFXU9oNMoE0mzo .icon-shape .label{text-anchor:middle;}#mermaid-svg-vTvFXU9oNMoE0mzo .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vTvFXU9oNMoE0mzo .rough-node .label,#mermaid-svg-vTvFXU9oNMoE0mzo .node .label,#mermaid-svg-vTvFXU9oNMoE0mzo .image-shape .label,#mermaid-svg-vTvFXU9oNMoE0mzo .icon-shape .label{text-align:center;}#mermaid-svg-vTvFXU9oNMoE0mzo .node.clickable{cursor:pointer;}#mermaid-svg-vTvFXU9oNMoE0mzo .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vTvFXU9oNMoE0mzo .arrowheadPath{fill:#333333;}#mermaid-svg-vTvFXU9oNMoE0mzo .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vTvFXU9oNMoE0mzo .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vTvFXU9oNMoE0mzo .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vTvFXU9oNMoE0mzo .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vTvFXU9oNMoE0mzo .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vTvFXU9oNMoE0mzo .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vTvFXU9oNMoE0mzo .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vTvFXU9oNMoE0mzo .cluster text{fill:#333;}#mermaid-svg-vTvFXU9oNMoE0mzo .cluster span{color:#333;}#mermaid-svg-vTvFXU9oNMoE0mzo div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vTvFXU9oNMoE0mzo .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vTvFXU9oNMoE0mzo rect.text{fill:none;stroke-width:0;}#mermaid-svg-vTvFXU9oNMoE0mzo .icon-shape,#mermaid-svg-vTvFXU9oNMoE0mzo .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vTvFXU9oNMoE0mzo .icon-shape p,#mermaid-svg-vTvFXU9oNMoE0mzo .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vTvFXU9oNMoE0mzo .icon-shape .label rect,#mermaid-svg-vTvFXU9oNMoE0mzo .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vTvFXU9oNMoE0mzo .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vTvFXU9oNMoE0mzo .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vTvFXU9oNMoE0mzo :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
调用 ajax 配置对象
解构 + 填默认值
创建 xhr
绑定 onload / onerror
open 初始化
遍历 headers 设置请求头
send 发送 body
status === 200?
success 回调
error 回调
【代码注释】(封装流程图)这张图就是 ajax() 函数体内部的执行顺序。
- 第一步「解构 + 填默认值」是封装的门面:调用方传进来的配置在这里被规整成完整参数。
- 中间的「创建 xhr → 绑事件 → open → 设头 → send」就是被收进函数的那五步样板代码------封装的价值就是让调用方再也不用写它们。
- 最后按
status分流到success或error,把「成功还是失败」的判断也收进了封装,调用方只管写两个回调。 - 市面应用 :axios、
fetch封装、各公司自研的request.js,骨架都是这张图,区别只在于是否用 Promise、是否有拦截器、错误判断是否更精细。
入门示例:最小可用的 ajax() 封装
下面是一个约 30 行的最小封装,配置项齐全、带默认值。保存为 ajax-wrap.html 直接用浏览器打开(它调用公开的 GitHub 接口):
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>ajax() 封装</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
button { padding: 8px 14px; cursor: pointer; }
pre { background: #f4f4f4; padding: 12px; border-radius: 6px; white-space: pre-wrap; max-height: 240px; overflow: auto; }
</style>
</head>
<body>
<h1>最小 ajax() 封装</h1>
<button id="btn">用封装函数请求数据</button>
<pre id="out"></pre>
<script>
/*
配置项:
url 请求地址
method 请求方式,默认 GET
headers 请求头,默认 {}
body 请求体(GET 省略)
dataType 响应体类型,传 'json' 即设置 xhr.responseType
success 成功回调 error 失败回调
*/
function ajax(options) {
// 解构 + 默认值:调用方只需关心自己要传的项
const { url, method = 'GET', headers = {}, body,
dataType, success = () => {}, error = () => {} } = options;
const xhr = new XMLHttpRequest();
if (dataType) xhr.responseType = dataType; // 声明响应类型
xhr.onload = () => {
// 只把 200 视为成功,其余交给 error
if (xhr.status === 200) success(xhr.response);
else error(xhr.status);
};
xhr.onerror = () => error(0); // 网络层失败
xhr.open(method, url);
// open 之后、send 之前,逐个设置请求头
for (const key in headers) xhr.setRequestHeader(key, headers[key]);
xhr.send(body); // GET 时 body 为 undefined
}
// 调用:只传了 url / dataType / success,其余走默认值
document.getElementById('btn').onclick = () => {
ajax({
url: 'https://api.github.com/users/octocat',
dataType: 'json',
success: res => {
document.getElementById('out').textContent = JSON.stringify(res, null, 2);
},
error: status => {
document.getElementById('out').textContent = '请求失败,状态码:' + status;
}
});
};
</script>
</body>
</html>
【代码注释】这段代码就是一个工程里真实可用的 Ajax 封装雏形。
const { url, method = 'GET', ... } = options是封装的精髓------解构赋值配合默认值,让调用方「传什么就生效什么,不传的自动补默认」。所以ajax({ url, dataType, success })这样只传三项就能跑。if (dataType) xhr.responseType = dataType:把第一章学的responseType收进了配置项,调用方写dataType:'json',success里拿到的就是已解析对象。- 请求头必须在
open之后、send之前 用setRequestHeader设置,这是 XHR 规范的硬性顺序------for...in遍历headers对象逐个设。 xhr.send(body):GET 请求时body是undefined,等价于send();POST 时把字符串或FormData传进来即可,封装内部不用区分。- 这个封装把成功判断简化成
status === 200------够用,但201(已创建)、204(无内容)会被误判为失败,所以下面的 Promise 版改用2xx区间。 - 市面应用 :这套封装直接可用于「同源接口」的项目;它和老 jQuery 的
$.ajax、现代的 axios 是同一思路的不同成熟度版本。
实战示例:Promise 版封装 + 拦截器
回调式封装在「请求 A 成功后再请求 B」时会层层嵌套。把它升级成 Promise 版,就能用 async/await 写出扁平的代码,还能挂拦截器统一处理 token 和登录态:
javascript
// Promise 版 ajax 封装
function ajax(options) {
const { url, method = 'GET', headers = {}, body, dataType = 'json' } = options;
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.responseType = dataType;
xhr.timeout = options.timeout || 10000;
xhr.onload = () => {
// 2xx 区间都算成功,比只认 200 更严谨
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response);
else reject({ status: xhr.status, response: xhr.response });
};
xhr.onerror = () => reject({ status: 0, message: '网络错误' });
xhr.ontimeout = () => reject({ status: 0, message: '请求超时' });
xhr.open(method, url);
for (const key in headers) xhr.setRequestHeader(key, headers[key]);
xhr.send(body);
});
}
// 简易拦截器:请求发出前统一加 token
function request(options) {
const token = localStorage.getItem('token');
options.headers = { ...options.headers };
if (token) options.headers['Authorization'] = 'Bearer ' + token;
return ajax(options).then(
res => res, // 响应成功,原样返回
err => {
// 响应拦截:统一处理「未登录」
if (err.status === 401) {
console.warn('登录态失效,应跳转登录页');
}
return Promise.reject(err); // 错误继续抛给业务
}
);
}
// 用 async/await 调用,多个请求扁平书写
async function loadPage() {
try {
const user = await request({ url: '/api/user' });
const orders = await request({ url: '/api/orders?uid=' + user.id });
console.log('用户:', user, '订单:', orders);
} catch (err) {
console.error('加载失败:', err.message || err.status);
}
}
【代码注释】这段把回调式封装升级成了「Promise + 拦截器」,是现代请求库的雏形。
- 核心改动:把
success/error回调换成resolve/reject。onload里status在2xx区间就resolve,否则reject------成功判断从「只认 200」放宽到「整个 2xx」,因为201/204也是成功。 reject时携带{ status, response },让上层能根据状态码分别处理(如401跳登录)。request函数演示了拦截器 思想:请求发出前自动从localStorage取 token 塞进Authorization头(请求拦截),响应失败时统一识别401(响应拦截)。业务代码因此完全不用关心 token 和登录态。- 最大的收益在
loadPage:两个有依赖关系的请求(先拿 user、再用user.id拿 orders)用await写成了两行平铺 的代码,而不是回调里套回调。try/catch统一兜住所有错误。 - 市面应用 :axios 的
interceptors.request/interceptors.response就是这个拦截器思想的完整版;几乎每个前端项目都会基于它封装一个request.js,统一加 token、统一弹错误提示、统一处理登录失效。
【实战要点】
- 经典应用场景 :任何中大型项目都会有一个统一的请求层(
request.js/http.js),集中处理基础路径、token、错误提示、登录失效跳转、loading 显隐。 - 常见坑 :① 在
open之前调setRequestHeader会抛错------请求头必须在open之后、send之前设。② 用同一个xhr对象open两次去复用,会出问题------每次请求都应new一个新的。③ 回调式封装里success抛了异常无人捕获,Promise 版要注意reject后业务层有没有catch。 - 性能与最佳实践 :封装层统一设
timeout,避免弱网请求无限挂起;同源接口用相对路径(/api/...)即可,不必写完整域名;先手写一遍裸 XHR 五步再用封装,别变成「只会调ajax()、不懂底层」。
【本章小结】
| 层次 | 形态 | 解决的问题 |
|---|---|---|
| 回调式 | success / error 回调 |
消除五步样板代码 |
| Promise 版 | resolve / reject |
支持 async/await,告别回调地狱 |
| 拦截器版 | 请求前/响应后钩子 | 统一 token、统一错误处理 |
| 配置设计 | 解构 + 默认值 | 「约定优于配置」,调用极简 |
记忆口诀 :配置带默认、五步进函数、回调升 Promise、拦截器统管。
【面试考点】
Q1:为什么要封装 Ajax?一个好的封装应该考虑哪些点?
A:因为裸写 XHR 每次都要重复「创建、绑事件、open、设头、send」五步,样板代码散落各处,要统一加 token 或统一处理错误就得改几十处。好的封装要考虑:① 配置项设计------用配置对象 + 解构默认值,让调用方只传必要项;② 成功判断------按 2xx 区间而非只认 200;③ 异步形态------用 Promise 而非回调,支持 async/await;④ 拦截器------请求前统一加 token、响应后统一处理 401 等;⑤ 超时与错误------统一 timeout,错误对象带上 status 便于上层分流。
Q2:setRequestHeader 为什么必须在 open 之后、send 之前调用?
A:这是 XHR 规范规定的状态约束。open 之前,xhr 处于 UNSENT 状态,请求行还没初始化,此时设头无处可放,会抛 InvalidStateError;send 之后请求已经发出,再设头也来不及了。只有 open 之后、send 之前这个 OPENED 状态窗口,请求头才能被正确写入。所以封装里设头的 for 循环一定夹在 open 和 send 中间。
六、记账本 REST 实战
把前面的同源、CORS、ajax() 封装全部用起来,做一个记账本:进入页面拉取账单列表、填表单添加账单、点删除按钮删除账单。它是一个最小但完整的「前端 Ajax + 后端 REST API」全链路项目。
名词解释
- RESTful API :用 HTTP 方法表达对资源的操作------
GET查、POST增、DELETE删、PATCH改。 - 统一响应体 :后端约定的固定结构
{ code, msg, data },前端按code判断业务结果。 - 业务状态码 :响应体里的
code字段(如'0000'表成功),区别于 HTTP 状态码。 FormData:以表单形式打包请求体的对象,常由new FormData(form元素)一步生成。- 事件委托 :把子元素的事件监听挂到父元素上,靠
event.target判断真正的触发源。
概念与底层原理
RESTful 风格 的核心是「用 HTTP 方法本身表达语义」。同一个资源路径 /api/account,配不同方法就是不同操作:GET 是查、POST 是增、DELETE 是删、PATCH 是改。这样接口语义自解释,不必造 /getAccount、/deleteAccount 这种动词路径。
为什么要有「业务 code」这一层? HTTP 状态码描述的是「通信层 」的结果------200 表示「请求被正确处理并返回」。但「通信成功」不等于「业务成功」:用户想删一条不属于自己的账单,HTTP 层面是 200(请求处理了),业务层面却是失败(无权限)。于是后端在响应体里再加一个 code 字段表达业务结果 。前端因此要做两层判断 :先看 HTTP 状态(xhr.status === 200,由封装处理),再看业务 code(res.code === '0000',由业务代码处理)。
添加账单为什么用 FormData? 表单里有文本、下拉框,未来还可能有文件(如票据图片)。new FormData(表单元素) 能一步把整个表单的字段按 name 收集成请求体,且天然支持文件。用 FormData 作请求体时有一条铁律:不要手动设 Content-Type 。因为 multipart/form-data 需要一个随机生成的 boundary 分隔符,浏览器会自动带上完整的 Content-Type: multipart/form-data; boundary=----xxx;你一旦手写,boundary 就丢了,服务端会解析失败。
删除按钮为什么用事件委托? 账单列表是动态渲染的,每条都有删除按钮,且数量随增删变化。如果给每个按钮单独 addEventListener,列表一刷新就要重新绑一遍。事件委托把监听器挂在不变的父容器 上,靠事件冒泡 + event.target 判断点的是哪个按钮,再从按钮的 data-id 自定义属性上读出账单 ID。一个监听器搞定任意多、动态变化的按钮。
增、删之后怎么保证页面和数据库一致? 两种策略:添加 后「重新 GET 整个列表」------最稳,页面一定和库一致;删除后「直接移除那个 DOM 节点」------最快,省一次请求。本项目两种都用上了。
账单页本身需要登录才能访问。 账单数据按用户隔离,所以账单页的路由挂了一个登录守卫中间件 ------未登录访问会被重定向到登录页;登录之后,接口路径里的 :uid 取的就是当前登录用户的 id,前端 Ajax 携带它去查「我自己的」账单。换言之,本章聚焦的是「登录之后」的 Ajax 数据交互;身份认证由服务端的会话机制负责,与 Ajax 解耦。
#mermaid-svg-2MXXalIIBiFip8EV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2MXXalIIBiFip8EV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2MXXalIIBiFip8EV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2MXXalIIBiFip8EV .error-icon{fill:#552222;}#mermaid-svg-2MXXalIIBiFip8EV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2MXXalIIBiFip8EV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2MXXalIIBiFip8EV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2MXXalIIBiFip8EV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2MXXalIIBiFip8EV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2MXXalIIBiFip8EV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2MXXalIIBiFip8EV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2MXXalIIBiFip8EV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2MXXalIIBiFip8EV .marker.cross{stroke:#333333;}#mermaid-svg-2MXXalIIBiFip8EV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2MXXalIIBiFip8EV p{margin:0;}#mermaid-svg-2MXXalIIBiFip8EV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-2MXXalIIBiFip8EV .cluster-label text{fill:#333;}#mermaid-svg-2MXXalIIBiFip8EV .cluster-label span{color:#333;}#mermaid-svg-2MXXalIIBiFip8EV .cluster-label span p{background-color:transparent;}#mermaid-svg-2MXXalIIBiFip8EV .label text,#mermaid-svg-2MXXalIIBiFip8EV span{fill:#333;color:#333;}#mermaid-svg-2MXXalIIBiFip8EV .node rect,#mermaid-svg-2MXXalIIBiFip8EV .node circle,#mermaid-svg-2MXXalIIBiFip8EV .node ellipse,#mermaid-svg-2MXXalIIBiFip8EV .node polygon,#mermaid-svg-2MXXalIIBiFip8EV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2MXXalIIBiFip8EV .rough-node .label text,#mermaid-svg-2MXXalIIBiFip8EV .node .label text,#mermaid-svg-2MXXalIIBiFip8EV .image-shape .label,#mermaid-svg-2MXXalIIBiFip8EV .icon-shape .label{text-anchor:middle;}#mermaid-svg-2MXXalIIBiFip8EV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-2MXXalIIBiFip8EV .rough-node .label,#mermaid-svg-2MXXalIIBiFip8EV .node .label,#mermaid-svg-2MXXalIIBiFip8EV .image-shape .label,#mermaid-svg-2MXXalIIBiFip8EV .icon-shape .label{text-align:center;}#mermaid-svg-2MXXalIIBiFip8EV .node.clickable{cursor:pointer;}#mermaid-svg-2MXXalIIBiFip8EV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-2MXXalIIBiFip8EV .arrowheadPath{fill:#333333;}#mermaid-svg-2MXXalIIBiFip8EV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-2MXXalIIBiFip8EV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-2MXXalIIBiFip8EV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2MXXalIIBiFip8EV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-2MXXalIIBiFip8EV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2MXXalIIBiFip8EV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-2MXXalIIBiFip8EV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-2MXXalIIBiFip8EV .cluster text{fill:#333;}#mermaid-svg-2MXXalIIBiFip8EV .cluster span{color:#333;}#mermaid-svg-2MXXalIIBiFip8EV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-2MXXalIIBiFip8EV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-2MXXalIIBiFip8EV rect.text{fill:none;stroke-width:0;}#mermaid-svg-2MXXalIIBiFip8EV .icon-shape,#mermaid-svg-2MXXalIIBiFip8EV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2MXXalIIBiFip8EV .icon-shape p,#mermaid-svg-2MXXalIIBiFip8EV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-2MXXalIIBiFip8EV .icon-shape .label rect,#mermaid-svg-2MXXalIIBiFip8EV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2MXXalIIBiFip8EV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-2MXXalIIBiFip8EV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-2MXXalIIBiFip8EV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
是
是
进入账单页
GET /api/account/:uid
code === 0000?
渲染账单列表
填表单点添加
FormData 打包
POST /api/account/:uid
code === 0000?
点删除按钮
事件委托取 data-id
DELETE /api/account/:id
code === 0000?
移除该 DOM 节点
【代码注释】(记账本数据流图)这张图是整个项目「请求---响应---更新视图」的全貌。
- 三条业务线------查、增、删------各自是一条「发请求 → 判断
code→ 更新视图」的链路。 - 注意「增」的分支:
POST成功后箭头指回了GET------这就是「添加后重新拉全量列表」策略,确保页面与库完全一致。 - 「删」的分支则在
code通过后直接「移除 DOM 节点」,不回GET------省一次请求,因为「删掉一行」前端自己就能精确完成。 - 市面应用:任何「列表 + 增删」的后台管理页面都是这张图;区别只在「增删后是局部更新还是整体刷新」的取舍。
入门示例:REST API 的服务端实现
服务端用 Express 把账单资源做成一组 RESTful 接口,统一返回 { code, msg, data }:
javascript
const express = require('express');
const router = express.Router();
const multer = require('multer');
const upload = multer({ dest: 'uploads/' }); // 处理可能的文件字段
const AccountModel = require('../models/accounts'); // 账单数据模型
// 查:某用户的全部账单,按时间倒序
router.get('/account/:uid', (req, res) => {
AccountModel.find({ userid: req.params.uid }).sort({ time: -1 }).exec((err, data) => {
if (err) return res.json({ code: '1001', msg: '读取失败', data: null });
res.json({ code: '0000', msg: '读取成功', data });
});
});
// 增:给某用户添加一条账单;upload.single 兼容带文件的表单
router.post('/account/:uid', upload.single('avator'), (req, res) => {
// userid 取自路径参数,再展开表单字段------userid 写在前防被表单覆盖
AccountModel.create({ userid: req.params.uid, ...req.body }, (err, data) => {
if (err) return res.json({ code: '1002', msg: '创建失败', data: null });
res.json({ code: '0000', msg: '创建成功', data });
});
});
// 删:按账单自身的 _id 删除
router.delete('/account/:id', (req, res) => {
AccountModel.deleteOne({ _id: req.params.id }, (err) => {
if (err) return res.json({ code: '1003', msg: '删除失败', data: null });
res.json({ code: '0000', msg: '删除成功', data: {} });
});
});
// 改:先更新,再查出最新文档返回
router.patch('/account/:id', (req, res) => {
AccountModel.updateOne({ _id: req.params.id }, req.body, (err) => {
if (err) return res.json({ code: '1005', msg: '更新失败', data: null });
AccountModel.findById(req.params.id, (e, data) =>
res.json({ code: '0000', msg: '更新成功', data }));
});
});
module.exports = router;
【代码注释】这段是记账本后端的核心------一组 RESTful 风格的账单接口。
- 同一路径、不同方法表达不同操作 :
/account配GET/POST/DELETE/PATCH分别是查/增/删/改,这就是 REST 的精髓。 - 统一响应体 :每个接口无论成败都返回
{ code, msg, data }。成功code为'0000',不同失败给不同code(读取失败1001、创建失败1002、删除失败1003、更新失败1005),前端据此能精确提示。 POST里{ userid: req.params.uid, ...req.body }的写法有讲究:userid写在展开运算符前面 ,确保它取自可信的路径参数,不会被表单里同名字段覆盖------防止用户伪造userid把账单塞给别人。upload.single('avator')是处理「表单里可能有文件」的中间件;即便本例没传文件,它也能正确解析multipart/form-data的表单体。- 一个真实的路由坑 :
GET /account/:uid(查列表)和「按id查单条」如果都写成GET /account/:xxx,路径模式完全一样,Express 只会匹配先注册的那个 ,后注册的永远不会执行。要查单条得换路径(如/account/detail/:id)或换设计。 - 市面应用:所有后台管理系统的 CRUD 接口都是这个结构;「统一响应体 + 业务 code」是国内后端接口最普遍的约定。
实战示例:完整可运行的记账本
下面是一个自包含、可直接运行 的记账本:用浏览器内存模拟后端 API,保存为 account-book.html 双击打开即可体验「加载列表 / 添加 / 删除」全流程。它的前端逻辑------ajax 封装、FormData 提交、事件委托删除、增后刷新/删后移节点------与真实项目完全一致。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>记账本</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
form { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
input, select { padding: 8px; }
.panel { display: flex; justify-content: space-between; align-items: center;
padding: 10px 12px; margin: 6px 0; border-left: 4px solid #4f7cff; background: #f4f6ff; }
.panel.expense { border-left-color: #d33; background: #fff4f4; }
.del { color: #d33; cursor: pointer; border: none; background: none; font-size: 16px; }
</style>
</head>
<body>
<h1>记账本</h1>
<form id="addForm">
<input type="text" name="title" placeholder="事项" required>
<input type="number" name="account" placeholder="金额" required>
<select name="type">
<option value="1">收入</option>
<option value="-1">支出</option>
</select>
<input type="date" name="time" required>
<button type="submit">添加</button>
</form>
<div id="accountBox"></div>
<script>
/* ===== 模拟后端:用内存数组冒充数据库,让示例能独立运行 ===== */
let db = [
{ _id: 'a1', title: '工资', account: 8000, type: 1, time: '2026-05-01' },
{ _id: 'a2', title: '午餐', account: 35, type: -1, time: '2026-05-02' }
];
function fakeApi(method, body) {
if (method === 'GET') return { code: '0000', data: [...db].reverse() };
if (method === 'POST') { db.push({ _id: 'a' + Date.now(), ...body }); return { code: '0000' }; }
if (method === 'DELETE') { db = db.filter(x => x._id !== body); return { code: '0000' }; }
}
/* ===== ajax 封装(同 §5):此处直接走 fakeApi,真实项目走 XMLHttpRequest ===== */
function ajax({ method = 'GET', body, success }) {
setTimeout(() => success(fakeApi(method, body)), 200); // setTimeout 模拟网络延迟
}
const addForm = document.getElementById('addForm');
const accountBox = document.getElementById('accountBox');
// 进入页面:加载账单列表
function loadAccount() {
ajax({
method: 'GET',
success: res => {
if (res.code !== '0000') return;
accountBox.innerHTML = '';
res.data.forEach(item => {
accountBox.innerHTML += `
<div class="panel ${item.type > 0 ? 'income' : 'expense'}">
<span>${item.time} ${item.title}</span>
<span>${item.type > 0 ? '+' : '-'}${item.account} 元
<button class="del" data-id="${item._id}">✕</button>
</span>
</div>`;
});
}
});
}
// 添加:FormData 打包表单 → POST → 成功后重新加载列表
addForm.onsubmit = () => {
const fd = new FormData(addForm);
// FormData 转普通对象,仅为让本示例的 fakeApi 易读;真实项目直接把 fd 作 body
const body = Object.fromEntries(fd);
body.account = Number(body.account);
body.type = Number(body.type);
ajax({ method: 'POST', body, success: res => { if (res.code === '0000') loadAccount(); } });
addForm.reset();
return false; // 阻止表单默认提交(整页刷新)
};
// 删除:事件委托------监听挂在父容器,靠 data-id 区分点了哪条
accountBox.onclick = e => {
if (!e.target.classList.contains('del')) return;
ajax({
method: 'DELETE',
body: e.target.dataset.id,
success: res => {
if (res.code === '0000') e.target.closest('.panel').remove(); // 删后直接移节点
}
});
};
loadAccount();
</script>
</body>
</html>
【代码注释】这个示例是一个能独立跑通的完整记账本,前端逻辑与真实项目一模一样。
- 为了让它「双击就能跑」,用一个内存数组
db+fakeApi函数冒充了后端;ajax封装内部走fakeApi。真实项目里 只需把ajax换成第五章那个走XMLHttpRequest的版本、method/url/body指向真实接口即可------上层的loadAccount/onsubmit/onclick逻辑一行都不用改。这正体现了「封装」的价值:业务代码与传输细节解耦。 - 添加 :
new FormData(addForm)一步把整个表单按name收集成请求体;onsubmit末尾return false阻止表单默认提交(否则浏览器会整页跳转刷新)。POST成功后调loadAccount()重新拉全量列表------这是「增后刷新」策略。 - 删除 :监听器只在父容器
accountBox上挂了一个(事件委托)。点击时先用e.target.classList.contains('del')确认点的是删除按钮,再从e.target.dataset.id读出账单_id。DELETE成功后用e.target.closest('.panel').remove()直接移除那一行------这是「删后移节点」策略,省一次GET。 - 真实项目用
FormData作body时不要手动设Content-Type;本示例为了fakeApi好读才转成了普通对象,这点务必分清。 - 市面应用:这套「列表渲染 + 表单 FormData 提交 + 事件委托删除 + 增删后更新视图」是所有后台管理页面的通用骨架,往上加分页、搜索、编辑即可演进成真实系统。
【实战要点】
- 经典应用场景 :一切「列表 + 增删改查」的后台页面------订单管理、商品管理、内容管理;统一响应体
{code,msg,data}是国内团队最普遍的接口约定。 - 常见坑 :① 用
FormData作请求体时手动设了Content-Type,丢了boundary,服务端解析失败。② 表单提交忘了return false/preventDefault,页面整体刷新,Ajax 白做了。③ 给每个删除按钮单独绑事件,列表刷新后旧监听器失效或重复绑------应当用事件委托。④ 业务code不是'0000'时(如权限不足)当成成功处理。 - 性能与最佳实践:增删后「整体刷新」最稳但费一次请求,「局部更新 DOM」最快但要自己维护一致性,按场景选;列表用模板字符串拼 HTML 时,用户输入要做转义防 XSS(见第八章)。
【本章小结】
| 环节 | 关键做法 | 要点 |
|---|---|---|
| 接口风格 | 同路径 + 不同 HTTP 方法 | GET/POST/DELETE/PATCH |
| 响应约定 | { code, msg, data } |
code==='0000' 表业务成功 |
| 两层判断 | HTTP 状态 + 业务 code | 「通信成功 ≠ 业务成功」 |
| 提交表单 | new FormData(form) 作 body |
别手动设 Content-Type |
| 删除交互 | 事件委托 + data-id |
一个监听器管所有按钮 |
记忆口诀 :方法表语义、code 判业务、FormData 别设头、删除靠委托。
【面试考点】
Q1:业务状态码 code 和 HTTP 状态码有什么区别?为什么要两层判断?
A:HTTP 状态码描述「通信层」------200 表示请求被服务器正确接收并返回了响应。但「通信成功」不代表「业务成功」:比如删一条没权限的数据,HTTP 是 200(请求确实处理了),业务上却失败了。所以后端在响应体里再加一个 code 字段表达业务结果(如 '0000' 成功、'1003' 删除失败)。前端要做两层判断:先确认 xhr.status === 200(通信成功,通常由封装统一处理),再确认 res.code === '0000'(业务成功,由业务代码处理)。漏掉任何一层,都可能把失败当成功。
Q2:添加账单为什么用 FormData?用它时为什么不能手动设 Content-Type?
A:用 FormData 是因为 new FormData(表单元素) 能一步把整个表单按字段 name 收集成请求体,写法极简,而且天然支持文件上传(未来表单里加图片字段也不用改)。不能手动设 Content-Type 是因为:FormData 提交用的是 multipart/form-data 格式,它需要一个随机的 boundary 分隔符来区隔各字段,浏览器会自动生成并写进完整的 Content-Type: multipart/form-data; boundary=----xxxx。你一旦手动写 Content-Type,就覆盖掉了浏览器自动带的那个、丢了 boundary,服务端无法切分字段,解析直接失败。
七、错误处理与调试
请求不会永远成功------断网、超时、404、500、登录失效都可能发生。一个专业的前端必须让「失败」也有确定的、对用户友好的表现。
名词解释
- 网络层错误 :请求根本没成功到达或返回,触发
onerror,此时连状态码都没有。 - HTTP 错误:请求到达了,但服务端返回了 4xx/5xx 状态码。
- 业务错误 :HTTP 200,但响应体
code表示业务失败。 - 重试(Retry):对「可恢复的失败」(如超时)自动再请求一次。
- 错误边界提示:把技术性错误翻译成用户能看懂的提示文案。
概念与底层原理
Ajax 的失败要分三层来看,每层的处理方式不同:
- 网络层错误 ------
onerror触发。请求没发出去或没回来:断网、DNS 失败、跨域被拦。此时xhr.status是0,什么信息都没有。处理方式:提示「网络异常,请检查连接」,可重试。 - HTTP 层错误 ------
onload触发,但status是 4xx/5xx。请求到达了服务器,但出了问题:400参数错、401未登录、403无权限、404资源不存在、500服务端崩了。处理方式:按状态码分支------401跳登录页,404/500给对应提示。 - 业务层错误 ------
status200,但res.code !== '0000'。通信和 HTTP 都正常,是业务规则没通过:余额不足、名称重复。处理方式:直接把后端的res.msg展示给用户。
为什么要区分三层? 因为应对方式天差地别:网络错误适合「重试」,HTTP 401 适合「跳登录」,业务错误适合「展示后端文案」。混成一句「请求失败」,既不利于排查,也不利于用户。
重试要克制。 只对「幂等且可恢复」的失败重试------超时、502/503 这种「服务临时不可用」可以重试。POST 创建类请求不要无脑重试 ,否则可能创建两条重复数据。重试还要有上限 和退避(每次重试间隔递增),否则服务一抖动,海量重试会把它彻底压垮。
调试方面,浏览器的 Network 面板是 Ajax 排错的第一现场:看请求有没有发出、状态码是多少、请求头/响应头对不对、响应体内容是什么。配合控制台的报错信息,绝大多数问题都能定位。
#mermaid-svg-MtCyi4g7Be5LzbBv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MtCyi4g7Be5LzbBv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MtCyi4g7Be5LzbBv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MtCyi4g7Be5LzbBv .error-icon{fill:#552222;}#mermaid-svg-MtCyi4g7Be5LzbBv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MtCyi4g7Be5LzbBv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MtCyi4g7Be5LzbBv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MtCyi4g7Be5LzbBv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MtCyi4g7Be5LzbBv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MtCyi4g7Be5LzbBv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MtCyi4g7Be5LzbBv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MtCyi4g7Be5LzbBv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MtCyi4g7Be5LzbBv .marker.cross{stroke:#333333;}#mermaid-svg-MtCyi4g7Be5LzbBv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MtCyi4g7Be5LzbBv p{margin:0;}#mermaid-svg-MtCyi4g7Be5LzbBv .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-MtCyi4g7Be5LzbBv .cluster-label text{fill:#333;}#mermaid-svg-MtCyi4g7Be5LzbBv .cluster-label span{color:#333;}#mermaid-svg-MtCyi4g7Be5LzbBv .cluster-label span p{background-color:transparent;}#mermaid-svg-MtCyi4g7Be5LzbBv .label text,#mermaid-svg-MtCyi4g7Be5LzbBv span{fill:#333;color:#333;}#mermaid-svg-MtCyi4g7Be5LzbBv .node rect,#mermaid-svg-MtCyi4g7Be5LzbBv .node circle,#mermaid-svg-MtCyi4g7Be5LzbBv .node ellipse,#mermaid-svg-MtCyi4g7Be5LzbBv .node polygon,#mermaid-svg-MtCyi4g7Be5LzbBv .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MtCyi4g7Be5LzbBv .rough-node .label text,#mermaid-svg-MtCyi4g7Be5LzbBv .node .label text,#mermaid-svg-MtCyi4g7Be5LzbBv .image-shape .label,#mermaid-svg-MtCyi4g7Be5LzbBv .icon-shape .label{text-anchor:middle;}#mermaid-svg-MtCyi4g7Be5LzbBv .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-MtCyi4g7Be5LzbBv .rough-node .label,#mermaid-svg-MtCyi4g7Be5LzbBv .node .label,#mermaid-svg-MtCyi4g7Be5LzbBv .image-shape .label,#mermaid-svg-MtCyi4g7Be5LzbBv .icon-shape .label{text-align:center;}#mermaid-svg-MtCyi4g7Be5LzbBv .node.clickable{cursor:pointer;}#mermaid-svg-MtCyi4g7Be5LzbBv .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-MtCyi4g7Be5LzbBv .arrowheadPath{fill:#333333;}#mermaid-svg-MtCyi4g7Be5LzbBv .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-MtCyi4g7Be5LzbBv .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-MtCyi4g7Be5LzbBv .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MtCyi4g7Be5LzbBv .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-MtCyi4g7Be5LzbBv .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MtCyi4g7Be5LzbBv .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-MtCyi4g7Be5LzbBv .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-MtCyi4g7Be5LzbBv .cluster text{fill:#333;}#mermaid-svg-MtCyi4g7Be5LzbBv .cluster span{color:#333;}#mermaid-svg-MtCyi4g7Be5LzbBv div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-MtCyi4g7Be5LzbBv .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-MtCyi4g7Be5LzbBv rect.text{fill:none;stroke-width:0;}#mermaid-svg-MtCyi4g7Be5LzbBv .icon-shape,#mermaid-svg-MtCyi4g7Be5LzbBv .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MtCyi4g7Be5LzbBv .icon-shape p,#mermaid-svg-MtCyi4g7Be5LzbBv .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-MtCyi4g7Be5LzbBv .icon-shape .label rect,#mermaid-svg-MtCyi4g7Be5LzbBv .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MtCyi4g7Be5LzbBv .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-MtCyi4g7Be5LzbBv .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-MtCyi4g7Be5LzbBv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} onerror / ontimeout
onload
4xx / 5xx
2xx
非 0000
0000
请求结束
哪个事件触发?
网络层错误
提示网络异常, 可重试
HTTP status?
HTTP 层错误
401 跳登录 / 其余按码提示
业务 code?
业务层错误
展示后端 msg
真正成功
【代码注释】(错误分层决策图)这张图是「一次请求结束后,如何判断它到底属于哪种结果」的完整决策树。
- 第一个分叉看「触发的是哪个事件」:
onerror/ontimeout走网络层;onload说明请求到达了,再往下看。 - 第二个分叉看 HTTP 状态码:4xx/5xx 是 HTTP 层错误,2xx 才继续。
- 第三个分叉看业务
code:到这一步通信和 HTTP 都没问题,code才是「业务成不成」的最终裁决。 - 只有走到最右下角------
onload+ 2xx +code==='0000'------才是「真正成功」。前面任何一个分叉都是一种需要单独应对的失败。 - 市面应用:所有成熟的请求库/请求层都内置了这棵决策树,业务代码只在最后拿到「成功数据」或「一个带类型的错误对象」。
入门示例:按状态码分层处理
下面的封装把三层错误的判断收进一个 request 函数,调用方只需 .then/.catch:
javascript
// 统一的错误对象:type 标明是哪一层错误
function makeError(type, message, status) {
return { type, message, status };
}
function request(options) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.timeout = options.timeout || 10000;
xhr.onload = () => {
// 第二层:HTTP 状态码
if (xhr.status < 200 || xhr.status >= 300) {
const map = { 401: '登录已失效', 403: '没有权限', 404: '资源不存在', 500: '服务器错误' };
return reject(makeError('http', map[xhr.status] || '请求异常', xhr.status));
}
// 第三层:业务 code
const res = xhr.response;
if (res && res.code !== '0000') {
return reject(makeError('business', res.msg || '业务失败', xhr.status));
}
resolve(res ? res.data : null); // 三层全过,才是成功
};
// 第一层:网络错误与超时
xhr.onerror = () => reject(makeError('network', '网络异常,请检查连接', 0));
xhr.ontimeout = () => reject(makeError('timeout', '请求超时', 0));
xhr.open(options.method || 'GET', options.url);
xhr.send(options.body);
});
}
// 调用方:catch 里按 type 给不同反应
request({ url: '/api/account/1001' })
.then(data => console.log('成功:', data))
.catch(err => {
if (err.type === 'network' || err.type === 'timeout') {
console.warn('可重试:', err.message);
} else if (err.type === 'http' && err.status === 401) {
console.warn('跳转登录页');
} else {
console.warn('提示用户:', err.message); // 业务错误,展示后端文案
}
});
【代码注释】这段把第六章「两层判断」扩展成了完整的「三层错误处理」。
- 关键是给错误对象加了
type字段------'network'/'timeout'/'http'/'business',让调用方一眼就知道「这是哪一层的失败」,从而采取不同对策。 onload里做了两道闸:先查 HTTPstatus是否在 2xx,不在就是 HTTP 错误;再查业务code是否'0000',不是就是业务错误;两道都过才resolve。onerror/ontimeout对应网络层,status给0------因为这一层根本没有状态码。- 调用方的
catch按type分流:网络/超时类提示「可重试」,HTTP401跳登录,业务错误直接展示后端的msg。同一个catch,三种截然不同的反应。 - 市面应用 :这就是企业级请求层的标准形态------业务代码永远只面对「干净的成功数据」或「一个带
type的错误对象」,所有脏活在封装里做完。
实战示例:带超时重试的请求
下面给请求加上「超时自动重试、带次数上限」的能力,并演示一个可运行的错误提示界面。保存为 retry-demo.html 打开:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>超时重试演示</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 560px; margin: 2rem auto; padding: 0 1rem; }
button { padding: 8px 14px; cursor: pointer; }
#log { margin-top: 16px; background: #f4f4f4; padding: 12px; border-radius: 6px; }
.toast { color: #d33; }
</style>
</head>
<body>
<h1>超时自动重试</h1>
<button id="btn">发起请求(必定超时以演示重试)</button>
<div id="log"></div>
<script>
const log = m => document.getElementById('log').innerHTML += m + '<br>';
// 带重试的请求:retries 为剩余重试次数
function requestWithRetry(options, retries = 2) {
return new Promise((resolve, reject) => {
const attempt = left => {
const xhr = new XMLHttpRequest();
xhr.timeout = options.timeout || 2000;
xhr.onload = () => resolve(xhr.response);
// 超时是「可恢复失败」:还有次数就退避后重试
xhr.ontimeout = () => {
if (left > 0) {
const delay = (3 - left) * 1000; // 退避:1s、2s 递增
log(`超时,${delay}ms 后重试,剩余 ${left} 次`);
setTimeout(() => attempt(left - 1), delay);
} else {
reject({ type: 'timeout', message: '多次重试仍超时' });
}
};
xhr.onerror = () => reject({ type: 'network', message: '网络异常' });
xhr.open(options.method || 'GET', options.url);
xhr.send(options.body);
};
attempt(retries);
});
}
document.getElementById('btn').onclick = () => {
document.getElementById('log').innerHTML = '';
log('① 发起请求...');
// 故意请求一个无法在 2s 内响应的地址来触发超时
requestWithRetry({ url: 'https://httpbin.org/delay/10', timeout: 2000 }, 2)
.then(() => log('② 成功'))
.catch(err => log('<span class="toast">② 最终失败:' + err.message + '</span>'));
};
</script>
</body>
</html>
【代码注释】这段演示「对可恢复的失败做有上限、有退避的重试」。
requestWithRetry用一个内部递归函数attempt(left)实现重试,left是剩余次数。每次超时,若left > 0就setTimeout后再attempt(left - 1);用尽次数才真正reject。- 只对超时重试 :
ontimeout里才有重试逻辑,onerror(网络层)直接失败------因为「超时」常是临时拥塞、值得再试,而 DNS 失败之类重试也没用。注意:示例请求是 GET(幂等),如果是POST创建类请求,重试可能造成重复数据,要格外谨慎。 - 退避(backoff) :重试间隔用
(3 - left) * 1000算成 1s、2s 递增,而不是固定立刻重试。这样服务端短暂抖动时不会被密集重试雪上加霜。 - 示例故意请求一个「延迟 10 秒才响应」的地址、却把
timeout设成 2 秒,于是必定超时,能完整看到「两次重试 → 最终失败」的过程。 - 市面应用:App 弱网环境下的「自动重连」、上传失败的「断点重传」、大厂网关的「失败退避重试」,内核都是这套「有上限 + 退避 + 只重试可恢复失败」的逻辑。
【实战要点】
- 经典应用场景 :统一请求层按错误
type分流------网络错误弹「检查网络」、401跳登录、业务错误弹后端msg;弱网场景对幂等请求做退避重试。 - 常见坑 :① 把所有失败笼统提示「请求失败」,用户和开发都无法判断问题。② 对
POST创建类请求无脑重试,造成重复下单/重复提交。③ 重试不设上限或不退避,服务一抖动就被重试洪流压垮。④ 跨域被拦时onerror里读xhr.status期望拿到码------其实是0。 - 性能与最佳实践 :调试首选 Network 面板(看状态码、请求/响应头、响应体)+ 控制台报错;重试要「有限次数 + 指数退避 + 只对幂等请求」;给用户的提示用
res.msg这类人话,技术细节写进控制台或上报日志。
【本章小结】
| 错误层 | 触发信号 | 典型应对 |
|---|---|---|
| 网络层 | onerror / ontimeout,status=0 |
提示网络异常,可退避重试 |
| HTTP 层 | onload 但 4xx/5xx |
按码分支:401 跳登录等 |
| 业务层 | 200 但 code!=='0000' |
展示后端 msg |
| 调试 | Network 面板 + 控制台 | 看码、看头、看响应体 |
记忆口诀 :错误分三层、网络可重试、HTTP 看码、业务看 code。
【面试考点】
Q1:Ajax 请求的失败分哪几类?分别怎么处理?
A:分三层。① 网络层错误------onerror/ontimeout 触发,请求没成功收发(断网、DNS 失败、跨域被拦),xhr.status 是 0,应提示「网络异常」并可对幂等请求重试。② HTTP 层错误------onload 触发但状态码是 4xx/5xx,请求到了服务端但出错了,应按码分支:401 跳登录、403 提示无权限、404/500 给对应文案。③ 业务层错误------HTTP 200 但响应体 code 不是成功值,通信没问题但业务规则没过(余额不足等),应直接展示后端返回的 msg。区分三层的意义在于应对方式完全不同。
Q2:请求失败要不要自动重试?要注意什么?
A:要分情况。只对「幂等且可恢复」的失败重试------超时、502/503 这类「服务临时不可用」适合重试;POST 这类创建请求不能无脑重试,否则会产生重复数据。重试必须满足三个约束:一是有次数上限 ,不能无限重试;二是用指数退避 ,间隔逐次拉长,避免服务抖动时被重试洪流压垮;三是只针对幂等请求(GET/DELETE/PUT 幂等,POST 通常不幂等)。否则重试不仅救不了系统,反而可能把它彻底打垮、或制造脏数据。
八、性能优化与生产实践
最后一章把请求做到「快」和「稳」:减少不必要的请求、控制高频触发、防住安全风险、留下可观测的日志。
名词解释
- 防抖(Debounce):高频触发时,只在「停止触发一段时间后」执行一次。
- 节流(Throttle):高频触发时,按固定间隔最多执行一次。
- 请求缓存:把响应结果按 key 存起来,重复请求直接用缓存。
- XSS(跨站脚本攻击):把恶意脚本注入页面并执行。
- CSRF(跨站请求伪造):诱导已登录用户的浏览器发出非自愿的请求。
- 请求监控:统计每个请求的耗时、成功率,用于发现慢接口和故障。
概念与底层原理
性能优化的第一原则是「少发请求」。 三个常用手段:
- 防抖 ------搜索联想这种
oninput高频场景,用户每敲一个字就发一次请求既浪费又乱序。防抖让「停止输入 300ms 后」才发一次,请求数从「按键次数」降到「搜索意图次数」。 - 请求缓存------同样的请求短时间内重复发(如来回切 Tab 都拉同一份配置),用一个带过期时间(TTL)的缓存挡掉重复请求。
- 请求合并------首屏需要用户信息、菜单、配置三份数据时,让后端提供一个聚合接口一次返回,而不是发三个请求。
安全的两个重点是 XSS 和 CSRF。 XSS 的典型入口是「把用户输入直接拼进 innerHTML」------记账本列表渲染就是高危点:如果某条账单的标题是 <img src=x onerror=alert(1)>,直接拼进 innerHTML 就会执行。防御核心是输出转义 :把 <、>、&、引号转成 HTML 实体,让它永远是「文本」而不是「标签」。CSRF 则是利用「浏览器自动带 Cookie」------防御靠 SameSite Cookie、关键操作加一次性的 CSRF Token、校验请求来源。
监控让问题可被发现。 通过给请求层埋点,统计每个接口的耗时和成功率,慢接口、高错误率接口才能在用户投诉之前被发现。生产环境通常接入专门的前端监控/APM 服务,把数据采集上报。
#mermaid-svg-uR8OHedsQqaQOSO0{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uR8OHedsQqaQOSO0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uR8OHedsQqaQOSO0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uR8OHedsQqaQOSO0 .error-icon{fill:#552222;}#mermaid-svg-uR8OHedsQqaQOSO0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uR8OHedsQqaQOSO0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uR8OHedsQqaQOSO0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uR8OHedsQqaQOSO0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uR8OHedsQqaQOSO0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uR8OHedsQqaQOSO0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uR8OHedsQqaQOSO0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uR8OHedsQqaQOSO0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uR8OHedsQqaQOSO0 .marker.cross{stroke:#333333;}#mermaid-svg-uR8OHedsQqaQOSO0 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uR8OHedsQqaQOSO0 p{margin:0;}#mermaid-svg-uR8OHedsQqaQOSO0 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-uR8OHedsQqaQOSO0 .cluster-label text{fill:#333;}#mermaid-svg-uR8OHedsQqaQOSO0 .cluster-label span{color:#333;}#mermaid-svg-uR8OHedsQqaQOSO0 .cluster-label span p{background-color:transparent;}#mermaid-svg-uR8OHedsQqaQOSO0 .label text,#mermaid-svg-uR8OHedsQqaQOSO0 span{fill:#333;color:#333;}#mermaid-svg-uR8OHedsQqaQOSO0 .node rect,#mermaid-svg-uR8OHedsQqaQOSO0 .node circle,#mermaid-svg-uR8OHedsQqaQOSO0 .node ellipse,#mermaid-svg-uR8OHedsQqaQOSO0 .node polygon,#mermaid-svg-uR8OHedsQqaQOSO0 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-uR8OHedsQqaQOSO0 .rough-node .label text,#mermaid-svg-uR8OHedsQqaQOSO0 .node .label text,#mermaid-svg-uR8OHedsQqaQOSO0 .image-shape .label,#mermaid-svg-uR8OHedsQqaQOSO0 .icon-shape .label{text-anchor:middle;}#mermaid-svg-uR8OHedsQqaQOSO0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-uR8OHedsQqaQOSO0 .rough-node .label,#mermaid-svg-uR8OHedsQqaQOSO0 .node .label,#mermaid-svg-uR8OHedsQqaQOSO0 .image-shape .label,#mermaid-svg-uR8OHedsQqaQOSO0 .icon-shape .label{text-align:center;}#mermaid-svg-uR8OHedsQqaQOSO0 .node.clickable{cursor:pointer;}#mermaid-svg-uR8OHedsQqaQOSO0 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-uR8OHedsQqaQOSO0 .arrowheadPath{fill:#333333;}#mermaid-svg-uR8OHedsQqaQOSO0 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-uR8OHedsQqaQOSO0 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-uR8OHedsQqaQOSO0 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uR8OHedsQqaQOSO0 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-uR8OHedsQqaQOSO0 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uR8OHedsQqaQOSO0 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-uR8OHedsQqaQOSO0 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-uR8OHedsQqaQOSO0 .cluster text{fill:#333;}#mermaid-svg-uR8OHedsQqaQOSO0 .cluster span{color:#333;}#mermaid-svg-uR8OHedsQqaQOSO0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-uR8OHedsQqaQOSO0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-uR8OHedsQqaQOSO0 rect.text{fill:none;stroke-width:0;}#mermaid-svg-uR8OHedsQqaQOSO0 .icon-shape,#mermaid-svg-uR8OHedsQqaQOSO0 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uR8OHedsQqaQOSO0 .icon-shape p,#mermaid-svg-uR8OHedsQqaQOSO0 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-uR8OHedsQqaQOSO0 .icon-shape .label rect,#mermaid-svg-uR8OHedsQqaQOSO0 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uR8OHedsQqaQOSO0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-uR8OHedsQqaQOSO0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-uR8OHedsQqaQOSO0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 300ms 内又输入
300ms 静默
是
否
高频输入
防抖计时器
缓存命中?
直接用缓存
发请求
结果转义后渲染
写入缓存 + 上报耗时
【代码注释】(性能优化链路图)这张图把本章的几个优化手段串成了一条请求处理流水线。
- 「高频输入」先进防抖:300ms 内持续输入就一直重置计时器,只有静默 300ms 才放行------大量无意义请求在这里被挡掉。
- 放行后先查缓存:命中就直接用,连请求都不发。
- 真要发请求时,结果在渲染前先转义 (防 XSS),渲染后再把结果写入缓存 、把耗时上报监控。
- 市面应用:搜索框、筛选器、实时校验(如「用户名是否已存在」)这些高频交互,背后基本都是这条「防抖 → 缓存 → 请求 → 转义渲染 → 上报」的流水线。
入门示例:防抖搜索 + 请求缓存
下面把第四章的搜索联想加上「防抖」和「缓存」,对比优化前后的请求数。保存为 debounce-search.html 打开:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>防抖搜索 + 缓存</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 520px; margin: 2rem auto; padding: 0 1rem; }
input { width: 100%; height: 40px; padding: 0 12px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 6px; }
#log { margin-top: 12px; background: #f4f4f4; padding: 12px; border-radius: 6px; height: 160px; overflow: auto; }
</style>
</head>
<body>
<h1>防抖 + 缓存</h1>
<input id="kw" placeholder="快速输入,观察下方请求次数">
<div id="log"></div>
<script>
const log = m => document.getElementById('log').innerHTML += m + '<br>';
const cache = new Map(); // 请求缓存:关键词 -> 结果
// 防抖:返回一个包装函数,delay 内重复调用只执行最后一次
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer); // 每次调用都取消上一个计时器
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 真正的「搜索」:先查缓存,未命中才「发请求」
function search(keyword) {
if (!keyword.trim()) return;
if (cache.has(keyword)) {
log(`「${keyword}」命中缓存,未发请求`);
return;
}
log(`「${keyword}」发起请求...`);
// 此处用 setTimeout 模拟一次网络请求
setTimeout(() => {
const result = keyword + ' 的搜索结果';
cache.set(keyword, result); // 结果写入缓存
log(`「${keyword}」请求返回,已缓存`);
}, 300);
}
// 给输入事件套上防抖:停止输入 400ms 才真正 search
document.getElementById('kw').oninput = debounce(e => search(e.target.value), 400);
</script>
</body>
</html>
【代码注释】这段用「防抖 + 缓存」两招,把高频输入产生的请求数压到最低。
debounce是通用工具:它返回一个新函数,内部维护一个timer。每次被调用都先clearTimeout取消上一个计时器、再重设------于是只有「连续 400ms 没有再被调用」时,真正的fn才执行一次。快速输入「abcd」四个字母只会触发一次search,而不是四次。cache(Map)做请求缓存:search先查cache.has(keyword),命中就直接用、连请求都不发;未命中才请求,并在返回后cache.set存起来。来回输入同一个词,第二次起都是缓存。- 两招叠加效果显著:防抖把「按键次数」降到「搜索意图次数」,缓存再把「重复意图」也挡掉。
- 真实项目里
search内部的setTimeout换成第五章的ajax()即可;缓存通常还要加 TTL(过期时间),避免数据长期不更新。 - 市面应用 :搜索框联想、表单实时校验、窗口
resize/滚动监听,全都靠防抖/节流控制频率;缓存则广泛用于「不常变的配置、字典数据」。
实战示例:渲染防 XSS 与请求埋点
记账本列表把账单标题拼进 innerHTML,是 XSS 高危点。下面演示「转义后再渲染」,并给请求加耗时埋点。保存为 safe-render.html 打开:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>安全渲染与埋点</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 560px; margin: 2rem auto; padding: 0 1rem; }
button { padding: 8px 14px; cursor: pointer; }
.row { padding: 8px 12px; margin: 6px 0; background: #f4f6ff; border-radius: 4px; }
#log { margin-top: 12px; color: #888; font-size: 13px; }
</style>
</head>
<body>
<h1>转义渲染 + 请求埋点</h1>
<button id="btn">渲染一条「含恶意脚本」的账单</button>
<div id="list"></div>
<div id="log"></div>
<script>
// 输出转义:把有 HTML 含义的字符转成实体,使其只能是「文本」
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// 请求埋点:包一层,统计耗时
function withTiming(name, asyncFn) {
const start = performance.now();
return asyncFn().finally(() => {
const cost = (performance.now() - start).toFixed(1);
document.getElementById('log').textContent = `接口「${name}」耗时 ${cost}ms`;
});
}
document.getElementById('btn').onclick = () => {
// 模拟一条标题里藏了脚本的账单(恶意输入)
const fakeRequest = () => new Promise(resolve =>
setTimeout(() => resolve({ title: '<img src=x onerror="alert(1)">', account: 99 }), 200));
withTiming('GET /api/account', fakeRequest).then(item => {
// 关键:title 经过 escapeHtml 再拼进 innerHTML
document.getElementById('list').innerHTML += `
<div class="row">${escapeHtml(item.title)} ------ ${item.account} 元</div>`;
});
};
</script>
</body>
</html>
【代码注释】这段示例同时演示了「防 XSS 的转义」和「请求耗时埋点」两个生产实践。
escapeHtml是防 XSS 的核心:它把<、>、&、引号替换成对应的 HTML 实体。账单标题里那段<img src=x onerror=alert(1)>转义后变成纯文本,浏览器只会把它「显示」出来、不会当标签执行。对比验证 :把escapeHtml(item.title)改成直接item.title,再点按钮,alert(1)就会弹出来------这就是一次真实的 XSS。- 转义的原则是「输出时转义 」:数据可以原样存库,但凡是要拼进
innerHTML的那一刻,必须先转义。用textContent赋值则天然安全(它不解析 HTML)。 withTiming演示请求埋点:用performance.now()在请求前后打点,.finally()保证无论成败都记录耗时。把它接到监控系统,就能统计每个接口的性能。- 市面应用 :所有 UI 框架(React、Vue)默认对插值做转义,正是
escapeHtml的内建版;只有用dangerouslySetInnerHTML/v-html时才会绕过它------那也是 XSS 高发区。耗时埋点则是前端性能监控(APM)的数据来源。
【实战要点】
- 经典应用场景:搜索联想/实时校验用防抖;不常变的字典、配置数据用带 TTL 的缓存;首屏多份数据用聚合接口合并请求;列表渲染用户内容前一律转义。
- 常见坑 :① 防抖的
timer变量声明在了包装函数内部、每次调用都重置,导致防抖失效------timer必须在闭包里保持。② 缓存不设过期时间,数据更新了前端还在用旧缓存。③ 把用户输入直接拼进innerHTML不转义,留下 XSS 漏洞。④ 有副作用的操作用 GET(易被 CSRF 利用),应当用 POST/DELETE。 - 性能与最佳实践 :防抖延迟一般 300~500ms(太短没效果、太长有迟滞感);缓存务必带 TTL;安全上「输出转义防 XSS、
SameSite+ Token 防 CSRF」要一起做;生产接入监控,盯住慢接口和错误率。
【本章小结】
| 方向 | 手段 | 要点 |
|---|---|---|
| 少发请求 | 防抖、缓存、请求合并 | 「按意图发,不按按键发」 |
| 防 XSS | 输出转义 / textContent |
「用户输入永远当文本」 |
| 防 CSRF | SameSite + CSRF Token |
「关键操作要凭证」 |
| 可观测 | 请求耗时与成功率埋点 | 「慢接口要先于用户发现」 |
记忆口诀 :防抖减请求、缓存挡重复、转义防注入、埋点保可观测。
【面试考点】
Q1:防抖和节流有什么区别?搜索联想该用哪个?
A:两者都用来控制高频触发。防抖(debounce)是「停止触发一段时间后才执行一次」------只要还在触发就一直推迟,适合「只关心最终状态」的场景。节流(throttle)是「固定间隔最多执行一次」------触发期间也会按节奏执行,适合「过程中也要响应」的场景。搜索联想用防抖 :用户连续打字时不该发请求,只在他停下来(意图明确)时发一次;如果用节流,打字过程中会按间隔发出若干次半截关键词的请求,既浪费又可能结果乱序。滚动加载、resize 这类「过程中也要反馈」的才用节流。
Q2:前端渲染列表时如何防止 XSS?
A:核心是「输出转义」------把用户提供的内容拼进页面前,把 <、>、&、引号等有 HTML 含义的字符替换成 HTML 实体,这样它只会被当作文本显示、不会被当作标签或脚本执行。具体做法:用 textContent/innerText 赋值天然安全(它们不解析 HTML);不得不用 innerHTML 时,必须先对动态内容做 escapeHtml。React 的 JSX 插值、Vue 的 {``{ }} 默认都做了转义,所以平时不用手动转;但用 dangerouslySetInnerHTML/v-html 时会绕过转义,是 XSS 高危点,必须确保内容可信或已转义。再叠加 CSP 响应头可进一步收紧。
总结
知识点回顾(思维导图)
#mermaid-svg-dMnBv3OmGvqPl9m3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dMnBv3OmGvqPl9m3 .error-icon{fill:#552222;}#mermaid-svg-dMnBv3OmGvqPl9m3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dMnBv3OmGvqPl9m3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dMnBv3OmGvqPl9m3 .marker.cross{stroke:#333333;}#mermaid-svg-dMnBv3OmGvqPl9m3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dMnBv3OmGvqPl9m3 p{margin:0;}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge{stroke-width:3;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section--1 rect,#mermaid-svg-dMnBv3OmGvqPl9m3 .section--1 path,#mermaid-svg-dMnBv3OmGvqPl9m3 .section--1 circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .section--1 polygon,#mermaid-svg-dMnBv3OmGvqPl9m3 .section--1 path{fill:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .section--1 text{fill:#ffffff;}#mermaid-svg-dMnBv3OmGvqPl9m3 .node-icon--1{font-size:40px;color:#ffffff;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-edge--1{stroke:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-depth--1{stroke-width:17;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section--1 line{stroke:hsl(60, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:lightgray;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:#efefef;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-0 rect,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-0 path,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-0 circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-0 polygon,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-0 path{fill:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-0 text{fill:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .node-icon-0{font-size:40px;color:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-edge-0{stroke:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-depth-0{stroke-width:14;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-0 line{stroke:hsl(240, 100%, 83.5294117647%);stroke-width:3;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:lightgray;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:#efefef;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-1 rect,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-1 path,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-1 circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-1 polygon,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-1 path{fill:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-1 text{fill:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .node-icon-1{font-size:40px;color:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-edge-1{stroke:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-depth-1{stroke-width:11;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-1 line{stroke:hsl(260, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:lightgray;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:#efefef;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-2 rect,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-2 path,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-2 circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-2 polygon,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-2 path{fill:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-2 text{fill:#ffffff;}#mermaid-svg-dMnBv3OmGvqPl9m3 .node-icon-2{font-size:40px;color:#ffffff;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-edge-2{stroke:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-depth-2{stroke-width:8;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-2 line{stroke:hsl(90, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:lightgray;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:#efefef;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-3 rect,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-3 path,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-3 circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-3 polygon,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-3 path{fill:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-3 text{fill:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .node-icon-3{font-size:40px;color:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-edge-3{stroke:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-depth-3{stroke-width:5;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-3 line{stroke:hsl(120, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:lightgray;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:#efefef;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-4 rect,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-4 path,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-4 circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-4 polygon,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-4 path{fill:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-4 text{fill:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .node-icon-4{font-size:40px;color:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-edge-4{stroke:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-depth-4{stroke-width:2;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-4 line{stroke:hsl(150, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:lightgray;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:#efefef;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-5 rect,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-5 path,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-5 circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-5 polygon,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-5 path{fill:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-5 text{fill:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .node-icon-5{font-size:40px;color:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-edge-5{stroke:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-depth-5{stroke-width:-1;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-5 line{stroke:hsl(180, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:lightgray;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:#efefef;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-6 rect,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-6 path,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-6 circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-6 polygon,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-6 path{fill:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-6 text{fill:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .node-icon-6{font-size:40px;color:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-edge-6{stroke:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-depth-6{stroke-width:-4;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-6 line{stroke:hsl(210, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:lightgray;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:#efefef;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-7 rect,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-7 path,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-7 circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-7 polygon,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-7 path{fill:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-7 text{fill:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .node-icon-7{font-size:40px;color:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-edge-7{stroke:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-depth-7{stroke-width:-7;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-7 line{stroke:hsl(270, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:lightgray;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:#efefef;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-8 rect,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-8 path,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-8 circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-8 polygon,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-8 path{fill:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-8 text{fill:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .node-icon-8{font-size:40px;color:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-edge-8{stroke:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-depth-8{stroke-width:-10;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-8 line{stroke:hsl(330, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:lightgray;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:#efefef;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-9 rect,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-9 path,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-9 circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-9 polygon,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-9 path{fill:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-9 text{fill:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .node-icon-9{font-size:40px;color:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-edge-9{stroke:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-depth-9{stroke-width:-13;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-9 line{stroke:hsl(0, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:lightgray;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:#efefef;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-10 rect,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-10 path,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-10 circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-10 polygon,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-10 path{fill:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-10 text{fill:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .node-icon-10{font-size:40px;color:black;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-edge-10{stroke:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge-depth-10{stroke-width:-16;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-10 line{stroke:hsl(30, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:lightgray;}#mermaid-svg-dMnBv3OmGvqPl9m3 .disabled text{fill:#efefef;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-root rect,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-root path,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-root circle,#mermaid-svg-dMnBv3OmGvqPl9m3 .section-root polygon{fill:hsl(240, 100%, 46.2745098039%);}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-root text{fill:#ffffff;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-root span{color:#ffffff;}#mermaid-svg-dMnBv3OmGvqPl9m3 .section-2 span{color:#ffffff;}#mermaid-svg-dMnBv3OmGvqPl9m3 .icon-container{height:100%;display:flex;justify-content:center;align-items:center;}#mermaid-svg-dMnBv3OmGvqPl9m3 .edge{fill:none;}#mermaid-svg-dMnBv3OmGvqPl9m3 .mindmap-node-label{dy:1em;alignment-baseline:middle;text-anchor:middle;dominant-baseline:middle;text-align:center;}#mermaid-svg-dMnBv3OmGvqPl9m3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Ajax 进阶
XHR 核心
responseType json
进度事件
timeout 超时
跨域
同源策略 三要素
拦读响应 不拦发请求
CORS
服务端响应头
简单请求 与 预检
带 Cookie 不用星号
JSONP
动态 script
回调函数名
仅 GET
封装
配置加默认值
Promise 与拦截器
记账本 REST
方法表语义
统一响应体 code
FormData 与事件委托
工程化
错误分三层
防抖 缓存
转义 与 监控
【代码注释】(知识回顾思维导图)这张导图是全文的「知识地图」,复习时对照它逐个分支自测。
- 「XHR 核心」是地基,「跨域 / CORS / JSONP」是本篇主线,「封装」把前面的请求逻辑工程化,「记账本 REST」是集大成的实战,「工程化」是上线前的保障。
- 能对着每个分支讲清「它是什么、为什么、怎么用」,就算真正掌握了 Ajax 进阶。
- 市面应用:这套知识结构对应前端项目里的「请求层」------从裸 XHR、到封装、到带拦截器的请求库、再到错误处理与监控,是每个前端工程师都要走一遍的路。
本篇在 XMLHttpRequest 基础之上,完成了 Ajax 进阶的完整闭环:
- XHR 核心 :
responseType让浏览器代解析、进度事件汇报传输过程、timeout兜住弱网------这些是后面封装每个配置项的来历。 - 同源策略:协议+域名+端口三要素,浏览器拦的是「JS 读响应」而非「请求发出」。
- CORS :官方跨域方案,服务端用响应头声明放行;简单请求直发、非简单请求先预检;带 Cookie 不能用
*。 - JSONP :借
<script>跨域能力的历史方案,仅 GET、错误处理弱、有安全风险,能用 CORS 就别用它。 - 封装 :把五步样板代码收进
ajax(),再升级为 Promise 版 + 拦截器,这是现代请求库的雏形。 - 记账本 REST :HTTP 方法表语义、
{code,msg,data}统一响应、FormData提交、事件委托删除------一个完整的前后端联调项目。 - 错误处理:网络层、HTTP 层、业务层三层错误分别应对;重试要有上限和退避。
- 工程化:防抖与缓存减少请求、输出转义防 XSS、埋点监控保障可观测性。
高频面试题速查
xhr.response和responseText的区别?(§1)- XHR 有哪些进度事件?
onload和onloadend区别?(§1) - 什么是同源策略?它限制的到底是什么?(§2)
- 为什么
<img>、<script>能跨域,Ajax 不行?(§2) - CORS 的简单请求和预检请求有什么区别?何时触发预检?(§3)
Access-Control-Allow-Origin: *有什么限制?带 Cookie 怎么配?(§3)- JSONP 的原理是什么?为什么只能 GET?(§4)
- JSONP 和 CORS 相比有哪些缺点?(§4)
- 为什么要封装 Ajax?好的封装要考虑哪些点?(§5)
setRequestHeader为什么必须在open之后、send之前?(§5)- 业务
code和 HTTP 状态码的区别?为什么两层判断?(§6) - 添加数据为什么用
FormData?为什么不能手动设Content-Type?(§6) - Ajax 失败分哪几类?分别怎么处理?(§7)
- 请求失败要不要自动重试?要注意什么?(§7)
- 防抖和节流的区别?搜索联想用哪个?(§8)
- 前端渲染列表时如何防 XSS?(§8)
学习建议
- 先跑通再深究 :依次把同源判断、CORS 联调、手写 JSONP、
ajax()封装、记账本五个示例跑通,再回头读「概念与底层原理」,体会会深得多。 - 善用 Network 面板 :跨域报错看 Network 的状态码、看响应头有没有 CORS 头、看预检
OPTIONS------这是排查 Ajax 问题最快的手段。 - 动手做对比实验 :把 CORS 头注释掉看报错、把
escapeHtml去掉看 XSS 弹窗、把防抖去掉看请求数暴涨------「破坏再修复」比单纯阅读记得牢。 - 往后延伸 :掌握本篇后,可继续学习用 Promise /
async/await重写封装、用fetch替代 XHR、用 axios 的拦截器体系,以及 WebSocket、SSE 等其它通信方式。
延伸阅读: