症状

我给自己的 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.wasm5.4 MBChromium 内核
WebParagraphcanvaskit/webparagraph/3.7 MB支持 TextCluster 的浏览器
兜底canvaskit/canvaskit.wasm7.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 不会自己消失。问题就出在这:

  1. flutter_bootstrap.js 的 loadServiceWorker 看到旧 registration,就调 update();
  2. update() 拉到一个新的 0 字节 SW;
  3. 这个 SW 脚本没有 skipWaiting(),所以永远停在 waiting 状态;
  4. _waitForServiceWorkerActivation 只能一直等到 4000ms 超时;
  5. 超时之后才继续注入 <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。

两个致命的点:

  1. 原始 Noto Sans SC 是 10.5MB 的 ttf,gzip 后仍有 6MB;
  2. 引擎是「画到某个字发现没字形才去下载」。也就是说首帧早就过去了,字才陆续到,到了还要重排一次。

国内访问 gstatic 基本不通,于是「没字」变成「一直没字」。

方案一:自托管 + 子集化

把两个字体随仓库托管,并且按 lib/ 里真实用到的字裁子集:

产物负责母本子集gzip
notosanssc/v36/<hash>.ttf汉字与中文标点2064 KB149 KB93 KB
roboto/v20/<hash>.ttfLatin 与 ASCII167 KB54 KB36 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.wasm preload 加回来;同时校验 preload 的文件在源码树里真实存在(防止 404)。
  • test/web_font_subset_test.dart:解析产物 cmap 表,和 lib/ 源码的实际用字比对——改了中文文案不重跑子集脚本,CI 直接失败。否则新字会渲染成豆腐块,而这个问题只在你恰好用到那个字时才会暴露。

这才算闭环:不是「我修好了」,是「我修好了,而且它不会再坏回去」。

为什么国内访问还要一个反代

顺带一句与首屏无关但绕不开的:浏览器根本读不到 platform.deepseek.com 的数据。

两条独立的墙:

  1. 同源策略:网页读不到平台的 localStorage,官方也没有 OAuth 回调地址;
  2. CORS:即便手动粘令牌,平台响应里没有任何 access-control-* 头(实测带 Origin 请求返回 HTTP 200,但响应头里一个都没有;preflight OPTIONS 直接 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 瀑布,再谈优化。
「下载慢」和「在等待」是两种病,用错药会像我第一次那样——把首屏优化得更慢。