obsidian-computer
前端识记前端面试

03-network

大厂前端面试标准答案(字节 / 腾讯 / 美团方向)。每题结构:【核心答案】30 秒口述版 → 【展开】原理 + 可运行代码 → 【追问 & 加分点】。

最后更新 2026年8月29日

网络与通信

大厂前端面试标准答案(字节 / 腾讯 / 美团方向)。每题结构:【核心答案】30 秒口述版 → 【展开】原理 + 可运行代码 → 【追问 & 加分点】。

目录


1. 常见 HTTP 状态码全解

【核心答案】

HTTP 状态码是三位数字,首位划分为五类:1xx 信息(请求已收到,继续处理)、2xx 成功、3xx 重定向(需要进一步动作,含缓存协商)、4xx 客户端错误(请求本身有问题,重试无意义除非改请求)、5xx 服务端错误(服务端故障,部分可重试)。面试里最该讲清楚的是这几组对比:200/204/206 的响应体差异、301/302/304/307/308 的语义与缓存差异、401/403 的"未认证 vs 无权限"、400/413/429 的客户端约束、以及 500/502/503/504 在网关链路上的定位区别——502/504 是网关对上游的判断,503 是本服务的主动过载保护。

【展开】

1xx 信息性响应

只在极少数场景出现,浏览器一般不暴露给业务代码。

码名称说明
100Continue客户端发了 Expect: 100-continue 头询问"我这个大 body 能发吗",服务端同意后才发送正文。用于避免大文件上传被 4xx 直接拒后白白传输。
101Switching Protocols协议切换。WebSocket 握手成功就是 101,响应头带 Upgrade: websocket + Connection: Upgrade。
103Early Hints在最终响应前先下发 Link: </a.css>; rel=preload,让浏览器提前建连/预载资源。CDN 场景(Cloudflare、Fastly)已经在用。

2xx 成功

码名称面试要点
200OK最常见。请求成功且有响应体。GET 返回资源,POST 返回处理结果。
201Created资源创建成功,RESTful 里 POST 创建应返回 201,并在 Location 头给出新资源 URL。
202Accepted已接受但未处理完,异步任务(如转码、批量导入)的标准返回。
204No Content成功但明确没有响应体,且必须没有。典型用于 DELETE 成功、PUT 更新成功、表单埋点上报(navigator.sendBeacon 常配 204)。前端注意:res.json() 解析 204 会抛错,要先判 status === 204。
205Reset Content让客户端重置表单视图,现实中几乎不用。
206Partial Content范围请求成功。请求头带 Range: bytes=0-1023,响应带 Content-Range: bytes 0-1023/9999 和 Accept-Ranges: bytes。视频拖拽播放、断点续传下载、大文件分块下载全靠它。服务端不支持 Range 时会忽略并返回 200 全量。
// 前端判 204 的正确姿势
const res = await fetch('/api/item/1', { method: 'DELETE' });
if (res.status === 204) return null;      // 无 body,不能调 res.json()
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();

3xx 重定向 / 缓存

码名称面试要点
301Moved Permanently永久重定向。浏览器会强缓存这个跳转关系,后续直接跳不再请求旧地址。SEO 权重会转移到新地址。
302Found临时重定向。默认不被缓存(除非显式带缓存头)。旧地址仍是"正主"。
303See Other强制用 GET 请求新地址。典型 POST-Redirect-GET 模式,防止刷新重复提交。
304Not Modified协商缓存命中。不是重定向,是"你本地那份还能用"。无响应体,浏览器直接读磁盘/内存缓存。触发条件:请求带 If-None-Match(对应 ETag)或 If-Modified-Since(对应 Last-Modified),服务端比对后判定未变更。
307Temporary Redirect等价 302,但严格保留原请求方法和 body。
308Permanent Redirect等价 301,但严格保留原请求方法和 body。

4xx 客户端错误

码名称面试要点
400Bad Request请求语法/参数不合法,服务端无法理解。参数校验失败最常见。
401Unauthorized名字有误导——实际含义是未认证(没登录 / token 失效 / token 缺失)。规范要求响应带 WWW-Authenticate 头。前端拦截器里通常用它触发"刷新 token 或跳登录页"。
402Payment Required保留状态码,部分 SaaS 用来表示"额度用完/需付费"。
403Forbidden已认证但无权限。身份是清楚的,就是不让你访问。前端应跳"无权限页"而不是登录页。注意有些站点出于安全把"资源不存在"也返回 403 以免泄露存在性。
404Not Found资源不存在。SPA 部署常见坑:刷新子路由 404,需要服务端把非静态资源请求 fallback 到 index.html(history 模式)。
405Method Not Allowed路径存在但方法不允许,响应须带 Allow: GET, POST。
408Request Timeout客户端在超时时间内没把请求发完。
409Conflict资源状态冲突。乐观锁版本号不一致、重复创建同名资源。
410Gone资源永久删除,比 404 语义更强,告诉爬虫别再来了。
413Content Too Large(旧名 Payload Too Large)请求体超限。Nginx 的 client_max_body_size(默认 1M)、Node 的 body-parser limit 都会触发。文件上传报 413 是分片上传的直接动因之一。
414URI Too LongURL 过长,GET 传大参数会踩。
415Unsupported Media TypeContent-Type 服务端不支持,比如后端只收 application/json 你发了 text/plain。
416Range Not SatisfiableRange 请求的范围越界。
422Unprocessable Content语法正确但语义校验失败(如邮箱格式不对)。很多框架(Laravel、NestJS class-validator)用它区别于 400。
429Too Many Requests限流。响应通常带 Retry-After: 60(秒数或日期)。前端应做指数退避重试,而不是立即重试。
431Request Header Fields Too Large请求头过大,Cookie 塞太多会触发。

5xx 服务端错误

码名称面试要点
500Internal Server Error服务端代码抛了未捕获异常,兜底错误。说明请求到达了业务服务。
501Not Implemented服务端不支持该方法(如不认识的 HTTP method)。
502Bad Gateway网关/反向代理拿到了上游的无效响应——上游进程挂了、端口没监听、返回了非法报文。典型场景:Nginx 后面的 Node 服务崩了。
503Service Unavailable服务暂时不可用:主动过载保护、限流熔断、维护中。可带 Retry-After。它是服务本身在说话,而不是网关判断。
504Gateway Timeout网关等上游超时。上游还活着但太慢(慢 SQL、死循环、下游依赖阻塞)。
505HTTP Version Not Supported协议版本不支持。
507Insufficient Storage存储空间不足(WebDAV 扩展)。

502 / 503 / 504 排障口诀:502 = 上游死了或答非所问;504 = 上游太慢没答完;503 = 我自己主动不干了。前两个是网关视角,第三个是服务视角。

【追问 & 加分点】

  1. "304 属于重定向吗?" —— 分类上归 3xx,但语义是缓存协商,不发生 URL 跳转。304 的前提是强缓存已失效(Cache-Control: max-age 过期或 no-cache),才会发协商请求。Cache-Control: no-store 则连协商都不做。
  2. "401 和 403 你的拦截器怎么处理?" —— 401 走 refresh token 静默续期,失败再跳登录;403 直接跳无权限页并上报(可能是越权探测)。二者绝不能混在一起处理,否则会出现"无权限被反复踢去登录"的死循环。
  3. "429 前端怎么优雅处理?" —— 读 Retry-After;没有就用指数退避 + 抖动(delay = base * 2^n + random),并且只对幂等请求自动重试,POST 类需要幂等键(Idempotency-Key)保护。
  4. "状态码和业务码怎么配合?" —— 主流做法是 HTTP 状态码表达传输层/协议层语义,业务失败用 200 + { code, message };但更严谨的 RESTful 主张业务错误也用 4xx。要点是团队内统一,并且监控系统按 HTTP 码统计可用性时不要被"200 包 error"污染。

2. 301 与 302 的区别、使用场景与 307/308

【核心答案】

301 是永久重定向,302 是临时重定向。核心差异有三点:缓存——301 会被浏览器强缓存(甚至写进本地,清缓存前不再请求旧地址),302 默认不缓存;SEO——301 会把搜索引擎权重转移到新 URL,旧 URL 被替换,302 保留旧 URL 为索引主体;方法保留——历史上浏览器实现 301/302 时会把 POST 改写成 GET 并丢弃 body,这是与规范不符但已成事实的行为,所以 HTTP/1.1 补充了 307(临时)和 308(永久),它们强制保留原方法和请求体。域名迁移、HTTP→HTTPS、URL 结构永久调整用 301;登录跳转、A/B 分流、活动页临时跳转用 302。

【展开】

三个维度的对比

维度301302307308
语义永久临时临时永久
默认可缓存是(默认可被缓存)否否是
保留请求方法否(浏览器普遍改写 POST→GET)否(同左)是是
保留请求体否否是是
SEO 权重转移转移到新 URL不转移不转移转移到新 URL

浏览器实际执行差异

第一次访问 http://old.com/a  (301 → https://new.com/a)
浏览器:发请求 → 收到 301 → 记录到重定向缓存 → 请求新地址

第二次访问 http://old.com/a
浏览器:不发网络请求,直接内部跳到 https://new.com/a
        DevTools Network 里 old.com 这条会显示 "(disk cache)" 或根本不出现

302 则每次都要往返一次旧地址。这带来一个非常常见的线上事故:误发 301 后,用户浏览器把跳转永久缓存了,即便服务端改回来,老用户仍然被跳到错误地址,只能靠改 URL、加 query 参数或让用户清缓存兜底。因此原则是:拿不准就先用 302,确认永久后再改 301。

301 的缓存也可以被 Cache-Control 覆盖:

HTTP/1.1 301 Moved Permanently
Location: https://new.example.com/a
Cache-Control: max-age=3600      # 只缓存 1 小时,给自己留后路

方法改写问题的由来

规范(RFC 7231)对 301/302 的措辞是"除非用户确认,否则不应改变方法",但早期浏览器为了兼容普遍把 POST 改成 GET。这个行为固化后无法回退,于是 RFC 7238 / 7231 引入:

  • 307 Temporary Redirect = 302 的严格版
  • 308 Permanent Redirect = 301 的严格版
// Node/Express 演示:POST 表单遇到不同重定向的结果
app.post('/submit', (req, res) => res.redirect(302, '/target'));
// 浏览器实际发出:GET /target,body 丢失 ❌

app.post('/submit', (req, res) => res.redirect(307, '/target'));
// 浏览器实际发出:POST /target,body 完整保留 ✅

所以 API 网关做接口迁移、需要保留 POST body 的场景,必须用 307/308,不能用 301/302。

典型使用场景

用 301(或 308):

  • 域名更换:old-brand.com → new-brand.com
  • 强制 HTTPS:http:// → https://(更推荐配合 HSTS,见下)
  • URL 规范化:去 www、去尾部斜杠、旧路由结构永久废弃
  • 站点重构后旧文章路径映射到新路径(保权重的关键)

用 302(或 307):

  • 未登录访问受保护页面 → 跳登录页(登录后还要回来,旧 URL 依然有效)
  • 活动期间首页临时跳专题页
  • A/B 测试、灰度分流、地域/语言分站
  • 服务临时维护跳公告页

一个 HTTPS 相关的加分补充

单纯用 301 把 HTTP 跳 HTTPS,第一次请求仍然是明文的,存在被中间人劫持的窗口。生产上应在 HTTPS 响应里追加 HSTS:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

之后浏览器会在发起请求前内部把 http 改成 https(DevTools 显示 307 Internal Redirect,注意这是浏览器伪造的,不是服务端返回的)。

【追问 & 加分点】

  1. "301 之后旧域名能不能下线?" —— 短期不能。搜索引擎需要数月完成权重迁移,且用户书签、外链仍指向旧域名。业界惯例是 301 保留至少 1 年再评估。
  2. "怎么判断线上是 301 还是 302?" —— DevTools Network 勾选 Preserve log 看首条响应的 Status;或 curl -I https://x.com;注意浏览器已缓存 301 后不会再发请求,需要 Disable cache 或用无痕窗口验证。
  3. "307 Internal Redirect 是什么?" —— 这不是服务端返回的,是浏览器内部产生的:HSTS 升级、Service Worker 拦截、upgrade-insecure-requests CSP 指令都会显示为它。
  4. "302 会不会有安全问题?" —— 会。开放重定向(Open Redirect):/login?redirect= 参数不校验就跳,攻击者构造 /login?redirect=https://evil.com 做钓鱼。修复方式是只允许站内相对路径或白名单域名,绝不直接把用户输入塞进 Location。

3. HTTPS 与 HTTP 的区别、握手与加密流程

【核心答案】

HTTPS = HTTP over TLS。HTTP 明文传输,可被窃听、篡改、冒充;HTTPS 在 TCP 与 HTTP 之间插入 TLS 层,提供机密性(加密)、完整性(MAC/AEAD 校验)、身份认证(CA 证书链)三重保障,默认端口 443。加密方案是混合加密:握手阶段用非对称加密/密钥交换(RSA 或更主流的 ECDHE)在不安全信道上协商出一个只有双方知道的会话密钥,之后所有应用数据用对称加密(AES-GCM / ChaCha20-Poly1305)传输。之所以不全用非对称,是因为非对称运算比对称慢 2~3 个数量级、对明文长度有限制、且不适合流式大数据;不全用对称,是因为对称加密无法解决"密钥怎么安全地送到对方手上"这个先有鸡还是先有蛋的问题。证书的作用则是防止中间人把自己的公钥冒充成服务器的公钥。

【展开】

3.1 HTTP 与 HTTPS 的差异

维度HTTPHTTPS
传输明文TLS 加密
默认端口80443
证书无需 CA 签发的证书(有成本,Let's Encrypt 免费)
建连开销TCP 三次握手TCP 三次握手 + TLS 握手(1~2 RTT)
完整性无保障,可被运营商插广告AEAD 校验,篡改即失败
身份认证无证书链验证服务端身份
浏览器能力受限:Service Worker、getUserMedia、navigator.geolocation、HTTP/2、剪贴板 API 等只在安全上下文可用全量可用
SEO排名劣势排名加权

3.2 TLS 1.2 完整握手(经典 RSA 与 ECDHE 两条路)

Client                                              Server
  |                                                   |
  |------------- ClientHello ------------------------>|
  |   支持的 TLS 版本、密码套件列表、Client Random、    |
  |   扩展(SNI 指明访问哪个域名、ALPN 协商 h2/http1.1)|
  |                                                   |
  |<------------ ServerHello --------------------------|
  |   选定版本 + 选定密码套件、Server Random           |
  |<------------ Certificate --------------------------|
  |   服务器证书链(叶子证书 + 中间 CA)               |
  |<------------ ServerKeyExchange --------------------|
  |   (仅 ECDHE 套件)服务端 ECDHE 公钥 +             |
  |    用证书私钥对参数的签名                          |
  |<------------ ServerHelloDone ----------------------|
  |                                                   |
  | [ 客户端验证证书:域名匹配?未过期?未吊销?        |
  |   签名链能追溯到本地信任的根 CA? ]                |
  |                                                   |
  |------------- ClientKeyExchange ------------------>|
  |   RSA 套件:用服务器公钥加密 Pre-Master Secret     |
  |   ECDHE 套件:发送客户端 ECDHE 公钥                |
  |------------- ChangeCipherSpec ------------------->|
  |------------- Finished(加密) ------------------->|
  |                                                   |
  |<------------ ChangeCipherSpec ---------------------|
  |<------------ Finished(加密) ---------------------|
  |                                                   |
  |============ 应用数据(AES-GCM 对称加密) =========>|

会话密钥是怎么算出来的(三个随机数):

Master Secret = PRF(Pre-Master Secret, "master secret",
                    Client Random + Server Random)

会话密钥组 = PRF(Master Secret, "key expansion",
                 Server Random + Client Random)
           → 客户端写密钥、服务端写密钥、各自的 IV

前两个随机数明文传输,Pre-Master Secret 是唯一的秘密。引入三个随机数而不是只用一个,是为了保证即便一端的随机数生成器质量差,最终密钥仍有足够熵,并且防重放。

RSA 密钥交换 vs ECDHE 密钥交换(这是 P6 级别的分水岭):

  • RSA 方式:客户端随机生成 Pre-Master Secret,用服务器证书里的公钥加密发过去。风险是没有前向保密(PFS)——如果服务器私钥日后泄露,攻击者可以解开此前录制的全部流量。
  • ECDHE 方式:双方各自生成临时椭圆曲线密钥对,交换公钥,各自用"自己的私钥 + 对方的公钥"算出相同的共享秘密。私钥用完即弃、从不上网。服务器的证书私钥只用于签名(证明"这个 ECDHE 公钥确实是我发的"),不参与密钥推导。所以即使证书私钥泄露,历史流量依然安全——这就是前向保密。

现代生产环境基本只启用 ECDHE 套件,TLS 1.3 直接删除了 RSA 密钥交换。

3.3 TLS 1.3 的改进

  • 握手压缩到 1-RTT:ClientHello 就把猜测的密钥交换参数(key_share)一起发出去,服务端一次回复即可完成。
  • 0-RTT 会话恢复:用上次的 PSK 直接在第一个包里带应用数据。代价是有重放攻击风险,只能用于幂等 GET。
  • 只保留 5 个 AEAD 套件,废弃 RSA 密钥交换、静态 DH、CBC 模式、RC4、SHA-1、压缩,强制前向保密。
  • ServerHello 之后的握手消息全部加密(包括证书),隐私性更好。

3.4 证书验证链条

根 CA 证书(预置在操作系统/浏览器信任库,自签名)
   └─ 签发 → 中间 CA 证书(服务器需要一并下发,缺失会导致部分客户端报错)
          └─ 签发 → 叶子证书(你的 example.com 证书)

客户端验证要做:

  1. 域名匹配:访问的 host 在证书 SAN 列表里(CN 已废弃)
  2. 有效期:当前时间在 notBefore / notAfter 之间
  3. 签名链:逐级用上级公钥验签,一直追到本地信任的根
  4. 吊销状态:CRL / OCSP,现代用 OCSP Stapling(服务端把 OCSP 响应缓存后随握手一起下发,避免客户端额外请求 CA、也避免隐私泄露)
  5. 用途:证书的扩展字段允许用于服务器身份认证

任何一步失败 → 浏览器拦截页(NET::ERR_CERT_*)。

3.5 为什么"混合加密"而不是纯非对称

对比项对称(AES-GCM)非对称(RSA-2048 / ECDSA)
速度极快,有 AES-NI 硬件指令,GB/s 级慢 2~3 个数量级
明文长度任意长度,流式RSA 单次加密上限 ≈ 密钥长度 - padding(2048 位仅约 245 字节)
密钥分发难题:怎么把密钥安全送给对方公钥可以公开传播
身份认证不能可以(签名)

结论:非对称解决"密钥分发与身份认证",对称解决"大流量高效加密",各取所长。如果全用非对称,一个几 MB 的 JS bundle 需要切成上万块分别做模幂运算,CPU 直接打满,完全不可用。

3.6 为什么证书是必需的(防中间人)

如果只有非对称加密没有证书:

客户端 --请求公钥--> [中间人] --请求公钥--> 服务器
客户端 <--中间人公钥-- [中间人] <--服务器公钥-- 服务器
(此后中间人可解密、篡改、再加密转发,双方完全无感)

证书的意义是引入可信第三方 CA:CA 用自己的私钥给"域名 + 服务器公钥"签名,中间人拿不到 CA 私钥就伪造不出对该域名有效的签名。这也解释了为什么抓包工具(Charles / Fiddler / mitmproxy)必须手动在系统里安装它的根证书——本质上是让用户主动信任一个中间人。

【追问 & 加分点】

  1. "HTTPS 就绝对安全了吗?" —— 不。TLS 只保护传输通道。XSS、CSRF、越权、SQL 注入、供应链投毒一个都挡不住;SNI 在 TLS 1.3 之前是明文的(ECH 才解决),DNS 查询、IP、流量大小仍会泄露访问目标;客户端被装了根证书就能被合法中间人解密。
  2. "能不能减少 TLS 握手开销?" —— Session ID / Session Ticket 会话复用(1-RTT)、TLS 1.3 的 0-RTT、OCSP Stapling 减少往返、HTTP/2 多路复用让一条 TLS 连接承载所有请求、<link rel="preconnect"> 提前完成 DNS+TCP+TLS。
  3. "数字签名和加密的方向为什么是反的?" —— 加密用对方的公钥(只有对方私钥能解,保证机密性);签名用自己的私钥(任何人用你的公钥可验,保证不可否认与完整性)。实际签名是对内容的哈希做私钥运算,而不是对全文。
  4. "HTTP/2 和 HTTP/3 与 TLS 的关系?" —— 浏览器实现的 HTTP/2 事实上强制 TLS,通过 TLS 的 ALPN 扩展在握手时协商协议;HTTP/3 基于 QUIC(UDP),把 TLS 1.3 直接内建进传输层,握手与连接建立合并,可做到 0~1 RTT,且解决了 TCP 队头阻塞。

4. SSE(Server-Sent Events):实现方式与 WebSocket 的取舍

【核心答案】

SSE 是 HTML5 规范中基于 HTTP 长连接的服务端单向推送能力:服务端返回 Content-Type: text/event-stream,保持连接不关闭,按 data: xxx\n\n 的文本格式持续下发消息,浏览器用原生 EventSource 接收,并内置断线自动重连和 Last-Event-ID 续传。它与 WebSocket 的核心区别是:SSE 是单向(服务端→客户端)、纯文本、跑在标准 HTTP 之上(天然兼容 HTTP/2、CDN、Nginx、鉴权头、压缩、现有网关),WebSocket 是全双工、支持二进制、需要 101 协议升级并使用独立的 ws:// 协议栈。AI 对话项目选 SSE,是因为大模型流式输出恰好是"一次请求、单向逐 token 推送、结束即关闭"的形态,用不上全双工;而 SSE 复用现有 HTTP 基建(网关鉴权、灰度、限流、日志、链路追踪全都不用改),服务端实现只需几行、断线重连是浏览器免费送的,运维和心智成本都远低于维护一套 WebSocket 长连接集群。

【展开】

4.1 服务端响应格式(协议本体)

必须的响应头:

HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: no        # 关键:关闭 Nginx 响应缓冲,否则消息会被攒着不下发

消息体是纯文本,以空行(\n\n)分隔一条消息,每行是 字段: 值:

: 这是注释行,常用来做心跳保活

event: message
id: 1024
retry: 3000
data: {"delta":"你"}

data: {"delta":"好"}

data: 第一行
data: 第二行

event: done
data: [DONE]

字段语义:

字段作用
data消息正文。多行 data 会被换行符拼接成一条。唯一必需的字段
event自定义事件名,前端用 addEventListener(名字, fn) 监听;不写则触发 message 事件
id消息 ID。浏览器会记住最后一个,断线重连时自动放进请求头 Last-Event-ID,服务端据此续传
retry告诉浏览器重连间隔(毫秒)
: 开头注释,被忽略;用于心跳,防止中间代理因空闲超时掐断连接

Node(Express)最小实现:

app.get('/api/chat/stream', async (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream; charset=utf-8',
    'Cache-Control': 'no-cache, no-transform',
    Connection: 'keep-alive',
    'X-Accel-Buffering': 'no',
  });
  res.flushHeaders(); // 立刻把响应头发出去,别等 body

  // 断线重连时客户端会带上这个头,用于续传
  const lastId = Number(req.headers['last-event-id'] || 0);

  // 心跳:每 15s 发一个注释帧,防止网关空闲超时断连
  const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000);

  // 客户端主动断开(关页面、abort)时清理资源,务必做,否则泄漏
  req.on('close', () => {
    clearInterval(heartbeat);
    res.end();
  });

  try {
    let id = lastId;
    for await (const token of callLLM(req.query.q, { from: lastId })) {
      id += 1;
      // 注意:data 内不能出现裸换行,用 JSON.stringify 天然规避
      res.write(`id: ${id}\n`);
      res.write(`data: ${JSON.stringify({ delta: token })}\n\n`);
    }
    res.write('event: done\ndata: [DONE]\n\n');
  } catch (e) {
    res.write(`event: error\ndata: ${JSON.stringify({ msg: e.message })}\n\n`);
  } finally {
    clearInterval(heartbeat);
    res.end();
  }
});

4.2 前端姿势一:原生 EventSource

// 优点:浏览器原生,自动重连、自动带 Last-Event-ID
// 限制:只能 GET,不能自定义请求头(无法带 Authorization),跨域要靠 CORS
const es = new EventSource('/api/chat/stream?q=hello', {
  withCredentials: true, // 跨域时携带 Cookie,服务端需配合 CORS 凭证配置
});

// 默认事件(服务端没写 event 字段时)
es.onmessage = (e) => {
  const { delta } = JSON.parse(e.data);
  render(delta);
};

// 自定义事件
es.addEventListener('done', () => {
  es.close(); // 必须手动关,否则浏览器会尝试重连
});

es.addEventListener('error', (e) => {
  // 注意:网络断开时也会触发 error,浏览器随后自动重连
  // readyState: 0=CONNECTING(重连中) 1=OPEN 2=CLOSED(不会再重连)
  if (es.readyState === EventSource.CLOSED) {
    console.error('连接已彻底关闭');
  }
});

EventSource 的三个硬伤:不能自定义请求头(token 只能塞 query 或 Cookie)、只能 GET(长 prompt 塞不进 URL)、HTTP/1.1 下受同域 6 连接并发限制(HTTP/2 无此问题)。这就是 AI 项目普遍改用 fetch 的原因。

4.3 前端姿势二:fetch + ReadableStream 手动解析

// 优点:可 POST、可自定义 Header(带 Authorization)、可用 AbortController 中断
// 代价:断线重连、Last-Event-ID 续传要自己实现
async function chatStream(body, { onDelta, onDone, signal }) {
  const res = await fetch('/api/chat/stream', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${getAccessToken()}`,
      Accept: 'text/event-stream',
    },
    body: JSON.stringify(body),
    signal, // 配合 AbortController 实现"停止生成"
  });

  if (!res.ok) throw new Error(`HTTP ${res.status}`);

  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = '';

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += value;

    // 以空行切分消息帧;\r\n\r\n 兼容部分服务端
    let idx;
    while ((idx = buffer.search(/\r?\n\r?\n/)) !== -1) {
      const raw = buffer.slice(0, idx);
      buffer = buffer.slice(idx).replace(/^\r?\n\r?\n/, '');

      let eventName = 'message';
      const dataLines = [];
      for (const line of raw.split(/\r?\n/)) {
        if (line.startsWith(':')) continue;              // 注释/心跳,跳过
        const i = line.indexOf(':');
        const field = i === -1 ? line : line.slice(0, i);
        // 规范:冒号后若有一个空格需去掉
        const val = i === -1 ? '' : line.slice(i + 1).replace(/^ /, '');
        if (field === 'event') eventName = val;
        else if (field === 'data') dataLines.push(val);
      }
      const data = dataLines.join('\n');
      if (!data) continue;

      if (eventName === 'done' || data === '[DONE]') return onDone?.();
      if (eventName === 'error') throw new Error(data);
      onDelta(JSON.parse(data).delta);
    }
  }
  onDone?.();
}

// 使用:支持"停止生成"
const ctrl = new AbortController();
chatStream({ q: '你好' }, { onDelta: render, signal: ctrl.signal });
// 点击停止按钮
stopBtn.onclick = () => ctrl.abort();

两个容易踩的坑:一是必须处理跨 chunk 的半条消息(TCP 分包不会按你的消息边界切,所以要有 buffer);二是不要用 TextDecoder 逐块 decode 而不带 {stream:true},多字节 UTF-8 汉字被截断会变乱码——上面用 TextDecoderStream 已经天然处理了。

4.4 SSE vs WebSocket 全面对比

维度SSEWebSocket
方向单向:服务端 → 客户端全双工
协议标准 HTTP(text/event-stream)独立协议,靠 HTTP 101 升级,之后是 ws/wss 帧协议
数据类型仅 UTF-8 文本(二进制需 Base64,膨胀 33%)文本 + 二进制(ArrayBuffer / Blob)
自动重连浏览器内置,含 Last-Event-ID 续传需自己实现重连 + 退避 + 状态机
心跳服务端发注释行即可需实现 ping/pong 应用层心跳
鉴权复用 HTTP:Cookie / Header(fetch 方式)握手时只能靠 Cookie 或 query,或连上后再发认证帧
基建兼容好:Nginx / CDN / 网关 / 链路追踪 / 限流全部现成差:需网关显式支持 Upgrade、长连接保活、负载均衡需会话保持
代理穿透好(就是普通 HTTP 响应)部分企业代理会阻断 Upgrade
并发限制HTTP/1.1 同域 6 个;HTTP/2 无此问题无此限制
服务端资源一个连接占一个响应流一个连接占一个 socket,通常更省(无 HTTP 头开销)
复杂度极低,几十行高:连接池、心跳、重连、状态同步、消息序号
典型场景AI 流式输出、股票行情、日志流、进度推送、通知IM、协同编辑、多人游戏、音视频信令、远程控制

4.5 AI 对话项目为什么选 SSE

  1. 通信形态天然匹配:一次提问对应一次流式回答,客户端在生成期间无需上行数据。WebSocket 的全双工能力完全用不上,属于杀鸡用牛刀。
  2. 复用全部 HTTP 基建:企业里网关的鉴权、限流、灰度、WAF、日志、APM 链路追踪都是围绕 HTTP 请求做的。SSE 就是一个响应慢一点的普通 GET/POST,零改造;WebSocket 要让整条链路都支持 Upgrade,还得解决 LB 会话保持、连接数监控、优雅重启时的连接迁移。
  3. 中断语义清晰:AbortController.abort() 直接断流,服务端 req.on('close') 感知后停止调用大模型、及时释放 GPU 配额。这正是"停止生成"按钮的实现。
  4. 协议已成事实标准:OpenAI、Anthropic 等主流大模型 API 的流式接口都是 SSE 格式(data: {...}\n\n + data: [DONE]),后端做透传即可,无需协议转换。
  5. 无状态、易水平扩容:一次请求打到任意一台机器都行,不需要像 WebSocket 那样维护"用户 ↔ 具体机器"的映射和跨机推送(Redis pub/sub 广播)。
  6. 成本:一个后端接口 + 一个前端解析函数,一天就能上线;WebSocket 方案通常要多做连接管理与运维观测。

反向地,如果产品要做"多端消息同步 + 服务端主动 push 未读 + 实时协同光标",那就该上 WebSocket,或者 SSE(下行)+ 普通 HTTP(上行)的混合方案。

4.6 SSE 的优缺点

优点:实现极简;浏览器原生自动重连与断点续传;纯 HTTP,穿透代理、兼容 CDN 与现有网关;调试方便(DevTools 有专门的 EventStream 面板,也能直接 curl -N);HTTP/2 下无连接数限制且共享连接。

缺点:单向,上行要另开请求;仅文本;IE / 旧 Edge 不支持原生 EventSource(需 polyfill,或直接用 fetch 方案);EventSource 不能自定义 Header;HTTP/1.1 下同域 6 连接上限;需要显式处理 Nginx 缓冲(proxy_buffering off / X-Accel-Buffering: no)和各级代理的空闲超时;服务端长时间占用一个响应流,需要控制并发上限。

Nginx 关键配置:

location /api/chat/stream {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Connection '';   # 清掉,避免 Connection: close
    proxy_buffering off;              # 关闭响应缓冲,逐块下发
    proxy_cache off;
    gzip off;                         # 压缩缓冲区同样会攒数据
    proxy_read_timeout 3600s;         # 别让长连接被超时掐断
    chunked_transfer_encoding on;
}

【追问 & 加分点】

  1. "SSE 怎么做鉴权?" —— 用 fetch 方案直接带 Authorization 头;若必须用 EventSource,只能靠 Cookie(跨域需 withCredentials + 服务端 Access-Control-Allow-Credentials: true 且 Origin 不能是 *),或用短时效一次性 ticket 放 query(别把长期 token 放 URL,会进网关日志和 Referer)。
  2. "连接被中间代理掐断怎么办?" —— 心跳注释帧(: ping\n\n)+ 服务端 retry 字段调重连间隔 + 客户端自己维护 Last-Event-ID 做幂等续传;后端要保证按 ID 续传时不重复计费、不重复扣 token。
  3. "HTTP/1.1 的 6 连接限制怎么绕?" —— 上 HTTP/2(多路复用,一条 TCP 承载全部流);或者多个 tab 共用一条连接,用 SharedWorker + BroadcastChannel 把消息广播给各个页面。
  4. "服务端怎么防止 SSE 拖垮进程?" —— 限制单用户并发流数、设置总时长上限、req.on('close') 里务必清理定时器与上游请求(否则大模型调用还在跑、GPU 还在计费)、Node 下注意 res.write 返回 false 时的背压处理。
  5. "和轮询/长轮询比呢?" —— 短轮询浪费请求、延迟高;长轮询(Comet)每收一条就要重建连接,头开销大;SSE 一条连接持续下发,是长轮询的标准化替代品。

5. 跨域体系:link/script 能跨域吗、CORS 与 JSONP

【核心答案】

跨域限制来自浏览器的同源策略(Same-Origin Policy),同源指协议、域名、端口三者完全一致。关键要分清两类行为:带 src/href 的标签发起的是"资源加载",本来就允许跨域——<script>、<link rel=stylesheet>、<img>、<video>、<iframe>、@font-face 都能跨域下载并生效;被限制的是**"跨域读取响应内容",也就是 JS 通过 XMLHttpRequest / fetch 拿数据,以及读取跨域 iframe 的 DOM、跨域 canvas 的像素、跨域 script 的详细报错信息。JSONP 正是钻了 <script> 可跨域的空子:让服务端返回一段 callback({...}) 的 JS 代码,加载即执行,从而"读"到数据——代价是只支持 GET 且把执行权完全交给了对方服务器**。CORS 才是规范解法:由服务端通过 Access-Control-Allow-Origin 等响应头授权浏览器把响应交给页面 JS。

【展开】

5.1 同源策略到底限制了什么

同源:协议 + 域名 + 端口 完全相同。

基准: https://a.example.com:443/p/1
https://a.example.com/p/2        ✅ 同源(443 是 https 默认端口)
http://a.example.com/p/1         ❌ 协议不同
https://b.example.com/p/1        ❌ 主机不同(子域不同也算跨域)
https://a.example.com:8443/p/1   ❌ 端口不同

被限制的:

  • XHR / fetch 读取跨域响应(请求可能已发出,只是结果被浏览器拦下不给 JS)
  • 读取跨域 iframe 的 contentDocument / contentWindow 的大部分属性
  • 读取跨域 <img> 绘制到 canvas 后的像素(getImageData 抛 tainted canvas 错误)
  • 跨域 script 的报错细节(window.onerror 只给 Script error.)
  • 读取跨域的 Cookie / localStorage / IndexedDB

不被限制的(跨域资源加载天然允许):

标签能否跨域说明
<script src>✅加载并执行。JSONP 的基础
<link rel="stylesheet" href>✅样式跨域生效。但 JS 通过 cssRules 读取跨域样式表内容会被拒(SecurityError),除非该表带了 CORS 头且 link 标了 crossorigin
<img src>✅显示正常;但绘制到 canvas 后污染画布,除非配 crossorigin + 服务端 CORS 头
<video> / <audio>✅同上
<iframe src>✅(除非被 CSP frame-ancestors / X-Frame-Options 阻止)加载可以,读 DOM 不行
@font-face⚠️字体是例外:浏览器对字体强制 CORS 检查,跨域字体必须服务端返回 Access-Control-Allow-Origin,否则不渲染
表单 <form action> 提交✅能提交(这正是 CSRF 的成因),但拿不到响应

面试常问的"link 标签可以跨域吗",标准答案是:可以跨域加载并生效,但 JS 读不到它的内容;且字体资源是必须 CORS 的例外。

5.2 CORS(跨域资源共享)原理

CORS 是 W3C 规范,本质是服务端通过响应头告诉浏览器"我允许这个源读我的响应",浏览器据此决定放行还是拦截。注意:CORS 是浏览器行为,服务端从未真正"拒绝"请求——所以 Postman、curl、服务端转发都不受跨域影响。

简单请求 vs 预检请求

同时满足以下条件才是简单请求(不发预检,直接发真实请求):

  1. 方法是 GET / HEAD / POST 之一
  2. 头部只包含安全列表字段:Accept、Accept-Language、Content-Language、Content-Type(且值受限)、Range(受限)
  3. Content-Type 只能是 text/plain、multipart/form-data、application/x-www-form-urlencoded
  4. 请求中没有注册事件监听的 XMLHttpRequestUpload 对象、没用 ReadableStream 作为 body

只要不满足任一条就是预检请求——这就是为什么 Content-Type: application/json 或者带 Authorization 头几乎必然触发 OPTIONS 预检。

# 1) 预检请求(浏览器自动发,业务代码看不到)
OPTIONS /api/data HTTP/1.1
Origin: https://web.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

# 2) 预检响应
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://web.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400        # 预检结果缓存 1 天,减少 OPTIONS 往返

# 3) 通过后才发真实请求
POST /api/data HTTP/1.1
Origin: https://web.example.com
...

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://web.example.com
Access-Control-Expose-Headers: X-Total-Count, X-Request-Id   # 不写的话 JS 读不到这些自定义响应头

关键响应头速查:

头作用
Access-Control-Allow-Origin允许的源。可为 *,但带凭证时不能用 *,必须回显具体 Origin
Access-Control-Allow-Credentials是否允许携带 Cookie。前端还需 credentials: 'include' / withCredentials = true
Access-Control-Allow-Methods预检响应,允许的方法
Access-Control-Allow-Headers预检响应,允许的自定义请求头
Access-Control-Expose-Headers默认 JS 只能读 6 个基本响应头,自定义头必须在此暴露
Access-Control-Max-Age预检缓存时长(秒),Chrome 上限 7200
// Express 手写 CORS(含白名单,比无脑 * 安全)
const WHITELIST = ['https://web.example.com', 'https://admin.example.com'];
app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (WHITELIST.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin); // 回显,不用 *
    res.setHeader('Vary', 'Origin');                      // 关键:避免 CDN 把某个源的响应缓存给别人
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
    res.setHeader('Access-Control-Expose-Headers', 'X-Request-Id');
    res.setHeader('Access-Control-Max-Age', '86400');
  }
  if (req.method === 'OPTIONS') return res.sendStatus(204); // 预检直接结束
  next();
});

5.3 JSONP 原理

利用 <script src> 不受同源策略限制、且加载后立即执行的特性:

function jsonp(url, params = {}, timeout = 8000) {
  return new Promise((resolve, reject) => {
    // 1. 生成全局唯一回调名,挂到 window 上
    const cbName = `__jsonp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
    const script = document.createElement('script');
    let timer;

    const cleanup = () => {
      delete window[cbName];
      script.remove();
      clearTimeout(timer);
    };

    // 2. 服务端返回的 JS 会调用这个函数,参数就是数据
    window[cbName] = (data) => { cleanup(); resolve(data); };

    // 3. 拼 URL,把回调名告诉服务端
    const qs = new URLSearchParams({ ...params, callback: cbName }).toString();
    script.src = `${url}?${qs}`;
    script.onerror = () => { cleanup(); reject(new Error('JSONP 加载失败')); };

    // 4. JSONP 没有原生超时,必须自己兜底,否则回调永远挂在 window 上
    timer = setTimeout(() => { cleanup(); reject(new Error('JSONP 超时')); }, timeout);

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

// 服务端返回的内容形如(Content-Type: application/javascript):
// __jsonp_1700000000_abc({"code":0,"data":[1,2,3]})

JSONP 的限制与风险:

  • 只支持 GET,无法 POST 大数据
  • 没有 HTTP 状态码语义,无法区分 404/500,只能靠 onerror 和超时
  • 安全风险致命:返回的内容是在你的页面上下文中执行的 JS。如果对方服务器被攻破或本身有恶意,可以直接偷 Cookie(非 HttpOnly 的)、篡改 DOM、发起任意同源请求。等于把 XSS 的执行权交给第三方。
  • 服务端若不校验 callback 参数,攻击者可注入 callback=<script> 或 callback=alert(1);// 造成反射型 XSS,因此服务端必须用白名单正则校验回调名(如 /^[\w$.]{1,64}$/)。
  • 现代项目应一律用 CORS,JSONP 只在维护老系统时才可能遇到。

5.4 其他跨域方案

方案适用场景说明
CORS通用首选服务端加响应头
开发代理本地开发Vite server.proxy / webpack devServer proxy,请求先打到本地 dev server 再由它转发,服务端之间无同源策略
Nginx 反向代理生产把 /api 代理到后端,前端看来同源,从根本上消除跨域
postMessage页面 ↔ iframe / 新窗口通信必须校验 event.origin,且 postMessage 第二参数指定确切目标源,别写 *
document.domain仅同主域不同子域已被现代浏览器废弃(Chrome 115+ 默认禁用),别在答案里当推荐方案
WebSocket长连接不受同源策略约束,但服务端必须自己校验 Origin 头,否则等于开放给任意站点
window.name / location.hash老古董面试可提一句"历史方案",不建议使用

5.5 恶意注入角度的安全边界

同源策略保护的是数据读取,不是请求发送。这条边界决定了几类经典漏洞:

  • CSRF:<form>、<img> 跨域发请求是允许的,浏览器还会自动带上目标站的 Cookie。攻击者不需要读到响应,只要"副作用发生"就够了(转账、改密码)。防御:SameSite=Lax/Strict Cookie、CSRF Token、校验 Origin/Referer、敏感操作用非简单请求(强制预检)。
  • XSS:一旦攻击脚本在你的源上执行,同源策略站在攻击者那边——它是同源的,能读所有数据。防御靠输出转义、CSP、HttpOnly Cookie、避免 innerHTML/eval。
  • CORS 配置漏洞:最危险的写法是"回显任意 Origin + Allow-Credentials: true",等于任何网站都能带着用户 Cookie 读你的接口数据,是典型的高危配置错误。白名单必须是全等匹配,别用 origin.endsWith('example.com')(evil-example.com 会绕过)或 includes。
  • 第三方 script 供应链:CDN 上的 JS 被劫持,页面直接沦陷。防御用 SRI(Subresource Integrity):
<script src="https://cdn.example.com/lib.js"
        integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
        crossorigin="anonymous"></script>
<!-- 哈希不匹配则拒绝执行;crossorigin 是 SRI 校验的前提 -->
  • crossorigin 属性:给 <script>/<img> 加上后,请求以 CORS 模式发出,服务端返回允许头才可用。收益是能拿到跨域脚本的完整错误堆栈(否则只有 Script error.),前端监控 SDK 依赖这一点。

【追问 & 加分点】

  1. "跨域请求到底发出去了吗?" —— 简单请求发出去了,服务端也执行了(所以跨域的 GET 副作用是真实发生的),只是响应被浏览器拦截不给 JS。非简单请求因为预检失败,真实请求不会发。
  2. "预检请求太多影响性能怎么办?" —— 设 Access-Control-Max-Age 缓存预检结果;或者干脆用同源反向代理消除跨域;注意 Chrome 对 Max-Age 的上限是 7200 秒。
  3. "为什么带 Cookie 时 Allow-Origin 不能是 *?" —— 规范强制。因为 * + 凭证等于允许任意站点携带用户身份读取数据,风险不可控。必须回显具体 Origin,并配 Vary: Origin 防止 CDN 串缓存。
  4. "SameSite 对跨域请求的影响?" —— Chrome 默认 SameSite=Lax:跨站请求只有顶级导航的 GET 才带 Cookie,XHR/fetch/iframe/img 都不带。所以跨站场景的 Cookie 鉴权必须显式设 SameSite=None; Secure(且必须 HTTPS)。这也是很多"本地能跑、上线跨域丢登录态"问题的根因。
  5. "跨域和跨站的区别?" —— 跨域看协议+域名+端口全等;跨站看 eTLD+1(可注册域)。a.example.com 和 b.example.com 是跨域但同站,所以 SameSite Cookie 在它们之间是共享的。

6. prefetch 与 preload 的区别与验证方法

【核心答案】

两者都是 <link rel> 的资源提示,但优先级和意图完全相反。preload 是"当前页面马上要用,请高优先级立即加载"——用于解决资源被发现得太晚的问题(如 CSS 里 @font-face 引用的字体、JS 里动态拼的图),加载优先级高,不加载就会阻塞渲染或造成闪烁;prefetch 是"未来某个页面可能会用,浏览器空闲时低优先级预取"——用于提前把下一跳路由的 chunk 拉进缓存,优先级最低(Lowest),会被当前页面的任何资源让路。一句话:preload 是当前导航的必需品,prefetch 是下次导航的投机品。验证方式是打开 DevTools Network:prefetch 命中后,真正使用时那条请求的 Size 列会显示 (prefetch cache)(preload 命中显示 (memory cache) 或 (disk cache)),Priority 列则能看出 prefetch 是 Lowest。

【展开】

6.1 preload

<!-- as 属性是必需的:决定优先级、Accept 头、CSP 策略、是否复用 -->
<link rel="preload" href="/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/img/hero.webp" as="image">
<link rel="preload" href="/js/critical.js" as="script">
<link rel="preload" href="/css/above-fold.css" as="style">

要点:

  • as 不写会导致资源被下载两次(浏览器无法匹配到后续的真实请求),并且控制台会警告。
  • 字体的 preload 必须加 crossorigin,即使同源。因为字体请求本身是匿名 CORS 模式,不加就匹配不上,同样下载两次。
  • preload 只是下载并放进缓存,不执行。as="script" 预载的 JS 不会运行,仍需 <script src> 引用它。
  • 预载后 3 秒内没被使用,Chrome 控制台会警告 The resource ... was preloaded using link preload but not used within a few seconds。这是判断"用错了"的直接信号。
  • 典型价值:打破资源发现的串行链。例如字体的原始发现路径是 HTML → CSS 下载解析 → @font-face → 字体下载,preload 让字体和 CSS 并行下载,直接消灭 FOIT/FOUT。

6.2 prefetch

<!-- 下一个路由的 chunk:空闲时预取,不影响当前页 -->
<link rel="prefetch" href="/assets/detail-page.a1b2c3.js" as="script">
<!-- 明确用户下一步大概率会去详情页时使用 -->

要点:

  • 优先级 Lowest,浏览器在空闲时才发起,且随时可能被推迟。
  • 资源进入 prefetch cache(一个短期缓存,Chrome 中大约保留 5 分钟),且跨导航有效——这是它与 preload 最本质的区别:preload 的收益局限在当前页面生命周期内。
  • 会消耗用户流量。移动端要结合 navigator.connection 判断网络状况,saveData 为 true 时应跳过。
  • webpack / Vite 有内置支持:
// webpack magic comment
import(/* webpackPrefetch: true */ './DetailPage');  // 生成 <link rel=prefetch>,父 chunk 加载完后插入
import(/* webpackPreload: true */ './Chart');        // 生成 <link rel=preload>,与父 chunk 并行加载

Next.js 的 <Link> 组件默认在视口内自动 prefetch 目标路由,就是这个机制的产品化。

6.3 对比表

维度preloadprefetch
意图当前页确定要用未来页可能要用
优先级按 as 决定,通常 High/HighestLowest
时机尽快,与解析并行浏览器空闲时
缓存归属当前导航的内存缓存prefetch cache,跨导航有效
未使用后果控制台警告 + 浪费带宽和优先级只是浪费一点带宽
强制性浏览器必须下载浏览器可以忽略(弱网、省流量模式、低电量下会跳过)
典型对象字体、首屏大图、关键 CSS/JS下一跳路由 chunk、翻页数据、下一张图

6.4 同族的其他提示

<link rel="dns-prefetch" href="https://cdn.example.com">   <!-- 只做 DNS 解析,开销极小 -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<!-- 提前完成 DNS + TCP + TLS 握手,省 100~500ms。但每个连接有成本,
     只对 2~4 个确定会用到的关键第三方域使用,滥用反而抢占带宽 -->

<link rel="modulepreload" href="/js/app.js">
<!-- ES Module 专用:不仅下载,还会解析模块并递归预载其依赖图,比 preload 更彻底 -->

rel="prerender" 已被弃用,现代替代是 Speculation Rules API:

<script type="speculationrules">
{
  "prefetch": [{ "where": { "href_matches": "/products/*" }, "eagerness": "moderate" }],
  "prerender": [{ "where": { "selector_matches": ".nav-primary a" }, "eagerness": "conservative" }]
}
</script>
<!-- Chrome 支持;prerender 会在后台真正渲染整个页面,点击瞬间呈现 -->

6.5 怎么验证 prefetch 生效了

方法一:Network 面板的 Size 列(最直接)

  1. 打开 DevTools → Network,取消勾选 Disable cache(勾了就全部失效,这是最常见的"验证失败"原因)。
  2. 加载首页,找到那条 prefetch 请求:Type 通常显示为对应类型,Priority 列显示 Lowest(Priority 列默认隐藏,右键表头勾选出来)。
  3. 触发跳转到目标路由。
  4. 看目标资源那条请求的 Size 列:
    • (prefetch cache) → prefetch 命中,生效了 ✅
    • (memory cache) / (disk cache) → 命中的是普通 HTTP 缓存
    • 显示实际字节数(如 128 kB)→ 没命中,走了网络 ❌
    • 同时 Time 列会是个位数毫秒

方法二:Performance API 判断

// prefetch 命中的资源,transferSize 为 0(没有网络传输)但 decodedBodySize > 0
const entries = performance.getEntriesByType('resource');
entries
  .filter((e) => e.transferSize === 0 && e.decodedBodySize > 0)
  .forEach((e) => console.log('走了缓存:', e.name, e.duration.toFixed(1) + 'ms'));

// 更精确地看 initiatorType
const hit = entries.find((e) => e.name.includes('detail-page'));
console.log(hit.initiatorType, hit.transferSize, hit.duration);

方法三:chrome://net-export 抓取网络日志,可以看到 prefetch 请求的完整生命周期与是否被复用,适合排查"为什么没命中"。

常见的"prefetch 没生效"原因清单:

  • DevTools 勾了 Disable cache
  • 响应头是 Cache-Control: no-store(明确禁止存储,prefetch 白做)
  • prefetch 后超过约 5 分钟才跳转,prefetch cache 已过期
  • 跨域资源未加 crossorigin,导致 CORS 模式不匹配,第二次重新下载
  • URL 不完全一致(多了个 query 参数、hash 不同)
  • 用户处于省流量模式 / 弱网,浏览器主动跳过了

【追问 & 加分点】

  1. "preload 用错了有什么代价?" —— 它会抢占高优先级带宽。给非关键资源 preload,等于把首屏关键资源挤到后面,LCP 反而变差。所以 preload 应该"少而准",通常一个页面不超过 3~5 个。
  2. "prefetch 会不会伤害用户?" —— 会浪费移动流量。应判断 navigator.connection.saveData 与 effectiveType,并配合意图信号(鼠标 hover 200ms、链接进入视口、IntersectionObserver)做按需 prefetch,而不是无脑全预取。
  3. "HTTP/2 Server Push 和 preload 什么关系?" —— Server Push 曾用 Link: rel=preload 头触发,但因为服务端不知道客户端缓存状态,经常推送冗余资源,Chrome 106 已移除对它的支持。现在的替代是 103 Early Hints:服务端在正式响应前先回一个 103 带 Link: rel=preload,让浏览器在后端还在渲染时就开始下载资源。
  4. "两者能不能一起用?" —— 可以,各司其职:当前页的字体/首屏图用 preload,路由级 chunk 用 prefetch。但不要对同一个资源同时用,会造成重复请求。

7. JWT 双 Token 登录、单点登录与前端权限体系

【核心答案】

单 Token 方案在"安全"和"体验"上无法兼得:有效期长则被盗后风险窗口大,有效期短则用户频繁掉线。双 Token 把职责拆开:Access Token 短命(1530 分钟)、无状态、每个请求都带、只用于接口鉴权;Refresh Token 长命(730 天)、有状态(服务端可存储可吊销)、只在 Access Token 过期时用一次、只发给专门的刷新接口。核心实现要点是:Access Token 放内存(或 memory + 少量降级),Refresh Token 放 HttpOnly + Secure + SameSite Cookie 且路径限定到 /auth/refresh,避免 XSS 拿到;刷新时做轮换(Rotation)+ 重放检测——每次刷新签发新的 refresh 并作废旧的,若发现旧 refresh 被二次使用,说明已泄露,立即吊销整个会话族。前端要用并发请求合并避免多个 401 同时触发多次刷新。SSO 则是把这套机制上移到统一认证中心(SSO Server),各子系统通过重定向 + 授权码换 token,共享认证中心的会话 Cookie 实现"一次登录处处可用"。前端权限管理分三层:路由级(动态路由表 + 守卫)、按钮级(指令/组件包裹)、接口级(后端最终裁决,前端只是体验优化)。

【展开】

7.1 为什么需要双 Token

方案问题
单个长期 Token被盗后攻击者可长期使用;服务端要吊销就得维护黑名单,JWT 的无状态优势荡然无存
单个短期 Token用户每 15 分钟被踢出去一次,体验灾难
双 TokenAccess 短命限制被盗窗口;Refresh 长命保证体验,且因为只在一个接口使用、可存储可吊销,风险面小得多

Access Token 载荷示例(注意:JWT 是 Base64Url 编码,不是加密,任何人都能解开看内容,绝不能放密码、身份证等敏感信息):

{
  "sub": "10086",              // 用户 ID
  "roles": ["editor"],
  "perms": ["article:write"],  // 权限量大时别塞进来,token 会超大,改为服务端查
  "sid": "sess_7f3a...",       // 会话 ID,用于批量吊销
  "iat": 1735689600,
  "exp": 1735691400,           // 30 分钟
  "iss": "auth.example.com",
  "aud": "api.example.com"
}

7.2 存储位置的权衡(高频追问)

存储XSS 风险CSRF 风险刷新页面保持结论
localStorage高(JS 可读,一行 localStorage.getItem 就偷走)无(不自动携带)是便利但不安全,多数团队仍在用
内存变量低(XSS 仍可能劫持请求,但拿不到持久凭证)无否(刷新即丢,靠 refresh 重新拿)Access Token 推荐
HttpOnly Cookie低(JS 读不到)有(需 SameSite + CSRF Token 防御)是Refresh Token 推荐

推荐组合:

Access Token  → 内存变量(页面刷新后用 Refresh 静默换回来)
Refresh Token → Set-Cookie: rt=xxx; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh; Max-Age=604800

Path=/auth/refresh 是个关键细节:这样 Refresh Token 只会在请求刷新接口时被浏览器携带,其他所有业务接口都不会带上它,大幅缩小暴露面(比如后端日志误打请求头时不会泄漏)。

7.3 完整流程

① 登录
   POST /auth/login {username, password}
   ← 200 { accessToken }  +  Set-Cookie: rt=...; HttpOnly; Path=/auth/refresh

② 正常请求
   GET /api/orders   Authorization: Bearer <accessToken>
   ← 200

③ Access 过期
   GET /api/orders   Authorization: Bearer <过期的>
   ← 401 { code: 'TOKEN_EXPIRED' }

④ 静默刷新(浏览器自动带上 rt Cookie)
   POST /auth/refresh
   ← 200 { accessToken: 新的 }  + Set-Cookie: rt=新的(轮换!旧的立即作废)

⑤ 用新 Access 重放原请求 → 200,用户完全无感

⑥ Refresh 也过期 / 被吊销
   ← 401 → 清空状态,跳登录页(带上 redirect 参数以便登录后回来)

7.4 前端拦截器实现(含并发合并 —— 这是面试重点)

import axios from 'axios';

const http = axios.create({ baseURL: '/api', withCredentials: true });

let accessToken = null;            // 内存存储
let refreshPromise = null;         // 关键:正在进行的刷新,用于合并并发

export const setAccessToken = (t) => { accessToken = t; };

// 请求拦截:注入 token
http.interceptors.request.use((config) => {
  if (accessToken) config.headers.Authorization = `Bearer ${accessToken}`;
  return config;
});

// 单例刷新:无论多少个请求同时 401,只会真正发一次 refresh
function refreshToken() {
  if (!refreshPromise) {
    refreshPromise = axios
      // 注意用裸 axios,避免走自己的拦截器造成递归
      .post('/auth/refresh', null, { withCredentials: true })
      .then((res) => {
        accessToken = res.data.accessToken;
        return accessToken;
      })
      .finally(() => {
        refreshPromise = null;     // 无论成败都要释放,否则后续永远刷不了
      });
  }
  return refreshPromise;
}

// 响应拦截:401 → 刷新 → 重放
http.interceptors.response.use(
  (res) => res,
  async (error) => {
    const { config, response } = error;

    // 只处理 401;403 是有权限问题,不该刷新
    if (response?.status !== 401 || !config) return Promise.reject(error);

    // 防止无限循环:刷新接口本身 401,或已重试过一次
    if (config._retried || config.url.includes('/auth/refresh')) {
      logout();
      return Promise.reject(error);
    }
    config._retried = true;

    try {
      const token = await refreshToken();          // 并发请求在此处排队等同一个 Promise
      config.headers.Authorization = `Bearer ${token}`;
      return http(config);                          // 重放原请求
    } catch (e) {
      logout();                                     // 刷新失败,彻底登出
      return Promise.reject(e);
    }
  }
);

function logout() {
  accessToken = null;
  const from = encodeURIComponent(location.pathname + location.search);
  location.replace(`/login?redirect=${from}`);
}

进阶做法:与其等 401 再刷新(会多一次失败请求 + 一次重放),不如在请求拦截器里提前判断 Access Token 剩余有效期(解析 exp,剩余 < 60s 就先刷新),这叫"无感刷新"。两种可以并存:主动刷新为主,401 兜底。多标签页场景还可以用 BroadcastChannel 把新 token 同步给其他 tab,避免每个 tab 各刷一次。

7.5 Refresh Token 轮换与重放检测(安全加分项)

服务端为每个登录会话维护一个 family(家族):
  family_id: 一次登录对应一个
  当前有效 refresh 的 jti(唯一 ID)

刷新时:
  1. 校验传入的 refresh 签名、未过期
  2. 查这个 jti 是否是该 family 的"当前有效"
     - 是   → 签发新 access + 新 refresh,把 family 的当前 jti 更新为新的,旧 jti 标记已用
     - 不是(说明这是一个已被用过的旧 refresh)
            → 判定为 token 泄露/重放,**吊销整个 family**,强制该用户所有设备重新登录,并告警

这个机制的价值:即使 Refresh Token 被窃取,攻击者用它刷新后,真实用户下次刷新会用到已作废的旧 token,从而触发检测,攻击窗口被压缩到一个刷新周期。

其他要点:Refresh Token 应在服务端存哈希值而非明文;登出时必须服务端主动删除;建议绑定设备指纹/UA/IP 段做异常检测。

7.6 基于双 Token 实现单点登录(SSO)

SSO 的本质:把认证从各个业务系统抽离到统一的认证中心(IdP / SSO Server),业务系统(SP)不再自己校验密码,只信任认证中心签发的凭证。

标准流程(OAuth 2.0 授权码模式,业界主流):

用户访问 app-a.example.com/orders(未登录)
   ↓ 302
https://sso.example.com/authorize
    ?client_id=app-a
    &redirect_uri=https://app-a.example.com/callback
    &response_type=code
    &state=<随机串,防 CSRF>
    &code_challenge=<S256(verifier)>&code_challenge_method=S256   ← PKCE,前端应用必备
   ↓
SSO 检查自己域下的会话 Cookie(sso.example.com 的 HttpOnly Cookie)
   ├─ 无会话 → 展示登录页 → 用户输入账密 → 建立 SSO 会话 Cookie
   └─ 有会话 → 直接跳过登录(这就是"单点"的来源)
   ↓ 302 回跳
https://app-a.example.com/callback?code=xxx&state=<校验一致>
   ↓ 前端用 code + code_verifier 换 token
POST https://sso.example.com/token
   ← { accessToken, refreshToken, idToken }
   ↓
app-a 拿到自己的双 Token,之后照常业务

用户再访问 app-b.example.com
   ↓ 同样跳 SSO,但 SSO 会话 Cookie 还在
   ↓ 无需再次输入密码,直接回跳 code
   → app-b 也拿到属于自己的双 Token(各系统 token 相互独立)

几个关键设计点:

  1. state 参数必须校验:前端生成随机串存 sessionStorage,回调时比对,防止 CSRF 式的授权码注入。
  2. PKCE 必须开启:纯前端应用无法安全保存 client_secret,PKCE(code_verifier / code_challenge)保证即使授权码在回跳 URL 中被截获,没有 verifier 也换不到 token。
  3. 别用隐式模式(implicit):直接把 token 放 URL fragment,会泄漏到浏览器历史、Referer、日志。OAuth 2.1 已废弃。
  4. 单点登出(SLO):比登录难得多。方案有 ① 后端通道登出(SSO 主动调各 SP 的登出回调接口,最可靠)② 前端通道登出(用隐藏 iframe 逐个访问各 SP 的登出 URL,受第三方 Cookie 限制,正在失效)③ Access Token 短命 + 每次刷新时校验 SSO 会话是否存活(最简单实用:SSO 会话没了,各系统最多 30 分钟内自然失效)。
  5. 同主域下的简化方案:如果所有子系统都在 *.example.com 下,可以把 SSO 会话 Cookie 的 Domain 设为 .example.com,天然共享。但跨主域(example.com 和 another.com)只能走上面的重定向流程。
  6. 第三方 Cookie 淘汰的影响:依赖隐藏 iframe 静默续期(prompt=none)的老方案在 Chrome 屏蔽第三方 Cookie 后会失效,新方案改用顶级导航重定向或 Refresh Token 轮换。

7.7 前端权限管理三层设计

核心认知(面试必须先说这句):前端权限控制的目的是"提升体验、减少无效操作",绝不是安全边界。真正的安全防线永远在服务端——每个接口都必须独立鉴权,因为前端代码在用户手里,可以任意篡改。

① 路由级权限

两种做法:

  • 静态路由 + 守卫过滤:路由表写全,每条 meta.roles 标注所需权限,导航守卫里判断。简单,但用户能在打包产物里看到全部路由结构。
  • 动态路由(中后台主流):登录后拉取用户权限,据此从完整路由表筛选出可访问的部分,用 router.addRoute 动态注入。菜单由同一份数据生成,保证菜单与路由天然一致。
// Vue Router 动态路由 + 守卫
router.beforeEach(async (to) => {
  if (to.meta.public) return true;                 // 白名单页面
  if (!userStore.token) return { path: '/login', query: { redirect: to.fullPath } };

  if (!userStore.routesLoaded) {
    const { menus, perms } = await api.getUserPermission();
    userStore.perms = new Set(perms);
    // 按权限过滤出可访问路由并注入
    filterRoutes(asyncRoutes, userStore.perms).forEach((r) => router.addRoute(r));
    userStore.routesLoaded = true;
    // 关键:此时 to 对应的路由刚注册,必须重新导航一次才能匹配到
    return { ...to, replace: true };
  }
  return true;
});

// 兜底:所有动态路由注册完后再加通配 404,否则会被提前匹配
// router.addRoute({ path: '/:pathMatch(.*)*', component: NotFound })

② 按钮级(元素级)权限

// Vue 3 自定义指令:v-permission="'order:delete'"
app.directive('permission', {
  mounted(el, binding) {
    const need = binding.value;
    const has = Array.isArray(need)
      ? need.some((p) => userStore.perms.has(p))    // 数组语义:任一即可
      : userStore.perms.has(need);
    // 移除而非隐藏:display:none 用户改 DevTools 就能点出来(虽然接口层仍会拦)
    if (!has) el.parentNode?.removeChild(el);
  },
});
// React:组件包裹更符合声明式,且支持 fallback
function Auth({ perm, fallback = null, children }) {
  const { perms } = useAuth();
  const list = Array.isArray(perm) ? perm : [perm];
  return list.some((p) => perms.has(p)) ? <>{children}</> : fallback;
}

// 用法
<Auth perm="order:delete" fallback={<Button disabled>删除</Button>}>
  <Button danger onClick={onDelete}>删除</Button>
</Auth>

③ 接口级权限

  • 服务端对每个接口独立校验,不信任任何前端传来的角色/权限字段。
  • 前端的配合:统一拦截 403,跳无权限页并上报(频繁 403 可能是越权探测)。
  • 敏感操作要求二次验证(短信/密码/生物识别),并考虑加幂等键。

权限模型选型:

模型说明适用
RBAC(角色)用户 → 角色 → 权限。主流中后台标配绝大多数场景
ABAC(属性)基于用户属性、资源属性、环境动态判定("本部门的、状态为草稿的、工作时间内可编辑")复杂业务规则
数据权限除了"能不能进这个页面",还有"能看到哪些行"(本人/本部门/全公司)。必须服务端在 SQL 层面做,前端只做展示中后台必备补充

【追问 & 加分点】

  1. "JWT 怎么主动吊销?" —— JWT 无状态是优点也是短板。方案:① Access 短命 + 吊销 Refresh,最多 30 分钟自然失效(成本最低,最常用)② 维护 jti 黑名单存 Redis,TTL 设为 token 剩余有效期 ③ 在 payload 放 sid,服务端维护会话表,登出时删会话——但这就退化成有状态了,要权衡。
  2. "多标签页怎么同步登录态?" —— BroadcastChannel 或 storage 事件广播 token 更新和登出事件;刷新用单例锁(可用 navigator.locks.request 跨 tab 加锁)避免多 tab 同时刷新导致 refresh 轮换互相作废。
  3. "algorithm 相关的安全坑?" —— 服务端必须固定校验算法,绝不能信任 token header 里的 alg。经典漏洞:攻击者把 alg 改成 none 让服务端跳过验签,或把 RS256 改成 HS256 用公钥当 HMAC 密钥伪造签名。
  4. "权限数据什么时候拉?" —— 登录后拉一次存 store,路由守卫里判断是否已加载。要考虑权限变更后的实时性:可以在每次刷新 token 时带回权限版本号,版本变了就强制重新拉取并重建路由。
  5. "OAuth 2.0 和 OIDC 什么关系?" —— OAuth 2.0 解决的是授权(第三方能拿到什么权限),OIDC 是在它之上加了一层认证(我是谁),核心产物是 id_token(一个包含用户身份信息的 JWT)。做 SSO 用 OIDC 才是标准答案,纯 OAuth 2.0 严格说不是认证协议。

8. 大文件分片上传:切片、秒传、断点续传

【核心答案】

大文件直传的问题是:容易超过 Nginx / 网关的 body 大小限制(413)和超时时间、一旦失败要从头再来、无法显示精确进度、也无法并发利用带宽。分片上传的核心思路是把文件用 Blob.slice 切成固定大小的块(通常 2~10MB),并发上传,服务端收齐后按序合并。在此之上叠加三个能力:秒传——先计算整个文件的内容 hash,上传前问服务端"这个 hash 存在吗",存在就直接建立关联返回 URL,零传输;断点续传——服务端返回该 hash 已收到的分片索引列表,前端跳过这些分片只传剩余部分;并发控制——用限制并发数的任务池(通常 3~6 个),避免打满连接数导致页面其他请求饿死。工程上最需要注意的是 hash 计算要放到 Web Worker 里(否则主线程卡死),并且对超大文件用抽样 hash(首尾 + 中间采样)在速度和碰撞率之间取平衡。

【展开】

8.1 整体流程

选择文件
  ↓
切片(Blob.slice)
  ↓
Web Worker 计算文件 hash(spark-md5 增量 / crypto.subtle)
  ↓
POST /upload/check  { hash, filename, total }
  ├─ { uploaded: true, url }         → 秒传,直接结束 ✅
  └─ { uploaded: false, exist: [0,1,2,5] }  → 已有分片列表
  ↓
过滤掉 exist 中的分片,剩余的进任务池并发上传(限流 4 个)
每片:POST /upload/chunk  FormData{ hash, index, chunk }
  ↓ 全部成功
POST /upload/merge  { hash, filename, total, chunkSize }
  ← { url }

8.2 切片

const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB。太小则请求数爆炸,太大则单片失败代价高

function createChunks(file, size = CHUNK_SIZE) {
  const chunks = [];
  for (let cur = 0, index = 0; cur < file.size; cur += size, index++) {
    // Blob.slice 是零拷贝的视图操作,不会真的把数据读进内存
    chunks.push({ index, blob: file.slice(cur, cur + size) });
  }
  return chunks;
}

8.3 计算 hash(Web Worker + 增量 MD5)

hash 是秒传和断点续传的唯一标识,必须由文件内容决定,不能用文件名+大小(改个名就失效,且不同文件可能同名同大小)。

// hash.worker.js —— 必须放 Worker,10GB 文件在主线程算 hash 会让页面完全卡死
import SparkMD5 from 'spark-md5';

self.onmessage = async ({ data: { chunks } }) => {
  const spark = new SparkMD5.ArrayBuffer();  // 增量计算,避免一次性读入整个文件爆内存
  for (let i = 0; i < chunks.length; i++) {
    const buf = await chunks[i].blob.arrayBuffer();
    spark.append(buf);
    self.postMessage({ type: 'progress', percent: ((i + 1) / chunks.length) * 100 });
  }
  self.postMessage({ type: 'done', hash: spark.end() });
};
// 主线程调用
function calcHash(chunks, onProgress) {
  return new Promise((resolve) => {
    const worker = new Worker(new URL('./hash.worker.js', import.meta.url), { type: 'module' });
    worker.postMessage({ chunks });
    worker.onmessage = ({ data }) => {
      if (data.type === 'progress') onProgress?.(data.percent);
      else { resolve(data.hash); worker.terminate(); }
    };
  });
}

超大文件的优化——抽样 hash(业界常用的折中):

// 全量 hash 一个 10GB 文件可能要几分钟,用户等不了
// 抽样策略:第一片全取 + 最后一片全取 + 中间每片取首中尾各 2 字节
// 速度提升上百倍,碰撞率对业务可接受;风险是极端情况下误判秒传
async function calcSampleHash(file, size = 2 * 1024 * 1024) {
  const spark = new SparkMD5.ArrayBuffer();
  const chunks = [file.slice(0, size)];                       // 第一片完整
  let cur = size;
  while (cur < file.size - size) {
    chunks.push(file.slice(cur, cur + 2));                    // 中间:首
    chunks.push(file.slice(cur + size / 2, cur + size / 2 + 2)); // 中间:中
    chunks.push(file.slice(cur + size - 2, cur + size));      // 中间:尾
    cur += size;
  }
  chunks.push(file.slice(-size));                             // 最后一片完整
  spark.append(await new Blob(chunks).arrayBuffer());
  // 把文件大小也混进去,进一步降低碰撞
  return spark.end() + '-' + file.size;
}

也可以用浏览器原生的 crypto.subtle.digest('SHA-256', buf),无需第三方库且有硬件加速,但它不支持增量计算,必须一次性传入完整 ArrayBuffer,因此只适合小文件或抽样场景。大文件全量 hash 仍需 spark-md5 这类增量库。另外 crypto.subtle 只在安全上下文(HTTPS / localhost)可用。

8.4 并发控制的任务池

// 限制并发数,支持失败重试和整体中断
async function runPool(tasks, limit = 4, { signal, retry = 3 } = {}) {
  let cursor = 0;
  const results = new Array(tasks.length);

  async function worker() {
    while (cursor < tasks.length) {
      if (signal?.aborted) throw new DOMException('已取消', 'AbortError');
      const i = cursor++;                       // 取号,天然避免竞争
      let lastErr;
      for (let attempt = 0; attempt <= retry; attempt++) {
        try {
          results[i] = await tasks[i]();
          lastErr = null;
          break;
        } catch (e) {
          if (e.name === 'AbortError') throw e; // 主动取消不重试
          lastErr = e;
          // 指数退避 + 抖动,避免所有失败分片同时重试打爆服务端
          await new Promise((r) => setTimeout(r, 2 ** attempt * 300 + Math.random() * 200));
        }
      }
      if (lastErr) throw lastErr;
    }
  }

  // 开 limit 个"工人"并行消费同一个游标
  await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
  return results;
}

为什么不用 Promise.all(chunks.map(upload))?因为那会同时发起全部请求,HTTP/1.1 下超过 6 个就排队、浏览器内存暴涨、且页面其他接口全被饿死。为什么不用 Promise.allSettled + 分批(每批 4 个等全部完成再下一批)?因为一批里最慢的那个会拖住整批,游标式任务池的吞吐明显更高。

8.5 完整上传器

const CHUNK_SIZE = 5 * 1024 * 1024;

export async function uploadLargeFile(file, { onProgress, signal } = {}) {
  // ---- 1. 切片 ----
  const chunks = createChunks(file, CHUNK_SIZE);

  // ---- 2. 计算 hash(Worker 中进行,不阻塞主线程)----
  const hash = await calcHash(chunks, (p) => onProgress?.({ stage: 'hash', percent: p }));

  // ---- 3. 秒传 & 续传检查 ----
  const { uploaded, url, exist = [] } = await fetch('/upload/check', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ hash, filename: file.name, total: chunks.length, size: file.size }),
    signal,
  }).then((r) => r.json());

  if (uploaded) {
    onProgress?.({ stage: 'upload', percent: 100 });
    return url;                                 // 秒传命中,零传输 ✅
  }

  // ---- 4. 断点续传:跳过服务端已有的分片 ----
  const existSet = new Set(exist);
  const pending = chunks.filter((c) => !existSet.has(c.index));

  // 进度基线要把已存在的分片算进去,否则续传时进度会从 0 开始,体验很差
  let doneBytes = existSet.size * CHUNK_SIZE;
  const report = () =>
    onProgress?.({
      stage: 'upload',
      percent: Math.min(100, (doneBytes / file.size) * 100),
    });
  report();

  // ---- 5. 并发上传 ----
  const tasks = pending.map((chunk) => async () => {
    const fd = new FormData();
    fd.append('hash', hash);
    fd.append('index', String(chunk.index));
    fd.append('chunk', chunk.blob);             // 字段顺序:文件放最后,便于服务端流式解析先拿到元信息
    const res = await fetch('/upload/chunk', { method: 'POST', body: fd, signal });
    if (!res.ok) throw new Error(`分片 ${chunk.index} 上传失败: ${res.status}`);
    doneBytes += chunk.blob.size;
    report();
  });

  await runPool(tasks, 4, { signal });

  // ---- 6. 通知合并 ----
  const merged = await fetch('/upload/merge', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ hash, filename: file.name, total: chunks.length, chunkSize: CHUNK_SIZE }),
    signal,
  }).then((r) => r.json());

  return merged.url;
}

使用与取消:

const ctrl = new AbortController();
uploadLargeFile(file, {
  signal: ctrl.signal,
  onProgress: ({ stage, percent }) => setTip(`${stage === 'hash' ? '解析中' : '上传中'} ${percent.toFixed(1)}%`),
})
  .then((url) => console.log('完成', url))
  .catch((e) => {
    if (e.name === 'AbortError') console.log('已暂停');   // 已上传分片仍在服务端,下次自动续传
    else console.error(e);
  });

pauseBtn.onclick = () => ctrl.abort();   // 暂停 = abort;恢复 = 重新调用,check 接口会返回已传分片

8.6 服务端要点(Node 骨架)

// 分片接收:以 hash 为目录名,index 为文件名落盘
app.post('/upload/chunk', upload.single('chunk'), async (req, res) => {
  const { hash, index } = req.body;
  const dir = path.join(TEMP_DIR, hash);
  await fs.promises.mkdir(dir, { recursive: true });
  // 先写临时名再 rename,保证"文件存在"等价于"内容完整",避免半截文件被 check 误判为已上传
  const tmp = path.join(dir, `${index}.part`);
  await fs.promises.rename(req.file.path, tmp);
  res.json({ code: 0 });
});

// 合并:必须按 index 数字序,且用流式追加,不能全读进内存
app.post('/upload/merge', async (req, res) => {
  const { hash, filename, total } = req.body;
  const dir = path.join(TEMP_DIR, hash);

  const files = await fs.promises.readdir(dir);
  if (files.length !== Number(total)) {
    return res.status(400).json({ code: 1, msg: '分片不完整' });
  }

  const target = path.join(UPLOAD_DIR, `${hash}${path.extname(filename)}`);
  const ws = fs.createWriteStream(target);
  for (let i = 0; i < total; i++) {
    // 串行 pipe,保证顺序;用 { end: false } 避免中途关闭写流
    await new Promise((resolve, reject) => {
      const rs = fs.createReadStream(path.join(dir, `${i}.part`));
      rs.on('error', reject);
      rs.on('end', resolve);
      rs.pipe(ws, { end: false });
    });
  }
  ws.end();

  await fs.promises.rm(dir, { recursive: true, force: true });   // 清理临时分片
  res.json({ code: 0, url: `/files/${path.basename(target)}` });
});

服务端还要考虑的:

  • Nginx 的 client_max_body_size 至少要大于单个分片大小
  • 临时分片目录要有定时清理任务(如 24 小时未完成的清掉),否则磁盘会被上传中断的垃圾撑爆
  • merge 接口要加锁 / 幂等,防止用户重复点击或并发触发导致文件损坏
  • 合并前后校验总文件 hash,防止分片错乱
  • 安全:校验文件类型(用文件头 magic number 而非扩展名)、限制大小、上传目录不可执行、文件名重命名(用 hash)避免路径穿越

8.7 云存储直传(现代大厂真实做法)

生产环境往往不让文件流经自己的服务器,而是前端直传对象存储(OSS / COS / S3):

前端 → 后端申请上传凭证(STS 临时 token 或预签名 URL,权限最小化、有效期几分钟)
     → 前端直接 PUT 到 OSS(走 OSS 的 Multipart Upload 分片协议)
     → 上传完成回调后端登记

好处:省服务器带宽和存储、天生支持分片与断点续传(OSS SDK 已封装 initiateMultipartUpload / uploadPart / completeMultipartUpload)、可直接接 CDN。面试提到这一点通常是加分项,说明你了解真实工程而不只是背造轮子代码。

【追问 & 加分点】

  1. "分片大小怎么定?" —— 权衡点:太小则请求数暴增(HTTP 头开销 + 服务端 IO 压力),太大则单片失败重传代价高、且可能撞上网关 body 限制。经验值 2~10MB;更好的做法是根据实测网速动态调整(弱网用 1MB,千兆用 10MB),OSS 的 Multipart 也要求分片不小于 5MB(最后一片除外)。
  2. "为什么 hash 必须放 Worker?" —— 读文件 + MD5 是 CPU 密集操作,主线程执行会导致页面完全无响应(连进度条都不动)。Worker 里跑则主线程只负责渲染进度。也可用 requestIdleCallback 分时切片,但 Worker 更彻底。
  3. "怎么防止 hash 碰撞导致秒传误判?" —— 全量 MD5 碰撞概率极低但理论存在;抽样 hash 风险更高。稳妥做法是 hash + 文件大小 做联合键,敏感业务(如网盘)再补一次服务端二次校验,或直接用 SHA-256。
  4. "上传中刷新页面怎么办?" —— 把 hash → 已上传分片索引 记在 IndexedDB,或者干脆每次都调 check 接口向服务端问(服务端是唯一可信来源,更简单也更准)。文件对象本身无法持久化,用户需要重新选择同一个文件——这是浏览器安全限制,无解,只能靠 hash 匹配上原来的进度。
  5. "进度条为什么会跳?" —— 若用 xhr.upload.onprogress 统计,多个分片并发时进度是多个流的叠加,且浏览器缓冲会让进度先冲到 100% 再等响应。更稳的做法是按"完成的分片数 × 分片大小"计算,牺牲平滑度换准确性;或者两者结合,用已完成分片做基线 + 进行中分片的实时字节做增量。

反向链接(0)

还没有笔记链接到这里。在 Obsidian 里用 [[笔记名]] 引用它,这里就会出现,关系图谱上也会多出一条连线。

评论

关系图谱

01-js-core02-browser03-network04-css-html05-react06-vue07-engineering08-scenarios09-handwritten-js10-algorithms11-ai-frontend前端识记前端面试

On this page

网络与通信目录1. 常见 HTTP 状态码全解【核心答案】【展开】1xx 信息性响应2xx 成功3xx 重定向 / 缓存4xx 客户端错误5xx 服务端错误【追问 & 加分点】2. 301 与 302 的区别、使用场景与 307/308【核心答案】【展开】三个维度的对比浏览器实际执行差异方法改写问题的由来典型使用场景一个 HTTPS 相关的加分补充【追问 & 加分点】3. HTTPS 与 HTTP 的区别、握手与加密流程【核心答案】【展开】3.1 HTTP 与 HTTPS 的差异3.2 TLS 1.2 完整握手(经典 RSA 与 ECDHE 两条路)3.3 TLS 1.3 的改进3.4 证书验证链条3.5 为什么"混合加密"而不是纯非对称3.6 为什么证书是必需的(防中间人)【追问 & 加分点】4. SSE(Server-Sent Events):实现方式与 WebSocket 的取舍【核心答案】【展开】4.1 服务端响应格式(协议本体)4.2 前端姿势一:原生 EventSource4.3 前端姿势二:fetch + ReadableStream 手动解析4.4 SSE vs WebSocket 全面对比4.5 AI 对话项目为什么选 SSE4.6 SSE 的优缺点【追问 & 加分点】5. 跨域体系:link/script 能跨域吗、CORS 与 JSONP【核心答案】【展开】5.1 同源策略到底限制了什么5.2 CORS(跨域资源共享)原理5.3 JSONP 原理5.4 其他跨域方案5.5 恶意注入角度的安全边界【追问 & 加分点】6. prefetch 与 preload 的区别与验证方法【核心答案】【展开】6.1 preload6.2 prefetch6.3 对比表6.4 同族的其他提示6.5 怎么验证 prefetch 生效了【追问 & 加分点】7. JWT 双 Token 登录、单点登录与前端权限体系【核心答案】【展开】7.1 为什么需要双 Token7.2 存储位置的权衡(高频追问)7.3 完整流程7.4 前端拦截器实现(含并发合并 —— 这是面试重点)7.5 Refresh Token 轮换与重放检测(安全加分项)7.6 基于双 Token 实现单点登录(SSO)7.7 前端权限管理三层设计【追问 & 加分点】8. 大文件分片上传:切片、秒传、断点续传【核心答案】【展开】8.1 整体流程8.2 切片8.3 计算 hash(Web Worker + 增量 MD5)8.4 并发控制的任务池8.5 完整上传器8.6 服务端要点(Node 骨架)8.7 云存储直传(现代大厂真实做法)【追问 & 加分点】