提高前端性能的案例研究

Avatar of Declan Rek
Declan Rek

DigitalOcean 提供适用于您旅程各个阶段的云产品。 立即开始使用 $200 免费信用额度!

De Voorhoede,我们尽力为客户尽可能提高前端性能。 说服每位客户遵循我们所有性能指南并非易事。 我们试图通过用他们自己的语言与他们交谈来说服他们,并解释性能对转化率的重要性,或将他们的性能与主要竞争对手进行比较。

顺便说一下,我们最近更新了我们的网站。 除了进行完全的设计改造之外,这也是将性能推向极致的绝佳机会。 我们的目标是掌控一切,专注于性能,为未来提供灵活性和让为我们的网站撰写内容变得有趣。 以下是如何为我们的网站掌握前端性能。 尽情享受!

为性能设计

在我们的项目中,我们每天都会与设计师和产品负责人讨论平衡美观和性能。 对于我们自己的网站来说,这很容易。 我们认为,良好的用户体验始于尽快交付内容。 这意味着 **性能 > 美观**。

良好的内容、布局、图像和交互性对于吸引受众至关重要,但这些元素中的每一个都会对页面加载时间和最终用户体验产生影响。 在每一步中,我们都考虑了如何在获得良好的用户体验和设计的同事,将对性能的影响降至最低。

内容优先

我们希望尽快为访问者提供核心内容(带有基本 HTML 和 CSS 的文本)。 每个页面都应支持内容的主要目的:传达信息。 增强功能,即 JavaScript、完整 CSS、网络字体、图像和分析不如核心内容重要。

掌控一切

在定义了我们为理想网站设定的标准之后,我们得出结论,我们需要完全控制网站的各个方面。 我们选择构建自己的静态网站生成器,包括资产管道,并自行托管它。

静态网站生成器

我们在 Node.js 中编写了自己的静态网站生成器。 它使用带有简短 JSON 页面元描述的 Markdown 文件来生成具有所有资产的完整网站结构。 它还可以与 HTML 文件一起使用,用于包含页面特定的 JavaScript。

请参阅下面用于生成实际 HTML 的博客文章的简化元描述和 Markdown 文件。

JSON 元描述

{
  "keywords": ["performance", "critical rendering path", "static site", "..."],
  "publishDate": "2016-08-12",
  "authors": ["Declan"]
}

以及 Markdown 文件

# A case study on boosting front-end performance
At [De Voorhoede](https://www.voorhoede.nl/en/) we try to boost front-end performance...

## Design for performance

In our projects we have daily discussions...

图像交付

httparchive.org 统计,平均网页大小为 2406kb,其中 1535kb 为图像。 由于图像占平均网站很大一部分,因此也是获得性能提升的最佳目标之一。

httparchive.org 2016 年 7 月按内容类型划分的平均每页字节数

WebP

WebP 是一种现代图像格式,它为 Web 上的图像提供了卓越的无损和有损压缩。 WebP 图像可能比其他格式的图像小得多:有时它们比 JPEG 对等图像小 25%。 WebP 被忽视很多,而且很少使用。 在撰写本文时,WebP 支持仅限于 Chrome、Opera 和 Android(仍占我们用户的 50% 以上),但我们可以优雅地降级为 JPG/PNG。

<picture> 元素

使用 picture 元素,我们可以从 WebP 优雅地降级到 JPEG 等更广泛支持的格式

<picture>
  <source type="image/webp" srcset="image-l.webp" media="(min-width: 640px)">
  <source type="image/webp" srcset="image-m.webp" media="(min-width: 320px)">
  <source type="image/webp" srcset="image-s.webp">
  <source srcset="image-l.jpg" media="(min-width: 640px)">
  <source srcset="image-m.jpg" media="(min-width: 320px)">
  <source srcset="image-s.jpg">
  <img alt="Description of the image" src="image-l.jpg">
</picture>

我们使用 Scott Jehl 编写的 picturefill 来为不支持 <picture> 元素的浏览器提供填充,并在所有浏览器中获得一致的行为。

我们将 <img> 用作不支持 <picture> 元素和/或 JavaScript 的浏览器的后备。 使用图像的最大实例可确保它在后备情况下仍然看起来很好。

生成

虽然图像交付方法已到位,但我们仍然需要弄清楚如何轻松地实施它。 我喜欢 picture 元素的功能,但讨厌编写上面的代码段。 特别是在我必须在编写内容时包含它时。 我们不想费心生成每张图像的 6 个实例,优化图像并在 Markdown 中编写 <picture> 元素。 因此我们

  • 生成原始图像在我们的构建过程中的多个实例,包括输入格式(JPG、PNG)和 WebP。 我们使用 gulp responsive 来执行此操作。
  • 压缩生成的图像
  • 写入 ![图像描述](http://placehold.it/350x150.png) 到我们的 Markdown 文件中。
  • 在构建过程中使用自定义编写的 Markdown 渲染器将编译传统的 Markdown 图像声明为完整的 <picture> 元素。

SVG 动画

我们为我们的网站选择了一种独特的图形风格,其中 SVG 插图起着重要作用。 我们这样做是因为几个原因。

  • 首先,SVG(矢量图像)往往比位图图像更小;
  • 其次,SVG 本质上是响应式的,并且可以完美缩放,同时始终保持超清晰。 因此无需进行图像生成和 <picture> 元素;
  • 最后但并非最不重要的是,我们可以通过 CSS 对其进行动画处理和更改! 设计性能的完美示例。 我们所有作品集页面 都有一个自定义制作的动画 SVG,它会在概述页面上重复使用。 它作为所有作品集项目的重复风格,使设计保持一致,同时对性能的影响很小。

查看此动画以及我们如何使用 CSS 对其进行更改。

查看 CodePen 上 De Voorhoede (@voorhoede) 制作的 更改内联 svg 样式

自定义网络字体

在深入研究之前,这里简要介绍一下浏览器对自定义网络字体的行为。 当浏览器在 CSS 中遇到指向用户计算机上不可用的字体的 @font-face 定义时,它将尝试下载此字体文件。 在下载过程中,大多数浏览器都不会使用此字体显示文本。 根本没有。 这种现象被称为“不可见文本闪烁”或 FOIT。 如果你知道要找什么,你几乎可以在网络上的任何地方找到它。 而且我认为,这对最终用户体验来说很糟糕。 它会延迟用户实现其核心目标:阅读内容。

但是,我们可以强制浏览器更改其行为,使其变成“无样式内容闪烁”(FOUT)。我们告诉浏览器首先使用一种普遍的字体,例如 Arial 或 Georgia。一旦自定义网页字体下载完成,它将替换标准字体并重新渲染所有文本。如果自定义字体无法加载,内容仍然可以完美阅读。虽然有些人可能认为这是一种回退,但我们认为自定义字体是一种增强功能。即使没有它,网站看起来也很好,并且 100% 可用。

左边是带有自定义网页字体的博文,右边是带有我们“回退”字体的博文。

只要您负责任地优化和提供自定义网页字体,它们就可以改善用户体验。

字体子集

子集是迄今为止提高网页字体性能最快的捷径。我建议所有使用自定义字体的网页开发人员都使用它。如果您完全控制内容并知道将显示哪些字符,您可以全面进行子集化。但即使只将字体子集化到“西方语言”,也会对文件大小产生巨大影响。例如,我们的 Noto Regular WOFF 字体,默认大小为 246KB,当子集化到西方语言时,会降至 31KB。我们使用了 Font squirrel 网页字体生成器,它非常易于使用。

字体面观察器

Bram Stein 的字体面观察器 是一个很棒的辅助脚本,用于检查字体是否已加载。它与您如何加载字体无关,无论是通过网页字体服务还是自行托管。在字体面观察器脚本通知我们所有自定义网页字体都已加载后,我们在 <html> 元素中添加了一个 fonts-loaded 类。我们相应地为页面设置样式。

html {
  font-family: Georgia, serif;
}

html.fonts-loaded {
  font-family: Noto, Georgia, serif;
}

注意:为了简洁起见,我没有在上面的 CSS 中包含 @font-face 声明 用于 Noto

我们还会设置一个 cookie 来记住所有字体都已加载,因此位于浏览器的缓存中。我们使用此 cookie 进行重复查看,我将在稍后解释。

在不久的将来,我们可能不需要 Bram Stein 的 JavaScript 来获得此行为。CSS 工作组已提出新的 @font-face 描述符(称为 font-display),其中属性值控制可下载字体在完全加载之前如何呈现。CSS 语句 font-display: swap; 将为我们提供与上述方法相同的行为。 阅读有关 font-display 属性的更多信息

延迟加载 JS 和 CSS

一般来说,我们采用尽快加载资产的方法。我们消除了阻止渲染的请求,并针对首次查看进行了优化,利用浏览器缓存进行重复查看。

延迟加载 JS

根据设计,我们的网站中没有太多 JavaScript 代码。对于我们已有的代码以及我们打算在将来使用的代码,我们开发了 JavaScript 工作流程。

<head> 中的 JavaScript 代码会阻止渲染,我们不希望这样。JavaScript 应该只增强用户体验;它对我们的访问者来说并不关键。修复阻止渲染的 JavaScript 代码的简单方法是将脚本放在网页的尾部。缺点是它只会等到完整的 HTML 下载完才开始下载脚本。

另一种方法是将脚本添加到头部,并通过在 <script> 标记中添加 defer 属性来延迟脚本执行。这使得脚本成为非阻塞脚本,因为浏览器几乎立即下载它,但在页面加载完成后才执行代码。

只剩下一个问题,我们没有使用 jQuery 之类的库,因此我们的 JavaScript 依赖于原生 JavaScript 功能。我们只想在支持这些功能的浏览器中加载 JavaScript 代码(即 芥末切割)。最终结果如下

<script>
// Mustard Cutting
if ('querySelector' in document && 'addEventListener' in window) {
  document.write('<script src="index.js" defer><\/script>');
}
</script>

我们将这段简短的内联脚本放在页面的头部,检测原生 JavaScript document.querySelectorwindow.addEventListener 功能是否受支持。如果支持,我们通过将 script 标记直接写入页面来加载脚本,并使用 defer 属性使其成为非阻塞脚本。

延迟加载 CSS

对于首次查看,我们网站最大的阻止渲染资源是 CSS。浏览器会延迟页面渲染,直到 <head> 中引用的完整 CSS 文件下载并解析。这种行为是故意的,否则浏览器在渲染过程中需要不断重新计算布局和重新绘制。

为了防止 CSS 阻止渲染,我们需要异步加载 CSS 文件。我们使用了 Filament Group 提供的令人赞叹的 loadCSS 函数。它会在 CSS 文件加载完成后提供回调,我们会在其中设置一个 cookie,表明 CSS 已加载。我们使用此 cookie 进行重复查看,我将在稍后解释。

异步加载 CSS 存在一个“问题”,即在 HTML 非常快地渲染时,它看起来就像没有应用 CSS 的纯 HTML,直到完整的 CSS 下载并解析。这就是关键 CSS 发挥作用的地方。

关键 CSS

关键 CSS 可以描述为使页面对用户看起来可识别的最小阻塞 CSS 量。我们专注于“折叠以上”内容。显然,折叠位置在不同的设备之间差异很大,因此我们进行了一个最佳猜测。

手动确定关键 CSS 是一个耗时的过程,尤其是在未来的样式更改过程中。有几个非常好的脚本可以用于在您的构建过程中生成关键 CSS。我们使用了 Addy Osmani 提供的出色 critical npm 模块

请查看下面使用关键 CSS 和使用完整 CSS 渲染的主页。请注意折叠位置,折叠以下的页面仍然处于某种程度上无样式状态。

左边是只使用关键 CSS 渲染的主页,右边是使用完整 CSS 渲染的主页。红色线代表折叠位置。

服务器

我们自己托管 de Voorhoede 网站,因为我们希望能够控制服务器环境。我们还希望通过更改服务器配置来实验如何提高性能。目前,我们使用的是 Apache Web 服务器,并且通过 HTTPS 提供我们的网站。

配置

为了提高性能和安全性,我们对如何配置服务器进行了一些研究。

我们使用 H5BP 模版 Apache 配置,这是一个非常好的起点,可以提高 Apache Web 服务器的性能和安全性。他们还为其他服务器环境提供配置。

我们为大多数 HTML、CSS 和 JavaScript 代码启用了 GZIP。我们为所有资源整齐地设置了缓存标头。请在下面的 文件级缓存部分 中阅读相关信息。

HTTPS

通过 HTTPS 提供您的网站可能会对您的网站性能产生影响。性能损失主要来自设置 SSL 握手,从而引入大量延迟。但正如往常一样,我们可以做一些事情来解决这个问题!

HTTP 严格传输安全是一个 HTTP 标头,它允许服务器告诉浏览器它应该只使用 HTTPS 进行通信。这样可以防止 HTTP 请求被重定向到 HTTPS。所有使用 HTTP 访问网站的尝试都应该自动转换为 HTTPS。这为我们节省了一个往返行程!

TLS 假启动允许客户端在第一个 TLS 往返行程后立即开始发送加密数据。这种优化将新的 TLS 连接的握手开销减少到一个往返行程。一旦客户端知道加密密钥,它就可以开始传输应用程序数据。握手剩余部分用于确认没有人篡改握手记录,并且可以在并行执行。

TLS 会话恢复通过确保浏览器和服务器以前通过 TLS 进行过通信,浏览器可以记住会话标识符,并在下次建立连接时重用该标识符,从而节省一次往返。

我听起来像一个 DevOps 工程师,但我不是。我只是读了一些东西并观看了一些视频。我非常喜欢来自 Google I/O 2016 的 Emily Stark 的《HTTPS 的神话破灭:粉碎安全性的都市传说》

Cookie 的使用

我们没有服务器端语言,只有一个静态的 Apache web 服务器。但 Apache web 服务器仍然可以进行服务器端包含 (SSI) 并读取 cookie。通过明智地使用 cookie 并提供由 Apache 部分重写的 HTML,我们可以提升前端性能。请看下面的例子(我们实际的代码稍微复杂一些,但归结起来都是一样的想法)

<!-- #if expr="($HTTP_COOKIE!=/css-loaded/) || ($HTTP_COOKIE=/.*css-loaded=([^;]+);?.*/ && ${1} != '0d82f.css' )"-->

<noscript><link rel="stylesheet" href="0d82f.css"></noscript>
<script>
(function() {
  function loadCSS(url) {...}
  function onloadCSS(stylesheet, callback) {...}
  function setCookie(name, value, expInDays) {...}

  var stylesheet = loadCSS('0d82f.css');
  onloadCSS(stylesheet, function() {
    setCookie('css-loaded', '0d82f', 100);
  });
}());
</script>

<style>/* Critical CSS here */</style>

<!-- #else -->
<link rel="stylesheet" href="0d82f.css">
<!-- #endif -->

Apache 服务器端逻辑是从 <!-- # 开始的类似注释的行。我们一步一步地看一下

  • $HTTP_COOKIE!=/css-loaded/ 检查是否还没有 CSS 缓存 cookie。
  • $HTTP_COOKIE=/.*css-loaded=([^;]+);?.*/ && ${1} != '0d82f.css' 检查缓存的 CSS 版本是否不是当前版本。
  • 如果 <!-- #if expr="..." --> 评估为 true,我们假设这是访问者的首次访问。
  • 对于首次访问,我们添加一个包含 render 阻塞 <link rel="stylesheet"><noscript> 标签。我们这样做是因为我们将会使用 JavaScript 异步加载完整的 CSS。如果 JavaScript 被禁用,这将是不可能的。这意味着作为回退,我们按顺序加载 CSS,即以阻塞方式加载。
  • 我们添加一个包含用于延迟加载 CSS 的函数、onloadCSS 回调以及设置 cookie 的内联脚本。
  • 在同一个脚本中,我们异步加载完整的 CSS。
  • onloadCSS 回调中,我们设置一个 cookie,其版本哈希作为 cookie 值。
  • 在脚本之后,我们添加一个包含关键 CSS 的内联样式表。这将是 render 阻塞的,但它将非常小,并防止页面以纯无样式的 HTML 显示。
  • <!-- #else --> 语句(表示 css-loaded cookie 存在)表示访问者的重复访问。因为我们可以假设 CSS 文件在某种程度上已经加载,我们可以利用浏览器缓存并以阻塞方式提供样式表。它将从缓存中提供,并且几乎立即加载。

相同的方法用于在首次访问中异步加载字体,假设我们可以在重复访问中从浏览器缓存中提供它们。

查看我们用来区分首次访问和重复访问的 cookie。

文件级缓存

由于我们严重依赖浏览器缓存来进行重复访问,我们需要确保我们正确地缓存。理想情况下,我们希望永远缓存资产(CSS、JS、字体、图像),只有当文件实际发生更改时才使缓存失效。如果请求 URL 是唯一的,则缓存将失效。我们在发布新版本时对我们的网站进行 git tag 标记,因此最简单的方法是在请求 URL 中添加一个查询参数,该参数包含代码库版本,例如 https://www.voorhoede.nl/assets/css/main.css?v=1.0.4。但是。

这种方法的缺点是,当我们编写一篇新的博客文章时(它是我们代码库的一部分,而不是存储在 CMS 中的外部存储),所有资产的缓存都会失效,而这些资产并没有发生任何更改。

在尝试升级我们的方法时,我们偶然发现了 gulp-revgulp-rev-replace。这些脚本帮助我们在文件名中添加内容哈希,从而实现每个文件的修订。这意味着请求 URL 只有在实际文件发生更改时才会更改。现在我们有了每个文件的缓存失效。这让我激动不已!

结果

如果你已经看到了这里(太棒了!),你可能想知道结果。可以使用 PageSpeed Insights 等工具来测试你的网站性能,这些工具可以提供非常实用的提示,而 WebPagetest 可以进行全面的网络分析。我认为测试你的网站渲染性能的最佳方法是,在疯狂地限制你的连接速度时,观察你的页面如何演变。这意味着:以一种可能不现实的方式进行限制。在 Google Chrome 中,你可以限制你的连接(通过检查器>网络选项卡),并查看请求是如何在页面构建时被缓慢加载的。

查看我们的主页如何在 50KB/s GPRS 连接上加载。

网站的网络分析以及页面在首次访问时如何演变。

请注意,我们在 50KB/s GPRS 网络上于 2.27 秒获得首次渲染,由胶片条中第一张图像以及瀑布视图中相应的黄色线表示。黄色线是在 HTML 下载后立即绘制的。HTML 包含关键 CSS,确保页面看起来可以使用。所有其他阻塞资源都将被延迟加载,因此我们可以在下载剩余内容时与页面交互。这正是我们想要的!

需要注意的另一件事是,在这样的慢速连接上永远不会加载自定义字体。字体面观察器会自动处理这个问题,但如果我们没有异步加载字体,你将在大多数浏览器中看到 FOIT 一段时间。

完整的 CSS 文件只在 8 秒后加载。相反,如果我们以阻塞方式加载完整的 CSS,而不是内联关键 CSS,我们将看到一个空白页面 8 秒。

如果你想知道这些时间与其他不太注重性能的网站相比如何,那就试试看。加载时间会飙升!

我们使用前面提到的工具测试我们的网站,也取得了一些不错的结果。PageSpeed Insights 为我们的移动性能提供了 100/100 的评分,太棒了!

当我们查看 WebPagetest 时,我们得到以下结果

voorhoede.nl 的 WebPagetest 结果。

我们可以看到我们的服务器运行良好,并且首次访问的 SpeedIndex 为 693。这意味着我们的页面在有线连接下 693 毫秒后就可以使用。看起来不错!

路线图

我们还没有完成,并且一直在不断改进我们的方法。在不久的将来,我们将重点关注

  • HTTP/2:它已经出现了,我们目前正在试验它。本文中描述的许多内容都是基于 HTTP/1.1 限制的最佳实践。简而言之:HTTP/1.1 诞生于 1999 年,当时表格布局和内联样式非常流行。HTTP/1.1 从未为包含 200 个请求的 2.6 MB 网页而设计。为了减轻我们可怜的旧协议的痛苦,我们合并了 JS 和 CSS、内联了关键 CSS、对小图像使用了 data URL 等。一切都是为了节省请求。由于 HTTP/2 可以通过相同的 TCP 连接并行运行多个请求,因此所有这些合并和减少请求的操作甚至可能被证明是一种反模式。在我们完成实验后,我们将迁移到 HTTP/2。
  • Service Workers:这是一个现代浏览器 JavaScript API,它在后台运行。它支持许多以前网站无法使用的功能,例如离线支持、推送通知、后台同步等等。我们正在玩转 Service Workers,但我们还需要在自己的网站上实现它。我保证我们会做到!
  • CDN:因此,我们想要控制权,并自己托管了网站。是的,是的,现在我们想要迁移到 CDN,以消除客户端和服务器之间物理距离造成的网络延迟。尽管我们的客户主要位于荷兰,但我们希望以一种反映我们最擅长的方式接触全球前端社区:质量、性能以及推动 web 发展。

感谢阅读!请访问我们的网站查看 最终结果。你有什么意见或问题吗?请通过 Twitter 告诉我们。如果你喜欢构建快速网站,为什么不加入我们