昨天我们产品的官网项目发了个新版本,满心欢喜等用户反馈。结果没过多久,就有人报 Bug,说线上的网站白屏了。
我赶紧跑去一看浏览器控制台,红彤彤的一条报错:
Failed to load module script: Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "text/html". Strict MIME type checking is enforced for module scripts per HTML spec.

这就奇了怪了。我本地跑 npm run dev 好好的,本地打包 npm run build && npm run preview 也是好好的。怎么一上 Cloudflare Pages 就拉胯了呢?
顺着这句报错,我开始了一场跟浏览器缓存和 Cloudflare 路由机制的斗智斗勇。
问题复现与"盲人摸象"
这句报错的意思很直白:浏览器想去请求一个 JS 模块文件,结果服务器(Cloudflare Pages)给它返回了一段 HTML 代码。 浏览器出于安全规范,一看 Content-Type 是 text/html,直接拒绝执行。
我第一反应是:是不是 Vite 打包的路径配置不对?或者静态资源没上传成功?
看了一下打包后的 dist/index.html,里面是这么引用资源的:
html
<script type="module" crossorigin src="/assets/index-u8OMbdyl.js"></script>
没毛病啊,绝对路径,带 hash 的文件名。再去看 Cloudflare Pages 上的部署产物,/assets/index-u8OMbdyl.js 这个文件确确实实是存在的。
那为什么浏览器去请求这个 JS 的时候,会拿到 HTML 呢?
这就不得不提我们在 SPA(单页应用)部署时都会加的一个经典配置:SPA Fallback(路由回退)。
为了让 React Router 这类前端路由在用户直接访问 /zh/sponsors 这种深层链接时不出 404,我们通常会在 Cloudflare Pages 的 public/_redirects 文件里加上这么一句:
text
/* /index.html 200
这句话的意思是:当用户请求任何不存在的文件时,不要报 404,而是把 index.html 的内容返回给他(HTTP 状态码依然给 200)。 然后让前端路由自己去解析 URL 并渲染对应的组件。
破案的线索来了。
既然服务器返回了 HTML,那就说明:浏览器请求的那个 JS 文件,在服务器上其实是不存在的!所以触发了 /* /index.html 200 这个规则,导致服务器把 index.html 当作 JS 返回了。
可是,我刚才明明看到新部署的包里是有这个 JS 的啊?
揪出真凶:薛定谔的 index.html 缓存
为了搞清楚浏览器到底在请求什么,我仔细看了一下报错 Network 面板里那个请求失败的 JS 文件名。
好家伙,浏览器请求的并不是最新部署的 index-u8OMbdyl.js,而是上一个版本的 index-OLD-HASH-1234.js!
整个案发过程终于被还原了,简直是一个环环相扣的连环案:
- 缓存复用 :用户之前访问过我们的网站,浏览器把当时的
index.html缓存到了本地。 - 部署更新:我们发布了新版本,Vite 打包出了全新的 JS 文件(hash 变了),老版本的 JS 被覆盖(或者清理)掉了。
- 加载旧入口 :用户刷新页面,浏览器发现本地有
index.html的缓存,由于我们没有在 Cloudflare Pages 上对 HTML 配置禁用缓存的头部,浏览器直接用了旧的 HTML。 - 请求旧资源:旧的 HTML 里写着要去加载旧 hash 的 JS。
- 触发 Fallback :浏览器向服务器请求旧 JS。服务器一看,我这儿没有这个文件啊。按照
_redirects里的规则,默默地把最新的index.html文本塞给了浏览器。 - MIME 报错:浏览器期待拿到一个 JavaScript,结果拿到一坨 HTML,当场掀桌子报错白屏。
解决思路一:不让 Cloudflare 把静态资源重写成 HTML
我的第一反应是去改 _redirects,把静态资源目录保护起来,让它即使找不到也直接返回 404,而不是变成 HTML。
于是我在 _redirects 里加了透传规则,把它放在 /* 前面:
text
/assets/* /assets/:splat 200
/* /index.html 200
理论上,这样写的话,找不到旧 JS 会直接报 404 Not Found。虽然网页还是会因为找不到 JS 而白屏,但至少报错会变成诚实的 404,而不是诡异的 MIME type 错误。
但是!这治标不治本啊。用户的目标是要看最新的网页,你给他个 404 白屏,体验依然是灾难。
真正的病根在于:作为入口的 index.html 绝对不能被缓存!
解决思路二:死磕 Cache-Control
要让 HTML 不缓存,办法很简单,加 Header 呗。
在 Cloudflare Pages 中,我们通过根目录的 _headers 文件来控制 HTTP 响应头。
我大笔一挥,写下了这样的配置:
text
/index.html
Cache-Control: public, max-age=0, must-revalidate
/assets/*
Cache-Control: public, max-age=31536000, immutable
这意思是:/index.html 每次都必须向服务器验证(不缓存),而带 hash 的静态资源缓它个一年。
改完提交,等部署完一测------还是报那个该死的错!
打开终端,用 curl 测试了一下真实请求:
bash
curl -sI https://xxxx.io/zh/sponsors | grep Cache-Control
# 结果为空...
竟然没有生效?
原来,这里藏着 Cloudflare Pages 路由匹配机制的一个巨坑(这也是 Codex 后来在 PR 里揪出来的盲点)。
当用户访问 https://xxxx.io/zh/sponsors 时,虽然最终返回的是 index.html 的内容(因为 _redirects 规则),但 Cloudflare 的 Header 匹配是基于原始请求 URL 的 ,也就是 /zh/sponsors。
(官方文档有写:Redirects run before headers and the redirect wins when both match,重写后的 SPA HTML 响应,匹配的依然是外层的 URL。)
所以,我给 /index.html 加的 Cache-Control,只有当用户在地址栏里硬生生地敲出 https://xxxx.io/index.html 时才会生效。而我们这是一个 SPA 应用,用户访问的都是 /zh/、/docs 这种路径,它们统统完美地避开了我设置的缓存控制,继续复用着本地的老掉牙的缓存。
解决思路三:那我就给全局加不缓存?
既然单点匹配不行,那我直接给全局 /* 加上不缓存,再用 /assets/* 去覆盖静态资源的缓存策略,总行了吧?
text
# 危险操作,切勿模仿!
/*
Cache-Control: public, max-age=0, must-revalidate
/assets/*
! Cache-Control
Cache-Control: public, max-age=31536000, immutable
幸好我在本地用 Wrangler(Cloudflare 的本地开发工具,高度还原线上环境,强烈推荐)跑了一下测试。
bash
npx wrangler pages dev dist
curl -sI http://127.0.0.1:8788/assets/index-xxx.js
你猜我看到了什么 Header?
text
Cache-Control: public, max-age=31536000, immutable, public, max-age=0, must-revalidate
它把两个规则拼接在一起了!这会导致所有的静态资源也失去了长期缓存的效果。
原来,Cloudflare Pages 在处理多个匹配规则的 Headers 时,如果是相同的 Header 名称,它有时不是覆盖,而是合并。这简直是个隐形炸弹。
终极解法:精确打击,拒绝合并
被折腾了半天,我终于认清了现实:最稳妥的办法,就是把所有需要渲染 HTML 的顶层路由,老老实实、明明白白地列出来。
于是,我们的 public/_headers 变成了这样:
text
# 明确针对所有前端 SPA 路由禁用缓存
/
Cache-Control: public, max-age=0, must-revalidate
/zh
Cache-Control: public, max-age=0, must-revalidate
/zh/*
Cache-Control: public, max-age=0, must-revalidate
/en
Cache-Control: public, max-age=0, must-revalidate
/en/*
Cache-Control: public, max-age=0, must-revalidate
/docs
Cache-Control: public, max-age=0, must-revalidate
/docs/*
Cache-Control: public, max-age=0, must-revalidate
# 静态资源保持其独立的规则,互不干扰
/assets/*
Cache-Control: public, max-age=31536000, immutable
这里还有一个小细节(也是 Codex 这个代码审查机器人严谨指出的):在 Cloudflare Pages 的匹配语法中,/docs/* 只能匹配 /docs/xxx 或者 /docs/,但匹配不到精确的 /docs(没有末尾斜杠)。所以必须把 /docs 和 /docs/* 都写上,才能做到滴水不漏。
采用这种写法后:
- 作用域极度明确 :只有访问这些特定路由的 HTML 会被加上
max-age=0。 - 彻底隔离静态资源 :因为没有使用
/*这种全局通配符,/assets/*下的资源绝对安全,不会发生任何诡异的 Header 拼接。
再次部署,强制刷新,终于搞定!
总结一下这趟踩坑之旅
如果你也在用 Cloudflare Pages(或者 Netlify、Vercel 等类似的 Serverless 平台)部署 SPA 单页应用,遇到莫名其妙的旧版本报错、MIME 错误,一定要检查这两点:
- SPA Fallback(如
/* /index.html 200)是一把双刃剑。 它能解决前端路由问题,但也意味着所有的 404 请求都会被包装成 HTML 返回。 - HTML 入口文件绝对不能缓存! 必须加上强有力的 Cache-Control,且由于 Serverless 平台的路由匹配机制,你必须确保这个 Header 绑定在你实际的访问路径上(比如
/、/about/*) ,而不是仅仅绑在服务器上的物理文件/index.html上。
折腾完这圈,我对浏览器缓存和 Cloudflare 的路由机制有了痛彻心扉的理解。希望这篇记录能帮大家少走点弯路。