我们如何在 PROXX 中使用代码拆分、代码内嵌和服务器端渲染。
在 2019 年 Google I/O 大会上,Mariko、Jake 和我发布了 PROXX,这款游戏是适用于网络的新型扫雷游戏克隆。PROXX 的独特之处在于,它专注于无障碍功能(您可以使用屏幕阅读器玩游戏!),并且能够在功能手机上与高端桌面设备上一样流畅运行。功能手机在多方面受到限制:
- 弱 CPU
- GPU 性能较弱或不存在
- 不支持触控输入的小屏幕
- 内存非常有限
但它们运行的是新式浏览器,价格非常实惠。出于这个原因,非智能手机正在新兴市场重新回归。其价格定位让之前买不起设备的全新受众群体能够上网并使用现代网络。据预测,2019 年仅印度就会售出约 4 亿部功能手机,因此功能手机用户可能会成为您受众群体的重要组成部分。此外,与 2G 类似的连接速度在新兴市场很常见。我们如何让 PROXX 在功能手机条件下正常运行?
性能至关重要,这包括加载性能和运行时性能。事实证明,良好的广告效果与提高用户留存率、提升转化率以及最重要的提高包容性密切相关。Jeremy Wagner 提供了更多关于效果为何重要的数据和见解。
此系列内容分为 2 个部分,本文为第 1 部分。第 1 部分侧重于加载性能,第 2 部分侧重于运行时性能。
把握现状
在真实设备上测试加载性能至关重要。如果您手边没有真实设备,建议您使用 WebPageTest,尤其是“简单”设置。WPT 会在具有模拟 3G 连接的真实设备上运行一系列加载测试。
3G 是一个不错的测速标准。虽然您可能已经习惯了 4G、LTE 或即将推出的 5G,但移动互联网的实际情况却截然不同。您可能正在火车上、参加会议、观看音乐会或乘坐飞机。您在那里的体验很可能更接近 3G,有时甚至更差。
尽管如此,我们将在本文中重点介绍 2G,因为 PROXX 的目标受众群体明确定位为非智能手机和新兴市场。WebPageTest 运行测试后,您会看到一个广告瀑布流(类似于您在 DevTools 中看到的内容),以及顶部的影片片段。影片片段会显示用户在应用加载期间看到的内容。在 2G 网络上,未优化的 PROXX 版本的加载体验非常糟糕:
通过 3G 加载时,用户会看到 4 秒的空白。在超过 2G 网络的情况下,用户在 8 秒内完全看不到任何内容。如果您了解广告效果的重要性,就会知道我们现在因为缺乏耐心,失去了很大一部分潜在用户。用户需要下载 62 KB 的所有 JavaScript 代码,才能在屏幕上显示任何内容。在这个场景中,一线希望的一点是,屏幕上显示的第二个事物也具有互动性。或者说有可能?
下载大约 62 KB 的压缩 JS 并生成 DOM 后,用户就可以看到我们的应用。该应用从技术层面是交互式的。不过,从视觉效果来看,情况却有所不同。Web 字体仍在后台加载,在字体准备就绪之前,用户将看不到任何文字。虽然此状态符合首次有意义的绘制 (FMP) 的条件,但肯定不符合正确交互的条件,因为用户无法分辨任何输入的内容。在 3G 网络上,应用还需要 1 秒钟的时间才能准备就绪;在 2G 网络上,则需要 3 秒钟的时间。总而言之,在 3G 网络上,应用需要 6 秒钟的时间才能变为可交互状态;在 2G 网络上,则需要 11 秒钟的时间。
广告瀑布流分析
现在,我们已经了解了用户看到的内容,接下来需要弄清楚用户看到的原因。为此,我们可以查看广告瀑布流,并分析为什么资源加载太晚。在 PROXX 的 2G 轨迹中,我们可以看到两个主要的危险标记:
- 这里有多条彩色细线。
- JavaScript 文件会形成一个链。例如,第二个资源仅在第一个资源加载完毕后才开始加载,第三个资源仅在第二个资源加载完毕后才开始加载。
减少连接数
每条细线(dns
、connect
、ssl
)代表创建了新的 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 应用清单的新连接是由提取规范规定的。同样,Web 应用清单不会阻止我们的应用呈现或变为可交互状态,因此我们没必要在意那么多。
不过,这两种字体及其样式存在问题,因为它们会阻止渲染和互动。如果我们查看 fonts.googleapis.com
提供的 CSS,就会发现它只有两个 @font-face
规则,每个字体一个。实际上,这些字体样式非常小,因此我们决定将其内嵌到 HTML 中,从而移除了一个不必要的连接。为避免为字体文件设置连接而产生费用,我们可以将其复制到自己的服务器。
并行加载
从瀑布图中可以看出,第一个 JavaScript 文件加载完毕后,系统会立即开始加载新文件。这是模块依赖项的典型结构。我们的主模块可能包含静态导入,因此在这些导入加载之前,JavaScript 无法运行。这里需要注意的重要一点是,这些类型的依赖项在构建时已知。我们可以使用 <link rel="preload">
标记,确保在收到 HTML 后立即开始加载所有依赖项。
结果
我们来看看所做的更改取得了什么成效。请务必不要更改测试设置中的任何其他可能导致结果偏差的变量,因此在本文的其余部分中,我们将使用 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。
我们的首次有效绘制时间从 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,我们需要内嵌关键模块及其依赖项。此任务的难度因您使用的捆绑器而异。
这缩短了 TTI 1 秒。现在,我们已到达 index.html
包含初始渲染和变为可互动状态所需的所有内容。HTML 可以在下载期间呈现,从而创建我们的 FMP。在 HTML 完成解析和执行后,应用就会变为交互式。
激进的代码分块
可以,我们的 index.html
包含实现互动所需的一切。但仔细检查后,发现它还包含所有其他内容。我们的 index.html
大约为 43 KB。我们将其与用户一开始可以与之互动的内容相关联:我们有一个用于配置游戏的表单,其中包含几个组件、一个开始按钮,可能还有一些用于保留和加载用户设置的代码。差不多就是这样了。43 KB 似乎很多。
如需了解 app bundle 大小的来源,我们可以使用 Source Map Explorer 或类似工具来细分 app bundle 的组成部分。正如预测的那样,我们的软件包包含游戏逻辑、渲染引擎、胜出屏幕、丢失屏幕和许多实用程序。着陆页只需要其中一小部分模块。将互动性所不需要的所有内容移至延迟加载的模块中,将会显著缩短 TTI。
我们需要做的是代码拆分。代码拆分会将单体化软件包拆分为可按需延迟加载的较小部分。Webpack、Rollup 和 Parcel 等热门捆绑器支持使用动态 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>
组件在加载时会被空 <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 可以告诉您!
我们的 FMP 和 TTI 相差只有 100 毫秒,因为这只是解析和执行内嵌 JavaScript 的问题。在 2G 上仅需 5.4 秒,应用便具有完全的互动性。所有其他不太重要的模块都在后台加载。
灵活操作
如果您查看上面的关键模块列表,就会发现渲染引擎并非关键模块的一部分。当然,在我们有渲染引擎来渲染游戏之前,游戏无法启动。我们可以停用“开始”按钮,直到渲染引擎准备好启动游戏,但根据我们的经验,用户通常需要很长时间才能配置游戏设置,因此这并不必要。大多数情况下,渲染引擎和其他其余模块在用户按下“开始”按钮时完成加载。在极少数情况下,如果用户的操作速度比网络连接速度快,我们会显示一个简单的加载屏幕,以等待其余模块完成。
总结
衡量至关重要。为了避免将时间花在不真实的问题上,我们建议在实施优化之前始终先进行衡量。此外,衡量应该通过 3G 连接在真实设备上完成,如果没有真实设备,则应在 WebPageTest 上进行测量。
通过影片片段,您可以深入了解用户在加载应用时的感受。通过广告瀑布流,您可以了解是哪些资源导致了加载时间过长。以下是您可以提高加载性能的核对清单:
敬请期待第 2 部分,我们将讨论如何在超受限设备上优化运行时性能。