Ajax 进阶:跨域、CORS、JSONP 与请求封装实战

导读:本文从 XMLHttpRequest 的进阶特性 (响应类型、超时、进度事件)出发,系统讲透 Web 开发绕不开的 同源策略与跨域 ,对比 CORS (官方方案)与 JSONP (历史方案)的原理与取舍,再把请求逻辑沉淀为一个可复用的 ajax() 封装 ,最后用一个 RESTful 记账本 把「前端发请求 → 后端响应 → 渲染列表」的全链路串起来。每个知识点都配有可直接保存为 .html 运行的示例,所有结论锚定 MDN 与 W3C 规范。适合已掌握 XHR 基本用法、希望进入工程实战的前端学习者。

权威参考:MDN XMLHttpRequestMDN 同源策略MDN CORSMDN FormDataFetch 标准

目录


零、导读与学习价值

0.1 示例覆盖清单

本文每个知识点都配有可运行示例,下表列出全部示例与对应章节,确保「读完即能动手」:

示例 练习要点 本文章节
XHR 进阶特性演示 responseType:'json'timeoutonprogress 进度事件、大 JSON 响应 §1
同源判断与跨域复现 URL.originisSameOrigin、跨域报错现场 §2
CORS 联调示例 服务端 Access-Control-Allow-Origin、按 Origin 白名单放行 §3
HTTPS 服务示例 https.createServer、协议不同即跨域 §3
手写 JSONP 示例 动态 <script>、回调函数名、服务端拼 cb(JSON) §4
搜索联想 JSONP 示例 输入联想、<datalist>、第三方 sugrec 接口 §4
ajax(options) 封装示例 统一配置项、responseTypesuccess/error 回调 §5
Promise 版封装示例 async/await、拦截器、统一错误出口 §5
记账本 REST 示例 GET/POST/DELETE /api/accountFormData、事件委托、{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/awaitfetch/axios------封装里的回调正是后续用 Promise 重写的对象。
  • 面试高频:「同源策略三要素」「CORS 简单请求与预检」「JSONP 为什么只能 GET」「业务 code 与 HTTP 状态码如何分层」是前端面试的必考题。

一、XMLHttpRequest 核心机制回顾

在进入跨域之前,先把 XMLHttpRequest(下称 XHR)容易被忽略的几个进阶能力补齐------它们是后面封装 ajax() 时每一个配置项的来历。

名词解释

  • XMLHttpRequest(XHR):浏览器提供的、用脚本发起 HTTP 请求的内置对象,是 Ajax 技术的底层引擎。
  • responseType :一个字符串属性,声明「希望浏览器把响应体解析成什么类型」,可取 ''/'text'/'json'/'blob'/'arraybuffer'/'document'
  • responseresponseTextresponseText 永远是字符串;response 的类型由 responseType 决定。
  • 进度事件(Progress Events) :一组描述请求生命周期的事件------loadstartprogressloaderrortimeoutloadend
  • timeout :毫秒数,超过该时长请求自动中止并触发 ontimeout
  • 同步请求 / 异步请求xhr.open() 第三个参数为 false 时为同步,会冻结页面;默认 true 为异步。

概念与底层原理

XHR 的一次请求是一个有限状态机readyState04 依次推进,每次推进都触发一次 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------这比拿到 responseTextJSON.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(总字节)。
  • loaderrortimeout 三者互斥 ,一次请求只会走其中一条分支;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()

实战示例:超时控制与进度事件

下面的示例请求一个较大的响应体 ,完整演示 timeoutonprogressonloadstartonloadend 的协作。它需要一个本地服务返回大数据,服务端代码如下:

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.responsexhr.responseText 有什么区别?

A:responseText 永远是字符串;response 的类型由 responseType 决定------默认('''text')时它也是字符串,设为 'json' 时它是已解析的对象,设为 'blob'/'arraybuffer' 时是二进制。一旦把 responseType 设成非文本类型,再访问 responseText 会抛 InvalidStateError,所以二者只能选其一。实践中请求 JSON 接口就直接用 responseType='json',省掉手动 JSON.parse 和它的 try/catch。

Q2:XHR 有哪些进度事件?onloadonloadend 区别是什么?

A:进度事件按顺序是 loadstart → progress(可多次)→ load / error / timeout / abort → loadendonload 只在「响应成功收完」时触发,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.comhttps://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.comapi.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 把跨域请求分成两类,区别在于「要不要先预检」:

简单请求------同时满足以下条件,浏览器直接发送:

  • 方法是 GETHEADPOST 之一;
  • 请求头只用了安全头部(AcceptAccept-LanguageContent-LanguageContent-Type);
  • Content-Type 只能是 text/plainmultipart/form-dataapplication/x-www-form-urlencoded 三者之一。

预检请求 ------只要有一条不满足(比如方法是 PUT/DELETE/PATCH、带了自定义头 AuthorizationContent-Typeapplication/json),浏览器就会在真实请求之前 ,自动先发一个 OPTIONS 请求去「问路」。这个 OPTIONS 不带业务数据,只带几个询问头:Access-Control-Request-Method(我接下来想用什么方法)、Access-Control-Request-Headers(我想带哪些头)。服务器用 Access-Control-Allow-MethodsAccess-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 corsapp.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:8080https://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-Typetext/plain/multipart/form-data/application/x-www-form-urlencoded 之一------这种请求浏览器直接发。只要有一条不满足(用了 PUT/DELETE/PATCH、带了 Authorization 等自定义头、Content-Typeapplication/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 的链路是这样的:

  1. 前端定义一个全局函数,比如 function parseData(data) { ... }
  2. 前端动态创建一个 <script>,把 src 指向跨域接口,并在 URL 里用查询参数告诉服务端这个函数名:?cb=parseData
  3. 服务端不返回 JSON ,而是返回一段 JS 代码字符串parseData([{...},{...}])
  4. 浏览器加载完这段 <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) :调用封装函数时传入的参数对象,含 urlmethodheadersbody 等。
  • 回调(Callback) :请求结束后被调用的函数,分 success(成功)和 error(失败)。
  • 拦截器(Interceptor):在请求发出前、响应返回后自动执行的「钩子」函数。

概念与底层原理

每写一个 Ajax 请求,都要重复「创建 xhr → 绑事件 → open → 设头 → send」这五步。同样的样板代码散落在几十个地方,一旦要统一加个 token 头、统一处理错误,就得改几十处。封装就是把这五步收进一个函数,调用方只关心「请求什么、成功后做什么」。

设计一个 ajax() 封装,要想清楚两件事:对外暴露哪些配置项默认值给什么。一个最小但够用的配置约定是:

  • url:请求地址(必填);
  • method:请求方法,默认 'GET'
  • headers:请求头对象,默认 {}
  • body:请求体,GET 时省略;
  • dataType:响应体类型,传 'json' 时内部设 xhr.responseType
  • success / error:成功/失败回调,默认空函数。

「默认值」是封装好不好用的关键。用 ES6 的解构赋值默认值 ,调用方只传 urlsuccess 就能用,其余自动补齐------这就是「约定优于配置」。

封装也分层次。回调式封装 最简单,但多个请求有先后依赖时会陷入「回调地狱」。再进一步是 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 分流到 successerror,把「成功还是失败」的判断也收进了封装,调用方只管写两个回调。
  • 市面应用 :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 请求时 bodyundefined,等价于 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/rejectonloadstatus2xx 区间就 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 状态,请求行还没初始化,此时设头无处可放,会抛 InvalidStateErrorsend 之后请求已经发出,再设头也来不及了。只有 open 之后、send 之前这个 OPENED 状态窗口,请求头才能被正确写入。所以封装里设头的 for 循环一定夹在 opensend 中间。


六、记账本 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,由封装处理),再看业务 coderes.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 风格的账单接口。

  • 同一路径、不同方法表达不同操作/accountGET/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 读出账单 _idDELETE 成功后用 e.target.closest('.panel').remove() 直接移除那一行------这是「删后移节点」策略,省一次 GET
  • 真实项目用 FormDatabody不要手动设 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 的失败要分三层来看,每层的处理方式不同:

  1. 网络层错误 ------onerror 触发。请求没发出去或没回来:断网、DNS 失败、跨域被拦。此时 xhr.status0,什么信息都没有。处理方式:提示「网络异常,请检查连接」,可重试。
  2. HTTP 层错误 ------onload 触发,但 status 是 4xx/5xx。请求到达了服务器,但出了问题:400 参数错、401 未登录、403 无权限、404 资源不存在、500 服务端崩了。处理方式:按状态码分支------401 跳登录页,404/500 给对应提示。
  3. 业务层错误 ------status 200,但 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 里做了两道闸:先查 HTTP status 是否在 2xx,不在就是 HTTP 错误;再查业务 code 是否 '0000',不是就是业务错误;两道都过才 resolve
  • onerror/ontimeout 对应网络层,status0------因为这一层根本没有状态码。
  • 调用方的 catchtype 分流:网络/超时类提示「可重试」,HTTP 401 跳登录,业务错误直接展示后端的 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 > 0setTimeout 后再 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 / ontimeoutstatus=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,而不是四次。
  • cacheMap)做请求缓存: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, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#039;');
    }

    // 请求埋点:包一层,统计耗时
    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 进阶的完整闭环:

  1. XHR 核心responseType 让浏览器代解析、进度事件汇报传输过程、timeout 兜住弱网------这些是后面封装每个配置项的来历。
  2. 同源策略:协议+域名+端口三要素,浏览器拦的是「JS 读响应」而非「请求发出」。
  3. CORS :官方跨域方案,服务端用响应头声明放行;简单请求直发、非简单请求先预检;带 Cookie 不能用 *
  4. JSONP :借 <script> 跨域能力的历史方案,仅 GET、错误处理弱、有安全风险,能用 CORS 就别用它。
  5. 封装 :把五步样板代码收进 ajax(),再升级为 Promise 版 + 拦截器,这是现代请求库的雏形。
  6. 记账本 REST :HTTP 方法表语义、{code,msg,data} 统一响应、FormData 提交、事件委托删除------一个完整的前后端联调项目。
  7. 错误处理:网络层、HTTP 层、业务层三层错误分别应对;重试要有上限和退避。
  8. 工程化:防抖与缓存减少请求、输出转义防 XSS、埋点监控保障可观测性。

高频面试题速查

  1. xhr.responseresponseText 的区别?(§1)
  2. XHR 有哪些进度事件?onloadonloadend 区别?(§1)
  3. 什么是同源策略?它限制的到底是什么?(§2)
  4. 为什么 <img><script> 能跨域,Ajax 不行?(§2)
  5. CORS 的简单请求和预检请求有什么区别?何时触发预检?(§3)
  6. Access-Control-Allow-Origin: * 有什么限制?带 Cookie 怎么配?(§3)
  7. JSONP 的原理是什么?为什么只能 GET?(§4)
  8. JSONP 和 CORS 相比有哪些缺点?(§4)
  9. 为什么要封装 Ajax?好的封装要考虑哪些点?(§5)
  10. setRequestHeader 为什么必须在 open 之后、send 之前?(§5)
  11. 业务 code 和 HTTP 状态码的区别?为什么两层判断?(§6)
  12. 添加数据为什么用 FormData?为什么不能手动设 Content-Type(§6)
  13. Ajax 失败分哪几类?分别怎么处理?(§7)
  14. 请求失败要不要自动重试?要注意什么?(§7)
  15. 防抖和节流的区别?搜索联想用哪个?(§8)
  16. 前端渲染列表时如何防 XSS?(§8)

学习建议

  • 先跑通再深究 :依次把同源判断、CORS 联调、手写 JSONP、ajax() 封装、记账本五个示例跑通,再回头读「概念与底层原理」,体会会深得多。
  • 善用 Network 面板 :跨域报错看 Network 的状态码、看响应头有没有 CORS 头、看预检 OPTIONS------这是排查 Ajax 问题最快的手段。
  • 动手做对比实验 :把 CORS 头注释掉看报错、把 escapeHtml 去掉看 XSS 弹窗、把防抖去掉看请求数暴涨------「破坏再修复」比单纯阅读记得牢。
  • 往后延伸 :掌握本篇后,可继续学习用 Promise / async/await 重写封装、用 fetch 替代 XHR、用 axios 的拦截器体系,以及 WebSocket、SSE 等其它通信方式。

延伸阅读:

相关推荐
杨先生哦1 小时前
【2026 热端攻防系列 2/12】DOM 型 XSS 深度实战:AI 多态变形免杀 + 全维度防御
前端·人工智能·笔记·安全·web安全·xss
sugar__salt1 小时前
前端Ajax核心原理与实战:从异步机制到接口请求全解析
前端·javascript·ajax
问心无愧05131 小时前
ctf show web入门115
android·前端·笔记
難釋懷1 小时前
Nginx缓冲区
前端·javascript·nginx
程序猿小泓1 小时前
2026 前端面试全攻略:手写题、算法与计网/TS 高频考点
前端·javascript·css
JustHappy10 小时前
古法编程秘籍(七):互联网到底是什么?把两台电脑怎么说话搞懂就够了
前端·后端·网络协议
老毛肚10 小时前
jeecg-boot-base-core 02 day
javascript·python
snow@li10 小时前
SEO-文章标题:写文章时候,分类+主标题+大纲+解释 作为标题 / 不点进去也知道全文覆盖什么 / 标题即架构
前端
kyriewen11 小时前
Git Commit 前自动修复代码风格?配置 Husky + lint-staged,从此 CR 只聊逻辑
前端·git·面试