打开线上 URL,浏览器标签页 title 闪一下就只剩空白:HTML 已经下载、DOM 已经挂上 <div id="root">,但里面什么都没渲染出来。这种”白屏不报错”几乎只有三类成因——客户端 JS 抛错被吞、资源路径错把 HTML 当 JS 加载、或 CSP 把脚本拦了。本文给出一套 10 分钟内能定位到根因的检查顺序,适用于 Astro、Next.js、Vite、CRA、Nuxt 等所有静态部署框架。
常见原因
按命中率从高到低:
1. 客户端 JS 在水合前抛错
最常见的一种:React/Vue 在第一次 render 就抛错,整棵树被丢弃,根节点变空。Console 里会有红色的 Uncaught TypeError: Cannot read properties of undefined (reading 'xxx') 或 Hydration failed because the initial UI does not match。
Uncaught Error: Minified React error #418; visit https://reactjs.org/docs/error-decoder.html?invariant=418
at chunk-XXX.js:1:12345
如何判断:DevTools → Console 看第一条红色错误。若是水合错误,多半是 server 渲染时间戳 / Math.random() / Date.now() 跟客户端不一致。
2. 资源 base path 错,JS 请求返回了 HTML
部署到子路径(/blog/、/docs/)或自定义域名时,build 时的 base 没改对,浏览器去请求 /assets/main.js 但服务器把 404 fallback 到 index.html,于是浏览器把整个 HTML 当 JS 解析,立刻报 Unexpected token '<'。
Refused to execute script from 'https://example.com/assets/main-abc123.js'
because its MIME type ('text/html') is not executable.
如何判断:DevTools → Network → 选中那个 JS 请求 → Response 标签页,如果看到 <!DOCTYPE html> 就是这个问题。
3. CSP 拦了内联脚本或外部 CDN
Vercel / Cloudflare / Netlify 上配了 Content-Security-Policy: script-src 'self',但你的框架注入了 inline <script>(Astro 的 is:inline、Next.js 的 __NEXT_DATA__),或者引用了 CDN(unpkg.com、cdn.jsdelivr.net),全被浏览器拒绝执行。
Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self'".
如何判断:Console 里搜 Content Security Policy 或 Refused to,每条都标注了被拒绝的 URL 和指令。
4. ES module 在老浏览器解析失败
build.target 设成了 esnext(默认输出 ES2022+ 语法),但用户浏览器是 Safari 14 之前 / 老 Edge / 微信内置浏览器,遇到 ??=、#private 字段、top-level await 直接 SyntaxError,整个模块加载失败。
如何判断:在 BrowserStack 或同事的旧设备上打开,Console 报 SyntaxError: Unexpected token,行号指向 bundle 文件。
5. Service Worker 缓存了旧的破损版本
上一次部署出过半坏的版本,SW 把它缓存了下来,之后即使新部署修好了,老用户依然加载旧的破 JS,永远白屏。
如何判断:DevTools → Application → Service Workers,看到一个 active 的旧 worker;Application → Cache Storage 里有过期的 bundle 文件名。
最短修复路径
Step 1:用 DevTools 抓第一条错误
无痕窗口打开页面,DevTools 一定要在打开页面之前就打开(否则会漏掉早期错误)。然后:
1. 切到 Console,把所有红色行复制出来
2. 切到 Network,勾选 Disable cache,刷新一次
3. 按 Type 列排序,看 JS / Document 请求的 Status 和 MIME type
200 OK + text/html 出现在 JS 行 = 路径问题;200 OK + text/javascript 但 console 报错 = 代码问题;Status 0 / blocked = CSP 或网络拦截。
Step 2:按错误类别对应修复
| 错误信息 | 真正原因 | 修复方向 |
|---|---|---|
Unexpected token '<' | base path 错 | 改 vite.config / astro.config 里的 base |
Hydration failed | SSR/CSR 不一致 | 用 <ClientOnly> 包住时间戳 / 随机数组件 |
Refused to execute inline script | CSP 太严 | 加 'unsafe-inline' 或 nonce |
Cannot read properties of undefined | 数据为空 | 加空值兜底 + error boundary |
SyntaxError: Unexpected token | target 太新 | 调低 build.target 到 es2018 |
Step 3:base path 修复
Vite / Astro 部署到 https://example.com/blog/:
// astro.config.mjs
export default defineConfig({
site: 'https://example.com',
base: '/blog',
trailingSlash: 'always',
});
Next.js:
// next.config.js
module.exports = {
basePath: '/blog',
assetPrefix: '/blog',
};
改完本地 npm run build && npm run preview 验证一次,再 push。
Step 4:根节点加 error boundary
让 JS 报错不再清空整页:
// React
import { ErrorBoundary } from 'react-error-boundary';
function Fallback({ error }: { error: Error }) {
return (
<div style={{ padding: 24 }}>
<h1>出错了</h1>
<pre>{error.message}</pre>
</div>
);
}
export default function App() {
return (
<ErrorBoundary FallbackComponent={Fallback}>
<YourApp />
</ErrorBoundary>
);
}
Vue 用 errorCaptured,Svelte 用 <svelte:boundary>(5.0+)。
Step 5:清掉坏的 Service Worker
如果是老用户白屏新用户正常,几乎一定是 SW 缓存。临时方案 + 永久修复:
// public/unregister-sw.js 临时挂一份,让所有访客自动清缓存
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(rs => {
rs.forEach(r => r.unregister());
});
caches.keys().then(keys => keys.forEach(k => caches.delete(k)));
}
部署一周后再撤掉这个脚本。
预防建议
- 根节点必加 error boundary,任何子组件崩了不会让整页变白
- CI 里跑一次 Playwright/Puppeteer 冒烟测试,访问首页断言
document.body.innerText.length > 100 - 部署后用 WebPageTest 或 Lighthouse CI 跑一次,能发现 CSP / MIME 类型错
build.target至少留两个 LTS 版本兼容性(如 Safari ≥ 14、Chrome ≥ 90)- Service Worker 注册时带版本号 + skipWaiting,避免老 SW 卡死新部署