nginx SPA 回落把 CSS 变成了 HTML:一个返回 200 却让样式失效的坑

现象

前端发版后,用户反馈页面样式乱了。打开控制台,报错是这个:

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.typesContent-TypeX-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 头。

如果 expiresadd_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.pnglogin-bg.jpgiconfont.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-cachePragma: no-cacheExpiresDate 早 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 才能看到原始响应头。默认的合并显示会把重复头拼在一起,看不出是一个头还是两个。

另外可以顺手确认链路上有没有缓存型代理:响应里如果出现 AgeViaX-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.htmlno-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」。

相关推荐
光影少年2 小时前
React Native网络、存储与原生能力
前端·react native·react.js
bonibabi2 小时前
基于 CopilotKit + Java SSE 构建 AI Agent 的前端实践指南
前端·agent
砺能2 小时前
谷歌浏览器中出现部分字体乱码问题
前端
To_OC2 小时前
绕开层层 props 搬运:我把 useContext 和自定义 Hook 跑通了
前端·react.js·前端框架
北凉温华2 小时前
Vue3 可视化打印设计插件|拖拽生成票据/标签打印模板(开源体验版)
前端
程序员黑豆2 小时前
Java变量详解:从入门到精通
java·前端·ai编程
andr_gale3 小时前
02_uniapp自定义上凸效果的底部TabBar
前端·javascript·uni-app·移动端
一心只读圣贤书3 小时前
AI 驱动前端测试实战:从需求文档到 Playwright 自动化用例
前端·人工智能
拖孩3 小时前
用 AI 重解千年观音灵签,做了一个微信小程序,每天摇一摇,命运给你回应
前端·后端·微信小程序