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

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

在 2019 年 Google I/O 大会上,Mariko、Jake 和我发布了 PROXX,这是一款适用于网络的现代扫雷机器人。PROXX 的与众不同之处在于,它注重无障碍功能(您可以使用屏幕阅读器来演奏!),并且能够在功能手机和高端桌面设备上一样运行。非智能手机在以下方面受到限制:

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

但他们运行的是现代浏览器,价格非常实惠。出于这个原因,非智能手机正在新兴市场重新回归。他们的价位让以前负担不起的全新受众能够上网并利用现代网络。预计 2019 年仅在印度将售出大约 4 亿部非智能手机,因此使用非智能手机的用户可能会成为您的受众群体的重要组成部分。除此之外,类似 2G 的连接速度在新兴市场很常见。我们是如何让 PROXX 在非智能手机环境下正常运行的?

<ph type="x-smartling-placeholder">
</ph>
PROXX 游戏内容。

性能非常重要,包括加载性能和运行时性能。事实证明,良好的效果与更高的用户留存率和转化率息息相关,最重要的是,更高的包容性。Jeremy Wagner 提供了更多关于效果为何重要的数据和见解。

此系列内容分为两部分,本文为第 1 部分。第 1 部分侧重于加载性能,第 2 部分侧重于运行时性能。

把握现状

在真实设备上测试加载性能至关重要。如果您手头没有实际设备,则建议您使用 WebPageTest,尤其是“simple”设置WPT 通过模拟 3G 连接在真实设备上运行一系列加载测试。

测量 3G 的速度是一个不错的速度。虽然您可能习惯了 4G、LTE,甚至很快甚至 5G,但移动互联网的现实情况却截然不同。无论您是在火车上、参加会议、听音乐会或搭乘飞机,您遇到的情况很可能更接近 3G 网络,有时甚至更糟。

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

<ph type="x-smartling-placeholder">
</ph>
这个幻灯影片视频展示了当 PROXX 通过模拟的 2G 连接在真实的低端设备上加载时,用户看到的内容。

通过 3G 网络加载时,用户会看到 4 秒的白色虚化画面。在超过 2G 网络的情况下,用户在 8 秒内完全看不到任何内容。如果您了解广告效果的重要性,就会知道我们现在因为缺乏耐心,失去了很大一部分潜在用户。用户需要下载全部 62 KB 的 JavaScript 代码,才能在屏幕上显示任何内容。在这个场景中,一线希望的一点是,屏幕上显示的第二个任何内容也具有互动性。或者说有可能?

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> 未经优化的 PROXX 版本的 [首次有效绘制][FMP] 在技术上是 [交互式][TTI],但对用户来说毫无用处。

下载了大约 62 KB 的 gzip 经过压缩的 JS 并生成了 DOM 之后,用户就可以看到我们的应用。应用具有技术交互性。不过,如果观察这种现象,就会发现另一种情况。网络字体仍在后台加载,在准备就绪之前,用户看不到任何文字。虽然此状态符合首次有效绘制 (FMP) 的条件,但肯定不符合交互式状态,因为用户无法分辨输入的内容。使用 3G 需要再花 1 秒,使用 2G 需要 3 秒,直到应用准备就绪。总而言之,该应用在 3G 网络上需要 6 秒,在 2G 网络上需要 11 秒才能进入可互动状态。

广告瀑布流分析

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

  1. 这里有多条彩色细线。
  2. JavaScript 文件构成链。例如,只有在第一个资源完成后才开始加载第二个资源,而第三个资源仅在第二个资源完成后才启动。
。 <ph type="x-smartling-placeholder">
</ph>
通过广告瀑布流,可以深入了解哪些资源在何时加载以及用时多长时间。

减少连接数

每条细线(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 的简单设置,并查看幻灯影片:

<ph type="x-smartling-placeholder">
</ph>
我们使用 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,然后以 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。

<ph type="x-smartling-placeholder">
</ph>
幻灯影片显示我们的 FMP 指标有显著提升。TTI 基本不受影响。

我们的首次有效绘制时间从 8.5 秒缩短到 4.9 秒,这是一项巨大的改进。TTI 仍然在 8.5 秒左右发生,因此在很大程度上未受到此更改的影响。我们在这里所做的只是感知方面的变化。有些人甚至还把这叫做“借口戏”。通过渲染游戏的中间视觉效果,我们可以改变感知加载性能,

内嵌

开发者工具和 WebPageTest 提供的另一个指标是首字节时间 (TTFB)。这是指从请求的第一个字节发送到所接收响应的第一个字节所用的时间。此时间通常也称为往返时间 (RTT),尽管这两个数字在技术上有所不同:RTT 不包括服务器端对请求的处理时间。DevTools 和 WebPageTest 在请求/响应块内使用浅色直观呈现 TTFB。

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> 请求的 light 部分表示请求正在等待接收响应的第一个字节。

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

这也是 HTTP/2 推送的最初设计初衷。应用开发者知道应用需要某些资源,因此可以将其推向底层。当客户端意识到需要提取其他资源时,这些资源就已经位于浏览器的缓存中。事实证明,HTTP/2 Push 太困难了,因此被认为不建议使用。此问题将在 HTTP/3 标准化期间重新考虑。目前,最简单的解决方案是内嵌所有关键资源,但会牺牲缓存效率。

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

。 <ph type="x-smartling-placeholder">
</ph>
通过内嵌 JavaScript,我们已将 TTI 从 8.5 秒减少到 7.2 秒。

我们的 TTI 缩短了 1 秒。现在,我们已到达 index.html 包含初始渲染和具有互动性所需的所有内容。HTML 可在下载过程中呈现,从而创建 FMP。当 HTML 完成解析和执行时,应用便具有交互性。

激进型代码拆分

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

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> PROXX 的着陆页。此处仅使用关键组件。

为了了解 bundle 大小的来源,我们可以使用源代码映射浏览器或类似工具来细分 bundle 包含的内容。正如预测的那样,我们的软件包包含游戏逻辑、渲染引擎、胜出屏幕、丢失屏幕和许多实用程序。着陆页只需要其中一小部分模块。将非交互性的所有非严格元素移至延迟加载的模块会显著降低 TTI。

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> 分析 PROXX 的 `index.html` 内容会发现大量不需要的资源。系统会突出显示关键资源。

我们需要做的是代码拆分。代码拆分会将整个软件包拆分为可以按需延迟加载的较小部分。WebpackRollupParcel 等热门捆绑器支持使用动态 import() 进行代码拆分。该捆绑器会分析您的代码,并内嵌所有静态导入的模块。您动态导入的所有内容都会放入各自的文件中,并且仅在执行 import() 调用后才会从网络中提取。当然,访问网络需要一定成本,因此建议您仅在有空闲时间时这样做。我们的重点是静态导入加载时需要的模块,然后动态加载所有其他模块。但是,您不应该等到最后一刻再延迟加载肯定会用到的模块。Phil Walton《Idle 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 会做出判断!

<ph type="x-smartling-placeholder">
</ph>
幻灯影片证实:我们的 TTI 现在是 5.4 秒。与最初的 11 秒相比,有极大提升。

我们的 FMP 和 TTI 相差仅 100 毫秒,因为它只涉及解析和执行内联 JavaScript。在 2G 上仅需 5.4 秒,应用便具有完全的互动性。所有其他不太重要的模块在后台加载。

灵活操作

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

总结

衡量很重要。为了避免将时间花在不真实的问题上,我们建议在实施优化之前始终先进行衡量。此外,衡量应该通过 3G 连接在真实设备上完成,如果没有真实设备,则应在 WebPageTest 上进行测量。

幻灯影片可让您深入了解用户加载应用的感觉。通过广告瀑布流,您可以了解是哪些资源导致了加载时间过长。以下是您可以提高加载性能的核对清单:

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

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