基于 PageSpeed 的性能优化实践

前言

网站性能至关重要,会影响 SEO 排名、转换率、用户跳出率以及用户体验等。在浏览器中加载缓慢的网站可能会慢慢失去用户,相反能够快速响应的网站通常会有机会获取更多流量,带来更大效益。

最近我们在站点做了一版性能优化,把主要着陆页面的 PageSpeed 分数从原本 30 左右提升到 80 分以上。

在这里分享下在这个过程中的一些经验,介绍下我们是如何达成这个结果,其中又涉及到哪些技术。

文章将从性能指标、性能测量与优化实践方案三个方面展开,期望可以给大家提供一些思路与参考。

性能指标

性能指标的发展与演进

针对线上项目做性能优化,首先需要有一个确定的可量化的评判标准,用以来判断优化工作是否有效。

传统的性能指标最典型的是 DOM Ready 时间 和页面加载时间(load time):前者指的是初始 HTML文档被完全加载和解析完成,一般是通过监听 DOMContentLoaded 事件获得;后者指的是整个页面所需的资源(包括脚本、样式、图片等)加载完成的时间,通过监听全局的 load 事件获取。

在早先前后端耦合的时代,是通过在服务端使用模板引擎渲染出 HTML,能比较好地反映网站性能。后来前端领域的迅猛发展,尤其是随着客户端渲染方案的盛行,以及各种动态技术的大量运用,这两个指标差不多已经失去其原有的意义,无法准确反映性能。

后来浏览器提供了 Navigation Timing API ,通过 perperformance.timing 可以获取从页面开始加载到结束整个过程中不同阶段的时间点。这很不错,开发者可以从多个维度去定义一些指标,通过简单的差值计算去监控站点性能。

比如在内部的用户行为追踪脚本(UBT)中就基于 timming API 主要定义了以下 7 个关键指标 DNS Connect Request Response Blank Domready Onload

  • DNS (domainLookupEnd - domainLookupStart)
  • Connect (connectEnd - connectStart)
  • Request (responseStart - requestStart)
  • Response (responseEnd - responseStart)
  • Blank (domInteractive - responseStart)
  • Domready (domContentLoadedEventEnd - navigationStart)
  • Onload (loadEventEnd - navigationStart)

同样,这些指标更侧重于技术细节,并不能很好地反映用户真正关心的问题。在做性能优化的时候,很可能面临的一种场景是,已经把某些特定指标如加载时间的数值大幅减少,但用户体验仍然很差。基于此,Chrome 团队和 W3C 性能工作组推出了一组 以用户为中心的性能指标,从用户角度更好地去评判页面性能。

这些主要指标包含:

指标介绍

FCP

FCP 指标测量的是页面从开始加载到页面内容的任何部分在屏幕上完成渲染的时间。“内容”可以是文本、图像(包括背景图像)、 元素或非白色的 元素。

这个指标回答了一个用户问题,正在运行吗

还有一个从名称上很接近的指标,FP (首次绘制),它们之间的区别如下:

  • FP first-paint 大致可以认为是白屏时间
  • FCP first-contentful-paint 大致可以认为是首屏时间

LCP

这个指标对应的关键用户问题是,是否有用,即页面是否已经呈现出对用户有用的内容。

早先有过一些类似的指标比如 FMP (首次有效绘制),但有效绘制的定义是什么通常很难解释,而且算法常常容易出错。

相反,最大内容绘制的定义简单明了,这里的“内容”和 FCP 中的定义基本一致,指的是在可视区域内的最大图片或文本块完成渲染的时间。

元素大小指的是内容占据的面积大小,即 size = width * height ,不包含边距边框。

大多数情况下,页面上最吸引用户的内容往往就是最大元素,可以认为这就是页面中最重要的元素。

TTI

可交互时间,对应的用户关注点是 可以使用吗

早期,关于可交互时间一直并没有一个清晰明确的定义。刀耕火种的时代,开发者自定义时间节点,并在代码中埋点来获取相关数据。

比如通过在 setTimeout 中放一个任务获取执行时间点,再计算到页面开始加载的差值。

setTimeout(function() {
    tti = new Date() - navigationStartTime
}, 0)

而在 Lighthouse 中,可交互时间指标有了更通用、标准化的定义。TTI 应从 FCP 时间点开始沿时间轴查找,如果出现 5 秒的静默窗口(没有长任务并且不超过 2 个正着处理的 GET 请求),那么最后一个长任务介绍的时间点即为可交互时间。

长任务指的是执行时间超过 50 ms 的任务。

主线程上若是存在导致阻塞状态的长任务,将导致无法响应用户交互。

tti

TBT

TBT 和 TTI 是一对配套指标,用于衡量在页面可交互之前的阻塞程度。

TBT 是指在 FCP 和 TTI 之间所有长任务超过 50ms 的部分的时间总和(注意不是长任务的时间总和)。

tbt

CLS

累积布局偏移指标用于衡量页面视觉稳定性。

单次布局偏移分数是影响分数(不稳定区域占可是区域的百分比)与距离分数(不稳定元素最大位移距离占比)的乘积。

CLS 指标本身一直在不断进化 ,便于更加准确地去衡量布局偏移对用户的影响。

其他

性能测量

了解了需要关注的性能指标,那应该怎么样去有效测量呢?

性能测量分两种类型,实验室测量与现场测量(真实用户监控)。有的指标只能通过实验室测量,或是只能现场测量。

实验室测量

实验室测量指的是在一个受控环境下,使用预定义的硬件设备和网络配置等规则去运行网站页面,进行性能数据采集,提取性能指标。

目前最流行的工具是 Google 的 Lighthouse ,最初作为一个独立的浏览器扩展程序需要开发者自行安装(支持 Firefox),目前已经集成到 Chrome DevTools 。

Lighthouse 不仅仅是一个性能测量工具,除此之外还提供 PWA 、SEO 、可访问性、最佳实践等审计报告。

在做性能优化的时候,如何有效评估优化方案的效果是一个问题,由于还没有发布到线上环境无法采集真实用户性能数据,这时候使用工具进行实验室测量就显得至关重要。

同时,Lighthouse 提供开源 CI 工具 Lighthouse CI 开发者能自行部署服务,并集成到现有的 CI 体系中。

现场测量

现场测量,也称真实用户监控(RUM),即实时采集真实用户性能数据。

实验室测量的是在一系列特定条件下的性能数据,不能完全反映现实世界中用户的真实情况。现场测量的优势在于样板量足够大,包罗各种不同设备不同网络环境下的数据,从统计上更能反映真实性能情况。另一方面,现场测量需基于浏览器提供的性能 Web API ,受限于当前设备采集到的数据不及实验室测量丰富。

定量评估的问题与方案

定量评估每一项优化方案的效果并不容易,原因包括环境差异问题,分数计算问题等。

解决方案是:

  • 开发模式启动站点应用与生产模式差别较大,将应用发布到测试服务器再进行性能测量
  • 本地启动 Lighthouse 进行测量,设备在不同时间的系统状态存在较大差异,应部署测量工具到固定服务器
  • 由于环境影响单次测量的差异可能很大,基于 lighthouse NPM 包一次性跑 10 次,去除最大值和最小值之后再取中位数和平均值作为参考
  • 性能分数有六大性能指标计算而来,某些指标的数值优化最终在分数上体现几乎没有差异,分开看具体指标数值更合理

性能优化方案

确定优化方向,并且有了可定量评估的方案之后,接下来要做的就是如何实施具体的优化方案。

性能优化是一个老生常谈,同时与时俱进的主题。早期大名鼎鼎的 雅虎 35 条性能军规 到现在大部分仍然适用,另一方面随着技术的发展,基于上述以用户为中心的性能指标,能更有针对性地实施方案。同时借助 Lighthouse 工具,能帮助我们有效评估具体方案的效果。

我们的应用是基于 React 技术栈,以下部分内容基于 React 来进行阐述。

减小包体积

网站应用与传统客户端应用很不同的一点在于,应用所需资源文件都是存放在远端服务器上的,每次访问都有相当大的性能开销是用于资源加载。

如何让资源高效加载成了一个非常重要的问题,其中最重要的一环是网络传输,专用的 CDN 服务器包含就近访问,资源缓存和压缩等功能,能节省大量网络传输时间,这是基础设施的角度。

从开发者的角度,首先可以对应用包体积进行瘦身。

包体积的问题主要表现在:

  • 不再使用的冗余代码
  • 复制粘贴的重复代码
  • 非必要的大体积类库
  • 未经优化的图片文件

冗余代码

冗余代码的产生有多种,比如是已经废弃不用但仍然被导入的功能模块,或者是在做 AB 实验完成后未完全移除的版本代码等。

借助相关工具,比如 Webapck 插件 webpack-bundle-analyzer 能用一种可视化的方式呈现每个包的具体模块信息,大小、包含关系一目了然。而 Chrome DevTools Coverage 工具能分析出运行过程中文件(脚本和样式)的使用情况,可作为参考更好地针对性地瘦身优化。

重复代码

重复代码很大一部分是实现相似功能的过程中,直接复制粘贴一方代码进行修改导致,借助 jsinspect 可以检测到相同和相似代码,然后进行合理抽象。还有一种情况是,依赖 NPM 包提供多种方式的代码,比如 dist 目录下的打包代码,lib 目录下的 CommonJS 代码,和 es 目录下的 ES Modules 代码。若是不小心在不同地方引入不同方式的包,就等同于是引入重复功能模块。更甚一步,在跨团队合作中依赖包只提供打包版本,也会出现 babel polyfill 代码多次重复,并且无从分析。解决方案是制定统一的标准,推荐 NPM 包都提供仅 babel 编译不打包版本。

类库开销

在类库的使用上同样需要注意,比如仅使用一两个方法就引入整个 lodash 库,推荐做法是按需引入,不用改变写法加入 babel-plugin-lodash 这类插件就能在代码构建时转换。另外一种情况是引入 moment 这类体积较大的库用作时间处理与格式化,可以视实际情况采用体积更小的替代品。对于更简单的需求,则完全可以基于原生 API 自行实现封装一些方法。

图片文件

未经优化的图片可高达几百 KB ,应在保证图片清晰度的情况压缩大小。

另一方面,为现代浏览器提供有更高效压缩算法的图片格式,相比传统的 PNG 和 JPG 格式,WebP 在同等质量下有更小的体积,注意做好降级方案。

优化资源加载

作为开发者做好包体积优化能节省网络传输时间,以及一部分代码执行时间,但更重要的是让资源有效加载,可从资源加载顺序和优先级方面着手。

Resource Hints

为了使页面可以快速加载,我们基于 PRPL 模式 进行优化。PRPL 是四个词的首字母缩写,分别代表:

  • Preload 预加载最重要的资源
  • Render 尽快渲染初始内容
  • Pre-cache 预缓存其他资源
  • Lazy load 懒加载其他路由和非关键资源

首先,我们需要优化关键路径资源,页面中要呈现的内容很多,但不是所有内容都需要第一时间呈现,优先呈现最重要的内容。浏览器并不知道哪些资源是最重要的,基于 Resource Hints 可以告诉浏览器资源优先级。常用的有以下几类:

  • preconnect 启动早期连接,包括 DNS 查找,TCP 握手等
  • preload 预加载资源并缓存,以便需要时立即使用
  • prefetch 预获取资源,优先级比 preload 低,浏览器自行判断合理时间执行操作

在使用过程需要注意:

  • 不要无限制的滥用,因为其自身会消耗资源,尤其是添加了但却未使用
  • 资源设置 crossorigin ,对应预处理提示也要设置,否则两者不匹配导致重复加载

Service Worker

使用 Service worker 缓存预载资源,对后续访问会有极大的性能提升,能节省大量网路传输开销。

在项目中推荐采用 Google 提供的 Workbox 库,可以通过配置的方式对不同类型资源应用不同缓存策略。

Service Worker 带来的优化效果不能从 PageSpeed Insights 网站上的分数直接体现,因为 PageSpeed 总是单次分数并且不使用缓存。

优化加载第三方脚本

应用依赖的第三方脚本通常会减慢页面加载速度,一般采用以下方式:按需加载和延迟加载。

按需加载

需用户交互才用到的功能模块应按需加载。举个例子,用户登录时要调用一个第三方验证模块,就没必要在页面一开始就引入该脚本,在用户执行登录操作时引入更合理。

延迟加载

像是 Google analysis 和合作商营销等第三方日志埋点脚本,业务需要无法移除,加载后占用大量性能资源。

由于本身没有依赖关系,可使用 defer script 延迟脚本的解析执行。更进一步,延迟到在可交互时间之后加载就基本不会有任何影响。

组件懒加载

可视区域之外的内容,和需要用户交互时才呈现的组件,都可采用懒加载,保证页面首要内容快速呈现。

要做懒加载,首先需要合理定义拆分点进行代码分割,然后基于动态导入和 React.lazy 即可实现。

对于大部分点击触发的组件来说,这样已经足够,但针对页面底部可视区域之外需常规滚动查看的内容,还要做一些额外的工作。可以自行封装实现一个组件,在内部进行判断内容是否可视,并监听 scroll 事件重新渲染。

实际中,我们结合 react-lazyload@loadable/component 实现所需功能,如下:

import React from 'react';
import loadable from '@loadable/component';
import LazyLoad from 'react-lazyload';

const LazyComponent = loadable(() => import(/* webpackChunkName: "home_lazy" */ './LazyComponent'));

export function HomePage() {
    return (
        <>
            
            
                
            
        
    );)
}

懒加载可能导致懒加载组件自身体验下降,可对用户比较频繁使用的组件预加载。

过度拆分可能会产生很多体积很小的包,可以适当地进行合并。借助 webpack magic comment ,配置相同的 chunk name 可以合并打包。

import loadable from '@loadable/component';

export const SortLayer = loadable(() => import(/* webpackChunkName: "depart_select_layer" */ './SortLayer'));
export const StopLayer = loadable(() => import(/* webpackChunkName: "depart_select_layer" */ './StopLayer'));
export const TimeLayer = loadable(() => import(/* webpackChunkName: "depart_select_layer" */ './TimeLayer'));

优化渲染方式

  • 服务端渲染
  • 预渲染

服务端渲染

CSR (客户端渲染)的最大问题在于受用户环境影响太大,一方面是网络层面脚本文件的加载,一方面是浏览器的执行效率,不同场景下差异可能非常大。

SSR (服务端渲染)则能解决这个问题,直出 HTML 能快速呈现页面主要内容,能很好地改善 FCP 和 LCP 指标。

SSR 相对 CSR 本质上来讲就两点:

  • 将渲染(这里是指 JavaScript 执行层面的)工作转移到服务端,毕竟服务端相对更可控
  • 在首屏之前避免减少资源网络传输,从而减少耗时,因为网络是更不可控的一个因素

实际上,大部分时候都是结合二者,针对首屏采用服务端渲染,让用户更快看到内容,其他仍使用客户端渲染的模式,减轻服务器压力,毕竟将大量用户的渲染任务转移到服务端会是一笔不小的开销。这时,结合缓存机制可以大大节省渲染时间。

预渲染

基于构建时的预渲染,是使用 webpack 和 babel 等工具提前生成对应的 HTML 以及引用的脚步和样式文件。还有一种方式是基于运行时的,使用 headless 浏览器。但预渲染并不适用于有大量动态内容的页面。

优化长任务

Long Task (长任务)的定义是执行时间超过 50 ms 的任务。我们知道,JavaScript 是单进程单线程的模型,主线程上一旦有耗时长的任务存在时,就会造成阻塞,无法响应用户输入。

Long Task 跟 Lighthouse 中的两个重要性能指标 TTI 和 TBT 息息相关,而这两个指标占比为 40% ,可以说优化好 Long Task 能大幅提升页面性能。

Long Task 可借助对应的 Long Task Web API 进行监控,开发过程中则使用 Chrome DevTools Performace 面板查看。需要注意的是,开发者的电脑配置可能很强,但用户尤其是移动端的用户环境并没有那么乐观,应该适当调低硬件配置和网络速度,这样能发现更多的 Long Task 。

任务类型有多种,除了最常见的脚本执行之外,还包括脚本解析编译、HTML 解析、CSS 解析、布局、渲染等。脚本执行是长任务的主要表现形式,这里着重说明在 JavaScript 执行上的一些优化方式:

  • requestIdleCallback API
  • Web Worker
  • 记忆函数
  • Debounce 和 Throttle

requestIdleCallback API

针对一些不重要的任务比如埋点日志可以直接丢到 requestIdleCallback 中,浏览器会在空闲时间执行。在不支持的环境可使用 shim) ,基于 setTimeout 实现近似的功能。

idlize 中封装了一些非常实用的帮助函数,使用这些方法可把任务延迟到需要的时候再执行。

Web Worker

如果项目中确实存在比较复杂的计算,可启动 Web Worker 单独另开一个线程来计算,并使用 message 通信。

记忆函数

如果一个函数被大量调用,合理运用记忆函数一个很好的选择,有大量的库可供我们选择,也可以根据使用场景自行实现。

Debounce 和 Throttle

针对 input change 和 scroll 等可能频繁触发的事件,避免无节制地调用。

React 性能优化

在 React 框架使用上有一些性能优化的实践,个人认为比较重要的有:

  • shouldComponenetUpdate
  • useMemouseCallback
  • 不可变数据

默认的 shouldComponenetUpdate 总是返回 true 但开发者知道什么时候应该更新,则可自行实现该生命周期方法。推荐大部分组件都使用 pureComponent 代替,函数组件则可使用 Memo

useMemouseCallback 都是记忆函数,可结合 Memo 避免不必要的重新渲染,或者是对昂贵计算的记忆。

state 和 props 都是不可变数据,在更新深层嵌套数据使用深拷贝不是一种好方式,可借助 Immer 这类库更好地编写。

最后说明一点,在必要的时候进行性能优化,大部分时候无需考虑,而且滥用方法反而损害性能。

减少布局偏移

如何调试监控

有对应的 Layout Instability API 可以帮助收集用户的布局偏移数据。

在开发调试中,Layout Shift 同样可以使用 Chrome DevTools Performance 进行分析,能查看每一次布局偏移的分数,进行针对性优化。

常用的优化方案有:

  • 为动态元素预静态预留空间
  • 图片宽高尺寸固定

预留空间可减少其他页面元素的偏移,比如出现在最顶部的广告位,在数据还未获取到的时候预先设置好一个容器,可避免后续大幅偏移。

针对整页动态的内容,使用骨架屏是一种很好的模式,业界已有不少成熟方案可自动生成。

设置图片宽高,则可以保证浏览器在加载图片过程中始终能分配正确的空间大小。

总结反思

借助上述中提到的性能测量方式,我们逐步实施优化方案并发布上线,经过近两个月断断续续的时间,最终让性能分数稳定在 80 分左右。

score

性能优化也适用于二八定律,优化方式很多,只是简单地堆砌使用很可能适得其反。不同场景下的优化方案千差万别,关键在于找准最核心的问题。以上仅提供一些思路作为参考。有些方案对特定指标效果很好,有些方案不会反映到指标分数,但有助提升用户体验。

再者,指标衡量的是单个页面速度,而作为开发者还应衡量后续页面,从整体的维度去平衡,真正从用户角度考虑。

你可能感兴趣的