从Lighthouse得分17到65:Web性能优化实战解析

本文详细记录了如何将基于Nuxt.js SSR构建的upgrad.com网站的Lighthouse性能评分从17分提升至65分的完整历程。文章深入探讨了关键渲染路径、资源懒加载、性能预算设定以及针对CSS和JavaScript的具体优化策略,并分享了实用的性能分析工具和实践经验。

Web性能及相关故事 — upgrad.com

2021年1月,我们在upGrad查看了我们网站的页面速度得分,并承认它们确实需要大幅改进。Lighthouse是衡量Web性能的一个流行且极其有用的工具,而upgrad.com的得分几乎没能超过两位数。情况并不乐观,但对我个人而言却很兴奋,因为这提供了一个需要解决的实际问题,而本文正是关于穿越Web性能惊涛骇浪的旅程。

太长不看版: 我们成功地在Lighthouse得分方面提升了网站性能,移动端和桌面端的得分分别从大约5分和15分提高到了50分和70分左右。我希望这些数字能鼓励你阅读整篇文章,我也会尽力让文章读起来有趣。希望你能坚持到最后!

我认为Web开发这个领域,对致力于网站/应用程序的开发者以及拥有产品的企业/个人都极为重要。当你同时扮演这两个角色时,情况会更好,比如当你启动自己的业余项目时。Web性能是一个既能让技术人员感兴趣地投入工作,又能直接影响业务层面的领域。如果你经常运行Lighthouse测试(现在是时候自动化它们了),你可能已经看到过这些事实展示出来。

速度确实重要。作为互联网用户,我们都知道等待页面加载是多么令人沮丧。有时,如果某些网站加载缓慢,我会同情它们的Web开发者,并多等几秒钟。不幸的是,现实世界的用户不会这样做。随着一切变得越来越即时,在网络上提供最佳体验是Web开发人员的核心职责之一。

2021年1月

我不想说得太诗意,但让我们回到2021年1月。upGrad的增长团队在这个月完成了一个关键项目——将认证逻辑从网站中分离出来,并集成基于手机OTP的登录+注册流程。这是一个有趣的项目,Akshay(我的队友)很快就会发表一篇相关的文章。但鉴于这一点,upgrad.com网站现在只包含展示性内容。在理想情况下,本可以用纯HTML和CSS构建网站,但像Nuxt.js这样的框架给我们带来了很多在可操作性和动态性方面的好处,毕竟我们有自己的内部CMS和编辑人员。

无论如何,网站的Lighthouse得分如下(桌面端):

桌面端17分通常意味着你的移动端得分会更低,大约在8-10分之间。上图中需要注意的数值是Web Vitals(核心Web指标)的数值,其中大部分是红色的。Lighthouse认为这些值是重要指标,表明了你的网站如何加载以及用户如何感知它。让我们简单了解一下浏览器如何加载页面,这样Web Vitals的含义就会更加清晰。

页面的旅程

每当浏览器从服务器接收到第一个HTML数据块时,它通常会经历上述流程。HTML是一种特殊的语言,它很宽容。因此,它的解析也很特殊。详细一点说,HTML没有像大多数语言那样的上下文无关语法。这种语法规定了语言中什么是有效的,什么是无效的。在HTML中,浏览器通常会纠正某些部分的错误。除此之外,HTML在解析过程中仍然可以改变!JavaScript可以使用document.write()向页面注入HTML,正因为如此,HTML解析被称为“可重入的”。

如果我们考虑一个简单示例页面的HTML:

场景1

1
2
3
4
5
<html>
    <body>
        <h1>Hello</h1>
    </body>
</html>

在这里,解析器将从顶部开始,继续识别相应的节点。它将找到打开的html -> 打开的body -> 打开的h1 -> 文本Hello -> 关闭的h1 -> 关闭的body -> 关闭的html。在解析结束时,通常会得到一个语法树,它将镜像相应的DOM树。该对象将是暴露给JavaScript内部document的对象。

本质上,DOM树将被构建,布局将完成,然后位图将被绘制在屏幕上。非常理想。

场景2

1
2
3
4
5
6
7
8
<html>
    <head>
        <link rel="stylesheet" href="/main.css" />
    </head>
    <body>
        <h1>Hello</h1>
    </body>
</html>

让我们考虑这个例子,它向页面添加了一个远程CSS样式表。页面上的任何形式的CSS都是渲染阻塞的。这意味着在所有CSS被解析之前,渲染器不会绘制页面——请记住上面的流程图,渲染树的构建要求CSS树准备就绪。

因此,在这个例子中,DOM树将被构建,但在CSS文件完全加载和解析之前,渲染不会发生。稍微不那么理想。

场景3

1
2
3
4
5
6
7
8
9
<html>
    <head>
        <link rel="stylesheet" href="/main.css" />
        <script>console.log('Hello from JavaScript!');</script>
    </head>
    <body>
        <h1>Hello</h1>
    </body>
</html>

让我们在head中添加一个<script>标签。页面上的任何形式的JavaScript通常是解析器阻塞的。HTML解析在脚本执行完毕之前被阻塞——这同样是因为JS有可能修改DOM,所以浏览器不希望继续解析,之后才发现它解析的代码已经过时了。你可以想象,如果你有远程JavaScript文件(通常是这种情况),这会受到怎样的影响。文件必须先下载,然后执行,解析器才能继续前进。非常不理想。

Mozilla Developer Network文档以及Google的web.dev文章对此有非常详细的解释。你可能想看看那些资料。

上面绕路的核心是要讨论什么可能阻碍我们的网站被加载/显示给用户。从讨论中,我们可以得出两个关键点:

  1. CSS是渲染阻塞的
  2. JavaScript是解析器阻塞的

这两点构成了可以进行的大量优化以加速网站的基础。核心Web指标也因为这些而受到影响。

任何渲染阻塞的东西都会恶化LCP。任何在页面加载时消耗大量处理能力的操作都会恶化FID,例如主线程上的同步JavaScript执行。任何写入文档并因此大幅改变页面布局的操作都会恶化CLS。

在初始阶段,我们的目标是减少LCP。

额外阅读: Overview | Web Fundamentals | Google Developers

识别改进领域和预算设定

要解决性能问题,通常可以先从内部审视(很有哲学意味)。在对系统进行复杂更改之前,必须先尝试在现有设置中找到可以改进的领域。很有可能有很多需要清理和优化的地方。至少对我们来说是这样。

但在开始任何工作之前,你如何知道该选择什么以及该更改将如何影响性能?这时就需要性能分析工具。有许多在线工具可以帮助你准确找到瓶颈所在。Lighthouse就是这样一种工具,但我认为它应该用于基准测试最终得分。还有一个叫做WebPageTest的在线工具,可以在各种场景下提供网站性能的详细报告。我个人发现它对识别单个问题非常有用。

以下是来自webpagetest.org的截图:

上面的报告详细展示了页面上正在发生的事情——从指标到瀑布视图以及上面标记的指标。这对于找出延迟某些指标的原因,然后有针对性地处理这些资源特别有帮助。

预算设定 这是一个有争议的话题,但每个人都可以有自己偏好的优化预算设定方式。这有助于我们为希望提供给用户的指标或体验维持一个阈值。我将这一部分保持简短,并链接到有用的在线资源:

我们从一些明显的可以针对的领域开始——一些我们已经知道但没有时间修复的问题。这包括一些未使用的脚本和一些全局加载的模块,而我们可以在本地加载它们。在Nuxt中,你可以定义包含在主包中的全局插件——如果不加以适当监控,这通常是一种不好的做法。简而言之,以下是一些可以针对的关键领域:

延迟加载所有非必要资源

影响 — 可交互时间(TTI)、首次内容绘制(FCP)

你必须延迟/懒加载所有对首次加载不关键且不影响SEO的资源。这包括模态框、非关键脚本、图像、样式表和应用程序的代码块。

对于我们的网站,当我们懒加载所有模态框内容时,我们看到了显著的差异。由于它本身是一个独立的模块,我们可以轻松地做到这一点。大多数框架都有用于懒加载组件的特殊语法,例如React中的React.lazy()和Vue中的import()语法。打包工具基于这种语法提供自动代码分割,以便你的代码块是分开的,并且只在请求时才加载。

由于Nuxt使用webpack,我们可以使用webpack的魔法注释,允许你定义代码块的加载行为。/* webpackPrefetch: true */ 就是这样一个有用的注释,它可以预取代码块,以便体验类似于同步加载它。

图像懒加载对于更快的网站至关重要,通常已经实现。但如果没有,你应该立即着手。有一些流行的库支持这样做,现在HTML中也有一种原生方式,尽管并非所有浏览器都支持。

告诉浏览器延迟加载脚本或异步加载它们的标准方法是正确使用deferasync关键字。defer将脚本的处理移到解析步骤的最后。脚本在后台并行获取。这解决了上面“场景3”中提到的问题。这也是为什么通常建议将脚本标签放在body的末尾。这有两种帮助——它允许脚本“看到”在解析器到达脚本标签之前构建的整个页面body,其次,解析器不会在页面开头的某个脚本处被阻塞。

async以类似的方式提供帮助,但不是在最后加载脚本,而是异步加载甚至处理它们。它们不会等待DOM树的构建,而是在完成加载后立即执行。asyncdefer的一个关键区别是,所有用defer加载的脚本都会阻塞DOMContentLoaded事件,而用async加载的则不会。

另一个可以利用框架的实用功能是脚本的动态加载。牢记核心思想——只加载构建页面所需的资源——我们可以动态地将<script>标签推送到网站头部。例如,这允许仅在实际显示视频的页面上加载YouTube播放器脚本,确保不会减慢根本没有视频的页面的速度。

限制关键资源 — 缩短关键渲染路径

影响 — 首次内容绘制(FCP)、最大内容绘制(LCP)

关键渲染路径(CRP)是页面在浏览器中从浏览器收到响应的第一个字节(HTML)到页面开始变得可见的旅程。我们的目标应该是尽可能缩短这个过程。随着资源被添加到这个路径中,开始绘制的时间不断增加,损害了页面加载体验。

CRP取决于HTML文档的解析方式。如果它只是静态HTML,你会得到一个很好的LCP分数。但实际上,HTML页面上通常会添加很多资源,这些资源有时对网站正常运行至关重要。如果这些资源是你的页面渲染所必需的,那么它们就贡献给CRP。解析器必须完成加载和执行它们的工作,然后继续解析页面,最后才由渲染器接管。

好的资源: Critical Rendering Path | Web Fundamentals | Google Developers

对我们来说,就是查看webpagetest的瀑布快照,看看是什么延迟了第一次渲染。经过检查,我们发现一个特定的CSS文件被关键加载,更糟糕的是,它不是通过CDN提供的。

找到这样的资源并解除它们的阻塞,帮助我们将初始渲染时间提高了很多——从原来的4.2秒改进到0.4秒。

你的页面的初始HTML响应必须仅包含关键CSS——页面正确样式化所需的CSS。所有其他都可以延迟加载。异步加载CSS的一种流行方法是使用link标签的media属性。

1
2
3
4
5
6
<link
    rel="stylesheet"
    href="/link/to/file.css"
    media="print"
    onload='this.media="all",this.onload=null'
/>

在这里,由于媒体值是“print”,浏览器中的HTML解析器将跳过等待此样式表并继续前进。样式表仍然会在后台加载,无论何时加载完成,它都会被解析。这可以防止在CSS链接标签处阻塞解析器,对于首次加载时不需要的样式表很有好处。

即使有了上述解决方案,我们也必须确保关键CSS是精简的。像PurgeCSS这样的工具可以通过检查最终生成的页面的HTML来自动清理页面上未使用的样式,从而实现自动化。

预加载关键资源

影响 — 最大内容绘制(LCP)

另一个重要的实践是预加载所有关键资源。如果你的网站首屏有一张图片,你必须预加载它,以便它能尽快可视可用。

可以使用preload关键字来做到这一点,许多打包工具会自动为你完成。

1
<link rel="preload" as="image" href="banner-image.webp" />

更多关于预加载的信息:Link types: preload - HTML: HyperText Markup Language | MDN

应用这些实践大大降低了我们的LCP——从3.9秒降低到0.9秒。

Google的web.dev以一种非常好的方式总结了上述要点——它介绍了PRPL模式。

  • P — 推送(或预加载)最重要的资源。
  • R — 尽快渲染初始路由。
  • P — 预缓存剩余资源。
  • L — 懒加载其他路由和非关键资源。

除了预缓存,我认为我已经谈到了其余要点。预缓存通过缓存具有较长生存时间的资源,有助于提高后续加载的性能。

upgrad.com的整体性能改进

由于网站运行在服务器端渲染的Nuxt上,这给性能优化带来了一些特殊的条件。任何SSR框架,配合CDN缓存,都能保证良好的TTFB和LCP,但在可交互时间上会有所损失,因为前端会发生一个水合过程。

以下是Nuxt中SSR工作原理的简要描述(来自nuxtjs.org):

服务器端渲染(SSR)是应用程序通过在服务器上显示网页而不是在浏览器中渲染的能力。服务器端向客户端发送完全渲染的页面;客户端的JavaScript包随后接管,允许Vue.js应用程序进行水合。

考虑到这一点,我们只针对整个过程中的渲染和预加载部分。我们计划在未来转向完全静态渲染,这应该可以解决水合问题。

无论如何,我们在4个月的时间里进行了这项练习,并偶尔处理性能特定的任务。我相信这个领域的大部分工作都必须缓慢而稳定地进行。当你开始时,需要相当多的试错、研究和阅读。但为改进然后维护性能所做的持续努力确实会带来很好的回报。我们的LCP(在桌面设置中)随时间改进情况如下:

我们的Lighthouse得分也提高了,正如文章开头提到的。当前得分:

我非常享受这项工作,并学到了很多关于页面如何渲染以及如何使它们更快的知识。我们的目标是达到更好的分数,因为仍有改进的空间,但说实话,当前的分数是一个可以喘口气、用新的视角回顾的好分数。作为后续步骤,我们正在关注以下几点:

  1. 性能基准测试 — 我们提高了分数,但现在我们的工作是保持在那里。持续监控是维护性能的关键部分。市面上有可用的工具,或者你也可以轻松地自己制作一个(我做过:P,稍后会链接它)。
  2. 性能优化的组件 — 我们正在考虑用更轻量级的替代品替换某些共享的UI组件。
  3. Brotli压缩和基础设施层面的更改 — Brotli已启用,但在实施方面需要一些改进。此外,在基础设施方面,我们期望减少到达源服务器之前所需的跳数。

如果没有我合作的团队、我的经理Maitrey和Rohan,这一整项壮举是不可能实现的。我、Rohan和Maitrey已经开了无数次会议来讨论这个问题,并且一直致力于构建一个高性能、高效的平台。事情像魔法一样奏效!同样,感谢产品负责人Rohit和Shahir,他们将其视为直接的业务影响(并不断提醒我们网站速度慢:D)。最后,感谢工程负责人Vishal和Puneet在整个练习中不断给予支持。

我已经在需要时链接了相关资源,但对于所有其他资源,这里有一个汇总列表:

这个故事最初发表在我的个人博客上。你也可以在那里找到我的其他文章。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计