我们如何在 PROXX 中使用代码拆分、代码内嵌和服务器端渲染。
在 2019 年 Google I/O 大会上,Mariko、Jake 和我发布了 PROXX,这是一款现代化的网页版扫雷游戏。PROXX 的独特之处在于,它注重无障碍功能(您可以使用屏幕阅读器玩游戏!),并且在非智能手机上的运行效果与在高端桌面设备上一样出色。非智能手机在多个方面受到限制:
- CPU 性能较弱
- GPU 性能较弱或没有 GPU
- 不支持触控输入的小屏幕
- 内存量非常有限
但它们运行现代浏览器,而且价格非常实惠。因此,非智能手机在新兴市场中正在卷土重来。这些设备的价格让之前买不起手机的全新受众群体也能上网,并使用现代网络。预计 2019 年仅在印度就会售出约 4 亿部非智能手机,因此非智能手机用户可能会成为您受众群体的重要组成部分。此外,在新兴市场中,2G 级别的网速是常态。我们是如何让 PROXX 在非智能手机条件下正常运行的?
性能非常重要,这包括加载性能和运行时性能。事实证明,良好的性能与用户留存率提高、转化率提高以及(最重要的是)包容性提高相关。Jeremy Wagner 提供了更多数据和见解,说明性能为何重要。
此系列内容分为 2 个部分,本文为第 1 部分。第 1 部分重点介绍加载性能,第 2 部分将重点介绍运行时性能。
捕获现状
在真实设备上测试加载性能至关重要。如果您手头没有真实设备,我建议您使用 WebPageTest,特别是“简单”设置。WPT 在真实设备上使用模拟 3G 连接运行一系列加载测试。
3G 是一个适合衡量的速度。虽然您可能已经习惯了 4G、LTE 甚至即将到来的 5G,但移动网络的实际情况却大不相同。您可能在火车上、会议中、音乐会上或飞机上。您在那里体验到的网络速度很可能更接近 3G,有时甚至更差。
话虽如此,但本文将重点介绍 2G,因为 PROXX 明确针对其目标受众群体中的非智能手机和新兴市场。WebPageTest 运行测试后,您会看到一个瀑布图(类似于您在开发者工具中看到的瀑布图)以及顶部的幻灯影片。影片带显示了用户在应用加载时看到的内容。在 2G 网络下,未优化版 PROXX 的加载体验非常糟糕:
通过 3G 网络加载时,用户会看到 4 秒钟的空白。在 2G 网络下,用户在 8 秒多的时间里完全看不到任何内容。如果您阅读过性能为何重要,就会知道我们现在已经因为用户缺乏耐心而失去了很大一部分潜在用户。用户需要下载全部 62 KB 的 JavaScript,屏幕上才会显示内容。在这种情况下,好的一面是,屏幕上出现任何内容时,该内容也是可互动的。或者说有可能?
在下载了大约 62 KB 的 gzip 压缩 JS 并生成 DOM 后,用户就可以看到我们的应用了。从技术上讲,该应用是可交互的。不过,从视觉效果来看,实际情况并非如此。网络字体仍在后台加载,在加载完成之前,用户看不到任何文本。虽然此状态符合首次有效渲染时间 (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: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 的简单设置,并查看幻灯影片:
这些更改将 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 秒时发生,因此基本上不受此更改的影响。我们在这里所做的是感知变化。有些人甚至可能称之为“障眼法”。通过渲染游戏的中间视觉效果,我们正在改善感知加载性能。
内嵌
开发者工具和 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 似乎有点大。
为了解 bundle 大小的来源,我们可以使用源代码映射资源管理器或类似工具来细分 bundle 的组成部分。正如预测的那样,我们的 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 部分,我们将讨论如何在资源极度受限的设备上优化运行时性能。