为什么每次发版,总有用户看到白屏?

为什么每次发版,总有用户看到白屏?

先说清楚这篇文章的读法。

它有两个半场,可以分开读:

  • 上半场是通用视角:不依赖任何框架、任何底座,盘点业界解决"发版白屏"的主流方案。你在任何项目里都能直接用。
  • 下半场是一个参考实现:LYStack 底座怎么把这些方案组装成"业务零代码"的默认能力。

如果你不关心 LYStack,读完上半场就可以关掉;如果你在读这个系列,下半场承接上一篇结尾按住没展开的 version.json 和离线缓存。

两个半场讲的是同一个问题的两副面孔:

白屏不是某次发版的偶然事故,而是"前端部署模型"的必然副产物------静态资源可以随时替换,而用户浏览器里跑着的那份,永远可能是旧的。


一、白屏到底是怎么发生的

先把机制讲透。不搞清楚白屏怎么来的,后面所有方案都是在背咒语。

现代前端项目的部署产物长这样:

text 复制代码
dist/
├── index.html          ← 不带 hash,每次发版被整个替换
├── app.a1b2c3.js       ← 带内容 hash,内容变 → 文件名变
├── chunk.d4e5f6.js
└── vendor.g7h8i9.css

这套设计本身的意图是好的:html 不带 hash 方便入口稳定,资源带 hash 可以永久缓存------内容一变名字就变,缓存永远不会过期。

问题出在发版的那一刻,服务器上的文件被整体替换了,而用户浏览器里"活着"的东西不止是文件

白屏场景可以穷举成四类。

场景一:旧 html 引用了已删除的资源

html 被某个中间层缓存了(代理、CDN 配置失误、浏览器强缓存),用户拿到的是上一个版本的 html

它引用的 app.a1b2c3.js 在服务器上已经被删了------新版本叫 app.x9y8z7.js

脚本 404,页面一片空白。

场景二:新 html + 残留的旧缓存资源

反过来,html 是新的,但某个资源文件被浏览器或 CDN 从缓存里捞出来的是旧的。

新旧代码混跑。这种不一定白屏,更可怕------幽灵 Bug:某个功能在部分用户机器上必现,你复现不了,因为他的浏览器正在运行一个世界上已经不存在的版本组合。

场景三:存活页签里的旧应用

这是最经典、也最被低估的一类。

用户上午打开了你的应用,页签一直开着。下午你发了版。

页面里的应用还是旧的,跑在内存里,一切正常------直到用户点了一个还没访问过的菜单,触发懒加载路由:

text 复制代码
内存中的旧应用 ──请求──> chunk.d4e5f6.js(旧文件名)
                            │
                            ▼
                     服务器:这个文件已经没有了(404)

动态 import() 失败,路由跳转中断,Vue 里表现为切不过去的白屏,React 里是 Suspense 抛错,webpack 时代叫 ChunkLoadError

用户看到的就是:点了没反应,或者页面白了

场景四:Service Worker 自己造成的版本固化

上了 SW 的项目会多一类:SW 把整套资源缓存在 Cache Storage 里,如果更新策略没设计好,用户可能永远拿到旧版本------发版十次,他看到的还是第一次访问时的页面。

这类白屏(准确说是"白屏在错误的版本上")是 SW 方案的自带风险,后面单独说。

一张图总结

text 复制代码
                服务器(随时整体替换)
                index.html + hash 资源
                    ▲
        ┌───────────┼───────────┬──────────────┐
        │           │           │              │
   旧 html      新 html      存活页签        SW 缓存
   + 404 资源   + 旧缓存资源  + 懒加载 404    + 不更新的策略
        │           │           │              │
        ▼           ▼           ▼              ▼
     白屏        幽灵 Bug     白屏/卡死      版本固化

四类场景,对应下面四层主流方案。没有哪个方案能单独包打天下,业界的共识是分层防御


二、上半场:主流方案全景

以下方案按"从地基到兜底"排序。每个方案给原理、骨架代码和边界,你可以对照自己的项目查漏。

方案一:缓存策略------一切的地基

第一件事不是写代码,是把缓存协议配对:

text 复制代码
index.html        → no-cache(协商缓存,每次问服务器要最新的)
hash 资源文件     → Cache-Control: max-age=31536000, immutable

html 永远协商,资源永久缓存。这一对组合直接消灭场景一和场景二的大部分------前提是它真的被执行

常见的翻车点:

  • CDN 或 nginx 给 html 配了强缓存(max-age=3600),发版后一小时内的用户全在拿旧 html;
  • 资源文件名没带 hash(比如 app.js),"永久缓存"等于永久不更新;
  • 前端路由的 History 模式下,nginx 把所有路径都回退到 index.html,但回退响应带了缓存头。

能力边界:这个方案只保护"新来的访客"。它管不了场景三------存活的页签根本不会重新请求 html。也管不了已经配错的中间层。

所以它是地基,不是全部。

方案二:ChunkLoadError 自愈------最后的急救

针对场景三,业界最普遍的做法是捕获加载失败,强制刷新一次

ts 复制代码
// 应用入口
window.addEventListener('error', (event) => {
  const message = event.message ?? '';
  if (
    message.includes('Loading chunk') ||
    message.includes('Failed to fetch dynamically imported module')
  ) {
    reloadOnce();
  }
});

window.addEventListener('unhandledrejection', (event) => {
  const message = String(event.reason?.message ?? event.reason ?? '');
  if (message.includes('dynamically imported module')) {
    reloadOnce();
  }
});

function reloadOnce(): void {
  // 防死循环:如果刷新一次还是失败,说明不是版本错配,别再刷了
  if (sessionStorage.getItem('__reloaded_for_chunk_error__')) {
    return;
  }
  sessionStorage.setItem('__reloaded_for_chunk_error__', '1');
  location.reload();
}

用 Vue Router 的项目通常再加一道路由级的:

ts 复制代码
router.onError((error) => {
  if (/dynamically imported module|Loading chunk/i.test(error.message)) {
    reloadOnce();
  }
});

刷新之后,浏览器重新请求 html,拿到新版本,世界恢复和平。

这个方案的坑有三个

  1. 丢状态。用户填了一半的表单,一个 reload 全没了。所以它叫急救,不叫治疗。
  2. 必须防死循环 。如果白屏的原因不是版本错配而是网络断了,无限 reload 会变成刷新风暴。sessionStorage 标记是最低配的保险。
  3. 它永远慢一拍。只有等错误真的发生了、用户真的点不进去 了,才会触发。用户已经在心里骂了一句,你才抢救。

方案三:主动版本检测------把抢救变成预告

比"等出错再救"更进一步:让页面自己知道世界上有了新版本

实现套路非常固定,各家大同小异:

第一步,构建时产出一个版本描述文件:

json 复制代码
// version.json,随构建产物一起部署
{
  "buildId": "a1b2c3d4",
  "buildTime": "2026-08-20T10:00:00Z"
}

第二步,运行时定期去拿它,和当前运行的版本比对:

ts 复制代码
const currentBuildId = __BUILD_ID__; // 构建时注入

async function checkVersion(): Promise<void> {
  const response = await fetch('./version.json?t=' + Date.now(), { cache: 'no-store' });
  const latest = await response.json();
  if (latest.buildId !== currentBuildId) {
    notifyUserToUpdate();
  }
}

setInterval(checkVersion, 5 * 60 * 1000);

第三步,提示用户。主流产品(掘金、飞书、各类后台系统)的做法基本是一个不打扰的 toast:

系统已更新,点击刷新后使用新版本

关键设计点是"提示刷新"而不是"强制刷新"。强刷会丢用户状态,把一个优化体验的机制做成了事故。

能力边界

  • 它需要用户"正在用"才能检测到(轮询、聚焦、切页签);
  • 提示之后,刷不刷的决定权在用户,在那之前他依然运行在旧版本上------所以它必须和方案二配合:检测是预告,自愈是兜底;
  • 检测请求本身要克制:no-store、加超时、失败静默,别让一个"锦上添花"的机制变成性能负担。

方案四:Service Worker------最强也最需要敬畏的方案

SW 一次性面对三类问题:资源缓存、版本更新、离线可用。业界的标准答案是 Workbox

js 复制代码
// sw.js ------ Workbox 预缓存 + 更新提示
import { precacheAndRoute } from 'workbox-precaching';
precacheAndRoute(self.__WB_MANIFEST);

self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});
ts 复制代码
// 注册侧 ------ 检测到新 SW 等待中,提示用户,确认后激活
const registration = await navigator.serviceWorker.register('./sw.js');

registration.addEventListener('updatefound', () => {
  const worker = registration.installing;
  worker?.addEventListener('statechange', () => {
    if (worker.state === 'installed' && navigator.serviceWorker.controller) {
      showUpdateToast('发现新版本', async () => {
        worker.postMessage({ type: 'SKIP_WAITING' });
        navigator.serviceWorker.addEventListener('controllerchange', () => location.reload(), { once: true });
      });
    }
  });
});

它能做到"下一次刷新必然是新版本",还能顺带解决弱网和离线。

但 SW 是四种方案里最容易翻车的

  • html 的缓存策略如果配成"缓存优先"且没有后台更新,就是上面说的版本固化------你亲手制造了场景四;
  • precache 清单靠运行时爬取(旧式的 runtime caching)容易漏资源、存错版本;
  • SW 的生命周期(installing → waiting → activating)和多页签共存的问题,调试成本不低。

一句话:SW 把缓存问题从"配置协议"升级成了"状态机编程"。用得好是终极方案,用不好是白屏制造商。

方案五:白屏监测------兜底的兜底

最后一块拼图:不管前面防得多好,你总需要一个"白屏真的发生了,我知道"的机制。

思路是对渲染结果采样,而不是捕获异常------因为白屏经常不抛异常(比如根节点渲染成了空):

ts 复制代码
function detectBlankScreen(): boolean {
  // 页面挂载后 3 秒,对视口做多点采样
  const points = [
    [0.5, 0.2], [0.2, 0.5], [0.8, 0.5], [0.5, 0.8], [0.5, 0.5],
  ];
  const blanks = points.filter(([x, y]) => {
    const element = document.elementFromPoint(
      innerWidth * x,
      innerHeight * y,
    );
    // 采样点打到的还是 html/body,说明这里没渲染出内容
    return element === document.body || element === document.documentElement;
  });
  return blanks.length >= 3;
}

检测到白屏后:上报监控平台(带上版本号、URL、UA),本地 reloadOnce() 一次尝试自愈。

它不解决问题,它解决**"问题发生了你却不知道"**。

方案六:部署侧策略------工程师的最后防线

前三类错配的物理根源是"旧资源被删了 "。那么最朴素的反向操作是:别删

  • 保留最近 N 个版本的 hash 资源(CDN/OSS 上旧 chunk 不立即清理,滚动删除);
  • html 永远即发即生效(no-cache);
  • 发布走灰度,回滚预案演练过。

保留旧资源之后,场景三的懒加载 404 直接物理消失------旧页签要的旧 chunk 一直拿得到,直到它自然消亡。这是很多大厂的隐性基础设施,前端代码一行不用写,但它有存储成本,且依赖运维配合。

上半场小结:一张对照表

方案 解决的场景 成本 定位
缓存策略配对 场景一、二 低(配置) 地基,必做
ChunkLoadError 自愈 场景三 低(几十行) 急救,必做
主动版本检测 场景三的前置预警 预告,强烈建议
Service Worker 全场景 + 离线 终极方案,按需
白屏监测 所有场景的发现 兜底的兜底
部署侧保留旧资源 场景三物理消除 低代码 + 高运维 基建配合

注意一个事实:这张表里没有一行是框架提供的。Vue 和 React 都不会替你做其中任何一件。这就是为什么绝大多数项目的白屏防御是残缺的------不是不知道方案,而是每个方案都散落在不同地方,没有一个角色负责把它们组装起来。

这就引出下半场。


三、下半场:LYStack 是怎么规避的

LYStack 的出发点不是发明新方案,而是回答一个问题:

上面那张表,能不能变成底座的默认能力,让业务一行代码都不用写?

先看它的设计立场,再看实现。这三个立场决定了所有实现细节:

  1. 增强能力定位:版本检测和离线缓存失败时,静默退场,绝不影响应用启动和业务请求;
  2. UI 与机制解耦:底座只广播事件,提示长什么样、要不要强刷,由应用自己决定;
  3. 不引入 Workbox:precache 清单在构建期生成,SW 用一个两百行以内的手写模板------SW 是状态机,状态机越小越好调试。

1. 构建期:给每个构建一个"内容指纹"

一切检测的前提是"当前版本"和"最新版本"可比较。

LYStack 在构建收尾阶段(两套 adapter 的 runtime 插件里)做三件事:

给每个 html 注入版本指纹

html 复制代码
<meta name="lystack-app-name" content="example-rsbuild" />
<meta name="lystack-build-id" content="example-rsbuild@0.0.0:a1b2c3d4" />
<meta name="lystack-build-time" content="2026-08-20T10:00:00.000Z" />

产出一个 version.json

json 复制代码
{
  "appName": "example-rsbuild",
  "packageVersion": "0.0.0",
  "buildId": "example-rsbuild@0.0.0:a1b2c3d4",
  "buildTime": "2026-08-20T10:00:00.000Z",
  "envMode": "production"
}

把版本检测脚本注入为 preEntry------在业务代码之前执行,业务对它零感知:

ts 复制代码
source: {
  preEntry: [versionCheckEntry, ...(offlineEnabled ? [offlineRegisterEntry] : [])],
}

这里有个最值得讲的细节:buildId 是内容寻址的,不是时间寻址的

ts 复制代码
const includedAssets = assets
  .filter(({ name }) => name !== VERSION_FILE_NAME && !name.endsWith('.gz'))
  .sort((left, right) => left.name.localeCompare(right.name));

for (const asset of includedAssets) {
  hash.update(asset.name);
  hash.update(asset.name.endsWith('.html') ? stripVersionMeta(String(asset.source)) : asset.source);
}

return hash.digest('hex').slice(0, 8);

buildId 里的 hash 由全部产物内容算出。两个推论:

  • 代码一个字没改,重新构建,buildId 不变 ------因为 buildTime 不参与 hash(html 参与计算前还会把版本 meta 剥掉)。CI 重跑、缓存重建、revert 后重发,都不会给用户弹"系统已更新"的假通知;
  • 任何产物变化,buildId 必变------不存在"改了代码但指纹没跟上"的漏报。

一个指纹,同时消灭误报和漏报。

2. 运行期:四个时机,一次通知,全部静默

检测脚本(构建时注入的那个 preEntry)的实现,是一个教科书式的"方案三"落地,但时机设计更完整:

ts 复制代码
if (currentBuildId) {
  window.addEventListener('load', () => void checkAppVersion(), { once: true });
  window.addEventListener('online', () => void checkAppVersion());
  window.addEventListener('focus', () => void checkAppVersion());
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') {
      void checkAppVersion();
    }
  });
  window.setInterval(() => void checkAppVersion(), CHECK_INTERVAL);
}

五个触发点,翻译成人话:

  • load:刚打开,查一次------最常见的"用户拿到的就是旧的"在第一次打开就被发现;
  • online:网络恢复了查------离线期间很可能错过了一次发版;
  • focus / visible:从别的页签切回来查------用户离开的几分钟里世界可能变了;
  • 定时轮询(5 分钟):给"一直挂在前台不切走"的用户兜底。

而请求本身把"克制"写进了每个参数:

ts 复制代码
const response = await fetch(VERSION_URL, {
  cache: 'no-store',
  signal: AbortSignal.timeout(15_000),
});
  • no-store:绝不用缓存回答"世界是不是变了"这个问题;
  • 15 秒超时:慢网络不堆积请求;
  • 页面不可见不查、正在查不重入、已通知不重复通知;
  • 整个 try/catch 吞掉所有异常------版本检测挂了,应用必须照常工作。

比对发现版本不一致时,它不弹任何 UI,只广播一个事件:

ts 复制代码
window.dispatchEvent(
  new CustomEvent('app-update-ready', {
    detail: {
      currentBuildId,
      latestBuildId: latest.buildId,
      latest,
    },
  }),
);

提示还是不强提示、toast 长什么样、要不要带"立即刷新"按钮------全部留给应用:

ts 复制代码
// 应用侧(示例):用 Naive UI 弹一个持续型的提示
window.addEventListener('app-update-ready', (event) => {
  const { latestBuildId } = (event as CustomEvent).detail;
  notification.create({
    content: '系统已更新',
    meta: `新版本 ${latestBuildId} 已就绪`,
    duration: 0,
    action: () => h(NButton, { onClick: () => location.reload() }, { default: () => '刷新' }),
  });
});

底座管事实,应用管体验。这就是立场二。

3. Service Worker:构建期生成清单,指纹命名缓存

离线缓存默认关闭,应用声明 offlineCache: true 启用,且只在 test 和 production 环境生效------原因后面讲。

启用后,构建期会从 sw-template.js 生成一个填好清单的 sw.js

text 复制代码
precache 清单(构建期就确定,不是运行时爬的):
├── 全部 js / css
├── 图片、字体(可配置)
├── 全部 html 页面
└── offline.html(兜底离线页)

生成的 SW 有几个关键行为:

缓存按构建指纹命名

text 复制代码
Cache Storage
├── lystack:example-app:a1b2c3d4   ← 当前版本
└── lystack:example-app:x9y8z7e0   ← 旧版本(激活时删除)

新版本 SW 安装时把新资源写进新缓存,激活时清掉所有旧指纹的缓存。不存在"新 SW 读写旧缓存"的错配------缓存的名字里就带着版本,版本换了缓存必然换

文档请求:缓存优先 + 后台再验证

js 复制代码
function handleDocument(event) {
  return caches.open(CACHE_NAME).then((cache) =>
    cache.match(cacheKey).then((cached) => {
      const network = fetch(event.request)
        .then((response) => {
          /* 命中白名单则顺手更新缓存 */
          return cache.put(cacheKey, response.clone()).then(() => response);
        })
        .catch(() => cached || caches.match('./offline.html'));

      return cached || network;
    }),
  );
}

文档从缓存秒回(保证打开速度和离线可用),同时网络响应在后台刷新缓存,下一次加载必然是新版本。配合上面的版本检测事件,"提示刷新 → 刷新即新版"闭环成立。

资源请求:缓存优先,网络回填。hash 资源内容寻址,缓存命中即正确。

离线兜底 :网络失败且无缓存时返回内置的 offline.html------一个写着"当前网络不可用"的静态页。宁可明确地告诉他没网,不要不明不白地白屏

逃生通道 :SW 监听 CLEAR_OFFLINE_CACHE 消息,一键清空全部离线缓存------线上出现诡异缓存问题时,有后悔药。

4. 为什么默认只在 test / production 启用

ts 复制代码
envModes: options?.envModes ?? ['test', 'production'],

开发环境不启用离线缓存,原因很实际:

dev 模式下每次改代码产物都在变,指纹每秒都不同。SW 在这个节奏下只会做两件事------缓存一堆马上作废的资源,以及不断触发"发现新版本"。在开发环境,版本检测没有意义,版本固化只有害处

而 test 环境启用,恰恰是为了让"离线缓存的更新流程"在类生产条件下被真实演练过------上一篇文章说过 EnvMode 与 BuildMode 分离:test 用的是生产级构建,SW 在 test 里的行为就是上线后的行为。

5. 组装对照:主流方案 → LYStack 落点

主流方案 LYStack 的落点 增强点
缓存策略配对 adapter 内建 hash 产物 + assetPrefix 契约化,不靠自觉
ChunkLoadError 自愈 ------(应用层兜底,未内建) 诚实留白
主动版本检测 version.json + 四时机检测 + app-update-ready 内容寻址指纹,重构建不误报
Service Worker 手写 SW 模板,构建期生成 precache 缓存按指纹命名,物理隔离版本
白屏监测 ------(交给监控平台) 诚实留白
部署侧保留旧资源 offline.html 兜底 + CLEAR_OFFLINE_CACHE 部分覆盖

注意那两行"诚实留白"。

ChunkLoadError 自愈和白屏监测没有被做进底座------前者涉及"是否接受丢状态"的产品决策,后者依赖具体监控平台,硬编码进底座反而绑架了应用。默认能力不等于全部能力,知道不做什么和知道做什么一样重要。应用侧要补这两块,上半场的骨架代码抄走就能用。


四、把整条防线串起来

LYStack 一次发版后的完整防线,按时间线走一遍:

text 复制代码
T0  发版,服务器整体替换,version.json 换新指纹
      │
T1  存活页签切回前台 / 定时器到点 / 重新联网
      │  fetch version.json(no-store, 15s 超时)
      ▼
T2  buildId 不一致 → 广播 app-update-ready
      │                └→ SW registration.update() 同步预热新缓存
      ▼
T3  应用层决定:toast 提示 / 静默 / 应用自定义策略
      │
T4  用户点击刷新 → 新 SW 已就绪,新 html 从新缓存秒出
      │
T5  (漏网之鱼)懒加载旧 chunk 失败 → 应用层自愈 reload(方案二)
      │
T6  (极端情况)网络断开 → offline.html 明确提示,而非白屏

从"预告"到"急救"到"离线兜底",每一环都不假设上一环完美工作。


最后

回到开头那句话:白屏不是 bug,是部署模型的必然副产物。

静态资源的"随时可替换"和用户浏览器里"跑着的旧版本",这对矛盾只要存在,白屏风险就存在。所有方案做的都是同一件事:缩短旧版本危险存活的窗口,并且让每一次失败都有明确的下场的退路

如果你只能记住三件事:

  1. html 协商缓存 + 资源永久缓存,是地基,先检查它;
  2. 版本检测解决"预告",ChunkLoadError 自愈解决"急救",两者缺一不可;
  3. SW 是最强的方案,也是唯一能把问题做更严重的方案------用它之前,先想清楚缓存命名和更新时机。

对系列读者多说一句:这个系列聊到现在------目录、请求层、分层、包边界、配置、构建工具------主角一直是"开发期的架构"。这一篇是第一次聊"部署之后的世界"。开发期的所有优雅,最终都要在发版那一刻接受检验。

LYStack 项目地址:

text 复制代码
https://github.com/liangy0323/LYStack

源码对应位置:

text 复制代码
packages/build-config/src/features/version.ts        # 指纹计算与 meta 注入
packages/build-config/src/features/offline.ts        # SW 生成与 precache 清单
packages/build-config/src/runtime/version-check.ts   # 四时机版本检测
packages/build-config/src/runtime/offline-register.ts# SW 注册与更新通知
packages/build-config/src/runtime/sw-template.js     # Service Worker 模板

下一篇

下一篇,从这个系列的"工程主线"切到一条新支线,也是我最近想得比较多的问题:

AI 写的代码能跑,但为什么不像"团队写的"代码?

模型越来越强,生成的代码越来越能跑。但放进一个真实团队的项目里,AI 的代码总是差一口气:目录放错位置、命名跟着感觉走、错误处理随手 console.log、不知道哪些约定不能碰。

不是模型不行,是没人把团队的隐性规范喂给它

下一篇,我会结合 LYStack 仓库里的 AGENTS.md.rules 文件,聊聊:

  • 为什么 README 写给人看,AGENTS.md 要写给 AI 看;
  • 一份能约束住 AI 的规范文件,需要包含哪几类内容;
  • 隐性知识("我们从来不这么做")怎么显性化成 AI 可执行的规则;
  • 怎么验证 AI 真的读了规范,而不是礼貌性忽略。
相关推荐
风月说与山鬼1 小时前
四、浏览器存储
前端·vue.js
liuxiaocheng1 小时前
上下文工程:LangChain 怎么组织「喂给模型的东西」
前端·后端
breeze jiang1 小时前
CSS 三栏布局怎么写:Flex、Grid 完整方案与 BFC 区别
前端·css
郭邯1 小时前
手写一个Cron表达式解析器:从需求分析到AI辅助实现
前端
阿黎梨梨1 小时前
Docker 容器化实战:从零搭建 Web 服务与反向代理
前端·后端·docker
Liora_Yvonne1 小时前
前端项目为什么需要一个配置单一真相源?
前端
禁止摆烂_才浅1 小时前
Vite 面试题
前端·面试·vite
GISer_Jing1 小时前
全栈AI实战:基于 TypeScript + LangChain + MCP 的企业级智能研发助手
前端·后端·ai·langchain·前端框架
禁止摆烂_才浅1 小时前
微信小程序高频面试题
前端·面试·微信小程序