为什么每次发版,总有用户看到白屏?
先说清楚这篇文章的读法。
它有两个半场,可以分开读:
- 上半场是通用视角:不依赖任何框架、任何底座,盘点业界解决"发版白屏"的主流方案。你在任何项目里都能直接用。
- 下半场是一个参考实现: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,拿到新版本,世界恢复和平。
这个方案的坑有三个:
- 丢状态。用户填了一半的表单,一个 reload 全没了。所以它叫急救,不叫治疗。
- 必须防死循环 。如果白屏的原因不是版本错配而是网络断了,无限 reload 会变成刷新风暴。
sessionStorage标记是最低配的保险。 - 它永远慢一拍。只有等错误真的发生了、用户真的点不进去 了,才会触发。用户已经在心里骂了一句,你才抢救。
方案三:主动版本检测------把抢救变成预告
比"等出错再救"更进一步:让页面自己知道世界上有了新版本。
实现套路非常固定,各家大同小异:
第一步,构建时产出一个版本描述文件:
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 的出发点不是发明新方案,而是回答一个问题:
上面那张表,能不能变成底座的默认能力,让业务一行代码都不用写?
先看它的设计立场,再看实现。这三个立场决定了所有实现细节:
- 增强能力定位:版本检测和离线缓存失败时,静默退场,绝不影响应用启动和业务请求;
- UI 与机制解耦:底座只广播事件,提示长什么样、要不要强刷,由应用自己决定;
- 不引入 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,是部署模型的必然副产物。
静态资源的"随时可替换"和用户浏览器里"跑着的旧版本",这对矛盾只要存在,白屏风险就存在。所有方案做的都是同一件事:缩短旧版本危险存活的窗口,并且让每一次失败都有明确的下场的退路。
如果你只能记住三件事:
- html 协商缓存 + 资源永久缓存,是地基,先检查它;
- 版本检测解决"预告",ChunkLoadError 自愈解决"急救",两者缺一不可;
- 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 真的读了规范,而不是礼貌性忽略。