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

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

在 2019 年 Google I/O 大会上,Mariko、Jake 和我发布了 PROXX,这款游戏是适用于网络的新型扫雷游戏克隆。PROXX 的独特之处在于,它专注于无障碍功能(您可以使用屏幕阅读器玩游戏!),并且能够在功能手机上与高端桌面设备上一样流畅运行。功能手机存在多方面的限制:

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

但它们运行的是新式浏览器,价格非常实惠。因此,非智能手机在新兴市场正在复苏。其价格定位让之前无法负担费用的全新受众群体能够上网并使用现代网络。据预测,2019 年仅印度就会售出约 4 亿部功能手机,因此功能手机用户可能会成为您受众群体的重要组成部分。此外,新兴市场的连接速度与 2G 相似。我们如何让 PROXX 在功能手机条件下正常运行?

PROXX 游戏内容。

性能至关重要,这包括加载性能和运行时性能。事实证明,良好的广告效果与提高用户留存率、提升转化率以及最重要的提高包容性密切相关。Jeremy Wagner 提供了更多数据和分析,介绍了为什么效果至关重要

此系列内容分为 2 个部分,本文为第 1 部分。第 1 部分重点介绍加载性能,第 2 部分重点介绍运行时性能。

捕获现状

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

3G 是一个不错的测速标准。虽然您可能已经习惯了 4G、LTE 或即将推出的 5G,但移动互联网的实际情况却截然不同。您可能正在火车上、参加会议、观看音乐会或乘坐飞机。您在那里的体验很可能更接近 3G,有时甚至更差。

尽管如此,我们将在本文中重点介绍 2G,因为 PROXX 的目标受众群体明确定位为非智能手机和新兴市场。WebPageTest 运行测试后,您会看到一个广告瀑布流(类似于您在 DevTools 中看到的内容),以及顶部的影片片段。影片片段会显示用户在应用加载期间看到的内容。在 2G 网络上,未优化的 PROXX 版本的加载体验非常糟糕:

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

通过 3G 加载时,用户会看到 4 秒的空白。在 2G 网络上,用户在 8 秒内完全看不到任何内容。如果您阅读了为什么性能至关重要一文,就会知道,由于用户耐心不足,我们现在已经失去了相当一部分潜在用户。用户需要下载 62 KB 的所有 JavaScript 代码,才能在屏幕上显示任何内容。在这种情况下,好消息是,屏幕上出现的任何内容都会是可交互的。或者说有可能?

未优化版本的 PROXX 中的 [首次有效绘制][FMP] 在_技术上_是 [可交互的][TTI],但对用户而言毫无用处。

下载大约 62 KB 的压缩 JS 并生成 DOM 后,用户就可以看到我们的应用。该应用从技术层面是交互式的。不过,从视觉效果来看,情况却有所不同。Web 字体仍在后台加载,在字体准备就绪之前,用户将看不到任何文字。虽然此状态符合首次有意义的绘制 (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:网络应用清单

index.html 的新连接不可避免。浏览器必须与我们的服务器建立连接才能获取内容。通过内嵌 Minimal Analytics 等代码,可以避免 Google Analytics 建立新的连接,但 Google Analytics 不会阻止我们的应用呈现或变为交互状态,因此我们并不太关心其加载速度。理想情况下,应在其他所有内容都已加载的空闲时间加载 Google Analytics。这样一来,它在初始加载期间就不会占用带宽或处理能力。由于清单必须通过非凭据连接加载,因此 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 的最大改进。实现此目的的常用技术是预渲染和服务器端渲染,这两种技术密切相关,在 Web 上呈现中对此进行了介绍。这两种方法都会在 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 几乎不受影响。

我们的首次有效绘制时间从 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 的着陆页。此处仅使用关键组件。

如需了解 app bundle 大小的来源,我们可以使用 Source Map Explorer 或类似工具来细分 app bundle 的组成部分。正如预期,我们的软件包包含游戏逻辑、渲染引擎、胜出屏幕、失败屏幕和一些实用程序。着陆页只需要其中的一小部分模块。将互动性所不需要的所有内容移至延迟加载的模块中,将会显著缩短 TTI。

分析 PROXX 的 `index.html` 内容后发现,其中包含许多不需要的资源。系统会突出显示关键资源。

我们需要做的是代码拆分。代码拆分会将单体化软件包拆分为可按需延迟加载的较小部分。WebpackRollupParcel 等热门捆绑器通过使用动态 import() 支持代码拆分。捆绑器会分析您的代码,并内嵌所有静态导入的模块。您动态导入的所有内容都将放入自己的文件中,并且只有在执行 import() 调用后才会从网络中提取。当然,访问影音平台需要付出代价,因此只有在有空的情况下才能这样做。这里的要诀是,在加载时静态导入至关重要的模块,并动态加载所有其他模块。不过,您不应等到最后一刻才延迟加载肯定会使用的模块。Phil Walton在有紧急请求时才启动模式是介于延迟加载和提前加载之间的理想中间模式。

在 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> 组件加载时,用于渲染动画背景图片的 <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 秒即可完全交互。所有其他不太重要的模块都在后台加载。

更多手法

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

总结

衡量至关重要。为避免浪费时间在虚假问题上,我们建议您始终先衡量,然后再实施优化。此外,如果没有实体设备,则应在连接到 3G 网络的真实设备上或 WebPageTest 上进行测量。

通过影片片段,您可以深入了解用户在加载应用时的感受。通过瀑布流,您可以了解哪些资源可能会导致加载时间过长。以下是可用于提升加载性能的核对清单:

  • 通过单次连接提交尽可能多的资源。
  • 预加载或内嵌资源,这些资源对于首次呈现和互动至关重要。
  • 预渲染应用以提升感知到的加载性能。
  • 利用积极的代码拆分来减少交互所需的代码量。

敬请期待第 2 部分,我们将讨论如何在极端受限设备上优化运行时性能。