2026/10/8 8:27:51

我们如何让一个 9 MB 的 WebAssembly 模块在不删除一个字节的情况下感觉更快

我们如何让一个 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: FCPropsWithChildrenProps ({...}) { ... if (room.getStorageSnapshot() null) { room.connect() throw room.getStorage() } ... }这是问题的第一部分。我们连接到实时存储并挂起直到项目数据到达。这个组件之下的所有内容对浏览器来说都还不存在。再深入一层还有一个奇怪的地方const Content: FCProps ({ 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} relpreload 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 relpreload / export const SkiaPrefetchLinks () Links relprefetch /这些改动促使我开始思考当用户悬停项目卡片时对 JS 分块做缓存预热……四个假设中三个奏效了好吧我想是时候分享我们优化的数据了。我设置了一个实验分两个分段——default默认和 on开启。让我们看看数据项目加载步骤default → on步骤p50p75p90Skia Loaded−19%−18%−19%Engine Initialized−17%−16%−15%Canvas Rendered−18%−17%−17%Images Loaded−16%−16%−18%代价——loading-state-showndefaultonΔp50409429205%p751250137312310%p90379841323359%正如你在表中所见我们改善了几个关键指标。让我们看看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-showndefault → on毫秒defaultonΔp5023936−85%p75631193−69%p901680708−58%三月我们在这个指标上牺牲了 123 毫秒让画布变得更快。四月我们拿回了 438 毫秒。我的收获好吧是时候结束这篇文章了。我得到了一些有用的见解。首先指标真的很重要。如果你想了解你的产品——它表现如何它对用户的实际体验如何——想想对你的产品来说哪些用户场景和页面是重要的。然后实现这些埋点让它们有用它们会向你展示盲点。这对我们很有效。多亏了一个关于改进指标的任务我注意到了没人谈论的性能问题。结果经过几次优化我们交付了同样多的数据——却快了 17%。对于 300 行没有删除一个字节的代码来说这不算差。在我们的案例中瓶颈并没有消失。我们仍然有数据量的问题但现在它对用户的影响比以前小了。关于我们的 WASM 模块体积我在那里也做了一堆优化我很乐意在下一篇文章中分享进展。相关阅读延伸外链以下为推荐的相关技术教程来自致知笔记如何将笔记本电脑连接到外接显示器如何检查 IP 地址是静态还是动态Static/Dynamic IP如何在 Windows 11/10 中创建密码重置盘