打开线上 URL,浏览器标签页 title 闪一下就只剩空白。HTML 已经下载、DOM 已经挂上 <div id="root">,但里面什么都没渲染出来。
最快的修法:用无痕窗口打开站点,并且在打开页面之前就先把 DevTools 开好,然后看 Console 里第一条红色错误,对照 Step 2 的表格。一条错误信息就能告诉你属于五类里的哪一类,其中四类只要改一行配置。
“白屏不报错”几乎只有三类根因:客户端 JS 抛错、整棵 render 树被丢弃;资源路径错,服务器把 HTML 当 JS 返回;或者 CSP 把脚本拦了。另外两类——build target 对访客浏览器太新、或者旧 Service Worker 缓存了破损版本——更少见,但各有明显特征。本文适用于 Astro、Next.js、Vite、Create React App、Nuxt、SvelteKit 等所有静态或混合部署框架。
常见原因
按命中率从高到低:
1. 客户端 JS 在水合前抛错
最常见的一种:React/Vue 在第一次 render 就抛错,整棵树被丢弃,根节点变空。Console 里会有红色的 Uncaught TypeError: Cannot read properties of undefined (reading 'xxx'),或者一条水合不一致错误。
Uncaught Error: Minified React error #418; visit https://react.dev/errors/418
at chunk-XXX.js:1:12345
在生产(压缩)构建里,水合不一致会以 Minified React error #418、#423 或 #425 的形式出现——其中 #418 就是”文本内容不一致”那种。#418 解码后的信息是 Hydration failed because the server rendered HTML didn't match the client.。React 19 改进了这块:现在会把 server 和 client 的值放在同一条错误里打印出来,而不是甩一大堆零散 warning;并且会跳过浏览器扩展和第三方脚本注入到 <head>/<body> 里的标签,这些不再触发误报。
如何判断:DevTools → Console 看第一条红色错误。水合不一致多半是 Date.now()、Math.random()、new Date().toLocaleString()、typeof window !== 'undefined' 分支,或依赖语言/时区的渲染在 server 构建和 client 之间对不上。如果不是水合错误而是一条普通 TypeError,那几乎都是数据没兜底:某个 API 字段是 undefined,组件去读它的属性就崩了。
2. 资源 base path 错,JS 请求返回了 HTML
这是部署后白屏最常见的单一原因。你部署到了子路径(/blog/、/docs/,或者 GitHub Pages 的 project 地址 user.github.io/repo/)或者换了新 host,但 build 时的 base 没改对。浏览器去请求 /assets/main.js,host 把这个路径 404 掉,然后 SPA 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 标签页。如果 body 开头是 <!DOCTYPE html> 而不是 JavaScript,就是这个问题——host 在浏览器期望脚本的地方返回了首页。切到 Headers 标签页,响应的 Content-Type 会是 text/html 而不是 text/javascript / application/javascript,而 Status 通常是 200(fallback “成功”了),所以很容易看漏。GitHub Pages、Azure Static Web Apps,以及任何 /* -> /index.html catch-all 把 /assets/* 也匹配进去的 host 都会出这个问题——截至 2026 年 6 月仍然如此。
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 在老浏览器解析失败
bundle 用了访客浏览器解析不了的语法——??=、私有类字段(#field)、top-level await——于是整个模块在你的代码跑起来之前就 SyntaxError 了。截至 2026 年 6 月,Vite 默认的 build.target 是 baseline-widely-available,对应 Chrome/Edge 111、Firefox 114、Safari 16.4(2026-01-01 时的 Baseline Widely Available 集合)。这个默认值对主流浏览器是安全的,所以这一类现在主要坑两种人:手动把 build.target 改成 'esnext' 的,以及用受限内置 webview 的访客——微信、老版本 Android System WebView、kiosk/POS 浏览器、或没更新系统的旧 iPhone 上的旧 Safari。
如何判断:在真正出问题的那台老浏览器上复现(BrowserStack,或者直接在报错的微信/内置浏览器里打开链接)。Console 报 SyntaxError: Unexpected token 或 SyntaxError: Unexpected identifier,行号指向你那个带 hash 的 bundle 文件,而且上面没有别的红色行。
5. Service Worker 缓存了旧的破损版本
上一次部署出过半坏的版本,SW 把它缓存了下来,之后即使新部署修好了,老用户依然加载旧的破 JS,永远白屏。
如何判断:DevTools → Application → Service Workers,看到一个 active 的旧 worker;Application → Cache Storage 里有过期的 bundle 文件名。一个很准的特征:普通窗口白屏,但无痕窗口正常(无痕没有注册 Service Worker)。
你属于哪一类?
按顺序回答,第一个”是”就是你的类别:
- 所有人都白屏,但新访客用无痕打开正常? 不是 SW,去看第 1–4 类。
- 你的普通窗口白屏,无痕正常? 旧 Service Worker(第 5 类)→ Step 5。
- Network 里某个
.js请求返回了<!DOCTYPE html>/Content-Type: text/html? base path(第 2 类)→ Step 3。 - Console 报
Refused to ... Content Security Policy? CSP(第 3 类)→ 改 header。 - Console 报
SyntaxError: Unexpected token指向 bundle,而且只在老/内置浏览器上出现? build target(第 4 类)→ 调低build.target。 - Console 报
Minified React error #418/#423/#425、Hydration failed或一条普通TypeError? 客户端 JS / 水合(第 1 类)→ Step 4。
最短修复路径
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 '<' / MIME type ('text/html') is not executable | base path 错;host 给 .js 请求返回了 index.html | 改 vite.config / astro.config 里的 base;把 /assets/* 从 SPA fallback 里排除 |
Minified React error #418 / #423 / Hydration failed | SSR/CSR 不一致 | 让时间戳 / 随机数 / 本地化组件只在客户端渲染 |
Refused to ... Content Security Policy | CSP 太严 | 放行被拦的来源,或用 nonce/hash 代替 'unsafe-inline' |
Cannot read properties of undefined | 数据没兜底 | 加空值判断 + 根节点 error boundary |
SyntaxError: Unexpected token(只在老/内置浏览器上) | build target 太新 | 调低 build.target(如 es2019)或加 @vitejs/plugin-legacy |
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',
};
如果 base 已经对了、但 .js 请求还是返回 text/html,说明 host 的 SPA fallback 把资源 URL 也一起改写了。把 assets 目录排除掉,别让 catch-all 匹配到它。在 Netlify 上,把 /assets/* 这条规则放在 catch-all 前面:
# netlify.toml —— 具体规则在前,catch-all 在最后
[[redirects]]
from = "/assets/*"
to = "/assets/:splat"
status = 200
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
在 Azure Static Web Apps 上,把这个目录加进 staticwebapp.config.json 的 navigationFallback.exclude(例如 "exclude": ["/assets/*", "/*.{js,css,png,svg}"])。Network 标签页就是证据:修好之后,那条 .js 行必须显示 Status 200 和 Content-Type: application/javascript,而不是 text/html。
改完本地 npm run build && npm run preview 验证一次再 push——preview 会用和 host 相同的 base path 跑真正的生产 bundle。
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+)。boundary 只是止住白屏,它现在暴露出来的那个底层报错你还得去修。
如果 boundary 暴露出的是一个水合不一致(第 1 类),要去修 server/client 差异的源头,而不是把它压下去。让会变的那部分只在客户端渲染:
// Next.js App Router —— 时钟只在客户端渲染
import dynamic from 'next/dynamic';
const Clock = dynamic(() => import('./Clock'), { ssr: false });
如果只是某一处不可避免的差异(比如服务端格式化的日期),就给那一个元素加 suppressHydrationWarning。在 Astro 里把岛标成 client:only="react",它就不会在服务端渲染。不要对整棵子树用 suppressHydrationWarning——那会把真正的 bug 藏起来。
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)));
}
部署一周后再撤掉这个脚本。
怎么确认修好了
别信你自己那个已经缓存过的浏览器。按这个顺序验证:
- 新访客:无痕窗口打开线上 URL,DevTools 开着。Console 应该零红色行,页面正常渲染。
- Network 证据:Network 标签页里每个
.js/.css行都是 Status200,Content-Type是application/javascript/text/css——资源行上不应该再有text/html。 - 没有 Service Worker:Application → Service Workers 显示没有注册(或只有你新的带版本号的那个);Cache Storage 里只有当前 bundle 的 hash。
- 老/内置浏览器(只在你命中第 4 类时才需要):在之前失败的那个微信或内置浏览器里重新打开。
- CI 兜底:一个 headless 冒烟测试访问首页,断言
document.body.innerText.length > 100,这样同样的白屏不会再被部署上去。
预防建议
- 根节点必加 error boundary,任何子组件崩了都不会让整页变白
- CI 里跑一次 Playwright/Puppeteer 冒烟测试,访问首页断言
document.body.innerText.length > 100 - 部署后用 WebPageTest 或 Lighthouse CI 跑一次,能发现 CSP / MIME 类型错
- Vite 的
build.target保持默认baseline-widely-available(截至 2026 年 6 月对应 Chrome/Edge 111、Firefox 114、Safari 16.4);只有当你的分析数据显示有相当一部分老/内置浏览器流量时,才加@vitejs/plugin-legacy - Service Worker 注册时带版本号 + skipWaiting,避免老 SW 卡死新部署
常见问题
页面白屏但 Console 里完全没有错误,怎么办?
有三种可能。要么是 DevTools 开晚了、早期错误已经滚走(带着 DevTools 一起重新加载一次),要么错误被 try/catch 或某个什么都不渲染的框架 boundary 吞了,要么 HTML 其实渲染出来了但被 CSS 全藏了(看 Elements 标签页——内容在不在,是不是 display:none / 高度为零?)。下结论说”没报错”之前,先勾上 Disable cache 再 hard-reload 一次。
为什么 localhost 正常、线上却白屏?
localhost 从根目录提供服务,所以 base 错了也不会触发;线上是从子路径或 CDN 提供的,资源 URL 就错了。dev 还不做压缩,所以 Minified React error #418 只在生产构建里出现。永远用 npm run build && npm run preview 复现,而不是 npm run dev。
我这儿正常,部分用户白屏。 两个常见嫌疑:旧 Service Worker 给这些回头用户发了破损的缓存版本(Step 5),或者 build target 对他们的浏览器太新——微信和其他内置 webview 上很常见(第 4 类)。无痕对比普通窗口是最快的判别器:只在普通窗口白屏就指向 Service Worker。
Unexpected token '<' 一定是 base path 问题吗?
几乎一定——它意味着浏览器在期望 JavaScript 的地方收到了 HTML,因为 host 给一个缺失的资源返回了 index.html。在 Network 标签页确认:那个失败的 .js 请求 Content-Type 是 text/html,body 开头是 <!DOCTYPE html>。改 base/basePath,并把 /assets/* 从 SPA fallback 里排除掉。
加了 suppressHydrationWarning 错误就没了,这样算修好了吗?
只有当这个差异确实是纯展示性的、而且只在单个元素上(一个日期、一个相对时间戳)时才算。它压住的是 warning、不是底层的不一致,所以 React 还是会在客户端丢弃并重渲染那个节点。任何结构性差异——不同的元素、typeof window 条件分支——都要去修根因,让会变的那部分改在客户端渲染。