我们如何改进 SmashingMag 性能
已发表: 2022-03-10本文得到了我们在 Media Temple 的亲爱的朋友的大力支持,他们为设计师、开发人员和您的客户提供了全方位的网络托管解决方案。 谢谢亲爱的朋友们!
每个网络性能故事都是相似的,不是吗? 它总是从期待已久的网站大修开始。 一天,一个经过全面打磨和精心优化的项目启动,在 Lighthouse 和 WebPageTest 中排名高并超过性能分数。 空气中弥漫着一种庆祝和全心全意的成就感——完美地反映在转发、评论、新闻通讯和 Slack 线程中。
然而随着时间的流逝,兴奋慢慢消退,紧急调整、急需的功能和新的业务需求悄悄出现。突然,在你不知不觉中,代码库变得有点超重和碎片化,第三方脚本必须提前一点加载,闪亮的新动态内容会通过第四方脚本的后门及其不速之客进入 DOM。
我们也去过 Smashing。 知道这一点的人不多,但我们是一个非常小的团队,大约 12 人,其中许多人是兼职工作,其中大多数人通常在某一天戴着许多不同的帽子。 近十年来,尽管性能一直是我们的目标,但我们从未真正拥有过专门的性能团队。
在 2017 年末进行了最新的重新设计之后,Ilya Pukhalski 在 JavaScript 方面(兼职),Michael Riethmueller 在 CSS 方面(每周几个小时),以及你真正的,用关键的 CSS 玩智力游戏并试图兼顾一些太多的事情。
碰巧的是,我们在忙碌的日常工作中失去了表现。 我们正在设计和构建东西,设置新产品,重构组件并发布文章。 所以到 2020 年底,事情有点失控了,黄红色的灯塔分数慢慢地全面出现。 我们必须解决这个问题。
那就是我们所在的地方
你们中的一些人可能知道我们在 JAMStack 上运行,所有文章和页面都存储为 Markdown 文件,Sass 文件编译成 CSS,JavaScript 使用 Webpack 分割成块,Hugo 构建静态页面,然后我们直接从 Edge CDN 提供服务。 早在 2017 年,我们就使用 Preact 构建了整个网站,但随后在 2019 年转移到了 React——并将其与一些用于搜索、评论、身份验证和结帐的 API 一起使用。
整个网站在构建时考虑到了渐进式增强,这意味着亲爱的读者,您可以完整阅读每篇 Smashing 文章,而无需启动应用程序。 这也不足为奇——最后,一篇发表的文章多年来并没有太大变化,而诸如会员身份验证和结帐之类的动态部分需要应用程序运行。
目前,部署大约 2500 篇文章的整个构建过程大约需要6 分钟。 随着时间的推移,构建过程本身也变得相当糟糕,包括关键的 CSS 注入、Webpack 的代码拆分、广告和功能面板的动态插入、RSS(重新)生成以及最终的边缘 A/B 测试。
2020 年初,我们开始对 CSS 布局组件进行大规模重构。 我们从未使用过 CSS-in-JS 或 styled-components,而是使用了一个很好的基于组件的 Sass 模块系统,它可以编译成 CSS。 早在 2017 年,整个布局是使用 Flexbox 构建的,并在 2019 年中期使用 CSS Grid 和 CSS 自定义属性进行了重建。 但是,由于新的广告位和新的产品面板,一些页面需要特殊处理。 因此,当布局工作时,它工作得不是很好,而且很难维护。
此外,带有主导航的标题必须更改以适应我们想要动态显示的更多项目。 另外,我们想重构站点中使用的一些常用组件,并且那里使用的 CSS 也需要进行一些修改——通讯框是最显着的罪魁祸首。 我们从使用实用程序优先的 CSS 重构一些组件开始,但我们从未达到在整个网站上一致使用它的地步。
更大的问题是大型 JavaScript 包——这并不奇怪——阻塞了主线程数百毫秒。 一个大的 JavaScript 包在仅仅发表文章的杂志上可能看起来不合适,但实际上,在幕后发生了大量的脚本。
我们为经过身份验证和未经身份验证的客户提供各种组件状态。 登录后,我们希望以最终价格显示所有产品,并且当您将书籍添加到购物车时,我们希望通过点击按钮保持购物车可访问 - 无论您在哪个页面上。 广告需要在不引起破坏性布局变化的情况下快速进入,突出我们产品的原生产品面板也是如此。 加上一个服务工作者,它缓存所有静态资产并为它们提供重复视图,以及读者已经访问过的文章的缓存版本。
所以所有这些脚本都必须在某个时候发生,即使脚本来得很晚,它也会消耗阅读体验。 坦率地说,我们在网站和新组件上煞费苦心,没有密切关注性能(2020 年我们还有其他一些事情要记住)。 转折点出乎意料地来了。 Harry Roberts 将他的(出色的)Web 性能大师班作为在线研讨会与我们一起举办,在整个研讨会期间,他以 Smashing 为例,强调我们遇到的问题并建议解决这些问题的方法以及有用的工具和指南。
在整个研讨会期间,我一直在努力记笔记并重新访问代码库。 在研讨会举行时,我们的 Lighthouse 得分在首页上为 60-68,在文章页面上约为 40-60——在移动设备上显然更差。 研讨会结束后,我们开始工作。
识别瓶颈
我们通常倾向于依靠特定的分数来了解我们的表现如何,但往往单一的分数并不能提供完整的画面。 正如 David East 在他的文章中雄辩地指出的那样,Web 性能不是一个单一的价值。 这是一个分布。 即使 Web 体验是全面优化的全面性能,也不能只是快速。 对某些访问者来说可能很快,但最终对其他一些访问者来说也会更慢(或慢)。
造成这种情况的原因很多,但最重要的一个是全球网络条件和设备硬件的巨大差异。 很多时候,我们无法真正影响这些事情,所以我们必须确保我们的经验能够适应它们。
本质上,我们的工作就是增加快速体验的比例并减少缓慢体验的比例。 但为此,我们需要正确了解分布的实际情况。 现在,分析工具和性能监控工具将在需要时提供这些数据,但我们专门研究了 CrUX,Chrome 用户体验报告。 CrUX 生成随时间推移的性能分布概览,并从 Chrome 用户那里收集流量。 其中大部分数据与谷歌在 2020 年宣布的 Core Web Vitals 相关,这些数据也有助于 Lighthouse 并在 Lighthouse 中公开。
我们注意到,总体而言,我们的业绩全年大幅下降,尤其是在 8 月和 9 月左右下降。 一旦我们看到这些图表,我们就可以回顾我们当时推出的一些 PR,以研究实际发生的情况。
很快就发现,就在这些时候,我们实时推出了一个新的导航栏。 该导航栏——用于所有页面——依靠 JavaScript 在点击或点击时在菜单中显示导航项,但它的 JavaScript 部分实际上是捆绑在app.js包中的。 为了改进 Time To Interactive,我们决定从包中提取导航脚本并内联提供它。
大约在同一时间,我们从(过时的)手动创建的关键 CSS文件切换到为每个模板(主页、文章、产品页面、活动、工作板等)生成关键 CSS 的自动化系统,并在期间内联关键 CSS构建时间。 然而,我们并没有真正意识到自动生成的关键 CSS 有多么沉重。 我们不得不更详细地探索它。
大约在同一时间,我们正在调整网络字体加载,尝试通过预加载等资源提示更积极地推送网络字体。 不过,这似乎对我们的性能工作产生了反作用,因为网络字体延迟了内容的呈现,在完整的 CSS 文件旁边被过度优先考虑。
现在,回归的一个常见原因是 JavaScript 的高成本,因此我们还研究了 Webpack Bundle Analyzer 和 Simon Hearne 的请求图,以直观地了解我们的 JavaScript 依赖关系。 一开始它看起来很健康。
一些请求来自 CDN、cookie 同意服务 Cookiebot、Google Analytics,以及我们用于提供产品面板和定制广告的内部服务。 看起来并没有很多瓶颈——直到我们更仔细地观察。
在性能工作中,通常会查看一些关键页面的性能——最有可能是主页,最有可能是一些文章/产品页面。 然而,虽然只有一个主页,但可能会有很多不同的产品页面,所以我们需要选择能够代表我们受众的页面。
事实上,由于我们在 SmashingMag 上发表了大量代码密集和设计密集的文章,多年来我们已经积累了数千篇包含大量 GIF、语法高亮代码片段、CodePen 嵌入、视频/音频的文章嵌入和嵌套线程的永无止境的评论。
当它们结合在一起时,它们中的许多都会导致DOM 大小的爆炸以及过多的主线程工作——减慢了数千页的体验。 更不用说随着广告的出现,一些 DOM 元素在页面生命周期的后期被注入,导致一连串的样式重新计算和重新绘制——这也是可以产生长时间任务的昂贵任务。
所有这些都没有显示在我们为上图中的一个非常轻量级的文章页面生成的地图中。 所以我们选择了我们拥有的最重的页面——全能的主页、最长的主页、嵌入很多视频的网页和嵌入很多 CodePen 的网页——并决定尽可能优化它们。 毕竟,如果它们很快,那么嵌入单个 CodePen 的页面也应该更快。
考虑到这些页面,地图看起来有点不同。 请注意指向 Vimeo 播放器和 Vimeo CDN 的粗线,其中 78 个请求来自 Smashing 文章。
为了研究对主线程的影响,我们深入研究了 DevTools 中的性能面板。 更具体地说,我们正在寻找持续时间超过 50 毫秒的任务(用右上角的红色矩形突出显示)和包含重新计算样式的任务(紫色条)。 第一个表明 JavaScript 执行成本很高,而后者会暴露由 DOM 中的内容动态注入和次优 CSS 引起的样式失效。 这给了我们一些可操作的指示,告诉我们从哪里开始。 例如,我们很快发现我们的网络字体加载需要大量的重绘成本,而 JavaScript 块仍然很重,足以阻塞主线程。
作为基线,我们非常仔细地研究了 Core Web Vitals,试图确保我们在所有这些方面都取得了良好的成绩。 我们选择专注于慢速移动设备——3G 速度慢、RTT 为 400ms 和传输速度为 400kbps,只是出于悲观的考虑。 毫不奇怪,Lighthouse 对我们的网站也不是很满意,为最重的文章提供完全稳定的红色分数,并不知疲倦地抱怨未使用的 JavaScript、CSS、屏幕外图像及其大小。
一旦我们有了一些数据,我们就可以专注于优化三个最重的文章页面,重点是关键(和非关键)CSS、JavaScript 包、长任务、Web 字体加载、布局转换和第三方-嵌入。 稍后我们还将修改代码库以删除遗留代码并使用新的现代浏览器功能。 看起来有很多工作要做,事实上我们在接下来的几个月里都很忙。
改善<head>
中的资产顺序
具有讽刺意味的是,我们研究的第一件事甚至与我们上面确定的所有任务都没有密切关系。 在性能研讨会上,Harry 花了相当多的时间来解释每个页面<head>
中的资产顺序,并指出快速交付关键内容意味着非常有策略地关注源代码中资产的排序方式.
现在,关键的 CSS 对 Web 性能有益,这不应该成为一个重大的启示。 然而,令人惊讶的是,所有其他资产(资源提示、网络字体预加载、同步和异步脚本、完整 CSS 和元数据)的顺序有多大差异。
我们将整个<head>
上下颠倒,将关键 CSS放在所有异步脚本和所有预加载资产(如字体、图像等)之前。我们已经分解了我们将预连接或通过模板预加载的资产和文件类型,以便仅针对特定类型的文章和页面提前请求关键图像、语法突出显示和视频嵌入。
总的来说,我们精心编排了<head>
中的顺序,减少了争夺带宽的预加载资源的数量,并专注于正确处理关键 CSS。 如果您想深入了解<head>
顺序的一些关键注意事项,Harry 在关于 CSS 和网络性能的文章中重点介绍了它们。 仅这一变化就为我们带来了大约 3-4 的 Lighthouse 得分。
从自动关键 CSS 回到手动关键 CSS
不过,移动<head>
标签只是故事的一个简单部分。 更困难的是关键 CSS 文件的生成和管理。 早在 2017 年,我们通过收集在所有屏幕宽度上呈现前 1000 个像素高度所需的所有样式,为每个模板手动手工制作关键 CSS。 这当然是一项繁琐且略显乏味的任务,更不用说驯服整个关键 CSS 文件家族和完整 CSS 文件的维护问题了。
因此,我们研究了将此过程自动化作为构建例程的一部分的选项。 可用的工具并不缺乏,所以我们测试了一些并决定运行一些测试。 我们已经设法将它们设置好并快速运行。 输出对于自动化流程来说似乎已经足够好了,因此在进行了一些配置调整后,我们将其插入并推送到生产环境中。 这发生在去年 7 月至 8 月左右,在上面 CrUX 数据的峰值和性能下降中很好地体现了这一点。 我们不断地来回调整配置,经常遇到简单的问题,例如添加特定样式或删除其他样式。 例如,除非 cookie 脚本已初始化,否则不会真正包含在页面中的 cookie 同意提示样式。
在 10 月,我们对网站进行了一些重大的布局更改,在研究关键的 CSS 时,我们又遇到了完全相同的问题——生成的结果非常冗长,并不是我们想要的. 因此,作为 10 月下旬的一项实验,我们都集中力量重新审视我们的关键 CSS 方法,并研究手工制作的关键 CSS会小得多。 我们深吸一口气,在关键页面上的代码覆盖工具上花了几天时间。 我们手动对 CSS 规则进行分组,并删除了两个地方的重复代码和遗留代码——关键 CSS 和主要 CSS。 这确实是一项急需的清理工作,因为多年来编写的许多样式在 2017-2018 年已经过时。
结果,我们最终得到了三个手工制作的关键 CSS 文件,以及另外三个目前正在进行中的文件:
- critical-homepage-manual.css (8.2 KB, Brotlified)
- critical-article-manual.css (8 KB, Brotlified)
- critical-articles-manual.css (6 KB, Brotlified)
- critical-books-manual.css(待完成的工作)
- critical-events-manual.css(待完成的工作)
- critical-job-board-manual.css(要完成的工作)
这些文件内联在每个模板的头部,目前它们在包含网站上曾经使用过(或不再真正使用)的所有内容的整体 CSS 包中复制。 目前,我们正在考虑将完整的 CSS 包分解为几个 CSS 包,这样杂志的读者就不会从工作板或书籍页面下载样式,但是当到达这些页面时会得到快速渲染使用关键 CSS 并异步获取该页面的其余 CSS - 仅在该页面上。
诚然,手工制作的关键 CSS 文件的大小并没有小很多:我们将关键 CSS 文件的大小减少了大约 14% 。 但是,它们以正确的顺序从头到尾包含了我们需要的一切,没有重复和覆盖样式。 这似乎是朝着正确方向迈出的一步,它使我们的 Lighthouse 又增加了 3-4 分。 我们正在取得进展。
更改 Web 字体加载
font-display
触手可及,字体加载在过去似乎是个问题。 不幸的是,在我们的情况下它并不完全正确。 亲爱的读者,您似乎访问了 Smashing Magazine 上的许多文章。 您还经常返回该站点阅读另一篇文章 - 可能是几个小时或几天后,或者可能是一周后。 我们在网站上使用font-display
时遇到的一个问题是,对于经常在文章之间移动的读者,我们注意到备用字体和网络字体之间有很多闪烁(这通常不应该发生,因为字体会正确缓存)。
这感觉不像是一个体面的用户体验,所以我们研究了选项。 在 Smashing 上,我们使用两种主要字体——Mija 用于标题,Elena 用于正文。 Mija 有两种粗细(Regular 和 Bold),而 Elena 有三种粗细(Regular、Italic、Bold)。 几年前,我们在重新设计期间放弃了 Elena 的 Bold Italic,因为我们只在几页上使用了它。 我们通过删除未使用的字符和 Unicode 范围来子集其他字体。
我们的文章大多以文本形式呈现,因此我们发现,在网站上的大部分时间里,最大内容的绘画要么是文章的第一段文字,要么是作者的照片。 这意味着我们需要特别注意确保第一段以备用字体快速出现,同时优雅地切换到 Web 字体,并减少重排。
仔细看一下首页的初始加载体验(慢了三倍):
在找出解决方案时,我们有四个主要目标:
- 在第一次访问时,立即使用备用字体呈现文本;
- 匹配后备字体和网络字体的字体指标,以最大限度地减少布局变化;
- 异步加载所有网络字体并一次性应用它们(最多 1 次重排);
- 在随后的访问中,直接以网络字体呈现所有文本(没有任何闪烁或重排)。
最初,我们实际上尝试在font-face
上使用font-display: swap 。 这似乎是最简单的选择,但是,如上所述,一些读者会访问许多页面,因此我们最终会在整个网站上渲染的六种字体出现很多闪烁。 此外,仅使用font-display ,我们无法对请求或重绘进行分组。
另一个想法是在初次访问时以备用字体呈现所有内容,然后异步请求和缓存所有字体,并且仅在后续访问时直接从缓存中提供 Web 字体。 这种方法的问题在于,许多读者来自搜索引擎,至少其中一些人只会看到一个页面——而且我们不想仅以系统字体呈现一篇文章。
那又是什么呢?
自 2017 年以来,我们一直在使用两阶段渲染方法进行 Web 字体加载,它基本上描述了两个渲染阶段:一个具有最小的 Web 字体子集,另一个具有完整的字体权重系列。 过去,我们创建了 Mija Bold 和 Elena Regular 的最小子集,它们是网站上最常用的权重。 两个子集都只包含拉丁字符、标点符号、数字和一些特殊字符。 这些字体( ElenaInitial.woff2和MijaInitial.woff2 )非常小——通常只有 10-15 KB 左右。 我们在字体渲染的第一阶段为它们提供服务,以这两种字体显示整个页面。
我们使用 Font Loading API 来做到这一点,它为我们提供了有关哪些字体已成功加载以及哪些尚未成功加载的信息。 在幕后,它通过向body添加一个类.wf-loaded-stage1来实现,样式以这些字体呈现内容:
.wf-loaded-stage1 article, .wf-loaded-stage1 promo-box, .wf-loaded-stage1 comments { font-family: ElenaInitial,sans-serif; } .wf-loaded-stage1 h1, .wf-loaded-stage1 h2, .wf-loaded-stage1 .btn { font-family: MijaInitial,sans-serif; }
因为字体文件很小,希望它们能很快通过网络。 然后当读者可以真正开始阅读文章时,我们异步加载字体的全部权重,并将.wf-loaded-stage2添加到body :
.wf-loaded-stage2 article, .wf-loaded-stage2 promo-box, .wf-loaded-stage2 comments { font-family: Elena,sans-serif; } .wf-loaded-stage2 h1, .wf-loaded-stage2 h2, .wf-loaded-stage2 .btn { font-family: Mija,sans-serif; }
因此,在加载页面时,读者会首先快速获得一个小的子集网络字体,然后我们切换到完整的字体系列。 现在,默认情况下,后备字体和网络字体之间的这些切换是随机发生的,基于首先通过网络出现的任何内容。 当您开始阅读一篇文章时,这可能会让人感到非常混乱。 因此,我们没有让浏览器决定何时切换字体,而是将 repaints 分组,将回流影响降至最低。
/* Loading web fonts with Font Loading API to avoid multiple repaints. With help by Irina Lipovaya. */ /* Credit to initial work by Zach Leatherman: https://noti.st/zachleat/KNaZEg/the-five-whys-of-web-font-loading-performance#sWkN4u4 */ // If the Font Loading API is supported... // (If not, we stick to fallback fonts) if ("fonts" in document) { // Create new FontFace objects, one for each font let ElenaRegular = new FontFace( "Elena", "url(/fonts/ElenaWebRegular/ElenaWebRegular.woff2) format('woff2')" ); let ElenaBold = new FontFace( "Elena", "url(/fonts/ElenaWebBold/ElenaWebBold.woff2) format('woff2')", { weight: "700" } ); let ElenaItalic = new FontFace( "Elena", "url(/fonts/ElenaWebRegularItalic/ElenaWebRegularItalic.woff2) format('woff2')", { style: "italic" } ); let MijaBold = new FontFace( "Mija", "url(/fonts/MijaBold/Mija_Bold-webfont.woff2) format('woff2')", { weight: "700" } ); // Load all the fonts but render them at once // if they have successfully loaded let loadedFonts = Promise.all([ ElenaRegular.load(), ElenaBold.load(), ElenaItalic.load(), MijaBold.load() ]).then(result => { result.forEach(font => document.fonts.add(font)); document.documentElement.classList.add('wf-loaded-stage2'); // Used for repeat views sessionStorage.foutFontsStage2Loaded = true; }).catch(error => { throw new Error(`Error caught: ${error}`); }); }
但是,如果第一个小字体子集没有快速通过网络怎么办? 我们注意到这似乎比我们想要的更频繁地发生。 在这种情况下,在 3s 超时后,现代浏览器会退回到系统字体(在我们的字体堆栈中,它将是 Arial),然后切换到ElenaInitial或MijaInitial ,稍后分别切换到完整的 Elena 或 Mija . 这在我们的品尝中产生了太多的闪光。 我们最初考虑只为慢速网络删除第一阶段渲染(通过网络信息 API),但后来我们决定完全删除它。
所以在 10 月,我们连同中间阶段一起移除了子集。 每当客户端成功下载 Elena 和Mija字体的所有权重并准备好应用时,我们就会启动第 2 阶段并立即重新绘制所有内容。 为了让回流变得不那么明显,我们花了一些时间来匹配后备字体和网络字体。 这主要意味着对页面第一个可见部分中绘制的元素应用稍微不同的字体大小和行高。
为此,我们使用了font-style-matcher
和 (ahem, ahem) 一些幻数。 这也是我们最初使用-apple-system和 Arial 作为全局后备字体的原因; 旧金山(通过-apple-system渲染)似乎比 Arial 好一点,但如果它不可用,我们选择使用 Arial 只是因为它广泛分布在大多数操作系统中。
在 CSS 中,它看起来像这样:
.article__summary { font-family: -apple-system,Arial,BlinkMacSystemFont,Roboto Slab,Droid Serif,Segoe UI,Ubuntu,Cantarell,Georgia,sans-serif; font-style: italic; /* Warning: magic numbers ahead! */ /* San Francisco Italic and Arial Italic have larger x-height, compared to Elena */ font-size: 0.9213em; line-height: 1.487em; } .wf-loaded-stage2 .article__summary { font-family: Elena,sans-serif; font-size: 1em; /* Original font-size for Elena Italic */ line-height: 1.55em; /* Original line-height for Elena Italic */ }
这工作得相当好。 我们确实会立即显示文本,并且 Web 字体会分组显示在屏幕上,理想情况下会在第一个视图中准确地导致一次重排,而在后续视图中完全没有重排。
下载字体后,我们将它们存储在service worker 的缓存中。 在随后的访问中,我们首先检查字体是否已经在缓存中。 如果是,我们从 service worker 的缓存中检索它们并立即应用它们。 如果没有,我们从fallback-web-font-switcheroo重新开始。
该解决方案在相对较快的连接上将重排次数减少到最少(一次),同时还将字体持久且可靠地保留在缓存中。 在未来,我们真诚地希望用 f-mods 代替幻数。 也许扎克·莱瑟曼会感到自豪。
识别和分解单体 JS
当我们研究 DevTools 的性能面板中的主线程时,我们确切地知道我们需要做什么。 有 8 个耗时在 70 毫秒到 580 毫秒之间的长任务,阻塞了界面并使其无响应。 一般来说,这些是成本最高的脚本:
- uc.js , cookie 提示脚本 (70ms)
- 由传入的full.css文件 (176ms) 引起的样式重新计算(关键 CSS 不包含所有视口中低于 1000px 高度的样式)
- 在加载事件上运行的广告脚本以管理面板、购物车等 + 样式重新计算 (276ms)
- 网页字体切换,样式重新计算(290ms)
- app.js评估(580 毫秒)
我们首先关注最有害的那些——可以说是最长的长期任务。
第一个是由于字体更改(从备用字体到网络字体)导致的昂贵的布局重新计算而发生的,导致超过 290 毫秒的额外工作(在快速笔记本电脑和快速连接上)。 通过仅从字体加载中删除第一阶段,我们能够获得大约 80 毫秒的时间。 这还不够好,因为远远超出了 50 毫秒的预算。 所以我们开始深入挖掘。
发生重新计算的主要原因仅仅是因为备用字体和网络字体之间的巨大差异。 通过匹配后备字体和网络字体的行高和大小,我们能够避免许多情况,即一行文本会在后备字体的新行上换行,但随后会稍微变小并适合前一行,导致整个页面的几何形状发生重大变化,从而导致大量布局变化。 我们也玩过letter-spacing
和word-spacing
,但效果不佳。
通过这些更改,我们能够再减少 50-80 毫秒,但我们无法将其减少到 120 毫秒以下而不以后备字体显示内容并随后以 Web 字体显示内容。 显然,它应该只影响第一次访问者,因为随后的页面视图将使用直接从 service worker 缓存中检索的字体呈现,而不会由于字体切换而导致代价高昂的重排。
顺便说一句,很重要的一点是,在我们的案例中,我们注意到大多数 Long Tasks 不是由大量 JavaScript 引起的,而是由Layout Recalculations和 CSS 解析引起的,这意味着我们需要做一些 CSS cleaning, especially watching out for situations when styles are overwritten. In some way, it was good news because we didn't have to deal with complex JavaScript issues that much. However, it turned out not to be straightforward as we are still cleaning up the CSS this very day. We were able to remove two Long Tasks for good, but we still have a few outstanding ones and quite a way to go. Fortunately, most of the time we aren't way above the magical 50ms threshold.
The much bigger issue was the JavaScript bundle we were serving, occupying the main thread for a whopping 580ms. Most of this time was spent in booting up app.js which contains React, Redux, Lodash, and a Webpack module loader. The only way to improve performance with this massive beast was to break it down into smaller pieces. So we looked into doing just that.
With Webpack, we've split up the monolithic bundle into smaller chunks with code-splitting , about 30Kb per chunk. We did some package.json cleansing and version upgrade for all production dependencies, adjusted the browserlistrc setup to address the two latest browser versions, upgraded to Webpack and Babel to the latest versions, moved to Terser for minification, and used ES2017 (+ browserlistrc) as a target for script compilation.
We also used BabelEsmPlugin to generate modern versions of existing dependencies. Finally, we've added prefetch links to the header for all necessary script chunks and refactored the service worker, migrating to Workbox with Webpack (workbox-webpack-plugin).
Remember when we switched to the new navigation back in mid-2020, just to see a huge performance penalty as a result? The reason for it was quite simple. While in the past the navigation was just static plain HTML and a bit of CSS, with the new navigation, we needed a bit of JavaScript to act on opening and closing of the menu on mobile and on desktop. That was causing rage clicks when you would click on the navigation menu and nothing would happen, and of course, had a penalty cost in Time-To-Interactive scores in Lighthouse.
We removed the script from the bundle and extracted it as a separate script . Additionally, we did the same thing for other standalone scripts that were used rarely — for syntax highlighting, tables, video embeds and code embeds — and removed them from the main bundle; instead, we granularly load them only when needed.
However, what we didn't notice for months was that although we removed the navigation script from the bundle, it was loading after the entire app.js bundle was evaluated, which wasn't really helping Time-To-Interactive (see image above). We fixed it by preloading nav.js and deferring it to execute in the order of appearance in the DOM, and managed to save another 100ms with that operation alone. By the end, with everything in place we were able to bring the task to around 220ms.
We managed to get some improvement in place, but still have quite a way to go, with further React and Webpack optimizations on our to-do list. At the moment we still have three major Long Tasks — font switch (120ms), app.js execution (220ms) and style recalculations due to the size of full CSS (140ms). For us, it means cleaning up and breaking up the monolithic CSS next.
It's worth mentioning that these results are really the best-scenario- results. On a given article page we might have a large number of code embeds and video embeds, along with other third-party scripts and customer's browser extensions that would require a separate conversation.
Dealing With 3rd-Parties
Fortunately, our third-party scripts footprint (and the impact of their friends' fourth-party-scripts) wasn't huge from the start. But when these third-party scripts accumulated, they would drive performance down significantly. This goes especially for video embedding scripts , but also syntax highlighting, advertising scripts, promo panels scripts and any external iframe embeds.
Obviously, we defer all of these scripts to start loading after the DOMContentLoaded event, but once they finally come on stage, they cause quite a bit of work on the main thread. This shows up especially on article pages, which are obviously the vast majority of content on the site.
The first thing we did was allocating proper space to all assets that are being injected into the DOM after the initial page render. It meant width
and height
for all advertising images and the styling of code snippets. We found out that because all the scripts were deferred, new styles were invalidating existing styles, causing massive layout shifts for every code snippet that was displayed. We fixed that by adding the necessary styles to the critical CSS on the article pages.
We've re-established a strategy for optimizing images (preferably AVIF or WebP — still work in progress though). All images below the 1000px height threshold are natively lazy-loaded (with <img loading=lazy>
), while the ones on the top are prioritized ( <img loading=eager>
). The same goes for all third-party embeds.
We replaced some dynamic parts with their static counterparts — eg while a note about an article saved for offline reading was appearing dynamically after the article was added to the service worker's cache, now it appears statically as we are, well, a bit optimistic and expect it to be happening in all modern browsers.
As of the moment of writing, we're preparing facades for code embeds and video embeds as well. Plus, all images that are offscreen will get decoding=async
attribute, so the browser has a free reign over when and how it loads images offscreen, asynchronously and in parallel.
To ensure that our images always include width and height attributes, we've also modified Harry Roberts' snippet and Tim Kadlec's diagnostics CSS to highlight whenever an image isn't served properly. It's used in development and editing but obviously not in production.
One technique that we used frequently to track what exactly is happening as the page is being loaded, was slow-motion loading .
First, we've added a simple line of code to the diagnostics CSS, which provides a noticeable outline for all elements on the page.
* { outline: 3px solid red }
* { outline: 3px solid red }
Then we record a video of the page loaded on a slow and fast connection. Then we rewatch the video by slowing down the playback and moving back and forward to identify where massive layout shifts happen.
Here's the recording of a page being loaded on a fast connection:
And here's the recording of a recording being played to study what happens with the layout:
By auditing the layout shifts this way, we were able to quickly notice what's not quite right on the page, and where massive recalculation costs are happening. As you probably have noticed, adjusting the line-height
and font-size
on headings might go a long way to avoid large shifts.
With these simple changes alone, we were able to boost performance score by a whopping 25 Lighthouse points for the video-heaviest article, and gain a few points for code embeds.
Enhancing The Experience
We've tried to be quite strategic in pretty much everything from loading web fonts to serving critical CSS. However, we've done our best to use some of the new technologies that have become available last year.
We are planning on using AVIF by default to serve images on SmashingMag, but we aren't quite there yet, as many of our images are served from Cloudinary (which already has beta support for AVIF), but many are directly from our CDN yet we don't really have a logic in place just yet to generate AVIFs on the fly. That would need to be a manual process for now.
We're lazy rendering some of the offset components of the page with content-visibility: auto . For example, the footer, the comments section, as well as the panels way below the first 1000px height threshold, are all rendered later after the visible portion of each page has been rendered.
我们已经玩了一些link rel="prefetch"
甚至link rel="prerender"
(NoPush prefetch) 页面的一些很可能用于进一步导航的部分——例如,为第一个预取资产头版上的文章(仍在讨论中)。
我们还预加载作者图像以减少最大内容绘制,以及在每个页面上使用的一些关键资产,例如跳舞的猫图像(用于导航)和用于所有作者图像的阴影。 然而,只有当读者碰巧在更大的屏幕(>800px)上时,它们才会被预加载,尽管我们正在研究使用网络信息 API 来更准确。
我们还通过删除遗留代码、重构许多组件以及删除文本阴影技巧来减小完整 CSS 和所有关键 CSS 文件的大小,我们使用该技巧来通过结合text-decoration-skip来实现完美的下划线-ink和text-decoration-thickness (终于!)。
待完成的工作
我们已经花费了大量时间来解决网站上的所有次要和主要更改。 我们注意到台式机的显着改进和移动设备的显着提升。 在撰写本文时,我们的文章在桌面上的 Lighthouse 得分平均在 90 到 100 之间,在移动设备上的平均得分在 65-80之间。
移动端得分不佳的原因显然是由于应用程序的启动和完整 CSS 文件的大小导致交互时间和总阻塞时间不佳。 所以那里还有一些工作要做。
至于接下来的步骤,我们目前正在研究进一步减小 CSS 的大小,并专门将其分解为模块,类似于 JavaScript,仅在当需要。
我们还探索了在移动设备上进一步捆绑实验的选项,以减少app.js对性能的影响,尽管目前这似乎并不重要。 最后,我们将研究 cookie 提示解决方案的替代方案,使用 CSS clamp()
重建我们的容器,用aspect-ratio
替换填充底部比率技术,并研究在 AVIF 中提供尽可能多的图像。
就是这样,伙计们!
希望这个小案例研究对您有用,也许您可以立即将一两种技术应用到您的项目中。 最后,性能就是所有细节的总和,这些细节加起来会影响或破坏客户的体验。
虽然我们非常致力于提高性能,但我们也致力于改善网站的可访问性和内容。 因此,如果您发现任何不正确的地方或我们可以做的任何事情来进一步改进 Smashing Magazine,请在本文的评论中告诉我们。
最后,如果您想了解此类文章的最新信息,请订阅我们的电子邮件通讯,以获取友好的网络提示、好东西、工具和文章,以及 Smashing cat 的季节性选择。