03-network
大厂前端面试标准答案(字节 / 腾讯 / 美团方向)。每题结构:【核心答案】30 秒口述版 → 【展开】原理 + 可运行代码 → 【追问 & 加分点】。
网络与通信
大厂前端面试标准答案(字节 / 腾讯 / 美团方向)。每题结构:【核心答案】30 秒口述版 → 【展开】原理 + 可运行代码 → 【追问 & 加分点】。
目录
- 1. 常见 HTTP 状态码全解
- 2. 301 与 302 的区别、使用场景与 307/308
- 3. HTTPS 与 HTTP 的区别、握手与加密流程
- 4. SSE:实现方式、与 WebSocket 的取舍
- 5. 跨域体系:link/script 能跨域吗、CORS 与 JSONP
- 6. prefetch 与 preload 的区别与验证方法
- 7. JWT 双 Token 登录、单点登录与前端权限体系
- 8. 大文件分片上传:切片、秒传、断点续传
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 信息性响应
只在极少数场景出现,浏览器一般不暴露给业务代码。
| 码 | 名称 | 说明 |
|---|---|---|
| 100 | Continue | 客户端发了 Expect: 100-continue 头询问"我这个大 body 能发吗",服务端同意后才发送正文。用于避免大文件上传被 4xx 直接拒后白白传输。 |
| 101 | Switching Protocols | 协议切换。WebSocket 握手成功就是 101,响应头带 Upgrade: websocket + Connection: Upgrade。 |
| 103 | Early Hints | 在最终响应前先下发 Link: </a.css>; rel=preload,让浏览器提前建连/预载资源。CDN 场景(Cloudflare、Fastly)已经在用。 |
2xx 成功
| 码 | 名称 | 面试要点 |
|---|---|---|
| 200 | OK | 最常见。请求成功且有响应体。GET 返回资源,POST 返回处理结果。 |
| 201 | Created | 资源创建成功,RESTful 里 POST 创建应返回 201,并在 Location 头给出新资源 URL。 |
| 202 | Accepted | 已接受但未处理完,异步任务(如转码、批量导入)的标准返回。 |
| 204 | No Content | 成功但明确没有响应体,且必须没有。典型用于 DELETE 成功、PUT 更新成功、表单埋点上报(navigator.sendBeacon 常配 204)。前端注意:res.json() 解析 204 会抛错,要先判 status === 204。 |
| 205 | Reset Content | 让客户端重置表单视图,现实中几乎不用。 |
| 206 | Partial 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 重定向 / 缓存
| 码 | 名称 | 面试要点 |
|---|---|---|
| 301 | Moved Permanently | 永久重定向。浏览器会强缓存这个跳转关系,后续直接跳不再请求旧地址。SEO 权重会转移到新地址。 |
| 302 | Found | 临时重定向。默认不被缓存(除非显式带缓存头)。旧地址仍是"正主"。 |
| 303 | See Other | 强制用 GET 请求新地址。典型 POST-Redirect-GET 模式,防止刷新重复提交。 |
| 304 | Not Modified | 协商缓存命中。不是重定向,是"你本地那份还能用"。无响应体,浏览器直接读磁盘/内存缓存。触发条件:请求带 If-None-Match(对应 ETag)或 If-Modified-Since(对应 Last-Modified),服务端比对后判定未变更。 |
| 307 | Temporary Redirect | 等价 302,但严格保留原请求方法和 body。 |
| 308 | Permanent Redirect | 等价 301,但严格保留原请求方法和 body。 |
4xx 客户端错误
| 码 | 名称 | 面试要点 |
|---|---|---|
| 400 | Bad Request | 请求语法/参数不合法,服务端无法理解。参数校验失败最常见。 |
| 401 | Unauthorized | 名字有误导——实际含义是未认证(没登录 / token 失效 / token 缺失)。规范要求响应带 WWW-Authenticate 头。前端拦截器里通常用它触发"刷新 token 或跳登录页"。 |
| 402 | Payment Required | 保留状态码,部分 SaaS 用来表示"额度用完/需付费"。 |
| 403 | Forbidden | 已认证但无权限。身份是清楚的,就是不让你访问。前端应跳"无权限页"而不是登录页。注意有些站点出于安全把"资源不存在"也返回 403 以免泄露存在性。 |
| 404 | Not Found | 资源不存在。SPA 部署常见坑:刷新子路由 404,需要服务端把非静态资源请求 fallback 到 index.html(history 模式)。 |
| 405 | Method Not Allowed | 路径存在但方法不允许,响应须带 Allow: GET, POST。 |
| 408 | Request Timeout | 客户端在超时时间内没把请求发完。 |
| 409 | Conflict | 资源状态冲突。乐观锁版本号不一致、重复创建同名资源。 |
| 410 | Gone | 资源永久删除,比 404 语义更强,告诉爬虫别再来了。 |
| 413 | Content Too Large(旧名 Payload Too Large) | 请求体超限。Nginx 的 client_max_body_size(默认 1M)、Node 的 body-parser limit 都会触发。文件上传报 413 是分片上传的直接动因之一。 |
| 414 | URI Too Long | URL 过长,GET 传大参数会踩。 |
| 415 | Unsupported Media Type | Content-Type 服务端不支持,比如后端只收 application/json 你发了 text/plain。 |
| 416 | Range Not Satisfiable | Range 请求的范围越界。 |
| 422 | Unprocessable Content | 语法正确但语义校验失败(如邮箱格式不对)。很多框架(Laravel、NestJS class-validator)用它区别于 400。 |
| 429 | Too Many Requests | 限流。响应通常带 Retry-After: 60(秒数或日期)。前端应做指数退避重试,而不是立即重试。 |
| 431 | Request Header Fields Too Large | 请求头过大,Cookie 塞太多会触发。 |
5xx 服务端错误
| 码 | 名称 | 面试要点 |
|---|---|---|
| 500 | Internal Server Error | 服务端代码抛了未捕获异常,兜底错误。说明请求到达了业务服务。 |
| 501 | Not Implemented | 服务端不支持该方法(如不认识的 HTTP method)。 |
| 502 | Bad Gateway | 网关/反向代理拿到了上游的无效响应——上游进程挂了、端口没监听、返回了非法报文。典型场景:Nginx 后面的 Node 服务崩了。 |
| 503 | Service Unavailable | 服务暂时不可用:主动过载保护、限流熔断、维护中。可带 Retry-After。它是服务本身在说话,而不是网关判断。 |
| 504 | Gateway Timeout | 网关等上游超时。上游还活着但太慢(慢 SQL、死循环、下游依赖阻塞)。 |
| 505 | HTTP Version Not Supported | 协议版本不支持。 |
| 507 | Insufficient Storage | 存储空间不足(WebDAV 扩展)。 |
502 / 503 / 504 排障口诀:502 = 上游死了或答非所问;504 = 上游太慢没答完;503 = 我自己主动不干了。前两个是网关视角,第三个是服务视角。
【追问 & 加分点】
- "304 属于重定向吗?" —— 分类上归 3xx,但语义是缓存协商,不发生 URL 跳转。304 的前提是强缓存已失效(
Cache-Control: max-age过期或no-cache),才会发协商请求。Cache-Control: no-store则连协商都不做。 - "401 和 403 你的拦截器怎么处理?" —— 401 走 refresh token 静默续期,失败再跳登录;403 直接跳无权限页并上报(可能是越权探测)。二者绝不能混在一起处理,否则会出现"无权限被反复踢去登录"的死循环。
- "429 前端怎么优雅处理?" —— 读
Retry-After;没有就用指数退避 + 抖动(delay = base * 2^n + random),并且只对幂等请求自动重试,POST 类需要幂等键(Idempotency-Key)保护。 - "状态码和业务码怎么配合?" —— 主流做法是 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。
【展开】
三个维度的对比
| 维度 | 301 | 302 | 307 | 308 |
|---|---|---|---|---|
| 语义 | 永久 | 临时 | 临时 | 永久 |
| 默认可缓存 | 是(默认可被缓存) | 否 | 否 | 是 |
| 保留请求方法 | 否(浏览器普遍改写 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,注意这是浏览器伪造的,不是服务端返回的)。
【追问 & 加分点】
- "301 之后旧域名能不能下线?" —— 短期不能。搜索引擎需要数月完成权重迁移,且用户书签、外链仍指向旧域名。业界惯例是 301 保留至少 1 年再评估。
- "怎么判断线上是 301 还是 302?" —— DevTools Network 勾选 Preserve log 看首条响应的 Status;或
curl -I https://x.com;注意浏览器已缓存 301 后不会再发请求,需要 Disable cache 或用无痕窗口验证。 - "307 Internal Redirect 是什么?" —— 这不是服务端返回的,是浏览器内部产生的:HSTS 升级、Service Worker 拦截、
upgrade-insecure-requestsCSP 指令都会显示为它。 - "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 的差异
| 维度 | HTTP | HTTPS |
|---|---|---|
| 传输 | 明文 | TLS 加密 |
| 默认端口 | 80 | 443 |
| 证书 | 无 | 需 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 证书)客户端验证要做:
- 域名匹配:访问的 host 在证书 SAN 列表里(CN 已废弃)
- 有效期:当前时间在 notBefore / notAfter 之间
- 签名链:逐级用上级公钥验签,一直追到本地信任的根
- 吊销状态:CRL / OCSP,现代用 OCSP Stapling(服务端把 OCSP 响应缓存后随握手一起下发,避免客户端额外请求 CA、也避免隐私泄露)
- 用途:证书的扩展字段允许用于服务器身份认证
任何一步失败 → 浏览器拦截页(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)必须手动在系统里安装它的根证书——本质上是让用户主动信任一个中间人。
【追问 & 加分点】
- "HTTPS 就绝对安全了吗?" —— 不。TLS 只保护传输通道。XSS、CSRF、越权、SQL 注入、供应链投毒一个都挡不住;SNI 在 TLS 1.3 之前是明文的(ECH 才解决),DNS 查询、IP、流量大小仍会泄露访问目标;客户端被装了根证书就能被合法中间人解密。
- "能不能减少 TLS 握手开销?" —— Session ID / Session Ticket 会话复用(1-RTT)、TLS 1.3 的 0-RTT、OCSP Stapling 减少往返、HTTP/2 多路复用让一条 TLS 连接承载所有请求、
<link rel="preconnect">提前完成 DNS+TCP+TLS。 - "数字签名和加密的方向为什么是反的?" —— 加密用对方的公钥(只有对方私钥能解,保证机密性);签名用自己的私钥(任何人用你的公钥可验,保证不可否认与完整性)。实际签名是对内容的哈希做私钥运算,而不是对全文。
- "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 全面对比
| 维度 | SSE | WebSocket |
|---|---|---|
| 方向 | 单向:服务端 → 客户端 | 全双工 |
| 协议 | 标准 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
- 通信形态天然匹配:一次提问对应一次流式回答,客户端在生成期间无需上行数据。WebSocket 的全双工能力完全用不上,属于杀鸡用牛刀。
- 复用全部 HTTP 基建:企业里网关的鉴权、限流、灰度、WAF、日志、APM 链路追踪都是围绕 HTTP 请求做的。SSE 就是一个响应慢一点的普通 GET/POST,零改造;WebSocket 要让整条链路都支持 Upgrade,还得解决 LB 会话保持、连接数监控、优雅重启时的连接迁移。
- 中断语义清晰:
AbortController.abort()直接断流,服务端req.on('close')感知后停止调用大模型、及时释放 GPU 配额。这正是"停止生成"按钮的实现。 - 协议已成事实标准:OpenAI、Anthropic 等主流大模型 API 的流式接口都是 SSE 格式(
data: {...}\n\n+data: [DONE]),后端做透传即可,无需协议转换。 - 无状态、易水平扩容:一次请求打到任意一台机器都行,不需要像 WebSocket 那样维护"用户 ↔ 具体机器"的映射和跨机推送(Redis pub/sub 广播)。
- 成本:一个后端接口 + 一个前端解析函数,一天就能上线;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;
}【追问 & 加分点】
- "SSE 怎么做鉴权?" —— 用 fetch 方案直接带
Authorization头;若必须用EventSource,只能靠 Cookie(跨域需withCredentials+ 服务端Access-Control-Allow-Credentials: true且 Origin 不能是*),或用短时效一次性 ticket 放 query(别把长期 token 放 URL,会进网关日志和 Referer)。 - "连接被中间代理掐断怎么办?" —— 心跳注释帧(
: ping\n\n)+ 服务端retry字段调重连间隔 + 客户端自己维护Last-Event-ID做幂等续传;后端要保证按 ID 续传时不重复计费、不重复扣 token。 - "HTTP/1.1 的 6 连接限制怎么绕?" —— 上 HTTP/2(多路复用,一条 TCP 承载全部流);或者多个 tab 共用一条连接,用
SharedWorker+BroadcastChannel把消息广播给各个页面。 - "服务端怎么防止 SSE 拖垮进程?" —— 限制单用户并发流数、设置总时长上限、
req.on('close')里务必清理定时器与上游请求(否则大模型调用还在跑、GPU 还在计费)、Node 下注意res.write返回false时的背压处理。 - "和轮询/长轮询比呢?" —— 短轮询浪费请求、延迟高;长轮询(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 预检请求
同时满足以下条件才是简单请求(不发预检,直接发真实请求):
- 方法是
GET/HEAD/POST之一 - 头部只包含安全列表字段:
Accept、Accept-Language、Content-Language、Content-Type(且值受限)、Range(受限) Content-Type只能是text/plain、multipart/form-data、application/x-www-form-urlencoded- 请求中没有注册事件监听的
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/StrictCookie、CSRF Token、校验Origin/Referer、敏感操作用非简单请求(强制预检)。 - XSS:一旦攻击脚本在你的源上执行,同源策略站在攻击者那边——它是同源的,能读所有数据。防御靠输出转义、CSP、
HttpOnlyCookie、避免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 依赖这一点。
【追问 & 加分点】
- "跨域请求到底发出去了吗?" —— 简单请求发出去了,服务端也执行了(所以跨域的 GET 副作用是真实发生的),只是响应被浏览器拦截不给 JS。非简单请求因为预检失败,真实请求不会发。
- "预检请求太多影响性能怎么办?" —— 设
Access-Control-Max-Age缓存预检结果;或者干脆用同源反向代理消除跨域;注意 Chrome 对 Max-Age 的上限是 7200 秒。 - "为什么带 Cookie 时
Allow-Origin不能是*?" —— 规范强制。因为*+ 凭证等于允许任意站点携带用户身份读取数据,风险不可控。必须回显具体 Origin,并配Vary: Origin防止 CDN 串缓存。 - "
SameSite对跨域请求的影响?" —— Chrome 默认SameSite=Lax:跨站请求只有顶级导航的 GET 才带 Cookie,XHR/fetch/iframe/img 都不带。所以跨站场景的 Cookie 鉴权必须显式设SameSite=None; Secure(且必须 HTTPS)。这也是很多"本地能跑、上线跨域丢登录态"问题的根因。 - "跨域和跨站的区别?" —— 跨域看协议+域名+端口全等;跨站看 eTLD+1(可注册域)。
a.example.com和b.example.com是跨域但同站,所以SameSiteCookie 在它们之间是共享的。
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 对比表
| 维度 | preload | prefetch |
|---|---|---|
| 意图 | 当前页确定要用 | 未来页可能要用 |
| 优先级 | 按 as 决定,通常 High/Highest | Lowest |
| 时机 | 尽快,与解析并行 | 浏览器空闲时 |
| 缓存归属 | 当前导航的内存缓存 | 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 列(最直接)
- 打开 DevTools → Network,取消勾选 Disable cache(勾了就全部失效,这是最常见的"验证失败"原因)。
- 加载首页,找到那条 prefetch 请求:Type 通常显示为对应类型,Priority 列显示
Lowest(Priority 列默认隐藏,右键表头勾选出来)。 - 触发跳转到目标路由。
- 看目标资源那条请求的 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 不同)
- 用户处于省流量模式 / 弱网,浏览器主动跳过了
【追问 & 加分点】
- "preload 用错了有什么代价?" —— 它会抢占高优先级带宽。给非关键资源 preload,等于把首屏关键资源挤到后面,LCP 反而变差。所以 preload 应该"少而准",通常一个页面不超过 3~5 个。
- "prefetch 会不会伤害用户?" —— 会浪费移动流量。应判断
navigator.connection.saveData与effectiveType,并配合意图信号(鼠标 hover 200ms、链接进入视口、IntersectionObserver)做按需 prefetch,而不是无脑全预取。 - "HTTP/2 Server Push 和 preload 什么关系?" —— Server Push 曾用
Link: rel=preload头触发,但因为服务端不知道客户端缓存状态,经常推送冗余资源,Chrome 106 已移除对它的支持。现在的替代是 103 Early Hints:服务端在正式响应前先回一个 103 带Link: rel=preload,让浏览器在后端还在渲染时就开始下载资源。 - "两者能不能一起用?" —— 可以,各司其职:当前页的字体/首屏图用 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 分钟被踢出去一次,体验灾难 |
| 双 Token | Access 短命限制被盗窗口;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=604800Path=/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 相互独立)几个关键设计点:
state参数必须校验:前端生成随机串存 sessionStorage,回调时比对,防止 CSRF 式的授权码注入。- PKCE 必须开启:纯前端应用无法安全保存 client_secret,PKCE(
code_verifier/code_challenge)保证即使授权码在回跳 URL 中被截获,没有 verifier 也换不到 token。 - 别用隐式模式(implicit):直接把 token 放 URL fragment,会泄漏到浏览器历史、Referer、日志。OAuth 2.1 已废弃。
- 单点登出(SLO):比登录难得多。方案有 ① 后端通道登出(SSO 主动调各 SP 的登出回调接口,最可靠)② 前端通道登出(用隐藏 iframe 逐个访问各 SP 的登出 URL,受第三方 Cookie 限制,正在失效)③ Access Token 短命 + 每次刷新时校验 SSO 会话是否存活(最简单实用:SSO 会话没了,各系统最多 30 分钟内自然失效)。
- 同主域下的简化方案:如果所有子系统都在
*.example.com下,可以把 SSO 会话 Cookie 的 Domain 设为.example.com,天然共享。但跨主域(example.com和another.com)只能走上面的重定向流程。 - 第三方 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 层面做,前端只做展示 | 中后台必备补充 |
【追问 & 加分点】
- "JWT 怎么主动吊销?" —— JWT 无状态是优点也是短板。方案:① Access 短命 + 吊销 Refresh,最多 30 分钟自然失效(成本最低,最常用)② 维护 jti 黑名单存 Redis,TTL 设为 token 剩余有效期 ③ 在 payload 放
sid,服务端维护会话表,登出时删会话——但这就退化成有状态了,要权衡。 - "多标签页怎么同步登录态?" ——
BroadcastChannel或storage事件广播 token 更新和登出事件;刷新用单例锁(可用navigator.locks.request跨 tab 加锁)避免多 tab 同时刷新导致 refresh 轮换互相作废。 - "algorithm 相关的安全坑?" —— 服务端必须固定校验算法,绝不能信任 token header 里的
alg。经典漏洞:攻击者把alg改成none让服务端跳过验签,或把 RS256 改成 HS256 用公钥当 HMAC 密钥伪造签名。 - "权限数据什么时候拉?" —— 登录后拉一次存 store,路由守卫里判断是否已加载。要考虑权限变更后的实时性:可以在每次刷新 token 时带回权限版本号,版本变了就强制重新拉取并重建路由。
- "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。面试提到这一点通常是加分项,说明你了解真实工程而不只是背造轮子代码。
【追问 & 加分点】
- "分片大小怎么定?" —— 权衡点:太小则请求数暴增(HTTP 头开销 + 服务端 IO 压力),太大则单片失败重传代价高、且可能撞上网关 body 限制。经验值 2~10MB;更好的做法是根据实测网速动态调整(弱网用 1MB,千兆用 10MB),OSS 的 Multipart 也要求分片不小于 5MB(最后一片除外)。
- "为什么 hash 必须放 Worker?" —— 读文件 + MD5 是 CPU 密集操作,主线程执行会导致页面完全无响应(连进度条都不动)。Worker 里跑则主线程只负责渲染进度。也可用
requestIdleCallback分时切片,但 Worker 更彻底。 - "怎么防止 hash 碰撞导致秒传误判?" —— 全量 MD5 碰撞概率极低但理论存在;抽样 hash 风险更高。稳妥做法是
hash + 文件大小做联合键,敏感业务(如网盘)再补一次服务端二次校验,或直接用 SHA-256。 - "上传中刷新页面怎么办?" —— 把
hash → 已上传分片索引记在 IndexedDB,或者干脆每次都调 check 接口向服务端问(服务端是唯一可信来源,更简单也更准)。文件对象本身无法持久化,用户需要重新选择同一个文件——这是浏览器安全限制,无解,只能靠 hash 匹配上原来的进度。 - "进度条为什么会跳?" —— 若用
xhr.upload.onprogress统计,多个分片并发时进度是多个流的叠加,且浏览器缓冲会让进度先冲到 100% 再等响应。更稳的做法是按"完成的分片数 × 分片大小"计算,牺牲平滑度换准确性;或者两者结合,用已完成分片做基线 + 进行中分片的实时字节做增量。
反向链接(0)
还没有笔记链接到这里。在 Obsidian 里用 [[笔记名]] 引用它,这里就会出现,关系图谱上也会多出一条连线。