1. 先把这四个状态码放回它们各自的系列里
这道题在面试里出现的频率高得有点不正常。面试官问“301、302、303、304 的区别”,很多人第一反应是背四条定义,背完就没了。但真正做过线上迁移、做过接口跳转、被浏览器缓存坑过的人,会本能地从“浏览器拿到这个码之后干了什么”这个角度回答。差别就在这儿。
HTTP 状态码 301、302、303、304 都属于 3xx 段,也就是重定向与缓存协商这一大类。但它们的语义、对请求方法的处理、可缓存性、对搜索引擎的影响,完全是四套逻辑。301 和 302 是“地址搬家”的两种说法,303 是把 POST 强制转成 GET 的专用工具,304 严格来说根本不是重定向,它是协商缓存命中后服务端给的一个“你别下载了,你本地那份还能用”的确认信号。把它和跳转混在一起讲,是面试里最常见的翻车点。
这篇内容适合两类人看:一类是准备前端面试、需要把这道题答出层次的人;另一类是日常写接口、配 Nginx、调 axios,遇到过“明明改了跳转地址但浏览器还在往老地址跑”这类怪事的人。我会从语义定义一直聊到代码验证、线上排查和面试追问,力争让看完的人不光是知道这四个数字,而是知道什么场景下该用哪个、用错了会付出什么代价。
1.1 3xx 这段到底在表达什么
RFC 把 3xx 定义为“重定向”类,但这个描述其实偏窄。更准确的理解是:3xx 表示“你这个请求我没有直接用 200 回应,但我要额外告诉你一些关于目标资源位置或缓存状态的信息”。这里的关键在于,3xx 响应往往需要客户端再做一个动作:要么去另一个地址取东西,要么确认本地副本有效。
所以 3xx 天然带有“客户端参与”的属性。服务端只负责发出指令,真正执行的是浏览器、curl、axios 或者 Node 的 http 模块。而不同的客户端,对同一条指令的执行细节并不完全一致,尤其是历史上 HTTP/1.0 和 HTTP/1.1 对 302 的处理分歧,直接催生了 303 和 307 这两个“补丁型”状态码。理解这个演化脉络,比死记四个定义有用得多。
还有一个容易被忽略的点:3xx 的可缓存性差异巨大。301 默认是可缓存的,浏览器甚至会把它写进本地缓存,你在服务端把配置改了,老用户依然会被跳到旧地址;302 默认不可缓存,只有带上明确的 Cache-Control 或 Expires 才会被缓存;304 本身就是缓存机制的产物。这一条决定了这四个码在“出问题之后有多难修”这个维度上,排名完全不同。
1.2 为什么前端面试偏爱吃这道题
面试官问这道题,真正想考的往往不是记忆,而是几件事的串联能力。第一,你知不知道 HTTP 语义和浏览器行为的区别;第二,你有没有踩过缓存的坑,能不能说出“301 缓存清不掉”这种只有实战才会遇到的现象;第三,你能不能把状态码和实际业务场景对上,比如登录后跳转、老域名迁移、表单重复提交、静态资源版本更新。
我在实际带人的过程中发现,能把这四个码讲清楚的人,通常对缓存体系、请求方法语义、浏览器调试面板都比较熟;而只会背定义的人,一追问“304 的响应里有 body 吗”“fetch 默认会不会跟重定向”就开始卡壳。所以这道题本质上是个探针,探的是你对 HTTP 这一层的理解深度。后面的内容我会按“语义、方法、缓存、场景”这条线展开,把这个探针能挖到的东西尽量挖干净。
2. 301 与 302:一条“永久”与“临时”的分界线
先说不严谨但好记的一句话版本:301 是“我以后就住这儿了,别再找老地址”,302 是“我这次临时在这儿,下回还去老地址”。这句话抓住了核心,但漏掉了两个要害:请求方法会不会变,以及这个响应会不会被浏览器记住。这两点才是实际开发中真正会咬人的地方。
2.1 301 Moved Permanently:发出去就很难收回
301 的全称是 Moved Permanently,语义是目标资源已经被永久性地迁移到了 Location 头指定的新地址。客户端以后再遇到这个老地址,应该直接用新的。浏览器对这个的落实非常彻底:它会把这条映射关系缓存下来,后续请求根本不会再打到服务端,直接从本地缓存里取出跳转规则执行。
这个特性在域名迁移、站点改版时是巨大的优势,因为老用户访问老地址会以最小的代价到达新地址,同时搜索引擎也会把老 URL 的权重传递到新 URL。但反过来,它也是事故高发区。我见过一个模拟项目,测试环境把一个接口用 301 指到了一个临时地址,上线后改回正确配置,结果测试同事的浏览器一直往那个已经不存在的地址跑,换了无痕窗口才恢复正常。原因就是他之前访问过,301 被浏览器记住了。
这里给一个实操上的提醒:涉及 301 的配置变更,验证时一定要用无痕窗口,或者手动清一次该站点的缓存数据。Chrome 的 DevTools 里勾选 Network 面板的 Disable cache 只对强缓存类的资源请求生效,对 301 这种导航级跳转的缓存不一定管用,别指望它。
注意:很多团队在做线上跳转配置前,会先在预发环境用 302 验证一整轮,确认链路没问题再换成 301。这个习惯能省掉大量回滚成本。
2.2 302 Found:名字改过、语义吵过的一个码
302 现在的正式名字是 Found,早期的名字是 Moved Temporarily。改名这件事本身就说明它历史上有多混乱。最经典的争议是:客户端收到 302 之后,原来那个 POST 请求该不该原样重发到新地址?
HTTP/1.0 时代,大量浏览器和客户端的实现是把 POST 改成 GET 再去访问 Location,这在表单提交后跳转到结果页的场景下非常好用,因为能避免用户刷新时重复提交。但这种“擅自改方法”的行为并不符合规范原意,规范说的是方法不应改变。于是后面才有了 303 和 307 来把这件事说清楚:303 明确说“请用 GET 去访问新地址”,307 明确说“请保持原方法和请求体”。
所以你在面试里如果只答“302 是临时重定向”,大概率会被追问一句“那 302 和 303 有什么区别”。这个追问的答案就在方法处理上。另外补充一句,302 默认不可缓存,这点和 301 正好相反,所以它在“改错了容易修”这个维度上比 301 友好得多。
2.3 到底该选哪个:看这个 URL 还会不会回来
判断标准其实很朴素:这个老地址以后还有没有可能重新承担原来的职责。如果是公司改名、站点整体搬迁、老接口永久下线,用 301。如果是活动页面临时换地址、灰度期间把部分流量导到新服务、A/B 测试期间切换入口,用 302。
还有一个实践中的折中方案:不确定的时候先用 302 跑一段时间,观察访问量和业务指标,确认新地址稳定承接之后再改成 301。这样即便中间出问题,改回来的代价也很小,因为 302 不会被浏览器长期记住。这个思路在模拟项目里验证过很多次,尤其是那种“迁移后还要保留老地址入口”的场景,先 302 后 301 几乎是标准动作。
另外提一句 SEO 层面的事:301 会把老 URL 的权重传递过去,搜索引擎也会逐步把索引换成新地址;302 则被认为只是临时状态,老 URL 仍然保留在索引里。如果你的需求是“让搜索引擎尽快更新”,那 301 才是对的;如果只是短期导流,用 301 反而会造成索引混乱。
3. 303 与 304:两个经常被误归类的主角
这两个码被放在同一道题里问,本身就有点“陷阱”的味道,因为它们的定位完全不同。303 确实是重定向,但它专治一种情况;304 压根不是重定向,它属于缓存协商。把它们并列,考的是你能不能识别出这种分类上的错位。
3.1 303 See Other:专门用来把 POST 摁成 GET
303 的语义非常明确:请客户端用 GET 方法去访问 Location 指定的地址,不管原请求是什么方法。它有一个很经典的应用模式,叫 PRG,也就是 Post/Redirect/Get。用户提交表单,服务端处理完之后返回 303,浏览器自动用 GET 去访问结果页。这样用户再刷新页面,刷的是 GET 请求,不会重复提交表单。
这个模式在实际项目里价值很高。我处理过一个订单提交的模拟场景,早期没做 PRG,用户网络慢的时候习惯性点两下提交按钮,直接产生两条记录。后来在提交接口上改成“处理完返回 303 跳到订单详情页”,重复提交的问题基本消失,因为浏览器地址栏已经变成详情页的 GET 地址了。
顺带把这组补丁讲全:303 是“换 GET”,307 是“保持原方法和请求体”。307 在需要把整个 POST 请求原封不动转发到新地址时使用,比如某些网关层的转发逻辑。而 308 则是 301 的“保持方法”版本。这四个码(302、303、307、308)经常被放在一起对比,把它们按“是否保持方法”和“是否永久”两个维度列成一张表,记忆负担会小很多。
| 状态码 | 语义 | 请求方法是否保持 | 是否可缓存 |
|---|---|---|---|
| 301 | 永久迁移 | 规范上应保持,实际有分歧 | 默认可缓存 |
| 302 | 临时找到 | 历史上常被改成 GET | 默认不缓存 |
| 303 | 参见其他 | 强制改为 GET | 一般可缓存 |
| 307 | 临时重定向 | 强制保持原方法和请求体 | 默认不缓存 |
| 308 | 永久重定向 | 强制保持原方法和请求体 | 默认可缓存 |
3.2 304 Not Modified:它不是跳转,是缓存确认
304 是这几个码里唯一一个“不带响应体”的。它的产生流程是:浏览器本地有一份缓存副本,但不确定还有没有过期,于是带上一组条件请求头发给服务端,比如 If-None-Match 带上之前收到的 ETag,或者 If-Modified-Since 带上之前收到的 Last-Modified。服务端一比对,发现资源没变,就回一个 304,意思是“你本地那份继续用”。
所以 304 的前提一定是条件请求。你用一个普通的 GET,不带任何条件头,服务端是不会回 304 的。这一点在面试里经常被追问:“304 是怎么触发的?”标准答案是客户端发起了带条件的请求,服务端校验后判定资源未变更。
再往下追一层,就会聊到 ETag 和 Last-Modified 的优先级。ETag 是内容指纹,精度更高;Last-Modified 是时间戳,精度只能到秒,而且存在“内容没变但修改时间变了”的误判。规范上建议 ETag 优先,实际实现里大多数服务端也是这么做的。如果你的项目里出现“明明文件内容改了,浏览器还拿旧的”,先去看是不是 Last-Modified 精度不够导致的误判,或者 CDN 层把 ETag 吃掉了。
3.3 304 报文里的几个硬性约束
304 响应不能带消息体,这一点必须记住。它的头部里通常会包含 Cache-Control、ETag、Date、Vary 等字段,用来让客户端更新本地缓存条目的元信息。有些服务端框架在实现 304 时处理不干净,还硬塞了一个空 body 或者错误的 Content-Length,导致某些客户端解析异常。
还有一个容易忽略的细节:304 只能由 GET 或 HEAD 触发。你用 POST 去请求一个资源,即便带了条件头,也不应该得到 304。这个边界在写中间件的时候要注意,别把条件判断逻辑套在所有方法上。
在实际调试里,Chrome DevTools 的 Network 面板会显示 304,但响应体那一栏是空的,Size 一栏会显示实际传输的字节数,通常很小。如果你想区分“浏览器根本没发请求,直接用的强缓存”和“发了请求、服务端回了 304”,看 Size 那一列就能分辨:前者显示的是 from disk cache 或 from memory cache,后者显示的是一个具体的小数字。
4. 动手验证:把四个状态码跑一遍
光看不练记不牢。这一段我搭一个最小实验环境,用原生 Node 把四个状态码都造出来,然后分别用浏览器、curl 和前端代码去观察,把“理论”和“实际表现”对上。
4.1 用几十行原生 Node 代码铺一个实验环境
不引任何框架,直接用 http 模块,避免依赖干扰。
const http = require('http'); const server = http.createServer((req, res) => { const url = req.url.split('?')[0]; if (url === '/old-permanent') { // 301:永久迁移 res.writeHead(301, { Location: '/target' }); res.end(); return; } if (url === '/old-temp') { // 302:临时跳转 res.writeHead(302, { Location: '/target' }); res.end(); return; } if (url === '/submit') { // 303:处理后转 GET res.writeHead(303, { Location: '/result' }); res.end(); return; } if (url === '/cached-asset') { const etag = '"v1-abc123"'; const clientEtag = req.headers['if-none-match']; if (clientEtag === etag) { // 304:协商缓存命中,只回头部,不带 body res.writeHead(304, { ETag: etag, 'Cache-Control': 'no-cache' }); res.end(); return; } res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', ETag: etag, 'Cache-Control': 'no-cache', }); res.end('资源的完整内容'); return; } if (url === '/result' || url === '/target') { res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('到达目标页'); return; } res.writeHead(404); res.end('not found'); }); server.listen(8080, () => { console.log('http://localhost:8080'); });跑起来之后,你依次访问/old-permanent、/old-temp、/submit、/cached-asset,把 DevTools 的 Network 面板打开,并勾上 Preserve log(保留日志)。这一步很关键,因为跳转过程中页面会刷新,不勾这个选项,前一条 301 记录会被清掉,你就只能看到最后那个 200。
访问/cached-asset的时候要刷新两次才能看到 304,第一次是 200 并把 ETag 带回来,第二次浏览器带上 If-None-Match,服务端比对命中,返回 304。这里我特意用了Cache-Control: no-cache,它的意思是“可以缓存,但每次使用前必须跟服务端确认”,也就是强制走协商缓存,这样便于观察 304。如果写成no-store,那是完全不缓存,你永远看不到 304。
4.2 命令行怎么观察重定向链路
curl 是最直接的观察工具。默认情况下 curl 不会自动跟随重定向,你只会看到第一个响应的状态码和 Location 头,这反而方便你确认服务端到底发了什么。
curl -i http://localhost:8080/old-permanent你会看到HTTP/1.1 301 Moved Permanently和一行Location: /target。想看完整链路就加-L:
curl -iL http://localhost:8080/old-permanent加上-L之后,curl 会跟着跳,最终输出目标页的内容。这里有个观察技巧:加-v参数能看到每一步的请求方法。你拿/submit试一下,用-X POST发出去,再配合-v看第二步用的是 GET 还是 POST,就能直观感受到 303 的“强制改 GET”到底是怎么回事。
curl -v -L -X POST http://localhost:8080/submit4.3 fetch 的 redirect 选项:前端拿到状态码的真实处境
前端代码里有个坑必须说清楚:fetch默认的redirect值是follow,也就是说浏览器会自动帮你把跳转走完,你的response.status拿到的是最终那个响应的状态码,通常是 200。你根本看不到中间的 301 或 302。
// 默认行为:自动跟随,拿不到中间状态码 const res = await fetch('/old-permanent'); console.log(res.status); // 200 console.log(res.redirected); // true console.log(res.url); // http://localhost:8080/targetres.redirected这个布尔值能告诉你“发生过跳转”,但具体是 301 还是 302,它不告诉你。想拿到原始状态码,得把redirect设成manual:
const res = await fetch('/old-permanent', { redirect: 'manual' }); console.log(res.status); // 0 console.log(res.type); // opaqueredirect这里会碰到一个跨域规范带来的限制:redirect: 'manual'返回的是一个 opaque-redirect 类型的响应,status是 0,headers也读不到,你只能知道“发生了跳转”这件事本身。这是浏览器的安全策略,不是 bug。如果你真的需要在业务代码里判断重定向类型,通常的做法是让服务端在响应头里额外带一个自定义字段,或者干脆用 XHR 配合服务端 CORS 配置来处理。
提示:如果你在项目里遇到“接口明明返回 302,但 axios 里拿到的是 200”,先别怀疑后端,去看客户端是不是默认跟随了重定向。axios 在浏览器环境下跟随行为由浏览器决定,在 Node 环境下由
maxRedirects配置控制。
5. 面试现场的答题结构和高频追问
前面讲的都是“知识”,这一段讲“怎么讲”。同一套知识,组织方式不同,面试官的评价差别很大。我的建议是按固定四步走:语义、方法、缓存、场景。每一步一句话,先把框架立起来,再等追问往深处挖。
5.1 一分钟版本:四步把框架立住
第一步说语义。301 是永久迁移,302 是临时跳转,303 是让客户端改用 GET 访问新地址,304 不是跳转而是协商缓存命中。
第二步说方法。301 和 302 在规范上不改变请求方法,但历史实现里 302 常被改成 GET,正因为这个歧义才补出了 303(强制 GET)和 307(强制保持)。304 只由 GET 或 HEAD 触发。
第三步说缓存。301 默认可缓存且浏览器会长期记住,302 默认不缓存,303 一般可缓存,304 本身就是缓存机制的产物且不带响应体。
第四步说场景。域名迁移用 301,临时导流用 302,表单提交后防重复用 303,静态资源版本控制配合 ETag 用 304。
这一分钟讲完,基本盘就稳了。接下来无论面试官从哪个角度切进来,你都有地方接。
5.2 追问一:301 的缓存怎么清
这个问题几乎必问,因为它直接检验你有没有被坑过。标准回答分三层:第一,浏览器把 301 当作强缓存处理,存在本地,服务端改配置对它无效;第二,用户侧可以通过清除该站点的浏览数据来清除,无痕窗口也是一个验证手段;第三,开发者侧如果已经上线了错误的 301,只能靠引导用户清缓存,或者在被跳转到的目标地址上做兼容处理,没有服务端一键撤销的办法。
这里可以补一句实操建议:正式启用 301 之前,先在预发环境用 302 跑通全链路,再切 301,把风险前置。这个习惯说出来,面试官会知道你是有线上意识的。
5.3 追问二:307 和 308 是干什么的
这两个是 302 和 301 的“保持方法”版本。302 因为历史上会改方法,语义不清晰,所以有了 307 来明确“保持原方法和请求体”;301 同理,308 是它的严格版本。回答时可以把 301、302、303、307、308 这五个按“是否保持方法”和“是否永久”两列排开,说清楚它们的定位差异,比单个背要清楚得多。
顺带说一句,307 在需要用户确认的场景下有额外行为,某些客户端在跨域且带敏感信息时会弹确认框。这个细节不是必须答,但提一句显得你读过规范。
5.4 追问三:强缓存和协商缓存怎么分
这是从 304 延伸出去的问题。强缓存看Cache-Control的max-age和Expires,命中时浏览器根本不发请求,DevTools 里显示 from memory cache 或 from disk cache。协商缓存看ETag配If-None-Match、Last-Modified配If-Modified-Since,命中时发请求、服务端回 304。
两者的关键差别在于“要不要跟服务端确认”。强缓存是本地直接判定,协商缓存是问一句再决定。回答时最好把“200 from cache”和“304”这两个在 DevTools 里的表现也说出来,能体现你确实动手调过。
5.5 答错率最高的三个点
第一个是把 304 归到重定向里。它不带 Location,不改变资源位置,只是告诉客户端“本地那份还能用”。
第二个是说 301 一定保持 POST 方法。规范原文的措辞是“用户代理可以改变请求方法”,规范本身留了口子,实践中不同客户端处理也不完全一致。严谨的说法是“301 在方法处理上存在历史分歧,需要严格保持时用 308”。
第三个是把 302 说成“一定不缓存”。准确说法是默认不可缓存,但如果服务端显式设置了Cache-Control或Expires,它也可以被缓存。规范里对可缓存性的描述是“只有明确指定时才可缓存”,别说成绝对。
6. 线上排查实录与速查清单
这一段讲几个有味道的坑,都是围绕这几个状态码发生的。我把现象、原因、处理方式整理出来,遇到类似情况可以直接对号入座。
6.1 三个真实味道的坑
第一个坑是 301 改不回来。某个模拟项目做老接口迁移,图省事直接上了 301。后来发现新地址的参数协议不兼容,需要改回原地址,结果测试同学的浏览器一直往新地址跑,接口全部报错。最后的处理方式是让测试同学清一次该站点的浏览数据,同时在文档里补了一条规范:涉及 301 的配置变更,必须先用 302 灰度验证。
第二个坑是 302 循环。某个模拟网关在做灰度时,把 A 路径 302 到 B 路径,B 路径的配置里又写了一条兜底规则指回 A,浏览器在两个地址之间来回跳,最后报 “重定向次数过多”。这类问题的排查方式是看 DevTools 的 Network 面板,如果看到一串连续的 302 且 URL 在循环,基本就能定位。根治办法是给重定向链路加一个跳数上限,或者在配置层保证跳转关系是单向的。
第三个坑是 304 和 CDN 打架。某个模拟项目上线新版本静态资源,文件名没变,只改了内容。结果一部分用户拿到的还是旧文件。查下来是 CDN 层缓存了旧的 ETag,回源时又因为If-None-Match对不上做了某些处理,导致链路里 304 的判断出现偏差。最后的解决方案是给静态资源加内容哈希做文件名,让“改内容”必然等于“改 URL”,从根上绕开协商缓存的复杂性。这个思路在前端构建里已经是标配了。
6.2 状态码速查表
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| 改了跳转配置,浏览器还往老地址跑 | 301 被浏览器缓存 | 换无痕窗口验证,引导用户清站点数据 |
| 接口拿到的 status 一直是 200 | 客户端自动跟随了重定向 | 检查 fetch 的 redirect、axios 的 maxRedirects |
| 页面报重定向次数过多 | 跳转链路形成环 | Network 面板看连续 3xx 的 URL 序列 |
| 文件内容更新了但用户还是旧版 | 强缓存未过期或 ETag 判断异常 | 看 Cache-Control,给资源加内容哈希 |
| 表单刷新后重复提交 | 未使用 PRG 模式 | 提交后返回 303 跳转到结果页 |
| 静态资源每次都要回源 | 缓存头配置成 no-store | 改为 no-cache 走协商缓存,或设合理 max-age |
最后分享一个我自己的习惯:凡是涉及跳转的配置变更,我都会在提交前问自己两个问题——这个地址以后还会回来吗?如果出问题,我能在多长时间内让用户恢复正常?第一个问题决定用 301 还是 302,第二个问题决定我要不要先灰度。这两个问题问完,绝大多数选型上的犹豫就消失了。至于 304,它其实提醒的是另一件事:缓存策略是要设计的,不是默认就对的。一个没配 Cache-Control、没配 ETag 的接口,性能和体验都会打折;反过来,配得太激进,发版时又要追着用户清缓存。这个平衡点,只能在具体项目的访问量和更新频率里慢慢找。