我们如何让一个 9 MB 的 WebAssembly 模块在不删除一个字节的情况下感觉更快
Recraft Studio 是一个网页应用,你可以在其中生成图片,然后在画布上对图片进行处理。画布位于一个非常重要的页面上——编辑器。这是一个拥有大量工具的巨型页面,有一天我们遇到了一个问题:编辑器的加载时间太长了。

四个 Pull Request 之后,编辑器在画布上完成首次渲染的速度提升了 15–19%,p50、p75 和 p90 三个百分位都一样。
而这只花了大约 300 行代码!
我觉得有趣的是,这些代码行没有做的事情:页面下载的字节数与之前完全相同——当时是 16.75 MB,现在是 16.71 MB。
什么都没有删除,什么都没有压缩。唯一改变的是最大的那个文件开始加载的时机。那个最大的文件就是我们的渲染引擎:我们自行 fork 的 Skia,编译成 WebAssembly,线上体积为 9.2 MB。
这个数字不是来自优化前后的对比图。
这次改动是作为一次真实的实验进行的,带有对照组——每组约 18,000 名用户,在相同的五天里运行。
漏斗数据显示 p75 需要 8.5 秒
一切始于一个相当普通的任务:我打算对我们自定义的性能标记做一些改进。我做了修改,加了几条额外的日志,然后我变得对这些数字好奇起来。
我打开了 Amplitude,拼了几张图表,然后我震惊了。这不是一个令人愉快的惊喜——用户看到画布上完整渲染场景的时刻,p75 大约需要 8.5 秒。没有人抱怨过,也没有发生事故。是监控埋点本身发现了这个问题。
以下是我测量的指标:
loading-state-shown— 显示加载器的时间websocket-connected— 与我们的实时存储建立连接的时间room-data-synced— 获取项目数据的时间skia-loaded— WASM 模块被下载的时间engine-initialized— 一切就绪后触发的回调:JS 分块、WASM 模块,全部到位,引擎在 React 中可用canvas-rendered— 画布上渲染出第一帧场景的时间images-loaded— 项目中所有图片数据加载完成的时间
读这些数字时有一点需要注意:canvas-rendered 和 engine-initialized 有时看起来顺序不对。第一帧场景是在 React 之外渲染的,而 engine-initialized 是从一个 useEffect 上报的——所以它触发的时间是 React 有空处理它的时间,而不是引擎真正就绪的时间。
这是一场灾难。
最大的文件最后才被请求
让我解释一下编辑器以前是如何加载的。
首先是 HTML,这当然不用说,还有一堆 JS 分块。等这些解析完成后,我们进行授权并连接到实时存储,等待项目数据到达。然后我们请求编辑器工作所需的数据——基础样式。最后,我们才发出对 WASM 模块的请求。
最后这个请求是最大的一个。太遗憾了。
为什么我们会陷入这种境地?原因很简单:WASM 模块的 import 位于一个懒加载分块(lazy chunk)中。这意味着浏览器在解析大量 JS 并等待样式返回之前,根本无法知道这个文件的存在。
说实话,它看起来糟透了:
你可能想问我一两个关于这个模块体积的问题:
- 9.2 MB 编译成本太高了。
- 9.2 MB 就是太大了。
第一个不是问题。浏览器一收到第一个字节就开始编译文件——编译是流式的,与下载并行进行,之后从缓存实例化几乎零成本。这不是 CPU 问题,而是网络问题。
第二个才是真正的问题,我会在下一篇文章中告诉你我们是如何解决的。
9 MB 最后才被发现,藏在三次网络往返和一棵 React 树后面
WASM 模块是这个页面下载的最大资源,而它的 URL 在我们任何一行 JS 运行之前就已经是已知的。它本可以随 HTML 一起发出。但实际上,它是在三次网络往返和一次 React 树遍历之后才发出的。让我给你看看原因。
export const EngineProvider: FC<PropsWithChildren<Props>> = ({...}) => {
...
if (room.getStorageSnapshot() === null) {
room.connect()
throw room.getStorage()
}
...
}
这是问题的第一部分。我们连接到实时存储并挂起,直到项目数据到达。这个组件之下的所有内容,对浏览器来说都还不存在。
再深入一层,还有一个奇怪的地方:
const Content: FC<Props> = ({ viewOnly = false }) => {
...
useConnectionDetect()
useLoadBasicStyleSuspense()
useGetCurrentUserStatistics()
...
}
同样的形态:我们停止遍历 React 树,等待数据加载完成。在 trace 中可以清楚地看到——基础样式的请求在房间数据落地 8 毫秒之后才发出。它不是在网络等待,而是在树中排队等自己的回合。
在它下面还有这个:
export const Stage = ({ children, ...props }: Props) => {
useKitInit()
useFontInit()
useInitSkiaWorker()
useProjectLoadTracker()(ProjectLoadStep.SkiaLoaded)
return <StageComponent {...props}>{children}</StageComponent>
}
连续两个 hook。第一个会挂起,所以第二个永远没有机会执行——我们真的是在等一个 9.2 MB 的 WASM 模块下载完,才开始请求字体。
现在你可以看到全貌了:
- 解析初始 JS 分块
- 等待实时项目存储连接
- 等待基础样式
- 下载并初始化 CanvasKit
- 等待字体
- 在画布上渲染第一帧场景
六步,而 URL 在第零步就已经可用了。
一个 preload 提示和模块作用域里的一次调用
现在让我告诉你我决定如何修复它。
首先,preload。这是我脑海中冒出的第一个想法。如果我们知道所有资源的 URL,为什么不把 preload 链接插入页面 head 中呢?
export const SkiaPreloadLinks = () => (
<NextHead>
{PRELOAD_HREFS.map(({ href, as, type, crossOrigin }) => (
<link
key={href}
rel="preload"
href={href}
as={as}
type={type}
crossOrigin={crossOrigin}
/>
))}
</NextHead>
)
其中 PRELOAD_HREFS 是:
const PRELOAD_HREFS: PreloadHint[] = [
{
href: `${canvaskitUrl}/canvaskit.wasm`,
as: 'fetch',
crossOrigin: canvaskitUrl !== '' ? 'anonymous' : undefined,
},
{
href: DEFAULT_FONT_PRELOAD_URL,
as: 'fetch',
crossOrigin: 'anonymous',
}
]
不用遍历 React 树,不用等待其他请求。浏览器立即开始加载关键资源。
这里有一个细节。DEFAULT_FONT_PRELOAD_URL 是一个硬编码的字符串,看起来很难看。但为了正确获取该 URL 而 import FontCache,会把一个 652 KB 的 fonts.json 拉进项目页面的打包产物——这样的 preload 反而吃掉了自己的收益。
我做的下一件事,是把初始化 Skia 的调用提升到模块作用域:
import { getInitialPresence, getInitialStorage } from '@utils/engine'
import { useProjectLoadTracker } from './hooks/useProjectLoadTracker'
initSkia()
type Props = {
viewOnly?: boolean
}
initSkia() 同时触发 initKit() 和 initFont(),两者并行,不等待任何一个。还记得之前 Stage 里的那两个 hook 吗——第一个挂起,第二个永远没机会运行?现在什么都不用等了。
这也意味着 FontCache.initFont() 必须从一个"挂起即抛错"的函数改写成幂等的记忆化 Promise。否则,预热的主动加载和 Suspense 路径会为同一个 fetch 打架。
最后一件事:我把样式请求尽可能提前了。不是用 Suspense 模式——只是发出请求,然后在 React 树更下层的地方处理它。
const Project = () => {
useLoadBasicStyle()
const t = useTranslations()
...
}
四行代码。当我们到达那个挂起的 hook 时,响应要么已经在缓存里,要么就快到了。在本地 trace 中,这个请求从大约 890 毫秒移动到了约 15 毫秒——这是我机器上的近似数字,不是生产环境的数据。
CanvasKit 请求现在在第一波就发出,与文档一起——大约提前了 1.5 秒。这就是为什么最慢的用户收益最大:那 1.5 秒本来是引导和认证的时间,而且你的连接越差,这个领先优势就越长。
我哪里搞错了:preload 与 prefetch
我以前忽略的主要问题是:我把这些改动同时部署在了两个页面上:
/project/*— 编辑器页面;/projects— 项目列表页,它是进入项目页面的入口。
最初,这看起来是对的,但并不完全对,因为我们对两个页面使用了相同的 rel。让我们看看对我们用途来说可能的 rel 值:
- preload — 表示资源必须被下载,因为当前页面立即需要它;
- prefetch — 表示资源必须被下载,但不是现在,在处理完关键资源之后再处理,因为下一个页面会用到它。
不幸的是,在第一个版本中,我用了 preload。它导致了列表页上的资源竞争。公平地说,修复超级简单且容易理解:在项目列表页用 prefetch,在编辑器页用 preload。
const Links = ({ rel }: { rel: 'preload' | 'prefetch' }) => {
...
return (
<NextHead>
{resourceHints.map(({ href, as, type, crossOrigin }) => (
<link
key={href}
rel={rel}
href={href}
as={as}
crossOrigin={crossOrigin}
type={type}
/>
))}
</NextHead>
)
}
export const SkiaPreloadLinks = () => <Links rel="preload" />
export const SkiaPrefetchLinks = () => <Links rel="prefetch" />
这些改动促使我开始思考:当用户悬停项目卡片时,对 JS 分块做缓存预热……
四个假设中,三个奏效了
好吧,我想是时候分享我们优化的数据了。我设置了一个实验,分两个分段——default(默认)和 on(开启)。
让我们看看数据:
项目加载步骤,default → on:
| 步骤 | p50 | p75 | p90 |
|---|---|---|---|
| Skia Loaded | −19% | −18% | −19% |
| Engine Initialized | −17% | −16% | −15% |
| Canvas Rendered | −18% | −17% | −17% |
| Images Loaded | −16% | −16% | −18% |
代价——loading-state-shown:
| default | on | Δ | |
|---|---|---|---|
| p50 | 409 | 429 | +20(+5%) |
| p75 | 1250 | 1373 | +123(+10%) |
| p90 | 3798 | 4132 | +335(+9%) |
正如你在表中所见,我们改善了几个关键指标。让我们看看 skia-loaded、engine-initialized 和 canvas-rendered,它们对我们来说是最重要的——这是用户真正可以开始工作的时刻。最慢的用户中了彩票:收益从中位数的约 670 毫秒到 p90 的约 1900 毫秒,所以每个人都变快了,但最慢的人获益最多。
例如,看看当我为所有人开启实验时的 skia-loaded 指标:
但我们的 loading-state-shown 略微变差了,因为页面启动时我们开始加载更多资源。p75 大约多了 120 毫秒,影响了所有用户,无论他们的设备如何。
四个假设中三个奏效了。有一个假设没成功。我原本以为,如果我把房间连接提前,我们会获得更好的耗时。好吧,我错了。连接在这里不是瓶颈。我的同事 Leonid 后来用另一种方式做到了,而且成功了,但那是另一个故事了。
新的瓶颈:认证、握手、房间数据
好吧,让我们精确地看看现在优化之后的情况。
这里我们看到 CanvasKit 先开始加载,这意味着 CanvasKit 已经加载完成,就坐在那里,等待编辑器代码来使用它。
当我们的 WASM 模块等待它大显身手的时机时,我们正在做一堆事情:认证 → WebSocket 连接 → 握手 → 获取房间数据。
同时我们还可以看到,渲染所需的关键数据也比以前早得多地开始加载。现在——那个请求不再等待 WASM 分块了!
大部分等待根本不是 CPU 工作——主线程大部分时间都是空闲的。
页面上最重的资源不再处于关键路径上了。
结果,我们得到了一种瓶颈被转移的局面,现在瓶颈是网络。
悬停预取:加载器从 631 → 193 毫秒
Yolo!让我们回到我之前提到的缓存预热。
既然我们在 loading-state-shown 指标上有些退化,我决定把它补回来。
第一,代码分割。我们在编辑器中渲染的一些组件根本不可能出现在第一帧上。我们有各种侧边栏——例如,mockup 面板只有在选中 mockup 图层时才会出现。初始化时选区是空的,所以那个面板根本不可能在那里。没有理由把它放进第一个分块里。
第二,预取。当用户悬停项目卡片时,我们可以预取编辑器分块——等光标移动到点击位置时,文件已经在路上了。我必须指出,Next.js 已经做了这件事,但没有做到位。他们的逻辑有一个细微差别:它只预取路由本身知道的分块,不会跟随你模块内部的嵌套动态导入。这正是我们的问题——我们的编辑器分块拉入了另一个动态分块。所以我自己实现了,大致是这样的:
export const usePrefetchEditorOnHover = () => {
const called = useRef(false)
return useCallback(() => {
if (called.current) {
return
}
called.current = true
void import('@/components/pages/Editor')
}, [])
}
const handlePrefetchEditor = usePrefetchEditorOnHover()
const handleEnter = useCallback(() => {
setHover(true)
handlePrefetchEditor()
}, [handlePrefetchEditor])
onMouseEnter={handleEnter}
代码分割面向所有人发布,只有预取放在了实验开关后面——所以实验测量的是悬停预热本身,没有别的。
结果,我们在这里获得了巨大的提升:
loading-state-shown,default → on,毫秒:
| default | on | Δ | |
|---|---|---|---|
| p50 | 239 | 36 | −85% |
| p75 | 631 | 193 | −69% |
| p90 | 1680 | 708 | −58% |
三月,我们在这个指标上牺牲了 123 毫秒,让画布变得更快。四月,我们拿回了 438 毫秒。
我的收获
好吧,是时候结束这篇文章了。我得到了一些有用的见解。
首先,指标真的很重要。如果你想了解你的产品——它表现如何,它对用户的实际体验如何——想想对你的产品来说,哪些用户场景和页面是重要的。然后实现这些埋点,让它们有用,它们会向你展示盲点。
这对我们很有效。多亏了一个关于改进指标的任务,我注意到了没人谈论的性能问题。
结果,经过几次优化,我们交付了同样多的数据——却快了 17%。
对于 300 行没有删除一个字节的代码来说,这不算差。
在我们的案例中,瓶颈并没有消失。我们仍然有数据量的问题,但现在它对用户的影响比以前小了。
关于我们的 WASM 模块体积:我在那里也做了一堆优化,我很乐意在下一篇文章中分享进展。
相关阅读(延伸外链)
以下为推荐的相关技术教程,来自致知笔记:

浙公网安备 33010602011771号