前几天帮人看一个挺邪门的线上问题:头像换新了,新注册用户看到的都是新图,一批老用户刷新十几次还是旧图,只有强制刷新才好。折腾了两个多小时,最后定位到半年前一次域名调整时留下的那条 301。这件事之后我对 301、302、303、304 这几个状态码的态度彻底变了——光背"永久重定向、临时重定向、查看其他位置、未修改"这四句话,在真实项目里几乎等于没学。前端虽然很少主动写这些码,但只要接口网关、CDN、Nginx 里有一层配了重定向,浏览器行为就全压在你这边,判断错一次就是几个小时起步。
这篇就按我自己踩过的路来捋:这四个码到底谁在"记"、记多久、对请求方法做了什么手脚、在 fetch 和 axios 里表现成什么样,以及面试被追问时怎么答得像真用过而不是刚背完。适合几种人看——准备面试但对这几个码只有模糊印象的;手上项目有重定向配置、想搞清楚副作用的前端;还有做接口联调时被 304 搞懵过的后端同学顺手扫一眼。
1. 控制台里的那几条 301 和 302,为什么前端必须单独拎出来讲
1.1 一次真实翻车:301 发出去就收不回来了
那次事故的经过大概是这样:项目早期用了一个旧域名做图片资源入口,后来整体迁移到新域名,运维在网关上加了一条 301 规则,把旧地址整段打到新地址。上线当天一切正常,新用户访问旧地址直接被跳到新地址,完美。
问题出在两个月后。图片服务又换了一次源站,新地址下的资源目录结构调整了一次。我们改了网关配置,把旧域名指向新目录,测试环境验证一切正常,发布。然后就开始有人反馈头像不更新。
排查过程没什么技术含量,就是笨办法:打开 DevTools,勾上 Disable cache,刷新,新图出来了。取消勾选,刷新,旧图回来了,Network 面板里那条请求压根不出现,或者出现但显示 from disk cache。到这里基本就锁定了——浏览器在这个 URL 上存了一份"这个地址应该跳到这里"的记忆,而且记的时间远比我们想象的长。
关键点在于:301 的缓存决策不完全由你当前的服务端响应决定。浏览器第一次收到 301 时会把这个映射关系存起来,后续再遇到同一个 URL,理论上可以完全不发请求,直接跳。你后来在服务端把 301 改成 302 甚至改成 200,对已经缓存的用户毫无作用,因为请求根本没到服务端。
记住一句话:302 你能随时反悔,301 一旦被大量客户端记住,反悔成本就很高。
这件事的处置方案最后是换了一个新的路径前缀,等于绕开被污染的 URL,而不是硬碰。代价是历史缓存清不掉,只能等用户自己清缓存或者换设备。
1.2 这四个码在前端代码里的落点比想象中近
很多人觉得状态码是服务端的事,前端只要看res.status === 200就完事。实际不是。至少有这么几个地方前端会被直接牵连:
| 场景 | 涉及的状态码 | 前端感知方式 |
|---|---|---|
| 接口地址迁移,网关配了重定向 | 301 / 308 | 请求静默跳到新地址,response.url变了 |
| 登录后跳转、表单提交后跳页 | 302 / 303 | POST 变成 GET,参数丢了 |
| 静态资源协商缓存 | 304 | Network 显示 304 或 from disk cache |
| 跨域接口被重定向 | 301 / 302 | 直接报 CORS 错误,但控制台看着像"跨域配置没写对" |
| 短链、活动页跳转 | 302 / 307 | 页面直接跳走,前端拿不到中间态 |
我印象最深的其实是跨域那条。有一次联调,前端报跨域,后端说 CORS 头都配了,查了半天发现接口返回了 302,跳转的目标地址没配 CORS 头,浏览器拒绝把最终响应交给 JS。这个现象很迷惑:Network 面板里能看到 302,能看到跳转后的请求,但fetch就是 reject,CORS 报错指向的还是最终那个地址,让人误以为是最终地址的配置问题。
再比如 304。如果你在 axios 拦截器里写了if (response.status !== 200) { ... },那 304 会走进异常分支,虽然大多数情况下浏览器缓存层会把 304 转成 200 交给 JS,但配置不当时确实能拿到 304,这时候你的业务逻辑就莫名其妙地断了。
2. 301 与 302:一字之差,浏览器的记忆策略完全不同
2.1 301 的"永久"到底是谁在记、记多久
规范上的说法是:301 表示目标资源已经永久迁移到新位置,后续所有请求都应该用新地址。这句话是给缓存层看的,而缓存层包括浏览器、中间代理、CDN 节点,每一层都可能记。
浏览器怎么记?没有统一的公开标准,各家实现不一样。我的实测经验(Chrome 近几个版本):
- 响应里没有
Cache-Control、Expires这类缓存指令时,301 依然会被缓存,而且是持久化的,关浏览器重开还在; - 加上了
Cache-Control: no-store才能比较可靠地阻止缓存,但这个头很多运维配重定向的时候根本不会加; - 清缓存、无痕窗口、换设备,都能绕过。
这就带来一个很现实的问题:你不能假设自己能清掉用户端已有的 301 缓存。上线前觉得"反正是 301,语义最正确",出事之后才发现语义正确救不了你。
我的实践原则:凡是"未来半年内可能再调整"的跳转,一律用 302 或 307。只有确定这个地址这辈子都不会再指回别处(比如老域名彻底退役),才用 301,并且一定配
Cache-Control明确缓存时长。
顺带说个细节:301 在 POST 请求上的行为也是乱的。规范本意是"用相同方法访问新地址",但历史实现里大多数浏览器会把 POST 降级成 GET。想保留方法又想永久跳,得用 308。这也是个面试加分点,很少人答得出来。
2.2 302 的历史包袱:为什么它干了 303 的活
302 的原始定义很简单:临时重定向,资源暂时在别处。因为它"临时",浏览器不应该长期缓存,每次请求应该回源问一下。这一点让 302 成了运维手里最安全的工具——改错了还能改回来。
但 302 有个历史遗留的坑。早期浏览器实现对 302 的处理是:不管原请求是 GET 还是 POST,跳到新地址时统一用 GET。这个行为后来被写进了事实标准,导致:
- 用户在表单页点了提交,服务端返回 302 跳到结果页;
- 浏览器用 GET 访问结果页,POST 的 body 丢了;
- 如果用户按 F5 刷新,浏览器可能会弹"是否重新提交表单"。
规范后来做了修正(区分出 303 和 307),但为了兼容,现在浏览器对 302 的处理仍然是"大多数情况把 POST 降级成 GET"。所以你在用 302 做大范围地址迁移时,如果原请求是带 body 的接口调用,要留意 body 会不会被吃掉。
这一点在前后端分离的项目里尤其容易翻车。比如某次接口网关做灰度,把一部分/api/v1/xxx302 到/api/v2/xxx,GET 请求没事,POST 请求全部变成无 body 的 GET,后端收到一个空请求,返回 400,前端看着请求地址是对的,一头雾水。
2.3 动手验证:本地起两个路由看浏览器怎么处理
光看文档记不住,我建议你自己跑一遍。用 Node 起一个最小服务就够:
const http = require('http'); const server = http.createServer((req, res) => { if (req.url === '/old-301') { res.writeHead(301, { Location: '/target' }); res.end(); } else if (req.url === '/old-302') { res.writeHead(302, { Location: '/target' }); res.end(); } else if (req.url === '/target') { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('arrived at ' + new Date().toISOString()); } else { res.writeHead(404); res.end('not found'); } }); server.listen(3000, () => console.log('http://localhost:3000'));跑起来之后做三件事:
第一,访问http://localhost:3000/old-301,看 Network 面板,确认跳转成功。然后关掉标签页、重开浏览器、再访问同一个地址,看请求数量。如果服务端的 301 分支打印的日志没增加,说明请求没到服务端,浏览器直接用了缓存。
第二,同样流程测/old-302,你会发现每次都会回源,服务端日志每次都打印。
第三,把 301 响应改成带缓存头,观察行为变化:
res.writeHead(301, { Location: '/target', 'Cache-Control': 'max-age=60' });这样浏览器只记 60 秒,过期后重新回源。这是我认为比较稳妥的写法——既表达了永久语义,又给了自己一个后悔窗口。
对比表格可以更直观:
| 维度 | 301 | 302 | 307 | 308 |
|---|---|---|---|---|
| 语义 | 永久迁移 | 临时跳转 | 临时跳转 | 永久迁移 |
| 默认可缓存 | 是 | 通常否 | 通常否 | 是 |
| 浏览器记忆时长 | 可能很久 | 基本不记 | 基本不记 | 可能很久 |
| POST 方法是否保留 | 多数实现改为 GET | 多数实现改为 GET | 保留 | 保留 |
| 适合场景 | 域名永久退役 | 灰度、临时维护 | 临时跳转且需保留方法 | 永久迁移且需保留方法 |
这张表我自己贴在工位上过,因为口头区分 301/302 谁都能说,真到写配置的时候,307 和 308 这两个才是解决实际问题的关键。
3. 303:POST 提交完之后该往哪走,这个码就是答案
3.1 PRG 模式与表单重复提交的老问题
303 定义的语义非常明确:请求已经处理完了,请用 GET 去另一个地址看结果。它的存在是为了解决一个经典问题——PRG(Post/Redirect/Get)模式。
想象一个提交订单的场景,服务端处理完 POST 之后直接返回 200 和订单详情页 HTML。用户一按 F5,浏览器会问"是否重新提交表单",用户点了确定,又是一次下单。这个体验很糟,而且会产生真实的数据问题。
PRG 的解法是:POST 处理完之后,服务端返回 303,Location 指向订单详情页;浏览器自动用 GET 去拿详情页。此时 URL 已经是详情页地址,用户按 F5 只是重新 GET 详情页,不会重复下单。
这个流程里 303 是最准确的表达,因为它明确告诉浏览器"下一个请求用 GET"。用 302 也能实现类似效果,但那是依赖浏览器对 302 的降级处理,属于事实标准而非规范行为。
3.2 303 与 302 在请求方法上的分歧
把差异说透就一句话:303 是规范明确要求改方法,302 是历史实现顺手改了方法。
看个具体对比。假设服务端收到一个 POST,然后返回重定向:
// 场景一:返回 302 res.writeHead(302, { Location: '/result' }); res.end(); // 场景二:返回 303 res.writeHead(303, { Location: '/result' }); res.end();在主流浏览器上,两种情况最终都是 GET 到/result,肉眼几乎看不出区别。差异体现在边界情况:
- 如果原请求方法是 PUT 或 DELETE,303 明确要求改成 GET,302 的行为在不同浏览器上不完全一致;
- 如果做了非浏览器客户端的对接(比如某些 HTTP 客户端库),它可能严格按规范处理 302(保持原方法),这时候行为就和浏览器不一致了,联调会出现"前端没事,脚本挂了"的诡异情况。
所以写服务端代码的时候,只要是"处理完写操作、跳到读页面"这个语义,老老实实用 303,别图省事用 302。这条经验我在两个项目里都强调过,因为一旦有非浏览器客户端接入,差别就会暴露。
Express 里的写法:
app.post('/order', (req, res) => { // ... 处理下单逻辑 res.redirect(303, `/order/${orderId}`); });注意 Express 的res.redirect默认是 302,必须显式传 303。这个默认值坑过不少人。
3.3 前端拿到的重定向结果长什么样
前端在 fetch 里对重定向的感知,取决于redirect参数的配置:
// 默认行为,自动跟随 const res = await fetch('/api/old', { redirect: 'follow' }); console.log(res.status); // 最终响应的状态码,比如 200 console.log(res.redirected); // true,表示中间发生过跳转 console.log(res.url); // 最终地址 // 手动模式 const res2 = await fetch('/api/old', { redirect: 'manual' }); console.log(res2.type); // 'opaqueredirect'(跨域时) 或 'basic' console.log(res2.status); // 跨域时是 0,拿不到 301/302这里有个很多人的误解:以为redirect: 'manual'就能拿到 301 状态码做判断。实际上在浏览器环境里,同源的情况下你能看到部分信息,跨域的话拿到的是一个 opaque 的响应,status是 0,headers基本是空的。这是浏览器的安全设计,防止通过重定向探测跨域资源。
想在 JS 里可靠地识别"发生过重定向",最实用的其实是response.redirected这个布尔值,加上response.url和请求时用的 URL 做对比。
XMLHttpRequest 里有个类似的东西:
const xhr = new XMLHttpRequest(); xhr.open('GET', '/api/old'); xhr.onload = () => { console.log(xhr.status); // 最终状态 console.log(xhr.responseURL); // 最终 URL,可以用来判断是否跳转过 }; xhr.send();responseURL这个属性在日常开发里用得很少,但排查重定向问题时非常好用——直接告诉你请求最终落在哪,不用一层层翻 Network。
4. 304 根本不是重定向:它是缓存双方的一次谈判
4.1 强缓存和协商缓存的分界线
把 304 和前面三个码放一起问,本身就有点诱导性。301、302、303 都是"你去别处",304 是"你手上那份还能用,别来烦我"。它的语义是缓存协商的结果,不是跳转。
要理解 304,得先分清两层缓存:
强缓存:浏览器看本地有没有未过期的副本,有就直接用,请求根本不发出去。判断依据是Cache-Control的max-age、s-maxage,或者老的Expires。表现是 Network 面板里显示(from disk cache)或(from memory cache),状态码 200,但你看不到响应体传输。
协商缓存:强缓存过期了,浏览器不得不问服务端,但会带上"我这儿有一份版本是 XXX 的副本,你看还能用吗"。服务端判断没变,返回 304,不带响应体,浏览器用本地那份。表现是 Network 面板显示 304,Size 那一列通常是零点几 KB 的头部开销。
这两层的顺序是固定的:先看强缓存,再走协商缓存。如果强缓存还没过期,协商缓存根本没机会触发,所以你有时候会看到明明服务端配了 ETag,Network 里却一次 304 都没有——因为压根没发请求。
一个典型的 Nginx 配置长这样:
location ~* \.(js|css|png|jpg|woff2)$ { etag on; if_modified_since exact; add_header Cache-Control "public, max-age=3600"; }意思是:静态资源强缓存 1 小时,同时开启 ETag 协商。1 小时内直接命中本地缓存不发请求;1 小时后发请求带上If-None-Match,服务端比对没变就返回 304。
4.2 ETag 与 Last-Modified 谁说了算
服务端判断"有没有改"有两套依据,对应浏览器发出的两类请求头:
| 请求头 | 依据 | 服务端响应头 | 精度 |
|---|---|---|---|
If-None-Match | ETag 值 | ETag | 高,可精确到内容哈希 |
If-Modified-Since | 最后修改时间 | Last-Modified | 低,只精确到秒 |
规范给的优先级很明确:两者都存在时,If-None-Match优先,If-Modified-Since被忽略。这个规则在前端侧的具体影响是:如果你在调试时想通过修改Last-Modified来让客户端更新,可能完全无效,因为 ETag 没变。
Last-Modified的精度问题也很实际。它的最小值是秒级,所以如果两个版本的资源在同一秒内生成,时间戳一样,浏览器就会拿到一个根本不匹配的旧文件。构建工具批量产出资源的时候这种情况并不罕见,尤其是 CI 环境里打得很快的时候。
ETag 也有自己的问题。Nginx 默认的etag on生成的是基于文件修改时间和大小的组合值,在某些多机部署、文件同步的场景下,同一份内容在不同机器上可能生成不同的 ETag,导致浏览器请求被负载均衡打到不同机器时反复 304 失败、反复重新下载。我遇到过一次,表现是用户在页面上反复操作,静态资源偶尔重新加载,整站闪烁。后来把 ETag 换成基于内容哈希的强 ETag 才解决。
一个实用判断:如果静态资源走多机部署,优先考虑用内容哈希命名文件(比如
main.a1b2c3.js),配合长max-age,比依赖 ETag 更省心。文件名变了就是新资源,文件名没变就是老资源,不需要任何协商。
4.3 前端如何在 DevTools 和代码里认出 304
DevTools 的 Network 面板需要开两个设置才看得全:Disable cache要关掉(勾上会强制每次回源),另外在面板的过滤栏里别把304过滤掉。Chrome 还有个Preserve log选项,跨页面跳转时保留请求记录,排查重定向链路很有用。
代码侧的感受就比较微妙了。大多数情况下前端拿不到 304,因为浏览器缓存层处理完之后会把结果包装成 200 交给 JS。但有些配置会导致 304 真的传递到 JS:
const res = await fetch('/api/config'); // 正常情况下这里拿到的是 200,即使底层发生了协商缓存 // 如果拿到 304,说明缓存层没有正确接管 console.log(res.status);如果确实拿到 304,响应体是空的,res.json()会直接抛错。这种情况通常出现在:服务端对接口(而非静态资源)返回了 304,但没有正确处理缓存头的组合;或者代码里手动设置了If-None-Match之类的请求头,绕过了浏览器的自动管理。
axios 里更要注意拦截器的写法。很多人这么写:
instance.interceptors.response.use( response => response, error => { if (error.response && error.response.status === 304) { // 想在这里处理 304 } return Promise.reject(error); } );实际上 304 通常不会走到错误分支,因为浏览器已经把它变成 200 了。这段代码大多数时候是死代码,写了也不会执行,反而给后面维护的人造成"这里已经处理过了"的错觉。
真正需要处理的场景是接口级别的手动缓存。如果你的业务里有"列表页返回数据量大,想用 ETag 做协商缓存省流量"的需求,做法是让服务端对 GET 接口输出 ETag,浏览器自动带If-None-Match,服务端返回 304,浏览器把之前缓存的响应体交给 JS。整个过程 JS 层无感知,拿到的还是完整数据。这个方案用起来很省事,但要注意接口数据如果涉及用户维度,必须把用户标识纳入缓存键的计算,否则会出现 A 用户看到 B 用户数据的严重问题。
5. 被追问时怎么答,以及日常开发里真正会踩的坑
5.1 按"语义、方法、缓存、前端表现"四层来答
面试里被问到这几个码的区别,只答"301 永久、302 临时"基本等于没答。我自己的答题顺序是这样:
第一层讲语义。301 是资源永久搬家,302 是临时挪一下,303 是处理完了去看结果,304 是没改你用本地的。四个码里前三个属于重定向类,304 属于缓存类,先把这个分类点出来,说明你不是死记硬背。
第二层讲请求方法。301 和 302 在历史实现上都会把 POST 降级成 GET,307 和 308 才是方法保留的版本,303 是唯一一个规范明确要求改成 GET 的。这一层能说清楚,面试官基本能确认你查过 RFC 或者踩过坑。
第三层讲缓存行为。301 和 308 默认可缓存且可能缓存很久,302、303、307 一般不缓存。这一层要带出实践建议:不确定的时候别用 301。
第四层讲前端表现。fetch的redirect三档配置,follow 会静默跟随、manual 拿到 opaque 响应、error 直接抛错;response.redirected和response.url用来判断是否跳转过;XHR 的responseURL;跨域重定向会触发 CORS 报错但提示具有误导性。
把四层讲完,即便细节有小出入,整体的专业度已经到位了。反过来,如果只答第一层,后面全靠面试官追问,那场面就比较被动。
5.2 六个高频翻车点与处置建议
下面这些是我和同事实际撞过的,列出来供对照:
| 问题现象 | 根因 | 处置方式 |
|---|---|---|
| 改了跳转目标,部分用户还是跳旧地址 | 301 被浏览器长期缓存 | 换新 URL 前缀,或给 301 加短 max-age 提前规避 |
| 报跨域错误,但 CORS 头检查没问题 | 中间发生了 302 跳转,目标地址没配 CORS | 在网关层确认重定向链路,给最终地址补 CORS 头 |
| POST 接口返回 400,请求体为空 | 网关 302 重定向导致方法降级 | 改用 307/308,或让重定向只发生在 GET 路径上 |
| 静态资源偶尔整站重新加载 | 多机 ETag 不一致 | 改用内容哈希文件名,或统一 ETag 生成算法 |
| 用户按 F5 弹出重新提交表单提示 | POST 后直接返回 200 | 改成 303 跳转到结果页,压住重复提交 |
| axios 拦截器里 304 分支从来没执行过 | 浏览器缓存层已把 304 转成 200 | 删掉死代码,改用响应头里的缓存标识做判断 |
再补两个不在表里但很折磨人的点。
一个是重定向循环。ERR_TOO_MANY_REDIRECTS这个报错出现时,浏览器通常在 20 次左右就放弃。常见成因是 HTTP 到 HTTPS 的跳转和另一条跳转规则互相打脸,比如 A 规则把 http 跳到 https,B 规则又把 https 的某个路径跳回 http,形成闭环。排查方法是在 Network 面板里按住 Shift 看完整请求序列,数一下是不是同一对地址在反复出现。
另一个是 Service Worker 和重定向的相互干扰。SW 拦截了请求之后自己返回重定向,和网络层返回的重定向混在一起,调试时很难分清是哪一层干的。遇到这种情况我的做法是先 Unregister SW,确认问题是否还在,把范围缩小再往下查。这个习惯帮我省过好几次时间。
最后分享一个配置层面的经验:凡是涉及重定向的配置,上线前一定要在无痕窗口里走一遍完整链路,并且用不同浏览器各试一次。普通窗口里存着历史缓存,你的验证结果可能是假的。我现在的习惯是打开无痕窗口、开 Network、不勾 Disable cache,这样看到的行为最接近一个全新用户。