我们如何在 PROXX 中使用代码拆分、代码内联和服务器端渲染。
在 2019 年 Google I/O 大会上,Mariko、Jake 和我发布了 PROXX,这是一种新型网络扫雷克隆应用。PROXX 与众不同的之处在于,它注重无障碍功能(您可以用屏幕阅读器播放!),而且该功能在非智能手机上也能像在高端桌面设备上运行一样。功能手机在以下方面受到限制:
- CPU 性能不佳
- GPU 弱或不存在
- 不支持触控输入的小屏幕
- 内存量非常有限
但它们运行的是现代浏览器,而且价格实惠。因此,功能手机在新兴市场再次掀起热潮。他们凭借自己的价位,吸引了以前负担不起的新受众上网并使用现代网络。预计仅在 2019 年,非智能手机就将在印度销售约 4 亿部,因此非智能手机用户可能会成为受众群体的很大一部分。除此之外,类似 2G 的连接速度在新兴市场非常普遍。我们是如何让 PROXX 在非智能手机条件下正常运行的?
性能非常重要,其中包括加载性能和运行时性能。事实证明,良好的性能有助于提升用户留存率、提升转化率,最重要的是,还能提高包容性。Jeremy Wagner 就性能的重要性提供了更多数据和数据洞见。
本系列分为两部分,本文是第 1 部分。第 1 部分重点介绍加载性能,第 2 部分重点介绍运行时性能。
追求现状
在真实设备上测试加载性能至关重要。如果您手头没有真实设备,我建议您执行 WebPageTest,尤其是“简单”设置。WPT 会在具有模拟 3G 连接的真实设备上运行一系列加载测试。
3G 的测量速度比较好。虽然你可能已经习惯了 4G、LTE 甚至 5G,但移动互联网的真实情况却截然不同。您可能正在火车、会议、音乐会或航班上。您在那里遇到的情况很可能是在更接近 3G 网络的情况下,有时甚至更糟。
尽管如此,我们还是在本文中重点介绍 2G,因为 PROXX 的目标受众群体是功能手机和新兴市场。在 WebPageTest 运行测试后,您会看到一个瀑布图(类似于您在开发者工具中看到的内容)以及顶部的幻灯影片。幻灯软片会显示用户在应用加载时所见的内容。在 2G 网络下,未经优化的 PROXX 版本的加载体验非常糟糕:
通过 3G 加载时,用户会看到 4 秒的空白状态。如果使用 2G 网络,用户在超过 8 秒的时间内看不到任何内容。如果您阅读了性能的重要性一文,那么您就会知道,由于缺乏耐心,我们现在错失了很大一部分潜在用户。用户需要下载全部 62 KB 的 JavaScript,屏幕上才会显示任何内容。此场景中的希望是,屏幕上显示的第二个内容也是具有互动性的。或者说有可能?
![](https://web.developers.google.cn/static/articles/load-faster-like-proxx/image/CwGKJEpvyPw9UmvJf3su.webp?authuser=6&hl=uk)
您下载了大约 62 KB 的 Gzip 压缩 JS 并生成了 DOM 后,就可以看到我们的应用。该应用在技术上是可交互的。然而,观察视觉环境却会发现一种不同的现实。网页字体仍在后台加载,在准备就绪之前,用户看不到任何文字。虽然此状态符合首次有效渲染时间 (FMP) 的条件,但肯定无法达到适当的互动条件,因为用户无法得知输入的内容是什么。连接到 3G 网络需要 1 秒,在 2G 网络下需要 3 秒时间,应用才能准备就绪。总的来说,在 3G 网络下,该应用需要 6 秒时间,在 2G 网络下需要 11 秒才能进入互动状态。
广告瀑布流分析
既然我们已经了解了用户看到的内容,接下来需要找出原因。为此,我们可以查看广告瀑布流,并分析资源加载太晚的原因。在 PROXX 的 2G 轨迹中,我们可以看到两大危险信号:
- 有多条彩色细线。
- JavaScript 文件形成链。例如,第二个资源只有在第一个资源完成后才会开始加载,而第三个资源只有在第二个资源完成后才会开始加载。
![](https://web.developers.google.cn/static/articles/load-faster-like-proxx/image/Vcd5JU5MJNr0IHyMMtAU.png?authuser=6&hl=uk)
减少连接数
每个细线(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 的最大改进。实现此目标的常见技术是预渲染和服务器端呈现,这两种技术彼此密切相关,在网络上呈现中对此进行了介绍。这两种技术都在 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。
First Meaningful Paint 从 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 似乎不大。
![](https://web.developers.google.cn/static/articles/load-faster-like-proxx/image/PDjREt9PrWz9oqayT3CE.webp?authuser=6&hl=uk)
为了解 bundle 大小的来源,我们可以使用 Source Map Explorer 或类似工具来细分 bundle 所含的内容。正如预测的那样,我们的软件包包含游戏逻辑、渲染引擎、胜出屏幕、失败屏幕和许多实用程序。着陆页中只需要这些模块中的一小部分。将互动并非严格要求的所有内容都移到延迟加载的模块中,将会显著减少 TTI。
我们需要做的是代码拆分。代码拆分功能可将单体式 bundle 拆分为多个可以按需延迟加载的较小部分。Webpack、Rollup 和 Parcel 等热门捆绑器支持使用动态 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 会告诉用户!
我们的 FMP 和 TTI 相差仅 100 毫秒,因为这只是解析和执行内联 JavaScript 问题。在 2G 网络下只用了 5.4 秒,应用就能够全面互动了。所有其他不太重要的模块都在后台加载。
更灵活
如果您查看以上关键模块列表,会发现渲染引擎并不属于关键模块。当然,在我们使用渲染引擎渲染游戏之前,游戏无法启动。我们可以停用“Start”按钮,直到渲染引擎准备好启动游戏,但根据我们的经验,用户通常需要足够长的时间来配置游戏设置,因此并不需要这样做。多数情况下,渲染引擎和其他剩余模块会在用户按“开始”时完成加载。在极少数情况下,用户会比网络连接速度快,我们会展示一个简单的加载屏幕,等待其余模块完成。
总结
测量很重要。为了避免将时间花在非实际问题上,我们建议您在实施优化之前先进行衡量。此外,应在使用 3G 连接的真实设备上进行测量;如果没有真实设备,则应在 WebPageTest 上进行测量。
幻灯影片可让您深入了解用户在加载应用时感受到的体验。瀑布流可以告诉您哪些资源导致了加载时间过长。为提高加载性能,您可以采取以下核对清单:
敬请关注第 2 部分,其中讨论了如何在超受限设备上优化运行时性能。