页面下载的字节数一个没少,编辑器却快了15% 到 19%。这不是压缩,也不是删代码——改的只是那个最大的文件什么时候开始下载。
Recraft Studio 是一个在网页上生成图片、再在画布上继续加工的应用。画布所在的编辑器页面很大,工具很多。团队某天盯上了一个问题:这个编辑器到底要加载多久。

四个 PR 之后,画布首次渲染在 p50、p75、p90 三个分位上都快了 15% 到 19%。代价大约是 300 行代码。
有意思的地方在于这些代码没做什么:页面下载的字节数和以前完全一样,之前 16.75 MB,现在 16.71 MB。没有删东西,也没有压缩。唯一变的是那个最大文件的加载时机——渲染引擎,也就是他们自己 fork 的 Skia,编译成 WebAssembly 后传输体积 9.2 MB。
这个数字不是来自一张前后对比图。改动是当作真实实验跑的,有对照组,每组约 1.8 万用户,持续同样的五天。
漏斗上写着 p75 是 8.5 秒
事情从一个很普通的任务开始:本来只是想改进一下自定义的性能标记。改完,顺手加了几条日志,然后对数字产生了好奇。
打开 Amplitude 拉了几张图,结果并不令人愉快——用户看到画布上完整渲染出场景的那一刻,p75 是约 8.5 秒。没有人抱怨过,也没有出过事故。是埋点本身发现了问题。
他们测量的节点包括:加载态出现、WebSocket 连接建立、项目数据同步完成、WASM 模块下载完成、引擎初始化、画布首次渲染、项目内所有图片加载完成。
有一个细节需要留意:画布首次渲染和引擎初始化有时看起来顺序是反的。首个场景是在 React 之外渲染的,而引擎初始化是从 useEffect 里上报的,所以它触发的时间取决于 React 什么时候轮到它,而不是引擎真正就绪的时刻。
结论是:一场灾难。
最大的文件,被排在最后请求
编辑器过去的加载顺序是这样的:先是 HTML,然后一堆 JS chunk。这些解析完之后,授权并连接实时存储,等项目数据到达。接着请求编辑器工作所需的基础样式。最后,才发出对 WASM 模块的请求。
最后这个,恰恰是最大的那个。
为什么会变成这样?原因很简单:WASM 模块的 import 放在一个懒加载 chunk 里。也就是说,浏览器在解析了大量 JS、又等样式返回之前,根本不知道这个文件存在。
关于这个模块的体积,有两个常见疑问。9.2 MB 编译起来是不是太贵?不是问题——浏览器拿到第一个字节就开始编译,编译和下载并行流式进行,之后从缓存实例化几乎免费。这不是 CPU 问题,是网络问题。9.2 MB 是不是太大了?这确实是个真问题,他们会在下一篇文章里讲怎么解决。
WASM 模块是这个页面下载的最大资源,而它的 URL 在团队任何一行 JS 运行之前就已经知道了。它本可以跟着 HTML 一起发出去。实际上它要等三次网络往返、再走一遍 React 树之后才出发。
第一个卡点:连接实时存储后,组件会挂起,直到项目数据到达。这个组件以下的所有内容,对浏览器来说还不存在。
第二个卡点形状相同:走到这里就停下等数据加载。从 trace 里能看得很清楚——基础样式的请求在房间数据落地后 8 毫秒才发出。它不是在等网络,是在等自己在树里的顺序。
再往下还有两个 hook 连着写。第一个会挂起,第二个就永远轮不到执行——他们真的是在等一个 9.2 MB 的 WASM 模块下载完,才开始请求字体。
完整链路于是变成六步:解析初始 JS chunk、等待实时项目存储连接、等待基础样式、下载并初始化 CanvasKit、等待字体、渲染画布首个场景。而那个 URL,在第零步就已经可用了。
一个 preload 提示,加一次模块作用域调用
修复思路的第一步是 preload,这是最先冒出来的念头。既然知道所有资源的 URL,为什么不把 preload link 插进页面 head?
他们用一个 SkiaPreloadLinks 组件,把 PRELOAD_HREFS 里的条目渲染成一组 link 标签,rel 为 preload,带上 as、type 和 crossOrigin。
剩下的关键动作,是在模块作用域里提前发起调用,让那个最大文件的请求不再排在 React 树的末尾,而是尽早出发。字节没变,顺序变了。


登录后才可以发布评论哦
打开小程序可以发布评论哦