使 Web 应用快速加载的技术,即使在功能手机上也是如此

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

在 Google I/O 2019 大会上,Mariko、Jake 和我发布了 PROXX,这是一个面向 Web 的现代扫雷克隆游戏。PROXX 的与众不同之处在于对无障碍功能的关注(您可以使用屏幕阅读器玩它!)以及在功能手机和高端桌面设备上都能良好运行的能力。功能手机在多个方面都受到限制

  • CPU 性能弱
  • GPU 性能弱或不存在 GPU
  • 没有触摸输入的小屏幕
  • 内存量非常有限

但它们运行的是现代浏览器,而且价格非常实惠。因此,功能手机在新兴市场正在卷土重来。它们的价格点使以前买不起的全新受众能够上网并使用现代 Web。据预测,仅在印度,2019 年将售出约 4 亿部功能手机,因此功能手机用户可能成为您的受众的重要组成部分。除此之外,类似于 2G 的连接速度在新兴市场也很常见。我们是如何设法使 PROXX 在功能手机条件下良好运行的呢?

PROXX 游戏玩法。

性能非常重要,这包括加载性能和运行时性能。研究表明,良好的性能与用户留存率的提高、转化率的提高以及——最重要的是——包容性的提高相关。 Jeremy Wagner为什么速度很重要 方面有更多的数据和见解。

这是分为两部分的系列文章的第一部分。第 1 部分侧重于加载性能,第 2 部分将侧重于运行时性能。

捕获现状

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

3G 是一个很好的衡量速度。虽然您可能习惯于 4G、LTE 甚至很快就会是 5G,但移动互联网的现实情况看起来却大相径庭。也许您在火车上、会议上、音乐会上或航班上。您在那里体验到的最有可能更接近 3G,有时甚至更糟。

话虽如此,我们在本文中将重点关注 2G,因为 PROXX 的目标受众明确针对功能手机和新兴市场。WebPageTest 运行测试后,您将获得一个瀑布图(类似于您在 DevTools 中看到的)以及顶部的胶片带。胶片带显示您的应用加载时用户看到的内容。在 2G 网络下,未优化的 PROXX 版本的加载体验非常糟糕

胶片带视频显示了在模拟 2G 连接的真实低端设备上加载 PROXX 时用户看到的内容。

在 3G 网络下加载时,用户会看到 4 秒的白色空白。在 2G 网络下,用户在超过 8 秒的时间内什么也看不到。 如果您阅读过 为什么性能很重要,您就会知道由于用户缺乏耐心,我们现在已经失去了一大部分潜在用户。用户需要下载所有 62 KB 的 JavaScript 才能在屏幕上显示任何内容。这种情况的一线希望是,屏幕上出现任何内容的同时,它也是可交互的。真的是这样吗?

未优化版本的 PROXX 中的 [First Meaningful Paint][FMP] _在技术上_ 是 [可交互的][TTI],但对用户来说毫无用处。

在下载了大约 62 KB 的 gzip 压缩 JS 并生成 DOM 后,用户可以看到我们的应用。该应用在技术上是可交互的。但是,查看视觉效果会显示不同的现实。Web 字体仍在后台加载,在它们准备就绪之前,用户看不到任何文本。虽然此状态符合 First Meaningful Paint (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 应用清单的新连接是 fetch 规范规定的,因为清单必须通过非凭据连接加载。同样,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 秒的永恒白色屏幕。可以说,FMP 的最大改进可以通过在您的 index.html 中发送样式化的标记来实现。实现此目的的常用技术是预渲染和服务器端渲染,它们密切相关,并在 Web 上的渲染 中进行了解释。这两种技术都在 Node 中运行 Web 应用,并将生成的 DOM 序列化为 HTML。服务器端渲染在服务器端按请求执行此操作,而预渲染在构建时执行此操作,并将输出存储为您的新 index.html。由于 PROXX 是一个 JAMStack 应用,并且没有服务器端,因此我们决定实施预渲染。

实现预渲染器的方法有很多种。在 PROXX 中,我们选择使用 Puppeteer,它启动没有 UI 的 Chrome,并允许您使用 Node API 远程控制该实例。我们使用它来注入我们的标记和我们的 JavaScript,然后将 DOM 作为 HTML 字符串读回。因为我们正在使用 CSS Modules,所以我们免费获得了我们需要的样式的 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 给我们的另一个指标是 Time To First Byte (TTFB)。这是从发送请求的第一个字节到接收到响应的第一个字节所花费的时间。此时间也通常称为往返时间 (RTT),尽管从技术上讲,这两个数字之间存在差异:RTT 不包括服务器端请求的处理时间。DevTools 和 WebPageTest 使用请求/响应块内的浅色可视化 TTFB。

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

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

这个问题最初是为 HTTP/2 Push 设计的。应用开发者知道需要某些资源,并且可以推送它们。当客户端意识到它需要获取其他资源时,它们已经存在于浏览器的缓存中。HTTP/2 Push 结果证明太难正确实现,并且被认为是不鼓励使用的。 此问题空间将在 HTTP/3 标准化期间重新审视。目前,最简单的解决方案是内联所有关键资源,但会牺牲缓存效率。

由于 CSS Modules 和我们基于 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 的着陆页。这里仅使用关键组件。

为了了解我们的捆绑包大小来自哪里,我们可以使用 source map explorer 或类似的工具来分解捆绑包的组成。正如预测的那样,我们的捆绑包包含游戏逻辑、渲染引擎、获胜屏幕、失败屏幕和一堆实用程序。着陆页只需要这些模块中的一小部分。将交互性严格不需要的所有内容移动到延迟加载的模块中将显着减少 TTI。

分析 PROXX 的 `index.html` 的内容会显示许多不需要的资源。关键资源已突出显示。

我们需要做的是 代码拆分。代码拆分将您的单体捆绑包分解为更小的部分,这些部分可以按需延迟加载。流行的捆绑器(如 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 秒后,应用就完全可交互了。所有其他不太重要的模块都在后台加载。

更多障眼法

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

结论

衡量非常重要。为了避免将时间浪费在不真实的问题上,我们建议始终先进行衡量,然后再实施优化。此外,衡量应在 3G 连接的真实设备上或在手边没有真实设备时在 WebPageTest 上完成。

胶片带可以深入了解用户加载您的应用的感觉。瀑布图可以告诉您哪些资源可能导致加载时间过长。以下是您可以执行以提高加载性能的清单

  • 通过一个连接尽可能多地交付资源。
  • 预加载甚至内联首次渲染和交互性所需的资源。
  • 预渲染您的应用以提高感知的加载性能。
  • 利用激进的 代码拆分 来减少交互性所需的代码量。

请继续关注第 2 部分,我们将在其中讨论如何在高度受限的设备上优化运行时性能。