现象
前端发版后,用户反馈页面样式乱了。打开控制台,报错是这个:
python
Refused to apply style from 'http://example.com/app/css/chunk-vendors.a1b2c3d4.css'
because its MIME type ('text/html') is not a supported stylesheet MIME type,
and strict MIME checking is enabled.
第一反应是 MIME 类型配错了,去检查 nginx 的 mime.types,一切正常,别的 CSS 都能正常加载。
再看 Network 面板,那个 CSS 请求的状态码是 200。不是 404,不是 500,是正常的 200。
点开 Response,内容是这样:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>...</title>
请求一个 .css,服务器返回了 index.html 的内容,还给了 200。
为什么这个问题难查
几个因素叠在一起,让它比普通的 404 难定位得多:
状态码是 200。 排查静态资源问题的习惯是先看有没有 4xx/5xx,全绿就容易跳过去。而这里所有请求都是 200。
报错信息指向 MIME,不指向 nginx。 浏览器只能告诉你「拿到的东西不是 CSS」,它不知道为什么。于是排查方向很容易跑偏到 mime.types、Content-Type、X-Content-Type-Options 这些地方。
不是每次都出现。 触发需要特定条件(下面讲),本地开发和刚部署完的机器上都复现不了。
命中磁盘缓存时看不到响应头。 DevTools 会显示 Provisional headers are shown,你连响应头都拿不到。核对必须先勾 Disable cache,或者直接在服务器上 curl。
根因
问题出在 SPA 的 history 路由回落配置上。
前端用 history 模式,用户直接访问 /app/user/list 这种路径时,服务器上并没有对应的文件,需要回落到 index.html 交给前端路由处理。标准写法是:
nginx
location /app/ {
alias /var/www/app/;
index index.html;
try_files $uri $uri/ @app_rewrite;
}
location @app_rewrite {
rewrite ^ /app/index.html last;
}
try_files $uri $uri/ @app_rewrite 的语义是:先找同名文件,找不到就找同名目录,还找不到就走 @app_rewrite 回落到 index.html。
问题在于这条规则不区分请求的是路由还是静态资源。
/app/user/list 找不到文件,回落到 index.html,正确。 /app/css/chunk-vendors.a1b2c3d4.css 找不到文件,也回落到 index.html,于是浏览器拿到 200 + HTML。
什么时候文件会找不到
带 contenthash 的文件名理论上是稳定的,为什么会缺失?
触发条件是:浏览器缓存了旧的 index.html,但服务器上已经是新版本的构建产物。
markdown
用户浏览器里的 index.html(旧)
└─ 引用 chunk-vendors.a1b2c3d4.css
服务器上的构建产物(新)
└─ chunk-vendors.e5f6g7h8.css
(a1b2c3d4 这个文件在新部署时被删掉了)
旧 index.html 去请求 a1b2c3d4,服务器上没有这个文件,try_files 把它回落成了 index.html。
所以这个问题的完整链路是:index.html 被缓存 + 部署时清理了旧产物 + try_files 无差别回落,三个条件同时满足。
这也解释了为什么难复现:新部署的机器上旧文件还在,缓存也是新的。
修法
核心是让带 hash 的构建产物在缺失时老实报 404,不要参与回落。
做法是在站点 location 里嵌套一个匹配 contenthash 的 location:
nginx
location /app/ {
alias /var/www/app/;
index index.html index.htm;
# 不带 hash 的一切(index.html、config.json、iconfont、public/ 下的东西):协商缓存
expires -1;
add_header Pragma "no-cache" always;
# 带 8 位 contenthash 的构建产物:永久缓存 + 缺失时报 404
location ~* "\.[a-z0-9]{8}\.(?:js|css|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|eot|mp4|webm)$" {
expires off;
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header Expires "Thu, 31 Dec 2037 23:55:55 GMT" always;
access_log off;
try_files $uri =404;
}
try_files $uri $uri/ @app_rewrite;
}
location @app_rewrite {
rewrite ^ /app/index.html last;
}
关键是嵌套 location 里的 try_files $uri =404。文件不存在就返回 404,不再走外层的回落。
这样改完,同样的场景下浏览器会拿到 404 而不是 200 + HTML。虽然样式仍然会缺,但报错直接指向真正的原因,Network 面板一眼就能看到是哪个文件没了。
四个容易踩的写法细节
1. 嵌套 location 里必须写 expires off
expires 指令会从父 location 继承。不关掉外层的 expires -1,它会和 immutable 同时生效,下发两个 Cache-Control 头:
ini
Cache-Control: public, max-age=31536000, immutable, no-cache
浏览器遇到冲突值行为不确定,等于长缓存白配了。
2. 不带 hash 的资源用 expires -1,不要用 add_header Cache-Control
expires -1 一条指令同时做两件事:下发一个过去时间的 Expires(给 HTTP/1.0 代理和老 IE),以及 Cache-Control: no-cache。而且只会有一个 Cache-Control 头。
如果 expires 和 add_header Cache-Control 混着写,会下发重复头。
3. 用 no-cache 不用 no-store
两者都是每次向服务器校验,但 no-cache 在内容没变时可以返回 304,no-store 每次都要传完整内容。对 index.html 这种小文件差别不大,但没必要浪费。
4. 正则匹配的是文件名格式,不是扩展名
这一点顺带解决了另一个问题。原来的配置是按扩展名分策略:
nginx
location ~* \.(png|jpg|jpeg|gif|ico|woff|woff2|ttf)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
这会把 logo.png、login-bg.jpg、iconfont.ttf 这些固定文件名 的资源也锁一年。而 immutable 连 F5 都不会校验,改了图只能让用户清缓存。
按「文件名带不带 8 位 hash」来分,才和构建产物的实际语义对上。
连带约束 :往 public/ 目录放文件时,别取 名字.八位字母数字.js 这种名字,会被正则误判成构建产物锁一年。
线上验证
改完之后在服务器上直接 curl,可以绕开浏览器缓存和本机代理:
bash
# 1. SPA 路由回落,期望 200 + no-cache
curl -I http://127.0.0.1/app/user/list
# 2. 带 hash 的产物,期望 max-age=31536000, immutable
curl -I http://127.0.0.1/app/js/app.<实际hash>.js
# 3. 缺失的 hash 资源,期望 404
curl -I http://127.0.0.1/app/js/app.deadbeef.js
# 4. 不带 hash 的资源,期望 no-cache + 有 Etag
curl -I http://127.0.0.1/app/logo.png
实测结果:
| 验证项 | 响应 | 判定 |
|---|---|---|
| 路由回落 | Cache-Control: no-cache、Pragma: no-cache、Expires 比 Date 早 1 秒,真 200 |
通过 |
| hash 产物 | Cache-Control: public, max-age=31536000, immutable 单个值 、Expires: 2037 |
通过 |
| 缺失的 hash 资源 | 404 Not Found,未回落成 index.html |
通过 |
| 不带 hash 的资源 | Cache-Control: no-cache 仅一条、带 Etag 可返 304 |
通过 |
单个 Cache-Control 是核心证据。 如果嵌套 location 里的 expires off 没生效,父级的 expires -1 会漏进来,值会变成 immutable, no-cache。
用浏览器验证时,DevTools 必须勾选 Raw 才能看到原始响应头。默认的合并显示会把重复头拼在一起,看不出是一个头还是两个。
另外可以顺手确认链路上有没有缓存型代理:响应里如果出现 Age、Via、X-Cache,说明中间有 CDN 或反向代理在缓存,排查范围要扩大。
遗留问题:存量的 immutable 资源救不回来
有一点需要说清楚:新配置只对「浏览器愿意来问服务端」的请求生效。
已经被 immutable 锁在客户端的资源,浏览器在有效期内根本不会发请求,服务端返回什么都无所谓。这部分只能靠用户强刷一次(Ctrl+F5),或者在页面上提供一个清缓存入口:
js
async function clearCacheAndHardReload() {
// 清除 Cache Storage
if ('caches' in window) {
const names = await caches.keys()
await Promise.all(names.map(n => caches.delete(n)))
}
// 注销 Service Worker
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations()
await Promise.all(regs.map(r => r.unregister()))
}
// 加时间戳强制重新获取主文档
const url = new URL(window.location.href)
url.searchParams.set('_force_reload_', Date.now())
window.location.href = url.toString()
}
只需要执行一次,之后新的缓存策略就接管了。
更彻底的做法是发版时生成一个 version.json(跟着 index.html 走 no-cache),前端定时拉取,发现版本变化就提示用户刷新。这能覆盖「用户长时间不整页刷新」的场景 ------ 那种情况服务端是无解的。
快速判定表
遇到「页面还是旧样式」,按这个顺序定位:
| 检查 | 判定 |
|---|---|
index.html 引用的 hash 与服务器文件名不一致 |
index.html 被缓存了,查缓存配置和代理层 |
| 一致但页面仍是旧样式 | 部署没更新,查发布流程 |
| CSS 请求返回 200 但内容是 HTML | try_files 无差别回落,按本文改配置 |
Cache-Control 有两个值 |
嵌套 location 缺 expires off |
小结
这个坑的本质是把「路由」和「静态资源」用同一条 try_files 规则处理了。
SPA 需要回落,但只有路由需要回落。带 contenthash 的构建产物一旦缺失就是异常状态,应该老实报错,而不是伪装成一个 200 的 HTML 让浏览器去猜。
一行 try_files $uri =404 就能把一个「极难排查的样式问题」变成一个「一眼就能看到的 404」。