使 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 运行测试后,您会看到一个瀑布(类似于您在开发者工具中看到的内容)以及顶部的幻灯影片。幻灯影片显示了您的应用加载期间用户看到的内容。在 2G 网络上,未优化的 PROXX 版本的加载体验非常糟糕:

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

通过 3G 加载时,用户会看到 4 秒的空白。在 2G 网络上,用户在 8 秒内完全看不到任何内容。如果您了解广告效果的重要性,就会知道我们现在因为缺乏耐心,失去了很大一部分潜在用户。用户需要下载全部 62 KB 的 JavaScript 代码,才能在屏幕上显示任何内容。在这种情况下,好消息是,屏幕上出现的任何内容都会是可交互的。或者说有可能?

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

下载大约 62 KB 的压缩 JS 并生成 DOM 后,用户就可以看到我们的应用。该应用从技术层面是交互式的。不过,从视觉效果来看,情况却有所不同。Web 字体仍在后台加载,在字体准备就绪之前,用户将看不到任何文字。虽然此状态符合首次有意义的绘制 (FMP) 的条件,但肯定不符合正确交互的条件,因为用户无法分辨任何输入的内容。使用 3G 网络需要再花 3 秒,使用 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,然后以 HTML 字符串的形式读回 DOM。由于我们使用的是 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 部分,我们将讨论如何在极端受限设备上优化运行时性能。