我们如何让一个 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 模块下载完,才开始请求字体。

现在你可以看到全貌了:

  1. 解析初始 JS 分块
  2. 等待实时项目存储连接
  3. 等待基础样式
  4. 下载并初始化 CanvasKit
  5. 等待字体
  6. 在画布上渲染第一帧场景

六步,而 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 模块体积:我在那里也做了一堆优化,我很乐意在下一篇文章中分享进展。


相关阅读(延伸外链)

以下为推荐的相关技术教程,来自致知笔记:

posted @ 2026-10-07 00:52  panxianren  阅读(4)  评论(0)    收藏  举报