静态站打开是白屏:10 分钟定位根因

线上 URL 打开后 title 闪一下就剩白屏、没有任何 UI 报错。先在打开页面前开好 DevTools,读 Console 第一条红色错误,再对照表格定位是 base path、水合、CSP、build target 还是旧 Service Worker。

打开线上 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.comcdn.jsdelivr.net),全被浏览器拒绝执行。

Refused to execute inline script because it violates the following 
Content Security Policy directive: "script-src 'self'".

如何判断:Console 里搜 Content Security PolicyRefused to,每条都标注了被拒绝的 URL 和违反的指令。

4. ES module 在老浏览器解析失败

bundle 用了访客浏览器解析不了的语法——??=、私有类字段(#field)、top-level await——于是整个模块在你的代码跑起来之前就 SyntaxError 了。截至 2026 年 6 月,Vite 默认的 build.targetbaseline-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 tokenSyntaxError: 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/#425Hydration 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 executablebase path 错;host 给 .js 请求返回了 index.htmlvite.config / astro.config 里的 base;把 /assets/* 从 SPA fallback 里排除
Minified React error #418 / #423 / Hydration failedSSR/CSR 不一致让时间戳 / 随机数 / 本地化组件只在客户端渲染
Refused to ... Content Security PolicyCSP 太严放行被拦的来源,或用 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.jsonnavigationFallback.exclude(例如 "exclude": ["/assets/*", "/*.{js,css,png,svg}"])。Network 标签页就是证据:修好之后,那条 .js 行必须显示 Status 200Content-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)));
}

部署一周后再撤掉这个脚本。

怎么确认修好了

别信你自己那个已经缓存过的浏览器。按这个顺序验证:

  1. 新访客:无痕窗口打开线上 URL,DevTools 开着。Console 应该零红色行,页面正常渲染。
  2. Network 证据:Network 标签页里每个 .js / .css 行都是 Status 200Content-Typeapplication/javascript / text/css——资源行上不应该再有 text/html
  3. 没有 Service Worker:Application → Service Workers 显示没有注册(或只有你新的带版本号的那个);Cache Storage 里只有当前 bundle 的 hash。
  4. 老/内置浏览器(只在你命中第 4 类时才需要):在之前失败的那个微信或内置浏览器里重新打开。
  5. 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-Typetext/html,body 开头是 <!DOCTYPE html>。改 base/basePath,并把 /assets/* 从 SPA fallback 里排除掉。

加了 suppressHydrationWarning 错误就没了,这样算修好了吗? 只有当这个差异确实是纯展示性的、而且只在单个元素上(一个日期、一个相对时间戳)时才算。它压住的是 warning、不是底层的不一致,所以 React 还是会在客户端丢弃并重渲染那个节点。任何结构性差异——不同的元素、typeof window 条件分支——都要去修根因,让会变的那部分改在客户端渲染。

相关阅读

标签: #部署 / 托管 #排查 #排查