【AI】Flutter Web 首屏白屏七八秒:三处真实原因的排查记录
症状
我给自己的 DeepSeek 用量监控写了 Web 版(deepseek-monitor),部署在 monitor.lucky845.top。
功能都正常,但打开网页要瞪着一片纯白等七八秒。不是「慢一点」,是白得让人以为站点挂了。
这篇文章记录真实的排查过程。三个原因都很反直觉,而且第一个原因是我自己「优化」出来的。
坑一:我 preload 了一份引擎永远不会加载的文件
第一反应很自然:Flutter Web 首屏慢,主要是要下载 CanvasKit(渲染引擎的 wasm),那就 preload 它,让浏览器尽早开始下载。
于是 index.html 里加了这么一行:
<link rel="preload" as="fetch"
href="canvaskit/canvaskit.wasm" crossorigin="anonymous">看起来完全合理。提交信息写的是「预取 CanvasKit wasm 加速首屏」。
然后首屏更慢了。
打开 DevTools 的 Network 面板,按体积排序,我看到了这样的东西:
canvaskit/canvaskit.wasm 7.3 MB ← 我 preload 的那份
canvaskit/chromium/canvaskit.wasm 5.4 MB ← 引擎真正加载的那份两份都下完了。也就是说,我那行 preload 让用户多下了 7.3MB(gzip 后 2.9MB,实测 1.28 秒),下完发现根本用不上。
为什么用不上
Flutter 引擎启动时不会盲加载根目录那份 wasm,而是按浏览器能力挑变体:
| 变体 | 路径 | 大小 | 适用 |
|---|---|---|---|
| Chromium 专用 | canvaskit/chromium/canvaskit.wasm | 5.4 MB | Chromium 内核 |
| WebParagraph | canvaskit/webparagraph/ | 3.7 MB | 支持 TextCluster 的浏览器 |
| 兜底 | canvaskit/canvaskit.wasm | 7.3 MB | 其他 |
现代 Chrome 走第一条。我 preload 的是第三条——永远不会被选中的那个兜底份。
正确的做法
把 preload 删掉。不是换成「预取正确的变体」,而是什么都不预取:
<!--
这里**故意不预取** canvaskit.wasm,别再把它加回来。
引擎不会加载 canvaskit/canvaskit.wasm,而是按浏览器能力挑变体:
Chromium 走 canvaskit/chromium/canvaskit.wasm(5.4MB / gzip 2.1MB),
支持 TextCluster 的走 canvaskit/webparagraph/(3.7MB)。
在 index.html 里 preload 根目录那份,等于让浏览器白下 7.3MB,
随后引擎再下它真正要的那份 —— 首屏凭空多背一次完整下载。
-->改完再测:canvaskit 只剩 1 个请求。
教训一:优化前先看真实的网络瀑布,不要假设「它会加载什么」。
手写 preload 等于你在替引擎猜它要什么文件。猜错了,代价是双倍下载。
坑二:一个 0 字节的 Service Worker 卡住首屏 4 秒
第一个问题修完,白屏短了一点,但还是有一个明显的空档:从 HTML 加载完,到 main.dart.js 开始下载,中间整段空白——开屏动画转的就是这段。
Network 面板里这个空档非常干净:没有任何请求。
这就不是「慢」的问题了,是有东西在等。
4 秒从哪来
Flutter 3.47 起废弃了 Service Worker:flutter_bootstrap.js 里写死 serviceWorkerVersion: null,构建出的 flutter_service_worker.js 是 0 字节(--pwa-strategy=none)。
但早期部署在用户浏览器里注册过的旧 SW 不会自己消失。问题就出在这:
flutter_bootstrap.js的loadServiceWorker看到旧 registration,就调update();update()拉到一个新的 0 字节 SW;- 这个 SW 脚本没有
skipWaiting(),所以永远停在waiting状态; _waitForServiceWorkerActivation只能一直等到 4000ms 超时;- 超时之后才继续注入
<script src="main.dart.js">。
于是 main.dart.js 晚发 4 秒。用户看到的就是「白屏 + 转圈 4 秒」。
修法:注入 bootstrap 之前先注销
既然 0 字节的 SW 只会带来等待,那就先清干净再加载:
<script>
(function () {
function injectBootstrap() {
var s = document.createElement('script');
s.src = 'flutter_bootstrap.js';
s.async = true;
document.body.appendChild(s);
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function (regs) {
return Promise.all(regs.map(function (r) { return r.unregister(); }));
}).then(injectBootstrap, injectBootstrap);
} else {
injectBootstrap();
}
})();
</script>注意 .then(injectBootstrap, injectBootstrap) —— 注销失败也必须继续,不能因为清缓存的动作把整个应用卡死。
结果:loader 走 getRegistration() 为空的快路径,不注册、不等待,那 4 秒空窗消失。
教训二:白屏不一定是「下载慢」,也可能是「在等一个永远等不到的事件」。
判断方法很简单——看 Network 里有没有一段完全空白的区间。有,就去找「等待」而不是「带宽」。
坑三:中文字体走 gstatic,国内等于没字
第三个问题在别的环境不一定暴露:页面出来了,但字全是空白/豆腐块,要过好几秒才恢复正常。
原因在 FontManifest.json。Web 版的 manifest 里只有图标字体(MaterialIcons、CupertinoIcons),正文汉字靠 fontFallbackBaseUrl 兜底——默认指向 fonts.gstatic.com。
两个致命的点:
- 原始 Noto Sans SC 是 10.5MB 的 ttf,gzip 后仍有 6MB;
- 引擎是「画到某个字发现没字形才去下载」。也就是说首帧早就过去了,字才陆续到,到了还要重排一次。
国内访问 gstatic 基本不通,于是「没字」变成「一直没字」。
方案一:自托管 + 子集化
把两个字体随仓库托管,并且按 lib/ 里真实用到的字裁子集:
| 产物 | 负责 | 母本 | 子集 | gzip |
|---|---|---|---|---|
notosanssc/v36/<hash>.ttf | 汉字与中文标点 | 2064 KB | 149 KB | 93 KB |
roboto/v20/<hash>.ttf | Latin 与 ASCII | 167 KB | 54 KB | 36 KB |
2064KB → 149KB,14 倍。做法是扫源码收集用字,再用母本 cmap 过滤:
# 按 lib/ 当前用字重新裁 —— 改了中文文案就跑这个
uv run --no-project --with fonttools python tool/subset_web_fonts.py
# 只校验不写文件
uv run --no-project --with fonttools python tool/subset_web_fonts.py --check两个必须记住的坑
坑 A:文件名不能改。
notosanssc/v36/<hash>.ttf 里的 hash 是引擎构建时写死的,--no-web-resources-cdn 之下引擎只认这一个名字。换子集是原地替换文件内容,不是加一个新文件。
坑 B:别用 GB2312 子集,它是坏的。
我一开始图省事,直接取 U+4E00–U+9FA0(CJK 统一表意文字基本区)。结果整个标点区都不在里面——,。、:;「」()→… 全被吃掉。
不是你的。点「连接账号」显示成了:
不是你的 点 连接账号界面上每个逗号句号都消失了。原因是中文标点 U+3000–U+303F、全角形式 U+FF00–U+FFEF 都不在那个区间里。
现在的做法是脚本扫 lib/ 得到真实用字,再用两个母本 cmap 的并集过滤掉母本里也没有的码位(比如 ✔✗✘),避免留下一个永远满足不了的检查。
方案二:预取一片,其余按需
Flutter 3.45+ 起引擎改用 v37 分片:按字符切、按需取。
32 片里有一片覆盖了 lib/ 中 118/413 个汉字(28.6%,按字符数排第一),27.5KB。把它作为首帧快路径预取:
<link rel="preload" as="fetch"
href="fonts/notosanssc/v37/<hash>.118.woff2" crossorigin>其余 31 片由引擎在真正用到时按需加载——只看看首页的用户不必背 217KB 全量。
加分项:骨架屏,治「纯白」
上面三条修完,首屏从七八秒降到一两秒。但一两秒的白屏仍然是白屏。
Flutter 引擎(main.dart.js ~0.7MB gzip + canvaskit ~1.9MB gzip)下载启动之前,<body> 是纯白的,没有任何东西可看。
所以加一个纯内联的骨架屏:不额外请求任何资源,不阻塞首屏,引擎画出第一帧后由 Dart 侧移除。
<style>
html, body { height: 100%; margin: 0; background: #0B1020; }
#boot-skeleton {
position: fixed; inset: 0; z-index: 9998;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
background: radial-gradient(120% 80% at 50% 0%, #16213D 0%, #0B1020 62%);
transition: opacity .32s ease;
}
#boot-skeleton[data-done="1"] { opacity: 0; pointer-events: none; }
/* 还有一条按 prefers-reduced-motion 关掉动画的规则 */
@media (prefers-reduced-motion: reduce) {
.bs-logo, .bs-bar { animation: none; }
}
</style>移除逻辑要留两条路:
// 优先由 Flutter 第一帧触发;定时器只是兜底,
// 万一引擎或网络彻底失败,30 秒后也不会让用户永远停在加载态。
setTimeout(dropSkeleton, 30000);兜底比好看重要。 骨架屏最怕的不是加载慢,是加载失败后永远停在那里,让用户以为在等但其实永远等不到。
工程化:把结论固化成测试
这三个坑有一个共同点:修完之后,很容易被人「顺手优化」回去——尤其是在半年后、换了个人、看到 preload 觉得「不加可惜」的时候。
所以每条结论都配了测试:
test/web_boot_assets_test.dart:拦住重新把canvaskit.wasmpreload 加回来;同时校验 preload 的文件在源码树里真实存在(防止 404)。test/web_font_subset_test.dart:解析产物 cmap 表,和lib/源码的实际用字比对——改了中文文案不重跑子集脚本,CI 直接失败。否则新字会渲染成豆腐块,而这个问题只在你恰好用到那个字时才会暴露。
这才算闭环:不是「我修好了」,是「我修好了,而且它不会再坏回去」。
为什么国内访问还要一个反代
顺带一句与首屏无关但绕不开的:浏览器根本读不到 platform.deepseek.com 的数据。
两条独立的墙:
- 同源策略:网页读不到平台的 localStorage,官方也没有 OAuth 回调地址;
- CORS:即便手动粘令牌,平台响应里没有任何
access-control-*头(实测带Origin请求返回HTTP 200,但响应头里一个都没有;preflightOPTIONS直接 429),浏览器会丢弃整个响应。
对照之下 api.deepseek.com 会回 access-control-allow-origin,所以 API Key 读余额在网页端直接可用。
解法是同源反向代理:线上用 Cloudflare Worker 挂在 monitor.lucky845.top/platform-api/*,转发到平台并补上 CORS 头。有个细节必须注意:平台对没有 User-Agent 的请求直接 429,所以反代必须透传浏览器的 UA。
小结
| 问题 | 表象 | 真实原因 | 代价 |
|---|---|---|---|
| 错误的 preload | 首屏更慢 | 预取了引擎不会加载的兜底变体 | 白下 7.3MB / 1.28s |
| 0 字节 SW 残留 | 网络空窗 | waiting 状态等到 4000ms 超时 | 晚发 4 秒 |
| 字体走 gstatic | 有页面没字 | 10.5MB ttf + 缺字形才下载 | 国内基本不可用 |
| 纯白 body | 白屏观感 | 引擎启动前无可渲染内容 | 骨架屏解决 |
如果只记一条:
白屏排查,先看 Network 瀑布,再谈优化。
「下载慢」和「在等待」是两种病,用错药会像我第一次那样——把首屏优化得更慢。