「CSS 不进缓存」和「JS 两份在跑」的共同根源
CSS 与 JS 在 Nginx 上的故障,几乎有一个共同的判别特征:页面的 HTML 是新的,样式或脚本却是旧的。原因在于,Nginx 对静态资源的处理链路比 HTML 短得多——它不经过 PHP,不经过你的模板,而是直接从 location 匹配、root 拼接、try_files 探测,然后由 add_header/expires 决定浏览器缓存多久。这条链路上少一环、错一个字,用户就会看到「白屏但不报错」「样式少一半」「控制台一片 404 或 MIME 错误」。
更要命的是这些故障的复现条件很窄:在你本机上刷新几次是好的,在手机上、在公司网络里、在开了广告拦截的浏览器里就不行。所以排查不能靠「再刷一次」,而要靠能持续输出的证据。这篇文章按诊断顺序梳理三个高频场景:资源 404 被兜底成 HTML、MIME 类型错误导致样式不生效、以及 301/302 把静态资源的请求路径改掉。
第一步:先确定浏览器到底请求了哪些 URL、拿到了什么
排查静态资源问题,不要先看配置文件,先看请求结果。curl -I 是最快的手段,但必须带上 Referer 和 Accept 头,因为有些站点的防盗链与内容协商依赖它们:
# 关键:带 Referer 才能通过防盗链;带 Accept 才能触发正确的 MIME 协商
curl -sI -H 'Referer: https://www.example.com/' \
-H 'Accept: text/css,*/*;q=0.1' \
'https://www.example.com/static/css/main.css?v=12'
# 想看完整响应头(含被 add_header 叠加的多份 Cache-Control)
curl -sD - -o /dev/null 'https://www.example.com/static/js/app.js'输出里最该盯的四个字段:HTTP/1.1 200、Content-Type、Content-Length、Cache-Control。判断方法很直接:如果 CSS 的 Content-Type 不是 text/css,浏览器会拒绝应用该样式表并在控制台报 Refused to apply style ... MIME type ('text/html') is not a supported stylesheet MIME type。这句话几乎等价于「这个 URL 返回的不是 CSS,而是一张 HTML 页面」。
# 一条命令区分「文件真的不存在」和「被兜底成了首页」
curl -s 'https://www.example.com/static/css/main.css' | head -c 200
# 若开头是 <!DOCTYPE html> 或 <html>,说明被 try_files 或 SPA fallback 兜底了场景一:静态资源 404 被兜底成首页 HTML
这是最常见的。典型配置是这样的:
location / {
try_files $uri $uri/ /index.php?$query_string;
}这段配置对 PHP 站点很常见,问题在于它是「全站兜底」:当请求的 /static/css/main.css 因为路径写错、权限不足、或者发布时被误删而不存在时,try_files 会依次尝试 $uri、$uri/,最后落到 /index.php。PHP 返回 200 和一张完整的 HTML 页面,浏览器于是收到了一个「HTTP 200 的 CSS 文件」,内容是 HTML。这条请求在日志里是 200,在监控里一切正常,只有用户看到样式没了。
正确的做法是把静态资源目录单独拆出来,并在其中禁用兜底:
# 静态资源专用 location:不存在就干脆 404,不要落到 PHP
location ^~ /static/ {
root /var/www/site;
try_files $uri =404;
access_log /var/log/nginx/static.log;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
}
# 兜底只给「看起来像页面」的请求,别给带扩展名的文件
location / {
try_files $uri $uri/ /index.php?$query_string;
}两个细节值得注意:^~ 前缀的作用是「一旦命中就不再检查正则 location」,可以防止后面某个 location ~ \.(js|css)$ 抢走匹配导致配置互相打架;try_files $uri =404 里的 =404 是必须写的,如果不写,Nginx 会继续往下走 location 匹配,等于白设。
除了 try_files,还有两类兜底会产生完全一样的症状:一是 CDN 或反向代理的 error_page 404 /index.html,二是 SPA 应用的 history 模式 fallback(try_files $uri /index.html)。后者如果在 location / 里写死,同样会把缺失的静态资源变成 200 的 HTML。SPA 站应该把 fallback 限定在无扩展名的路径上:
location / {
# 带扩展名的请求绝不回落到 index.html
location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2?|ico|map)$ {
try_files $uri =404;
expires 7d;
}
try_files $uri /index.html;
}场景二:MIME 类型错了,文件在但浏览器不认
比 404 更隐蔽的是「文件确实存在、返回 200、长度也对,但浏览器就是不执行」。原因通常是 Content-Type 与实际内容不符,而浏览器对 CSS/JS 的 MIME 校验是硬校验(尤其当响应带 X-Content-Type-Options: nosniff 时,浏览器不再做任何猜测)。
Nginx 的 MIME 映射来自 include mime.types;。常见的三种失效原因:
- mime.types 没被 include 到当前 server 块所在的上下文(比如配置写在
conf.d/但 include 被注释掉了),此时所有静态文件都返回default_type,默认是application/octet-stream。 - 响应头被重复设置,比如上游 PHP 或 CDN 已经写了
Content-Type: text/html,Nginx 的types只对本地静态文件生效,对proxy_pass返回的内容不起作用。 - 扩展名不在映射表里,例如
.mjs、.woff2(老版本)、.webmanifest。这类文件会落到default_type,然后 JS 模块因 MIME 不对而被浏览器拒绝加载。
# 检查当前 server 生效的 default_type 与 mime 映射是否包含目标扩展名
nginx -T 2>/dev/null | grep -E 'default_type|include.*mime.types' | head
grep -E 'mjs|woff2|webmanifest' /etc/nginx/mime.types补全映射有两种方式。推荐在 http 层统一补,避免每个 server 重复:
# /etc/nginx/conf.d/mime-extra.conf
types {
text/javascript mjs js;
font/woff2 woff2;
application/manifest+json webmanifest;
image/avif avif;
image/webp webp;
}注意 types {} 块是追加语义,不要在这个块里重复声明 text/html,否则会产生冲突。补完后必须 nginx -t && systemctl reload nginx,然后重新用 curl -I 验证:
curl -sI 'https://www.example.com/static/js/app.mjs' | grep -i content-type
# 期望:content-type: text/javascript
# 若仍是 application/octet-stream,先确认这段 types 块被 include 到了正确的 http 上下文场景三:301 改了路径,静态资源被带到别的域名或别的目录
第三类故障最容易被误判为「缓存问题」。表现是:HTML 正常,DevTools 里 CSS 请求显示 301 或 302,随后被重定向到一个 200 的 HTML 页面,样式丢失。根源通常是下面三段配置之一:
# 1) 强制 HTTPS 的 rewrite 忘了排除静态目录(很少出错,但存在)
server {
listen 80;
return 301 https://$host$request_uri; # 用 $request_uri 而不是 $uri,才能保留查询串
}
# 2) 目录补斜杠导致重定向:/static/css 被补成 /static/css/
location /static {
# 少了斜杠的 location 会触发自动 301 到 /static/,若后面没有正确映射就 404
}
# 3) 反向代理里 proxy_redirect 默认改写了 Location 头
location /app/ {
proxy_pass http://127.0.0.1:8080/;
proxy_redirect default; # 上游返回的绝对路径会被改写成 $scheme://$host/app/...
}第一条是很多人踩过的坑:return 301 https://$host$uri 会丢掉查询字符串(?v=12 这类版本号没了),而用了 $request_uri 才完整保留。虽然对静态资源影响不大,但会让缓存键分裂,反而加剧「有时候新有时候旧」。
第二条的机理值得说清楚:当 location /static 匹配到请求 /static(无尾斜杠),Nginx 会做一次内部 301 补成 /static/。如果 /static/ 对应的目录里没有 index 文件、也没有对应的 try_files,就会出现「请求 301 → 落地 404 → 被兜底成首页」。排查时在 DevTools 的 Network 面板打开「Preserve log」,看跳转链条是最快的:一发请求出现两次颜色不同的记录,就是重定向。
# 直接看跳转链,不跟随、不缓存
curl -sI 'https://www.example.com/static/css/main.css' | grep -iE '^(HTTP|location)'缓存策略:让版本号成为唯一的失效手段
排查完上面三类问题之后,还有一件事必须做:给静态资源设置「长缓存 + 内容指纹」的策略,而不是靠 Cache-Control: no-cache 来「保险」。很多站长遇到样式不更新,第一反应是把 CSS 的缓存时间改成 0,这会让每次访问都回源,性能直接崩掉,而且并不能解决真正的问题。
推荐的分层策略:
# 带内容指纹的静态资源(main.a1b2c3.css):永久缓存
location ~* \.(css|js|woff2|svg|png|jpg|webp)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# HTML 与 API:不缓存或极短缓存,保证内容即时更新
location ~* \.html$ {
add_header Cache-Control "no-cache, must-revalidate";
}immutable 告诉浏览器「这个 URL 的内容永远不会变,刷新时连条件请求都不用发」,前提是你的文件名里确实带了 hash 或版本号。Typecho、WordPress 这类 PHP 站点通常没有构建流程,无法自动生成 hash,此时最好的替代方案是把版本号写进查询串并在模板里统一由一个常量控制:
<link rel="stylesheet" href="/static/css/main.css?v=<?php echo SITE_ASSET_VER; ?>">这样每次发布只需要改一个常量,所有引用自动失效,且不影响 expires 1y 带来的性能收益。注意:如果前面有 CDN,查询串必须计入缓存键,否则 ?v=1 和 ?v=2 会命中同一份缓存。可以在 CDN 控制台确认「忽略查询参数」这一项是关闭的。
一次完整的验收流程
改完配置后,不要只看首页。按下面五步走一遍,能把上面三类故障全部覆盖:
BASE=https://www.example.com
for u in / /static/css/main.css /static/js/app.js /static/js/app.mjs /favicon.ico; do
printf '%-30s ' "$u"
curl -s -o /dev/null -w '%{http_code} %{content_type} %{size_download}B %{time_total}s\n' \
-H 'Referer: https://www.example.com/' "$BASE$u?_=$(date +%s)"
done判据很清晰:静态资源的状态码必须是 200(不能是 301/302/404/500),content_type 必须是 text/css、text/javascript 这类正确值,size_download 不能是一个和 HTML 页面一模一样的大小(那说明被兜底了)。
最后补一个只有真实用户才会暴露的问题:混合内容(Mixed Content)。当页面是 HTTPS,但 CSS 里 url() 引用了 http:// 的字体或图片,或者 JS 动态插入了 http 资源,浏览器会拦截并静默忽略,表现同样是「样式少一部分」。检查方式是看控制台里的 Mixed Content 警告,或者在服务器上扫一遍静态资源:
grep -rhoE 'url\((http://[^)]+)\)' /var/www/site/static/css/ | sort -u | head把命中的 URL 改成协议相对的 // 或直接 https,问题即消失。
小结
CSS/JS 类故障的排查顺序应该是:先看响应(状态码、Content-Type、长度),再看跳转链,最后才看缓存头。因为缓存头只会让「正确的响应」多存活一会儿,它不会制造 200 的 HTML 冒充 CSS——那一定是兜底规则或重定向造成的。把静态资源目录从全站兜底里拆出来、补全 MIME 映射表、用内容指纹代替 no-cache,这三件事做完之后,同一类问题复发率会降到接近零。