Skip to content

Day4 HTTP 协议、浏览器缓存、跨域 ​

📖 基础知识讲解【基础原理 + 高级深挖知识点】 ​

基础原理 ​

1. HTTP 报文结构(能默写才算真懂) ​

text
┌── 请求报文 ─────────────────────────┐   ┌── 响应报文 ─────────────────────────┐
│ GET /api/user?id=1 HTTP/1.1         │   │ HTTP/1.1 200 OK                     │ 起始行
│ Host: api.example.com               │   │ Content-Type: application/json      │
│ Cookie: token=abc                   │   │ Cache-Control: max-age=31536000     │ 头部
│ If-None-Match: "a1b2c3"             │   │ ETag: "a1b2c3"                      │
│                                     │   │                                     │ 空行
│ {"name":"jack"}                     │   │ {"code":0,"data":{}}                │ 实体
└─────────────────────────────────────┘   └─────────────────────────────────────┘

核心特征: 无状态(靠 Cookie / Token 维持会话)、无连接(1.1 起默认长连接)、基于请求 - 响应模型、明文传输(HTTPS 加一层 TLS)。

2. 常用方法与状态码(面试高频快查) ​

方法语义幂等安全可缓存
GET获取资源✅✅✅
POST提交 / 新建❌❌❌(除非显式允许)
PUT全量更新✅❌❌
PATCH局部更新❌❌❌
DELETE删除✅❌❌
HEAD只要响应头✅✅✅
OPTIONS探测能力(CORS 预检)✅✅❌
状态码含义关键细节
200成功命中强缓存时也是 200,但标注 from disk/memory cache
206部分内容配合 Range,大文件分片下载 / 断点续传
301永久重定向浏览器会强缓存跳转结果,慎用(改回来很难清)
302临时重定向可能把 POST 改成 GET,严谨场景用 307
304未修改协商缓存命中,不返回 body,仅返回头
307 / 308临时 / 永久重定向严格保持原请求方法与 body
400 / 401 / 403 / 404参数错 / 未认证 / 无权限 / 不存在401 要重新登录,403 是已登录但没权限
413 / 414体过大 / URL 过长Nginx client_max_body_size 常见报错
429请求过多限流,配合 Retry-After
500 / 502 / 503 / 504服务端错 / 网关错 / 不可用 / 网关超时502 多为上游挂了,504 多为上游慢

3. HTTP 版本差异(必背主线) ​

维度HTTP/1.0HTTP/1.1HTTP/2HTTP/3
传输层TCPTCPTCPQUIC(UDP)
连接短连接长连接 keep-alive、管线化单连接多路复用单连接多路复用
数据格式文本文本二进制分帧二进制分帧
头部不压缩不压缩HPACK 压缩QPACK 压缩
队头阻塞有应用层有(串行响应)应用层解决,TCP 层仍有彻底消除
握手 RTTTCP 3 次TCP 3 次(+TLS)TCP + TLS1-RTT / 0-RTT
其他—Host 头、分块传输、Range服务端推送(已基本废弃)、流优先级连接迁移(切网不断连)

一句话串起来:1.1 省连接、2 省请求轮次与头部体积、3 省握手与丢包代价。

4. 浏览器缓存:强缓存 vs 协商缓存 ​

维度强缓存协商缓存
是否发请求不发(直接读本地)发(带校验头去问服务端)
响应状态码200 (from disk/memory cache)304 Not Modified(命中)/ 200(未命中)
相关头Cache-Control: max-age、ExpiresETag/If-None-Match、Last-Modified/If-Modified-Since
优先级先走强缓存强缓存失效后才走
适用带 hash 的 js / css / 图片 / 字体html、接口数据、文件名不变的资源

完整决策链路(面试默画):

text
发起请求
   │
   ├─ 有本地缓存?── 否 ─────────────────────────────> 直接请求服务端 → 200
   │        │是
   │        ▼
   │  强缓存是否过期?(Cache-Control:max-age 优先于 Expires)
   │        │
   │        ├─ 未过期 ──> 直接用本地副本  200 (from memory/disk cache)  【0 请求】
   │        │
   │        └─ 已过期 ──> 带 If-None-Match(ETag) / If-Modified-Since 请求
   │                               │
   │                               ├─ 资源未变 ──> 304,用本地副本并刷新缓存头
   │                               └─ 资源已变 ──> 200 + 新 body + 新 ETag

5. 缓存存放在哪(四级缓存,按查找顺序) ​

位置特点典型场景
Service Worker开发者完全可控,可拦截请求自定义缓存策略PWA、离线可用
Memory Cache内存,读取最快,Tab 关闭即失效当前页重复引用的图片、脚本
Disk Cache磁盘,容量大、可跨会话持久绝大多数静态资源
Push CacheHTTP/2 服务端推送专用,只在会话期内有效已基本不用

小文件和当前页正在使用的资源倾向进内存,大文件进磁盘;强制刷新(Ctrl+F5)会带 Cache-Control: no-cache 跳过强缓存,普通刷新(F5)通常只跳过内存缓存。

6. 同源策略与跨域 ​

同源 = 协议 + 域名 + 端口 三者完全一致,任一不同即跨域(注意:http://a.com 与 https://a.com 跨域,a.com 与 www.a.com 跨域,IP 与域名跨域)。

同源策略限制的三类行为:

  1. AJAX / Fetch 请求:请求其实已经发出,是浏览器把响应拦截了(所以后端能收到日志);
  2. DOM 访问:跨域 iframe / window 之间不能互相读 DOM;
  3. 存储读取:Cookie、LocalStorage、IndexedDB 按源隔离。

不受同源策略限制的标签: <img>、<script>、<link>、<iframe>、<video> —— JSONP 正是利用了 <script> 的这个特性。

跨域方案全景对比:

方案原理适用 / 限制
CORS服务端返回 Access-Control-Allow-* 响应头主流首选,支持所有方法,需后端配合
Nginx 反向代理同源域名转发到真实后端,服务端之间无跨域生产常用,前端零改造
devServer proxy本地 Node 服务代理转发仅开发环境
JSONP<script> 加载 + 全局回调仅 GET、有 XSS 风险,兼容老浏览器才用
postMessage窗口 / iframe 间消息通信跨窗口通信,必须校验 origin
WebSocket协议本身不受同源策略限制实时通信,服务端需校验 Origin
document.domain主域相同的子域互信(已废弃)仅同主域,Chrome 115+ 已默认禁用
window.name / hash借助窗口属性或 URL hash 传值古早方案,了解即可

✨ 高级深挖知识点 ​

1. Cache-Control 指令全表(加分项密集区) ​

指令作用使用场景
max-age=31536000资源在客户端的新鲜期(秒),优先级高于 Expires带 hash 的静态资源(一年)
s-maxage=600只对代理 / CDN 生效的新鲜期,覆盖 max-ageCDN 缓存 html 短期
no-cache允许缓存但每次必须协商校验(≠ 不缓存)html 入口文件
no-store完全不缓存,连磁盘都不写支付、含敏感数据的接口
private只允许浏览器缓存,代理 / CDN 不得缓存含用户信息的响应
public浏览器与代理都可缓存公共静态资源
must-revalidate过期后必须向源站校验,不允许直接用过期副本对一致性要求高的资源
immutable新鲜期内即便用户刷新也不发协商请求内容哈希资源,配合 max-age
stale-while-revalidate=60过期后 60s 内先返回旧副本,后台异步静默更新体验优先的接口 / 页面(SWR)
stale-if-error=86400源站报错时允许继续用过期副本兜底容灾降级

最佳实践组合:

text
hash 静态资源 :  Cache-Control: public, max-age=31536000, immutable
html 入口     :  Cache-Control: no-cache            (或 max-age=0, must-revalidate)
用户私有接口  :  Cache-Control: private, no-store
CDN 分发页面  :  Cache-Control: max-age=0, s-maxage=600, stale-while-revalidate=60

Expires 是 HTTP/1.0 遗产,值为绝对时间,受客户端系统时间影响(改本地时间就失效);Cache-Control: max-age 是相对时间,更可靠且优先级更高。

2. ETag vs Last-Modified 深挖 ​

维度Last-Modified / If-Modified-SinceETag / If-None-Match
本质资源最后修改时间资源内容指纹(hash 或 inode+size+mtime)
精度秒级,1 秒内多次修改感知不到内容级,改一个字节就变
优先级低高(两者都在时服务端先校验 ETag)
弱点内容未变但时间变(如重新部署)会误判为更新计算有 CPU 成本;分布式多机需保证一致
强弱—强校验 "abc" / 弱校验 W/"abc"(语义等价即可命中)

典型工程坑:

  • 多机部署 ETag 不一致:Nginx 默认 ETag = mtime + size,不同机器文件时间戳不同 → 同一资源在 A 机命中、B 机 200,缓存命中率骤降。解法:统一用内容 hash 生成 ETag,或构建产物同步时保留 mtime。
  • Gzip 改变 ETag:开启压缩后部分服务器会修改或丢弃 ETag,需确认网关行为。
  • 只有 Last-Modified 时,服务端用 If-Modified-Since <= mtime 判断,所以「文件回滚成旧版本」会因时间变新而被判为更新。

3. CORS 完整机制 ​

简单请求(不触发预检)必须同时满足:

  1. 方法是 GET / POST / HEAD;
  2. 头部只在白名单内(Accept、Accept-Language、Content-Language、Content-Type、Range 等);
  3. Content-Type 只能是 text/plain、multipart/form-data、application/x-www-form-urlencoded。

所以只要你发 application/json,就一定会触发 OPTIONS 预检 —— 这是最高频的面试追问点。

预检流程(非简单请求):

text
浏览器                                          服务端
  │  OPTIONS /api/user                            │
  │  Origin: https://web.a.com                    │
  │  Access-Control-Request-Method: PUT           │
  │  Access-Control-Request-Headers: content-type,authorization
  │ ─────────────────────────────────────────────> │
  │                                               │  校验 Origin / Method / Headers
  │  204 No Content                               │
  │  Access-Control-Allow-Origin: https://web.a.com
  │  Access-Control-Allow-Methods: GET,POST,PUT   │
  │  Access-Control-Allow-Headers: content-type,authorization
  │  Access-Control-Allow-Credentials: true       │
  │  Access-Control-Max-Age: 86400   ← 预检结果缓存一天
  │ <───────────────────────────────────────────── │
  │                                               │
  │  PUT /api/user  (真实业务请求)               │
  │ ─────────────────────────────────────────────> │

CORS 响应头速查:

响应头作用
Access-Control-Allow-Origin允许的源,* 或具体域名(带凭证时不能用 *)
Access-Control-Allow-Methods预检放行的方法
Access-Control-Allow-Headers预检放行的自定义请求头
Access-Control-Allow-Credentials是否允许携带 Cookie,需前端同时设 withCredentials / credentials:'include'
Access-Control-Expose-Headers前端能读到的响应头白名单(默认只能读 6 个基础头)
Access-Control-Max-Age预检结果缓存秒数(Chrome 上限 7200s)
Vary: Origin动态回显 Origin 时必加,否则 CDN 会把 A 域的响应发给 B 域

带凭证跨域三件套(缺一不可):

  1. 后端 Access-Control-Allow-Origin 为具体域名(不可 *);
  2. 后端 Access-Control-Allow-Credentials: true;
  3. 前端 xhr.withCredentials = true 或 fetch(url, { credentials: 'include' });
  4. Cookie 本身要满足 SameSite=None; Secure(Chrome 80+)。
SameSite 值行为影响
Strict完全禁止第三方上下文携带最安全,但从外链跳回来会丢登录态
Lax(默认)只有**顶级导航的安全方法(GET)**才携带跨域 XHR / POST / iframe 不带 Cookie
None任何跨站请求都携带,必须同时加 Secure(HTTPS)跨域鉴权唯一可行选项

Chrome 80 起默认 Lax,这是「本地联调能登录、换域名就掉登录」的根因。规避思路:① 统一主域 + Cookie 设 Domain=.a.com;② Nginx 代理成同源;③ 改用 Authorization: Bearer <token> 头部鉴权,彻底绕开 Cookie。

5. HTTPS 与 TLS 握手 ​

text
TCP 三次握手 (1 RTT)
   │
   ▼
TLS 握手
  ① ClientHello  → 支持的 TLS 版本、加密套件列表、Client Random
  ② ServerHello  ← 选定套件、Server Random、证书链(公钥)
  ③ 客户端校验证书(CA 信任链、域名、有效期、吊销状态)
  ④ 密钥交换(RSA 或 ECDHE,ECDHE 具备前向安全性)→ 生成会话密钥
  ⑤ Finished 双向确认 → 之后全部用对称加密通信
  • 非对称加密只用来安全交换对称密钥,之后数据用对称加密(AES-GCM),因为对称加密快得多。
  • TLS 1.2 需 2-RTT,TLS 1.3 只需 1-RTT,会话复用(Session Ticket / PSK)可做到 0-RTT。
  • 中间人攻击的防线是证书校验:抓包工具(Charles / Whistle)之所以能解 HTTPS,是因为你手动信任了它的根证书;移动端可用 SSL Pinning(证书 / 公钥固定)防护。
  • Strict-Transport-Security(HSTS)强制浏览器后续只用 HTTPS,避免首次 http 跳转被劫持。

6. 队头阻塞的三层真相 ​

层级现象谁能解决
HTTP/1.1 应用层同一连接请求必须按序返回,前一个慢全部排队;浏览器只好开 6 个并发连接HTTP/2 多路复用
TCP 传输层任一包丢失,整个连接的字节流都要等重传(TCP 保序)只有 QUIC(流级独立重传)
浏览器连接数同域最多 6 个 TCP 连接域名分片(HTTP/1.1 时代)/ HTTP/2

HTTP/2 反而在弱网更差的经典结论:多路复用把所有流塞进一个 TCP 连接,一旦丢包,所有流一起卡;而 HTTP/1.1 的 6 条连接里只有 1 条受影响。QUIC 在 UDP 之上自己实现流控与重传,流之间互不影响,这才是 HTTP/3 的核心价值。

7. HTTP/2 细节补充 ​

  • 二进制分帧层:一个 TCP 连接上有多个 Stream,每个 Stream 由 HEADERS / DATA 等 Frame 组成,帧带 Stream ID,可乱序发送、接收端按 ID 重组。
  • HPACK:静态表(61 个常见头)+ 动态表 + 哈夫曼编码,重复请求头几乎零开销(1.1 下每个请求重复发几百字节 Cookie/UA)。
  • 服务端推送(Server Push)已被 Chrome 106 移除,面试提一句「现在用 103 Early Hints + preload 替代」是加分项。
  • HTTP/2 下域名分片、雪碧图、资源内联这些 1.1 优化反成负担(破坏缓存粒度、增加体积)。

8. 代理与 CDN 缓存 ​

  • Vary: Accept-Encoding, Origin, User-Agent:告诉缓存服务器按哪些请求头区分缓存副本;Vary: * 等于禁用缓存;滥用 User-Agent 会让命中率崩盘。
  • CDN 回源与缓存分层:边缘节点 → 区域节点 → 源站;通过 s-maxage 控制边缘 TTL,发布后用 主动刷新(purge)/ 预热 让新版本生效。
  • 缓存击穿与版本控制:永远不要靠「清缓存」来发布,而是靠 文件名 hash(内容指纹) 天然失效;html 用 no-cache 保证入口实时。

❓ 高频面试题【基础简答 + 连环追问链 + 精简口述答案 + 项目关联话术】 ​

题 1:强缓存与协商缓存的区别?Cache-Control / Expires、ETag / Last-Modified 优先级? ​

精简口述答案:

强缓存不发请求,直接读本地副本,状态码是 200 并标注 from memory/disk cache,由 Cache-Control: max-age 和 Expires 控制;协商缓存会发请求但不传 body,带上 If-None-Match / If-Modified-Since 让服务端校验,资源没变就返回 304,浏览器继续用本地副本。浏览器先判强缓存,失效后才走协商缓存。优先级上:Cache-Control 高于 Expires(后者是绝对时间,受本地时钟影响);ETag 高于 Last-Modified(前者是内容指纹,后者只有秒级精度)。

连环追问链:

  1. 追问:no-cache 和 no-store 的区别?
    • 答:no-cache 是「可以缓存,但每次用之前必须找服务端校验」,走的是协商缓存,能命中 304;no-store 是「完全不准存」,每次都完整重新下载,用于支付、隐私类响应。
  2. 追问:max-age=0 和 no-cache 等价吗?
    • 答:基本等价(都会去校验),但严格说 max-age=0 允许某些代理在配置下返回过期副本,加上 must-revalidate 才完全等价于 no-cache。
  3. 追问:stale-while-revalidate 的作用?
    • 答:资源过期后的宽限期内,先把旧副本立刻返回给用户,同时后台异步发请求更新缓存。用户感知是「秒开」,代价是可能看到一次旧数据,非常适合对实时性要求不极致的页面和接口。
  4. 追问:为什么静态资源能设一年强缓存却不怕更新不了?
    • 答:因为文件名里带内容 hash,内容一变文件名就变,等于换了一个新 URL,老缓存自然作废;真正不能强缓存的是 html 入口,它负责引用最新的 hash 文件名。
  5. 追问:普通刷新(F5)、强制刷新(Ctrl+F5)、地址栏回车三者的缓存行为差异?
    • 答:地址栏回车 → 强缓存、协商缓存都可能命中;F5 → 跳过内存缓存,一般带 max-age=0 走协商校验;Ctrl+F5 → 带 Cache-Control: no-cache + Pragma: no-cache,强缓存协商缓存全跳过,直接 200 重新下载。
  6. 追问:接口数据能不能用 HTTP 缓存?
    • 答:GET 幂等接口可以用 ETag + no-cache 做协商缓存,省带宽;含用户私有数据的必须加 private,敏感接口直接 no-store。实际项目里更常用前端内存缓存 / SWR 方案,可控性更强。

✅ 项目关联话术(按真实经历替换):

项目构建产物(js / css / 图片 / 字体)文件名统一带 contenthash,Nginx 下发 Cache-Control: public, max-age=31536000, immutable;index.html 固定 no-cache,保证发版后用户刷新就能拿到新的 hash 引用。上线后静态资源缓存命中率约 90%,二次打开首屏从 1.6s 降到 0.5s 左右。另外踩过一次坑:多台机器 Nginx 默认按 mtime 生成 ETag 导致同一文件在不同机器 ETag 不同,命中率异常,后来统一由构建产物 hash 生成才解决。

题 2:什么是跨域?同源策略限制哪些场景?CORS 的完整流程? ​

精简口述答案:

同源策略是浏览器最核心的安全机制,协议、域名、端口三者完全一致才算同源,任一不同就是跨域。它限制三类行为:AJAX / Fetch 的响应读取、跨域 DOM 访问、以及 Cookie / Storage 的读取。注意跨域请求其实已经发到服务端了,只是响应被浏览器拦住不给 JS 读。CORS 分两种:简单请求直接发出去,浏览器根据响应里的 Access-Control-Allow-Origin 决定放不放行;非简单请求(比如 application/json 的 POST、PUT、带自定义头)浏览器会先发一个 OPTIONS 预检请求,服务端确认方法和请求头都允许后,才发真正的业务请求。

连环追问链:

  1. 追问:什么情况下会触发预检?怎么避免?
    • 答:方法不是 GET/POST/HEAD、带了自定义头(如 Authorization、X-Token)、或 Content-Type 不是那三种白名单类型(最常见的就是 application/json)。想避免可以改用表单类型提交,但一般不值得,更合理的是配 Access-Control-Max-Age 缓存预检结果。
  2. 追问:跨域携带 Cookie 要注意什么?
    • 答:后端 Access-Control-Allow-Origin 必须是具体域名不能是 *,并返回 Access-Control-Allow-Credentials: true,前端要开 withCredentials / credentials: 'include',同时 Cookie 本身要 SameSite=None; Secure,四个条件缺一不可。
  3. 追问:前端为什么读不到自定义响应头(比如分页总数)?
    • 答:默认只能读 6 个基础头,自定义头要后端通过 Access-Control-Expose-Headers: X-Total-Count 显式暴露。
  4. 追问:JSONP 的优缺点?
    • 答:优点是利用 <script> 天然跨域,兼容性极好;缺点是只支持 GET、无法拿到真实状态码、错误处理困难,而且要执行对方返回的脚本,被劫持就等于 XSS,现在只在对接老系统时才用。
  5. 追问:Nginx 反向代理为什么能解决跨域?
    • 答:跨域是浏览器的限制,服务端之间请求没有同源策略。把接口代理到和页面相同的域名下,浏览器看到的就是同源请求,自然不拦。
  6. 追问:服务端配了 Access-Control-Allow-Origin: * 还报跨域,可能是什么原因?
    • 答:常见三种:① 请求带了凭证,* 对带凭证的请求无效;② 预检 OPTIONS 被网关 / 鉴权中间件拦截返回了 401 或 404,压根没带上 CORS 头;③ 重定向后的响应没有 CORS 头;④ 头被下发了两次(Nginx 和后端都加),浏览器判定非法。
  7. 追问:CORS 能防住 CSRF 吗?
    • 答:不能完全防。CORS 只管「能不能读响应」,表单提交、<img> 这类简单请求照样能打到服务端造成写操作。防 CSRF 要靠 CSRF Token、SameSite Cookie、校验 Origin / Referer。

✅ 项目关联话术(按真实经历替换):

前后端分离项目,开发环境用 Vite 的 server.proxy 把 /api 代理到测试服务;生产环境走 Nginx 同域转发,前端完全无感知。对外开放的开放平台接口才单独用 CORS 白名单:后端按请求 Origin 动态回显并加上 Vary: Origin,避免 CDN 把 A 域的响应错发给 B 域;同时配了 Access-Control-Max-Age: 86400,OPTIONS 请求量下降了一大半。

题 3:HTTP/1.1、HTTP/2、HTTP/3 的核心差异? ​

精简口述答案:

HTTP/1.1 是文本协议,默认长连接,但同一连接响应必须按序返回,存在应用层队头阻塞,浏览器只能靠同域 6 个并发连接和域名分片缓解。HTTP/2 改成二进制分帧,一个 TCP 连接上多路复用多个流,配合 HPACK 头部压缩和流优先级,解决了应用层队头阻塞;但它跑在 TCP 上,一旦丢包,所有流都要等重传,TCP 层队头阻塞依然存在。HTTP/3 换成基于 UDP 的 QUIC,在用户态自己实现流控和重传,流与流之间独立,彻底消除队头阻塞,并把 TLS 握手合并进连接建立做到 1-RTT 甚至 0-RTT,还支持连接迁移,手机从 WiFi 切 4G 连接不中断。

连环追问链:

  1. 追问:HTTP/2 都多路复用了,为什么丢包还会阻塞所有流?
    • 答:因为 TCP 对上层提供的是有序可靠的字节流,中间任何一个包丢了,后面的数据即使到达也必须在内核缓冲区里等重传完成才能交付。HTTP/2 在应用层看不到这一层,无能为力。
  2. 追问:QUIC 基于 UDP,怎么保证可靠性?
    • 答:QUIC 在用户态重新实现了序号、确认、重传、拥塞控制和流量控制,并且是按 Stream 独立管理的,所以某个流丢包不影响其他流;同时内置 TLS 1.3,加密是强制的。
  3. 追问:HTTP/2 下还需要雪碧图、域名分片、资源内联吗?
    • 答:不需要,甚至有害。这些都是为绕开 1.1 并发限制的 hack,在 2 下会破坏缓存粒度(改一张小图整张雪碧图失效)、域名分片还会拆散多路复用的单连接优势。
  4. 追问:HTTP/2 的服务端推送现在还用吗?
    • 答:基本废弃了,Chrome 106 已移除。原因是难以判断客户端是否已缓存,容易推送浪费带宽;现在用 103 Early Hints + preload / preconnect 替代。
  5. 追问:升级 HTTP/2 / HTTP/3 需要改前端代码吗?
    • 答:不需要,是传输层协议升级,由网关 / CDN 开启即可(HTTP/2 必须 HTTPS,HTTP/3 需通过 Alt-Svc 响应头告知客户端可升级)。要做的是把 1.1 时代的优化 hack 撤掉。

✅ 项目关联话术(按真实经历替换):

静态资源站和主站网关统一开启 HTTP/2 后,首屏几十个小资源从 6 并发排队变成单连接并行,瀑布图里的排队(Queueing)时间几乎消失,首屏资源加载总时长下降约 30%;同时把原来为绕 1.1 并发做的域名分片(img1/img2/img3)收敛成一个域名,复用同一条连接和 TLS 会话,弱网下表现反而更稳。

题 4:HTTPS 的加密流程是怎样的?为什么抓包工具能看到 HTTPS 明文? ​

精简口述答案:

HTTPS = HTTP + TLS。流程是先 TCP 三次握手,然后 TLS 握手:客户端发 ClientHello 带上支持的版本和加密套件,服务端回 ServerHello 和证书,客户端校验证书链(是否受信任 CA 签发、域名是否匹配、是否过期吊销),接着通过 ECDHE 等算法协商出对称会话密钥,之后所有数据用对称加密传输。非对称加密只用于安全地交换对称密钥,因为非对称运算慢。抓包工具能看明文,是因为它充当中间人,用自己的根证书重新签发了目标站点证书,而你手动在系统里信任了这张根证书 —— 所以 HTTPS 的安全底线其实是证书信任链。

连环追问链:

  1. 追问:为什么不全程用非对称加密?
    • 答:非对称加密(RSA/ECC)运算量比对称加密(AES)大几个数量级,全程使用性能无法接受;而且对称密钥通过非对称安全交换一次即可,兼顾安全与性能。
  2. 追问:TLS 1.3 相比 1.2 快在哪?
    • 答:1.2 需要 2-RTT,1.3 砍掉了冗余往返做到 1-RTT,会话复用场景支持 0-RTT(有重放攻击风险,只能用于幂等请求);同时废弃了 RSA 密钥交换等不安全套件,强制前向安全。
  3. 追问:怎么防止中间人攻击?
    • 答:Web 端依赖 CA 体系 + HSTS(强制 HTTPS)+ 证书透明度;App 端可做 SSL Pinning,把服务端证书或公钥内置到客户端,不信任系统证书库,抓包工具就失效了。
  4. 追问:HTTPS 一定更慢吗?
    • 答:首次握手确实多几个 RTT,但配合 TLS 1.3、会话复用、OCSP Stapling、HTTP/2 多路复用后,整体往往比 HTTP/1.1 更快;而且 HTTP/2、HTTP/3 本身就要求加密。

✅ 项目关联话术(按真实经历替换):

移动端 H5 调试时用 Whistle / Charles 抓 HTTPS,需要在手机上安装并信任根证书;上线前针对核心接口做了 Origin / Referer 校验与签名验签,防止被中间人改包重放。另外全站配置 HSTS,杜绝用户从 http 入口被劫持跳转。

精简口述答案:

先分清是「没带 Cookie」还是「带了但被拒」。没带 Cookie 的典型原因是:前端没开 withCredentials、Cookie 的 SameSite 是默认的 Lax 导致跨站 XHR 不携带、Cookie 的 Domain / Path 不匹配、或者 HTTP 环境下设了 SameSite=None 却没有 Secure 被浏览器直接丢弃。解决路径有三条:最优是做成同源(Nginx 代理或统一主域 + Domain=.a.com 共享 Cookie);其次是完整配好跨域凭证四件套(具体 Origin + Allow-Credentials + withCredentials + SameSite=None; Secure);最省事的是改用 Token 放在 Authorization 头里,不依赖 Cookie,天然没有 SameSite 问题,但要注意 XSS 下的存储安全。

连环追问链:

  1. 追问:Cookie 和 Token(localStorage)鉴权各自的安全风险?
    • 答:Cookie 自动携带,易受 CSRF,需靠 SameSite + CSRF Token 防护,但配 HttpOnly 能免疫 JS 窃取;Token 放 localStorage 不怕 CSRF,但 XSS 下可被直接读走。折中方案是 HttpOnly Cookie 存 refresh token + 内存存 access token。
  2. 追问:主域相同、子域不同(a.jd.com 与 b.jd.com)怎么共享登录态?
    • 答:Cookie 设 Domain=.jd.com 即可共享(document.domain 方案已废弃);但 AJAX 依然跨域,仍要配 CORS。
  3. 追问:怎么判断请求到底带没带 Cookie?
    • 答:看 DevTools Network 里该请求的 Request Headers 有没有 Cookie,并在 Application → Cookies 里确认 SameSite / Secure / Domain 字段;Chrome 还会在 Cookie 被拦截时给出黄色警告提示原因。

✅ 项目关联话术(按真实经历替换):

管理后台曾出现「本地联调能登录、测试环境掉登录」的问题,排查发现测试环境前后端域名不同,Cookie 是默认 Lax 没带上。短期方案是后端把会话 Cookie 改为 SameSite=None; Secure 并开启凭证跨域,长期方案是 Nginx 统一把 /api 代理到同域,顺便彻底消除了预检请求。

✍️ 手撕代码【核心实现 + 边界 Case 自测清单 + 变种拓展题】 ​

题 1:原生 JS 实现 JSONP(生产级:超时 + 错误处理 + 清理) ​

核心实现:

js
let jsonpId = 0;

function jsonp(url, { params = {}, callbackKey = "callback", timeout = 8000 } = {}) {
  return new Promise((resolve, reject) => {
    // 1. 回调函数名唯一,避免并发请求互相覆盖
    const callbackName = `__jsonp_cb_${Date.now()}_${jsonpId++}`;
    const script = document.createElement("script");
    let timer = null;

    // 2. 统一清理:全局变量、script 标签、定时器,防内存泄漏
    const cleanup = () => {
      delete window[callbackName];
      script.onerror = null;
      script.parentNode && script.parentNode.removeChild(script);
      timer && clearTimeout(timer);
    };

    // 3. 服务端返回的是 callbackName({...}) 这段可执行脚本
    window[callbackName] = (data) => {
      cleanup();
      resolve(data);
    };

    // 4. 网络错误 / 404:script 的 onerror 能捕获(但拿不到状态码)
    script.onerror = () => {
      cleanup();
      reject(new Error(`JSONP request failed: ${url}`));
    };

    // 5. 超时兜底:服务端不调用回调时 onerror 不会触发
    if (timeout > 0) {
      timer = setTimeout(() => {
        cleanup();
        reject(new Error(`JSONP timeout: ${url}`));
      }, timeout);
    }

    // 6. 拼接查询串(注意编码,防止特殊字符破坏 URL)
    const query = Object.entries({ ...params, [callbackKey]: callbackName })
      .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
      .join("&");
    script.src = `${url}${url.includes("?") ? "&" : "?"}${query}`;

    document.body.appendChild(script);
  });
}

// 使用
jsonp("https://api.example.com/search", { params: { kw: "前端" } })
  .then((res) => console.log(res))
  .catch((err) => console.error(err));

✅ 边界 Case 自测:

  • 并发调用:回调名必须唯一(时间戳 + 自增 id),否则后一个请求会覆盖前一个的回调。
  • 清理彻底:成功、失败、超时三条路径都要 delete window[cb] 并移除 <script>,否则全局变量和 DOM 会不断堆积。
  • 超时必须有:服务端返回了 200 但不调用回调时,onerror 不会触发,Promise 会永远挂起。
  • 参数编码:params 中含中文、&、= 时必须 encodeURIComponent。
  • 无法取消 / 拿不到状态码:这是 JSONP 的固有缺陷,面试要主动点出。
  • 安全性:返回内容会被当脚本执行,只能用于完全可信的接口。

🔁 变种拓展题: 给 JSONP 加「同 URL 请求去重」和「取消」能力。

js
const pending = new Map(); // url -> promise,相同请求复用

function jsonpOnce(url, options) {
  if (pending.has(url)) return pending.get(url);
  const p = jsonp(url, options).finally(() => pending.delete(url));
  pending.set(url, p);
  return p;
}

题 2:postMessage 跨窗口 / iframe 通信(带 origin 校验与请求 - 响应封装) ​

核心实现:

js
// ========== 父页面 ==========
const iframe = document.getElementById("child");
const CHILD_ORIGIN = "https://child.example.com";

// ✅ 第二个参数必须写明确的 targetOrigin,绝不要用 '*'(会把数据发给任何占位页面)
iframe.onload = () => {
  iframe.contentWindow.postMessage({ type: "INIT", payload: { token: "abc" } }, CHILD_ORIGIN);
};

window.addEventListener("message", (e) => {
  // ✅ 必须校验来源,否则任意页面都能向你发消息
  if (e.origin !== CHILD_ORIGIN) return;
  if (e.source !== iframe.contentWindow) return; // 进一步校验发送方窗口
  console.log("收到子页面消息:", e.data);
});

// ========== 子页面(iframe 内) ==========
const PARENT_ORIGIN = "https://parent.example.com";

window.addEventListener("message", (e) => {
  if (e.origin !== PARENT_ORIGIN) return; // ✅ 白名单校验
  const { type, payload } = e.data || {};
  if (type === "INIT") {
    localStorage.setItem("token", payload.token);
    // 回传消息给父页面
    e.source.postMessage({ type: "INIT_DONE" }, e.origin);
  }
});

进阶:封装成带 id 的请求 - 响应模型(类似 RPC):

js
function createMessenger(targetWindow, targetOrigin) {
  const callbacks = new Map();
  let seq = 0;

  window.addEventListener("message", (e) => {
    if (e.origin !== targetOrigin) return; // 安全第一
    const { __id, __res, data } = e.data || {};
    if (__res && callbacks.has(__id)) {
      callbacks.get(__id)(data); // 唤醒对应的 Promise
      callbacks.delete(__id);
    }
  });

  return function request(type, payload, timeout = 5000) {
    return new Promise((resolve, reject) => {
      const __id = `msg_${seq++}`;
      const timer = setTimeout(() => {
        callbacks.delete(__id);
        reject(new Error(`postMessage timeout: ${type}`));
      }, timeout);

      callbacks.set(__id, (data) => {
        clearTimeout(timer);
        resolve(data);
      });

      targetWindow.postMessage({ __id, type, payload }, targetOrigin);
    });
  };
}

✅ 边界 Case 自测:

  • 必校验 e.origin(白名单),并尽量校验 e.source,否则是明确的安全漏洞。
  • targetOrigin 不要写 *:目标页若被替换成恶意页面,敏感数据会直接泄露。
  • 时序问题:iframe 未加载完就 postMessage 会丢消息,要等 onload 或由子页面先发 READY 握手。
  • 数据可克隆:传参走结构化克隆算法,函数、DOM 节点、循环引用(部分场景)无法传递。
  • 卸载清理:组件销毁时 removeEventListener,并清空等待中的回调与定时器。

🔁 变种拓展题: 同源多标签页通信用 BroadcastChannel 或 storage 事件;跨域且需长连接用 WebSocket;跨域共享存储可用「中间 iframe + postMessage」代理方案。

题 3:Node 实现强缓存 + 协商缓存(把缓存链路跑通一遍) ​

核心实现:

js
const http = require("http");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");

// 用文件内容生成 ETag,保证多机一致(不要用 mtime + size)
const genETag = (buf) => `"${crypto.createHash("md5").update(buf).digest("hex")}"`;

http
  .createServer((req, res) => {
    const filePath = path.join(__dirname, "public", req.url === "/" ? "index.html" : req.url);
    if (!fs.existsSync(filePath)) {
      res.writeHead(404).end("Not Found");
      return;
    }

    const buf = fs.readFileSync(filePath);
    const stat = fs.statSync(filePath);
    const etag = genETag(buf);
    const lastModified = stat.mtime.toUTCString();
    const isHtml = filePath.endsWith(".html");

    // 1. 下发缓存策略:html 走协商缓存,hash 静态资源走一年强缓存
    res.setHeader("ETag", etag);
    res.setHeader("Last-Modified", lastModified);
    res.setHeader(
      "Cache-Control",
      isHtml ? "no-cache" : "public, max-age=31536000, immutable"
    );

    // 2. 协商缓存校验:ETag 优先于 Last-Modified
    const ifNoneMatch = req.headers["if-none-match"];
    const ifModifiedSince = req.headers["if-modified-since"];
    const etagMatched = ifNoneMatch && ifNoneMatch === etag;
    const timeMatched = !ifNoneMatch && ifModifiedSince === lastModified;

    if (etagMatched || timeMatched) {
      res.writeHead(304).end(); // ✅ 304 不能带 body
      return;
    }

    res.writeHead(200).end(buf);
  })
  .listen(3000);

✅ 边界 Case 自测:

  • 第一次请求 → 200;刷新 → html 返回 304、静态资源 from disk cache。
  • 304 响应不能携带 body,否则浏览器解析异常。
  • ETag 必须由内容生成,多机部署才一致;弱校验要写成 W/"xxx"。
  • If-None-Match 可能携带多个值(逗号分隔)或 *,严谨实现要拆分比对。
  • 同时存在两种校验头时,以 ETag 为准。
  • 大文件应改为 fs.createReadStream 流式响应,并支持 Range → 206。

🔁 变种拓展题: 加上 Range 分片响应(断点续传)与 gzip / br 压缩协商(依据 Accept-Encoding,并下发 Vary: Accept-Encoding)。

题 4:CORS 中间件与 Nginx 代理配置(后端视角把跨域配对) ​

核心实现(Node / Express 风格中间件):

js
const WHITE_LIST = ["https://web.example.com", "https://admin.example.com"];

function cors(req, res, next) {
  const origin = req.headers.origin;

  // 1. 动态回显白名单内的 Origin(带凭证时不能用 *)
  if (origin && WHITE_LIST.includes(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    // 2. 动态回显必须加 Vary,否则 CDN 会把 A 域的响应发给 B 域
    res.setHeader("Vary", "Origin");
  }

  res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Request-Id");
  // 3. 前端需要读的自定义响应头必须显式暴露
  res.setHeader("Access-Control-Expose-Headers", "X-Total-Count,X-Request-Id");
  // 4. 缓存预检结果,减少 OPTIONS 请求
  res.setHeader("Access-Control-Max-Age", "86400");

  // 5. 预检请求直接短路返回,绝不能进鉴权中间件(OPTIONS 不带业务凭证)
  if (req.method === "OPTIONS") {
    res.writeHead(204).end();
    return;
  }
  next();
}

Nginx 反向代理(生产首选,前端零改造):

nginx
server {
  listen 80;
  server_name web.example.com;

  location / {
    root /data/dist;
    try_files $uri $uri/ /index.html;   # SPA history 路由兜底
    add_header Cache-Control "no-cache"; # 入口 html 不强缓存
  }

  # 带 hash 的静态资源:一年强缓存
  location ~* \.(js|css|png|jpg|webp|woff2)$ {
    root /data/dist;
    add_header Cache-Control "public, max-age=31536000, immutable";
  }

  # 接口同域转发 —— 跨域问题从根上消失
  location /api/ {
    proxy_pass http://127.0.0.1:8080/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_http_version 1.1;              # 保持长连接
    proxy_set_header Connection "";
  }
}

✅ 边界 Case 自测:

  • OPTIONS 预检必须在鉴权之前短路,否则会被 401 拦掉导致「配了 CORS 还报跨域」。
  • 同一响应头不能被 Nginx 和后端各加一次,重复的 Access-Control-Allow-Origin 会被浏览器判为非法。
  • 动态回显 Origin 必带 Vary: Origin;白名单比对要全等,不要用 includes 做子串匹配(evil-example.com 能绕过)。
  • 重定向(301/302)后的响应同样需要 CORS 头。
  • Nginx add_header 默认只在 2xx/3xx 生效,错误响应要加 always。

🔁 变种拓展题: Vite / Webpack devServer 代理配置。

js
// vite.config.ts
export default {
  server: {
    proxy: {
      "/api": {
        target: "http://test-api.example.com",
        changeOrigin: true, // 重写 Host 头,避免后端域名校验失败
        rewrite: (p) => p.replace(/^\/api/, ""),
        cookieDomainRewrite: "localhost", // Cookie 域名重写,解决本地登录态
      },
    },
  },
};

题 5:前端请求层缓存(SWR 思路 + 请求去重) ​

核心实现:

js
const cache = new Map(); // key -> { data, time }
const inflight = new Map(); // key -> promise,并发去重

async function requestWithCache(url, { maxAge = 30_000, swr = 300_000 } = {}) {
  const key = url;
  const hit = cache.get(key);
  const now = Date.now();

  // 1. 新鲜期内:直接返回缓存(等价强缓存)
  if (hit && now - hit.time < maxAge) return hit.data;

  const revalidate = () => {
    // 2. 相同请求并发时只发一次(请求去重)
    if (!inflight.has(key)) {
      const p = fetch(url)
        .then((r) => r.json())
        .then((data) => {
          cache.set(key, { data, time: Date.now() });
          return data;
        })
        .finally(() => inflight.delete(key));
      inflight.set(key, p);
    }
    return inflight.get(key);
  };

  // 3. 过期但在 SWR 宽限期内:先返回旧数据,后台静默刷新
  if (hit && now - hit.time < swr) {
    revalidate().catch(() => {}); // 失败也不影响本次返回
    return hit.data;
  }

  // 4. 彻底过期或无缓存:等待真实请求
  return revalidate();
}

✅ 边界 Case 自测:

  • 并发 10 次相同请求,Network 面板应只有 1 条请求。
  • SWR 返回旧数据时不能阻塞,后台刷新失败要静默吞掉,不能抛给当前调用方。
  • 缓存 key 要包含查询参数和关键请求头(如语言、租户 id),否则串数据。
  • 用户私有数据在退出登录时必须清空缓存,否则会泄露到下一个账号。
  • 写操作(POST/PUT/DELETE)成功后要主动失效相关 key。
  • 需要容量上限时换成 LRU,避免长会话内存无限增长。

🔁 变种拓展题: 用 AbortController 实现请求取消与超时;用 Service Worker 做离线缓存(Cache Storage + stale-while-revalidate 策略)。

js
function fetchWithTimeout(url, ms = 8000) {
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), ms);
  return fetch(url, { signal: ctrl.signal }).finally(() => clearTimeout(timer));
}

⚠️ 当日踩坑实录 ​

常见坑解决方案
❌ CORS 配 Access-Control-Allow-Origin: *,带 Cookie 跨域直接报错✅ 带凭证时禁止通配符:回显具体域名 + Access-Control-Allow-Credentials: true + 前端 withCredentials
❌ 后端明明配了 CORS,浏览器仍报跨域✅ 多半是 OPTIONS 预检被鉴权中间件 / 网关拦截返回了 401、404;预检要在鉴权前短路返回 204
❌ Access-Control-Allow-Origin 出现两次(Nginx + 后端都加)✅ 只在一处下发 CORS 头,重复头会被浏览器判定非法
❌ 动态回显 Origin 没加 Vary: Origin,CDN 把 A 域响应发给 B 域✅ 动态回显必须配 Vary: Origin,并保证白名单全等匹配而非子串匹配
❌ 前端读不到 X-Total-Count 等自定义响应头✅ 后端加 Access-Control-Expose-Headers,默认只暴露 6 个基础头
❌ 静态资源设了强缓存,发版后用户拿不到新资源✅ 文件名带 contenthash(换 URL 即换缓存),index.html 固定 no-cache
❌ 以为 no-cache 就是不缓存✅ no-cache 是「每次都去协商校验」,真正不缓存是 no-store
❌ 多机部署后缓存命中率骤降、ETag 频繁失效✅ Nginx 默认 ETag = mtime + size,多机不一致;改为按文件内容 hash 生成
❌ 只用 Last-Modified,1 秒内多次修改 / 回滚旧版本导致缓存判断错误✅ 启用 ETag(内容指纹),其优先级高于 Last-Modified
❌ 返回 304 时带上了 body✅ 304 只能返回头部,带 body 会导致浏览器解析异常
❌ Expires 生效异常(用户改了系统时间)✅ 用 Cache-Control: max-age 相对时间,优先级也更高
❌ 登录态跨域丢失:请求压根没带 Cookie✅ Chrome 80+ 默认 SameSite=Lax,跨站 XHR 不携带;改 SameSite=None; Secure,或代理成同源
❌ SameSite=None 配了但 HTTP 环境下 Cookie 被直接丢弃✅ None 必须搭配 Secure,即必须 HTTPS
❌ postMessage 用 * 作为 targetOrigin / 不校验 e.origin✅ 发送写明确域名、接收做 origin 白名单校验(必要时校验 e.source),否则是实打实的安全漏洞
❌ iframe 还没加载完就 postMessage,消息丢失✅ 等 onload,或让子页面先发 READY 做握手再通信
❌ JSONP 回调名写死,并发请求互相覆盖;用完不清理全局变量和 <script>✅ 回调名加时间戳 + 自增 id,成功 / 失败 / 超时三条路径统一 cleanup
❌ JSONP 服务端不调用回调,Promise 永远 pending✅ 必须加超时兜底(onerror 捕获不到这种情况)
❌ 以为跨域请求「没发出去」✅ 请求已到达服务端,只是响应被浏览器拦截;排查时要看服务端日志而不是只盯前端报错
❌ 升级 HTTP/2 后仍保留域名分片、雪碧图✅ 撤掉 1.1 时代 hack:域名收敛以复用单连接,雪碧图拆回独立资源以提升缓存粒度
❌ 指望 HTTP/2 在弱网一定更快✅ 单连接丢包会阻塞所有流(TCP 层队头阻塞),弱网收益有限,要上 HTTP/3 / QUIC
❌ 用 301 做临时跳转,改回来发现浏览器死活不生效✅ 301 会被浏览器强缓存,临时跳转用 302 / 307
❌ 接口被浏览器缓存导致数据不更新(IE / 部分代理)✅ 私有数据加 Cache-Control: private, no-store,或在 URL 上加时间戳破缓存
❌ 抓包工具能看到 HTTPS 明文就以为 HTTPS 不安全✅ 那是因为手动信任了抓包工具根证书;安全底座是证书信任链,移动端可做 SSL Pinning 防护
❌ 本地开发代理能登录,部署后 Cookie 失效✅ devServer 的 changeOrigin / cookieDomainRewrite 只在本地生效,生产要靠 Nginx 同域或 CORS 凭证配置

📌 当日复盘任务 ​

  • [ ] 不看资料默画一遍浏览器缓存完整决策流程:有无缓存 → 强缓存是否过期 → 协商校验 → 304 / 200
  • [ ] 用一句话说清 no-cache 与 no-store、max-age 与 s-maxage、private 与 public 的区别
  • [ ] 整理自己项目的缓存策略清单:html / 带 hash 的 js、css / 图片字体 / 接口分别配什么头,并说明理由
  • [ ] 口述 ETag 与 Last-Modified 的优先级、精度差异,以及多机部署下 ETag 不一致的成因与修复
  • [ ] 打开真实项目的 Network 面板,分别用「地址栏回车 / F5 / Ctrl+F5」对比缓存命中情况,记录状态码差异
  • [ ] 口述 CORS 简单请求与预检请求的判定条件与完整流程,并默写 6 个核心 Access-Control-* 响应头
  • [ ] 口述「跨域携带 Cookie 的四个必要条件」,并解释 SameSite 三个取值的行为差异
  • [ ] 列出 5 种以上跨域方案及各自适用场景,说明为什么生产环境首选 Nginx 代理 / CORS
  • [ ] 手写一遍 JSONP(含唯一回调名、超时、清理),并说出它的三个固有局限
  • [ ] 手写 postMessage 通信的安全写法,解释不校验 origin 会带来什么攻击面
  • [ ] 用 Node 跑通一个 demo:html 走协商缓存返回 304、静态资源命中强缓存,截图记录
  • [ ] 对比 HTTP/1.1 / 2 / 3 的核心差异,并解释「HTTP/2 为什么没解决 TCP 层队头阻塞」
  • [ ] 口述 HTTPS 的 TLS 握手步骤,解释为什么非对称加密只用于交换密钥
  • [ ] 复述一条完整排查链路:「接口报跨域」→ 看 Network 的 OPTIONS 与响应头 → 判断是预检被拦 / 头缺失 / 凭证配置 → 定位修复
  • [ ] 2 分钟内完整口述「HTTP 版本演进 + 缓存两级策略 + 跨域与 CORS」,确保逻辑连贯无卡顿