使 Web 应用即使在非智能手机上也能快速加载的技术

我们如何在 PROXX 中使用代码拆分、代码内联和服务器端渲染。

在 2019 年 Google I/O 大会上,Mariko、Jake 和我发布了 PROXX,这是一种新型网络扫雷克隆应用。PROXX 与众不同的之处在于,它注重无障碍功能(您可以用屏幕阅读器播放!),而且该功能在非智能手机上也能像在高端桌面设备上运行一样。功能手机在以下方面受到限制:

  • CPU 性能不佳
  • GPU 弱或不存在
  • 不支持触控输入的小屏幕
  • 内存量非常有限

但它们运行的是现代浏览器,而且价格实惠。因此,功能手机在新兴市场再次掀起热潮。他们凭借自己的价位,吸引了以前负担不起的新受众上网并使用现代网络。预计仅在 2019 年,非智能手机就将在印度销售约 4 亿部,因此非智能手机用户可能会成为受众群体的很大一部分。除此之外,类似 2G 的连接速度在新兴市场非常普遍。我们是如何让 PROXX 在非智能手机条件下正常运行的?

PROXX 游戏内容。

性能非常重要,其中包括加载性能和运行时性能。事实证明,良好的性能有助于提升用户留存率、提升转化率,最重要的是,还能提高包容性。Jeremy Wagner性能的重要性提供了更多数据和数据洞见。

本系列分为两部分,本文是第 1 部分。第 1 部分重点介绍加载性能,第 2 部分重点介绍运行时性能。

追求现状

在真实设备上测试加载性能至关重要。如果您手头没有真实设备,我建议您执行 WebPageTest,尤其是“简单”设置WPT 会在具有模拟 3G 连接的真实设备上运行一系列加载测试。

3G 的测量速度比较好。虽然你可能已经习惯了 4G、LTE 甚至 5G,但移动互联网的真实情况却截然不同。您可能正在火车、会议、音乐会或航班上。您在那里遇到的情况很可能是在更接近 3G 网络的情况下,有时甚至更糟。

尽管如此,我们还是在本文中重点介绍 2G,因为 PROXX 的目标受众群体是功能手机和新兴市场。在 WebPageTest 运行测试后,您会看到一个瀑布图(类似于您在开发者工具中看到的内容)以及顶部的幻灯影片。幻灯软片会显示用户在应用加载时所见的内容。在 2G 网络下,未经优化的 PROXX 版本的加载体验非常糟糕:

幻灯影片视频展示了 PROXX 通过模拟的 2G 连接在真实的低端设备上加载时用户看到的内容。

通过 3G 加载时,用户会看到 4 秒的空白状态。如果使用 2G 网络,用户在超过 8 秒的时间内看不到任何内容。如果您阅读了性能的重要性一文,那么您就会知道,由于缺乏耐心,我们现在错失了很大一部分潜在用户。用户需要下载全部 62 KB 的 JavaScript,屏幕上才会显示任何内容。此场景中的希望是,屏幕上显示的第二个内容也是具有互动性的。或者说有可能?

未经优化的 PROXX 版本中的 [首次有效绘制][FMP] 从技术层面来讲是 [交互式][TTI],但对用户而言毫无用处。

您下载了大约 62 KB 的 Gzip 压缩 JS 并生成了 DOM 后,就可以看到我们的应用。该应用在技术上是可交互的。然而,观察视觉环境却会发现一种不同的现实。网页字体仍在后台加载,在准备就绪之前,用户看不到任何文字。虽然此状态符合首次有效渲染时间 (FMP) 的条件,但肯定无法达到适当的互动条件,因为用户无法得知输入的内容是什么。连接到 3G 网络需要 1 秒,在 2G 网络下需要 3 秒时间,应用才能准备就绪。总的来说,在 3G 网络下,该应用需要 6 秒时间,在 2G 网络下需要 11 秒才能进入互动状态。

广告瀑布流分析

既然我们已经了解了用户看到的内容,接下来需要找出原因。为此,我们可以查看广告瀑布流,并分析资源加载太晚的原因。在 PROXX 的 2G 轨迹中,我们可以看到两大危险信号:

  1. 有多条彩色细线。
  2. JavaScript 文件形成链。例如,第二个资源只有在第一个资源完成后才会开始加载,而第三个资源只有在第二个资源完成后才会开始加载。
通过瀑布流,您可以深入了解哪些资源在加载的时间和用时。

减少连接数

每个细线(dnsconnectssl)代表创建新的 HTTP 连接。建立新连接的成本很高,因为在 3G 上大约需要 1 秒,在 2G 上大约需要 2.5 秒。在我们的瀑布流中,我们看到了以下新连接:

  • 请求 1:我们的 index.html
  • 请求 5:来自 fonts.googleapis.com 的字体样式
  • 请求 8:Google Analytics(分析)
  • 请求 9:来自 fonts.gstatic.com 的字体文件
  • 请求 14:Web 应用清单

不可避免地会为 index.html 创建新连接。浏览器必须与我们的服务器建立连接,以获取内容。可以通过内嵌 Minimal Analytics 等功能来避免与 Google Analytics(分析)建立新的关联,但 Google Analytics(分析)不会阻止我们的应用呈现或进入互动状态,因此我们并不关心应用的加载速度。理想情况下,Google Analytics(分析)应该在空闲时间加载,此时其他一切都已经加载完毕。这样,它在初始加载期间就不会占用带宽或处理能力。Web 应用清单的新连接由提取规范规定,因为清单必须通过无凭据的连接加载。同样,Web 应用清单不会阻止我们的应用呈现或进入可交互状态,因此我们不需要太在意。

不过,这两种字体及其样式存在问题,因为它们会阻止渲染和互动。如果我们看一下 fonts.googleapis.com 提供的 CSS,就会发现只有两条 @font-face 规则,每种字体对应一条规则。字体样式实际上非常小,因此我们决定将其内嵌到 HTML 中,消除了一个不必要的连接。为节省字体文件的连接设置成本,我们可以将它们复制到我们自己的服务器。

并行加载

通过查看瀑布流,我们可以看到,在第一个 JavaScript 文件加载完成后,新文件会立即开始加载。这是模块依赖项的典型示例。我们的主模块可能具有静态导入,因此在加载这些导入之前,JavaScript 无法运行。这里要注意的重要一点是,这些类型的依赖项在构建时已知。我们可以使用 <link rel="preload"> 标记来确保所有依赖项在我们收到 HTML 的那一刻开始加载。

成果

让我们来了解一下我们的变更已经取得了哪些成果。切勿更改测试设置中的任何其他变量,以免影响结果,因此我们将在本文的其余部分使用 WebPageTest 的简单设置来查看幻灯影片:

我们使用 WebPageTest 的幻灯影片来查看我们所做的更改已经实现的效果。

这些更改将我们的 TTI 从 11 降至 8.5,相当于我们计划移除的连接设置时间约为 2.5 秒。干得好。

预渲染

虽然我们刚刚降低了 TTI,但并未真正影响用户必须经久不衰的 8.5 秒白色屏幕。可以说,通过在 index.html 中发送带样式的标记,可以实现 FMP 的最大改进。实现此目标的常见技术是预渲染和服务器端呈现,这两种技术彼此密切相关,在网络上呈现中对此进行了介绍。这两种技术都在 Node 中运行 Web 应用,并将生成的 DOM 序列化为 HTML。服务器端渲染是在服务器端为每个请求执行此操作,而预渲染在构建时执行此操作,并将输出存储为新的 index.html。由于 PROXX 是一款 JAMStack 应用,没有服务器端,因此我们决定实现预渲染。

实现预渲染程序的方法有很多。在 PROXX 中,我们选择了使用 Puppeteer,它可以在没有任何界面的情况下启动 Chrome,并允许您使用 Node API 远程控制该实例。我们使用它来注入标记和 JavaScript,然后将 DOM 作为 HTML 字符串读回。由于我们使用的是 CSS 模块,因此可以免费获得所需的样式的 CSS 内嵌。

  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setContent(rawIndexHTML);
  await page.evaluate(codeToRun);
  const renderedHTML = await page.content();
  browser.close();
  await writeFile("index.html", renderedHTML);

实施后,我们有望改进 FMP。我们仍然需要加载和执行相同数量的 JavaScript,所以我们预计 TTI 不会有太大的变化。如果有的话,我们的 index.html 变大了,可能会让 TTI 略有下降。只有一个方法:运行 WebPageTest。

该幻灯影片显示了 FMP 指标的明显改进。TTI 基本上不受影响。

First Meaningful Paint 从 8.5 秒缩短到了 4.9 秒,这是一个巨大的改进。我们的 TTI 仍在 8.5 秒左右发生,因此在很大程度上不受此变更的影响。我们在这里所做的只是感知变化。有些人甚至可能称之为“骗子”。通过呈现游戏的中间视觉效果,我们提升了用户感知的加载性能。

内嵌

DevTools 和 WebPageTest 提供的另一个指标是首字节传输时间 (TTFB)。这是指从请求的第一个字节到所收到响应的第一个字节所用的时间。该时间通常也被称为往返时间 (RTT),尽管从技术层面来讲,这两个数字有所不同:RTT 不包括服务器端请求的处理时间。DevTools 和 WebPageTest 会在请求/响应块内使用浅色显示 TTFB。

请求的浅色部分表示请求正在等待接收响应的第一个字节。

通过查看瀑布流,我们可以看到所有请求大部分时间都在等待响应的第一个字节到达。

这个问题就是 HTTP/2 推送最初的设计初衷。应用开发者知道需要特定资源,并且可以将这些资源推送进去。当客户端意识到需要提取其他资源时,这些资源已经在浏览器的缓存中了。经证明,HTTP/2 推送太难做到正确,因此不建议使用。此问题空间将在 HTTP/3 标准化期间重新访问。目前,最简单的解决方案是内嵌所有关键资源,但代价是缓存效率。

得益于 CSS 模块和基于 Puppeteer 的预渲染程序,我们的关键 CSS 已经被内嵌。对于 JavaScript,我们需要内嵌关键模块及其依赖项。此任务的难度因您使用的捆绑器而异。

通过内嵌 JavaScript,我们已将 TTI 从 8.5 秒缩短至 7.2 秒。

我们的 TTI 时间缩短了 1 秒。现在,我们的 index.html 包含初始渲染和实现可交互所需的所有内容。HTML 可以在下载过程中呈现,从而创建 FMP。当 HTML 完成解析和执行时,应用即进入互动状态。

激进型代码拆分

是的,我们的 index.html 包含实现互动所需的所有条件。但仔细看看后发现,它里面也包含所有其他内容。我们的 index.html 大小约为 43 KB。我们来考虑一下用户开始时可与之互动的内容:我们有一个用于配置游戏的表单,其中包含几个组件、一个启动按钮,以及一些用于保留和加载用户设置的代码。差不多了。43 KB 似乎不大。

PROXX 的着陆页。此处仅使用关键组件。

为了解 bundle 大小的来源,我们可以使用 Source Map Explorer 或类似工具来细分 bundle 所含的内容。正如预测的那样,我们的软件包包含游戏逻辑、渲染引擎、胜出屏幕、失败屏幕和许多实用程序。着陆页中只需要这些模块中的一小部分。将互动并非严格要求的所有内容都移到延迟加载的模块中,将会显著减少 TTI。

分析 PROXX 的 `index.html` 内容会显示大量不需要的资源。系统会突出显示关键资源。

我们需要做的是代码拆分。代码拆分功能可将单体式 bundle 拆分为多个可以按需延迟加载的较小部分。WebpackRollupParcel 等热门捆绑器支持使用动态 import() 拆分代码。打包器将分析您的代码并内嵌以静态方式导入的所有模块。动态导入的所有内容都将放入其自己的文件中,并且只有在执行 import() 调用后,才会从网络中提取。当然,访问广告网络会产生费用,因此只有在有空闲时间时,才应这么做。关键在于以静态方式导入在加载时至关重要的模块,并动态加载所有其他模块。但是,您不应等到最后才延迟加载肯定会用到的模块。Phil WaltonIdle Until Urgent 归因模型适合在延迟加载和即时加载之间保持健康的中间环境。

在 PROXX 中,我们创建了一个 lazy.js 文件,用于静态导入不需要的所有内容。然后,我们可以在主文件中动态导入 lazy.js但是,我们的一些 Preact 组件最终出现在了 lazy.js 中,这有点复杂,因为 Preact 无法开箱即处理延迟加载的组件。因此,我们编写了一个 deferred 组件封装容器,用于在实际组件加载之前渲染占位符。

export default function deferred(componentPromise) {
  return class Deferred extends Component {
    constructor(props) {
      super(props);
      this.state = {
        LoadedComponent: undefined
      };
      componentPromise.then(component => {
        this.setState({ LoadedComponent: component });
      });
    }

    render({ loaded, loading }, { LoadedComponent }) {
      if (LoadedComponent) {
        return loaded(LoadedComponent);
      }
      return loading();
    }
  };
}

有了此基础,我们就可以在 render() 函数中使用组件的 Promise。例如,在该组件加载期间,用于呈现动画背景图片的 <Nebula> 组件将被替换为空的 <div>。组件加载完毕并可供使用后,<div> 便会被替换为实际的组件。

const NebulaDeferred = deferred(
  import("/components/nebula").then(m => m.default)
);

return (
  // ...
  <NebulaDeferred
    loading={() => <div />}
    loaded={Nebula => <Nebula />}
  />
);

一切就绪后,我们将 index.html 缩减至仅 20 KB,不到原始大小的一半。这对 FMP 和 TTI 有什么影响?WebPageTest 会告诉用户!

视频画面确认:我们的 TTI 现在为 5.4 秒。与原来的 11 秒相比有了极大的改进。

我们的 FMP 和 TTI 相差仅 100 毫秒,因为这只是解析和执行内联 JavaScript 问题。在 2G 网络下只用了 5.4 秒,应用就能够全面互动了。所有其他不太重要的模块都在后台加载。

更灵活

如果您查看以上关键模块列表,会发现渲染引擎并不属于关键模块。当然,在我们使用渲染引擎渲染游戏之前,游戏无法启动。我们可以停用“Start”按钮,直到渲染引擎准备好启动游戏,但根据我们的经验,用户通常需要足够长的时间来配置游戏设置,因此并不需要这样做。多数情况下,渲染引擎和其他剩余模块会在用户按“开始”时完成加载。在极少数情况下,用户会比网络连接速度快,我们会展示一个简单的加载屏幕,等待其余模块完成。

总结

测量很重要。为了避免将时间花在非实际问题上,我们建议您在实施优化之前先进行衡量。此外,应在使用 3G 连接的真实设备上进行测量;如果没有真实设备,则应在 WebPageTest 上进行测量。

幻灯影片可让您深入了解用户在加载应用时感受到的体验。瀑布流可以告诉您哪些资源导致了加载时间过长。为提高加载性能,您可以采取以下核对清单:

  • 通过一个连接分发尽可能多的资产。
  • 预加载,甚至是首次渲染和互动所需的内嵌资源。
  • 预渲染您的应用,以提高感知加载性能。
  • 积极使用代码拆分来减少实现互动所需的代码量。

敬请关注第 2 部分,其中讨论了如何在超受限设备上优化运行时性能。