Patrick,携程资深前端开发工程师,专注于前端工程化和性能优化。
前言
网站性能对于用户体验、转化率和流失率、SEO 排名等至关重要,Trip.com 主要用户来自海外,对网站访问性能有更高的要求。能够快速响应的网站通常有机会获取更多流量,并为用户带来更好的体验。
近期我们对 Trip.com 机票站点做了一版性能优化,通过对主要 landing 页面进行系统优化,将页面的 PageSpeed 评分从原本 30 左右提升到 80 分以上。
这里分享在优化过程中的一些经验,将从性能指标、性能测量与优化实践方案三个方面展开,期望可以给大家提供一些思路和参考。
一、性能指标
1.1 性能指标的发展与演进
针对线上项目做性能优化,首先需要有一个确定的可量化的评判标准,用来判断优化工作是否有效。
1.1.1 传统的性能指标以及它们存在的问题
传统的性能指标最典型的是 DOM Ready 时间和页面加载时间(load time):前者指的是初始 HTML 文档完全加载和解析完成的时间,一般是通过 DOMContentLoaded 事件获得;后者指的是整个页面所需的资源(包括脚本、样式、图片等)加载完成的时间,通过全局的 load 事件获取。
普遍存在的问题是:在早先前后端耦合的时代,通过在服务端使用模板引擎渲染出 HTML,能比较好地反映网站性能。后来前端领域的迅猛发展,尤其是随着客户端渲染方案的盛行,以及各种动态技术的大量运用,这两个指标差不多已经失去其原有的意义,无法准确反映性能。
1.1.2 指标和用户实际感受之间的差异
再往后,采用浏览器提供的 Navigation Timing API ,通过 performance.timing 获取从页面开始加载到结束全过程中不同阶段的时间点。用这种方法,开发者可从多个维度去定义一些指标,通过简单的差值计算得到数据,并以此去监控站点性能。
比如在携程现有的UBT(User Behavior Tracking)中基于 Timing 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)
1.1.3 以用户为中心的性能指标
上述这些指标更侧重于技术角度,跟用户在实际使用过程中的真实感受会有偏差。以此为标准去做性能优化的话,很可能面临的一种场景是,已经把某些特定指标如加载时间的数值大幅减少,但用户体验仍然不佳。基于此,Chrome 团队和 W3C 性能工作组推出了一组 以用户为中心的性能指标,从用户角度更好地去评判页面性能。
这些指标主要包含:
1.2 指标介绍
接下来简单介绍下主要性能指标的具体定义:
1.2.1 FCP
FCP 指标测量的是页面从开始加载到页面内容的任何部分在屏幕上完成渲染的时间。“内容”可以是文本、图像(包括背景图像)、<svg> 元素或非白色的 <canvas> 元素。
这个指标回答了一个用户问题,应用正在运行吗。
还有一个从名称上很接近的指标,FP (首次绘制),它们之间的区别如下:
- FP first-paint 大致可以认为是白屏时间
- FCP first-contentful-paint 大致可以认为是首屏时间
1.2.2 LCP
这个指标对应的关键用户问题是,内容是否有用,即页面是否已经呈现出对用户有用的内容。
早先有过一些类似的指标比如 FMP (首次有效绘制),但有效绘制的定义是什么通常很难解释,而且算法容易出错。
相反,最大内容绘制的定义简单明了,这里的“内容”和 FCP 中的定义基本一致,指的是在可视区域内的最大图片或文本块完成渲染的时间。
元素大小指的是内容占据的面积大小,即 size = width * height ,不包含边距边框。
大多数情况下,页面上最吸引用户的内容往往就是最大元素,可以视为页面中最重要的内容。
1.2.3 TTI
可交互时间,对应的用户关注点是可以使用吗。
早期,关于可交互时间一直并没有一个清晰明确的定义。刀耕火种的时代,开发者通过自定义时间节点,并在代码中埋点来获取相关数据。
比如通过在 setTimeout 中放一个任务获取执行时间点,再计算到页面开始加载的差值。
setTimeout(function() {
tti = new Date() - navigationStartTime
}, 0)
或者,在使用 React 等特定框架时,通过向主要组件的生命周期函数 componentDidMount 埋点,并以此计算 TTI 时间。
而在 Lighthouse 中,可交互时间指标有了更通用、标准化的定义。TTI 应从 FCP 时间点开始沿时间轴查找,如果出现 5 秒的静默窗口(没有长任务并且不超过 2 个正在处理的 GET 请求),那么最后一个长任务执行结束的时间点即为可交互时间。
长任务指的是执行时间超过 50 ms 的任务。
定义的根据是,主线程上若不存在导致阻塞状态的长任务,则视为此时已可以响应用户交互。
' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
1.2.4 TBT
TBT 和 TTI 是一对配套指标,用于衡量在页面可交互之前的阻塞程度。TBT 是指在 FCP 和 TTI 之间所有长任务超过 50ms 的部分的时间总和(注意不是长任务的时间总和)。' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
1.2.5 CLS
累积布局偏移指标用于衡量页面视觉稳定性。单次布局偏移分数是影响分数(不稳定区域占可视区域的百分比)与距离分数(不稳定元素最大位移距离占比)的乘积。CLS 指标本身一直在不断进化 ,便于更加准确地去衡量布局偏移对用户的影响。
1.2.6 其他
二、性能测量
了解需要关注的性能指标之后,面临的问题是应该怎么样去有效测量呢?性能测量分两种类型,实验室测量与现场测量(真实用户监控)。部分指标只能通过实验室测量,或是只能现场测量。
2.1 实验室测量
实验室测量指的是在一个受控环境下,使用预定义的硬件设备和网络配置等规则去运行网站页面,进行性能数据采集,提取性能指标。目前最流行的工具是 Google 提供的 Lighthouse ,最初作为一个独立浏览器扩展程序推出,需开发者自行安装(支持 Firefox),目前已经集成到 Chrome DevTools 。Lighthouse 不仅仅是一个性能测量工具,除此之外还提供 PWA 、SEO 、可访问性、最佳实践等审计报告。在做性能优化的时候,如何有效评估优化方案的效果是一个问题,由于还未发布到线上环境无法采集真实用户性能数据,这时使用工具进行实验室测量就显得尤为重要。同时,Lighthouse 提供开源 CI 工具 Lighthouse CI ,开发者能自行部署服务,并集成到现有的 CI 体系中。
2.2 现场测量
现场测量,也称真实用户监控(RUM),即实时采集真实用户性能数据。实验室测量的是在一系列特定条件下的性能数据,不能完全反映现实世界中用户的真实情况。现场测量的优势在于样板量足够大,包罗各种不同设备不同网络环境下的用户性能数据,从统计上更能反映真实性能情况。缺点是,现场测量需基于浏览器提供的性能 Web API ,受限于当前设备采集到的数据不及实验室测量丰富。
2.3 定量评估的问题与方案
定量评估每一项优化方案的效果并不容易,原因包括环境差异问题,分数计算问题等。开发模式启动站点应用与生产模式差别较大,将应用发布到独立测试服务器再进行性能测量
本地启动 Lighthouse 进行测量,在不同时间的系统状态差异较大,部署测量工具到特定服务器
由于环境影响单次测量的差异可能很大,基于 lighthouse NPM 包一次性跑 10 次,去除最大值和最小值之后再取中位数和平均值作为参考
性能分数由 6 大性能指标计算而来,单项指标的数值优化最终在分数上体现可能没有差异,分开对比具体指标数值
三、性能优化方案
确定优化方向,并且有了可定量评估的方案之后,接下来要做的就是如何实施具体的优化方案。性能优化是一个老生常谈,同时与时俱进的主题。早期大名鼎鼎的 雅虎 35 条性能军规 到现在大部分仍然适用,另一方面随着技术的发展,基于上述以用户为中心的性能指标,能更有针对性地实施方案。同时借助 Lighthouse 工具,能更有效地评估具体方案效果。我们的 Web 应用基于 React 技术栈,以下部分内容基于 React 来进行阐述。
3.1 减小包体积
Web 应用与传统客户端应用很不同的一点在于,应用所需资源文件都是存放在远端服务器上的,每次访问都有相当大的性能开销是用于资源获取。如何让资源高效加载成了一个非常重要的问题,其中最重要的一环是网络传输,专用 CDN 服务器包含就近访问,资源缓存和传输体积压缩等功能,能节省大量网络传输时间,这是基础设施的角度。从应用开发者的角度,首先可以对应用包体积进行瘦身。
3.1.1 冗余代码的优化
冗余代码的产生有多种,比如是已经废弃不用但仍然被导入的功能模块,或者是在做 AB 实验完成后未完全移除的版本代码等。借助相关工具,比如 Webpack 插件 webpack-bundle-analyzer 能用一种可视化的方式呈现每个包的具体模块信息,体积大小、依赖关系一目了然。而 Chrome DevTools Coverage 工具能分析出运行过程中文件(脚本和样式)的使用情况,可作为参考更好地针对性地瘦身优化。
3.1.2 重复代码的优化
重复代码很大一部分是实现相似功能的过程中,直接复制粘贴一方代码进行修改导致,借助 jsinspect 可以检测到相同和相似代码,然后进行合理抽象。还有一种情况是,依赖 NPM 包提供多种方式的代码,比如 dist 目录下的打包代码,lib 目录下的 CommonJS 代码,和 es 目录下的 ES Modules 代码。若是不小心在不同地方引入不同方式的包,就等同于是引入重复功能模块。更甚一步,在跨团队合作中依赖包只提供打包版本,也会出现 babel polyfill 代码多次重复,并且无从分析。解决方案是制定统一的标准,推荐 NPM 包都提供仅 babel 编译不打包版本。
3.1.3 类库开销的优化
在类库的使用上同样需要注意,比如仅使用一两个方法就引入整个 lodash 库,推荐做法是按需引入,不用改变写法加入 babel-plugin-lodash 这类插件就能在代码构建时转换。另外一种情况是引入 moment 这类体积较大的库用作时间处理与格式化,可以视实际情况采用体积更小的替代品。对于更简单的需求,则完全可以基于原生 API 自行实现封装一些方法。
3.1.4 图片文件的优化
未经优化的图片可高达几百 KB ,应在保证图片清晰度的情况下合理压缩体积。另一方面,为现代浏览器提供有更高效压缩算法的图片格式,相比传统的 PNG 和 JPG 格式,WebP 在同等质量下有更小的体积,注意做好降级方案。
3.1.5 更激进的做法
关于编译构建,传统做法为了浏览器兼容性引入很多非必要的 polyfill ,解决方案是提供一个动态 polyfill 脚本,根据当前客户端提供不同内容的 polyfill 。另一方面,现代浏览器已经支持越来越多的 ES6+ 语法特性,针对这部分用户可提供 ES6+ 版本的代码,并使用 ES Modules 格式,从而大幅减小包体积。
3.2 优化资源加载
做好包体积优化能节省网络传输时间,以及一部分代码执行时间,但更重要的是让资源有效加载,可从资源加载顺序和优先级方面着手。
3.2.1 Resource Hints
为了使页面可以快速加载,我们基于 PRPL 模式 进行优化。PRPL 是四个词的首字母缩写,分别代表:首先,我们需要优化关键路径资源,页面中要呈现的内容很多,但不是所有内容都需要第一时间呈现,应优先呈现最重要的内容。浏览器并不知道哪些资源是最重要的,基于 Resource Hints 可以告诉浏览器资源优先级。常用的有以下几类:preconnect 启动早期连接,包括 DNS 查找,TCP 握手等
preload 预加载资源并缓存,以便需要时立即使用
prefetch 预获取资源,优先级比 preload 低,浏览器自行判断合理时间执行操作
3.2.2 Service Worker
使用 Service worker 缓存预载资源,对后续访问会有极大的性能提升,能节省大量网路传输开销。在项目中推荐采用 Google 提供的 Workbox 库,可以通过配置的方式对不同类型资源应用不同缓存策略。Service Worker 带来的优化效果不能从 PageSpeed Insights 网站上的分数直接体现,因为 PageSpeed 总是单次分数并且不使用缓存。
3.2.3 优化加载第三方脚本
应用依赖的第三方脚本通常会减慢页面加载速度,一般采用以下方式:按需加载和延迟加载。需用户交互才用到的功能模块应按需加载。举个例子,用户登录时要调用一个第三方验证模块,就没必要在页面一开始就引入该脚本,在用户执行登录操作时引入更合理。像是 Google analysis 和合作商营销等第三方日志埋点脚本,由于业务需要无法移除,加载后占用大量性能资源。由于这类脚本和应用没有依赖关系,可使用 defer script 延迟脚本的解析执行。更进一步,延迟到在可交互时间之后加载就基本不会有任何影响。' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
3.3 组件懒加载
可视区域之外的内容和需要用户交互时才呈现的组件,都可采用懒加载,保证页面首要内容快速呈现。要做懒加载,首先需要合理定义拆分点进行代码分割,然后基于动态导入和 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( './LazyComponent'));
export function HomePage() {
return (
<>
<MainComponent />
<LazyLoad>
<LazyComponent />
</LazyLoad>
</>
);)
}
懒加载可能导致懒加载组件自身体验下降,可对用户比较频繁使用的组件进行预加载。
过度拆分可能会产生很多体积很小的包,可适当地进行合并。借助 webpack magic comment ,配置相同 chunk name 即可合并打包。
import loadable from '@loadable/component';
export const SortLayer = loadable(() => import( './SortLayer'));
export const StopLayer = loadable(() => import( './StopLayer'));
export const TimeLayer = loadable(() => import( './TimeLayer'));
3.4 优化渲染方式
3.4.1 服务端渲染
CSR (客户端渲染)的最大问题在于受用户环境影响太大,一方面是网络层面脚本文件的加载,一方面是浏览器的执行效率,不同环境下差异很大。SSR (服务端渲染)则能解决这个问题,直出 HTML 能快速呈现页面主要内容,能很好地改善 FCP 和 LCP 指标。实际上,大部分时候都是结合二者,针对首屏采用服务端渲染,让用户更快看到内容,其他仍使用客户端渲染的模式,减轻服务器压力,毕竟将大量用户的渲染任务转移到服务端会是一笔不小的开销。这时,结合缓存机制可以大幅节省渲染时间。3.4.2 预渲染
基于构建时的预渲染,是使用 webpack 和 babel 等工具提前生成对应的 HTML 以及引用的脚步和样式文件。还有一种方式是基于运行时的,使用 headless 浏览器。但预渲染并不适用于有大量动态内容的页面。3.5 优化长任务
Long Task (长任务)的定义是执行时间超过 50 ms 的任务。我们知道,JavaScript 是单进程单线程的模型,主线程上一旦有耗时长的任务存在时,就会造成阻塞,无法响应用户输入。Long Task 跟 Lighthouse 中的两个重要性能指标 TTI 和 TBT 息息相关,而这两个指标占比为 40% ,可以说优化好 Long Task 能大幅提升页面性能。Long Task 可借助对应的 Long Task Web API 进行监控,开发过程中则使用 Chrome DevTools Performance 面板进行查看和调试。需要注意的是,实际用户尤其是移动端的用户环境通常不够乐观,调试过程应适当调低硬件配置和网络速度,模拟大部分用户的实际使用情况,发现并优化更多的 Long Task 。任务类型有多种,除了最常见的脚本执行之外,还包括脚本解析编译、HTML 解析、CSS 解析、布局、渲染等。脚本执行是长任务的主要表现形式,因此着重说明在 JavaScript 执行上的一些优化方式:
3.5.1 requestIdleCallback API
针对一些不重要的任务比如埋点日志可以直接丢到 requestIdleCallback 中,浏览器会在空闲时间执行。在不支持的环境可使用 shim ,基于 setTimeout 实现近似的功能。库 idlize 中封装了一些非常实用的帮助函数,使用这些方法可把任务延迟到需要的时候再执行。3.5.2 Web Worker
如果项目中确实存在比较复杂的计算,可启动 Web Worker 单独另开一个线程来计算,并使用 message 通信。
3.5.3 记忆函数
如果一个函数被大量调用,合理运用记忆函数一个很好的选择,有大量的库可供我们选择,也可以根据使用场景自行实现。
3.5.4 Debounce 和 Throttle
针对 input change 和 scroll 等可能频繁触发的事件,结合 Debounce 或 Throttle 避免无节制地调用。
3.6 React 性能优化
在 React 框架使用上有一些性能优化的实践,常用的有:使用 PureComponent 和 Memo 避免不必要的重新渲染,复杂场景适当使用 shouldComponentUpdate 或是 areEqual 方法
函数组件可使用 useMemo 用于记忆计算结果,其他场景可引入外部库如 reselect 简化处理
在更新 state 深层嵌套数据时避免使用深拷贝,可借助 immer 这类库来处理不可变数据
保持 DOM 结构简洁,避免层级过深。比如,最简单的一个点是使用 React.Fragment
最后说明一点,仅在必要的时候进行性能优化,大部分情况下无需考虑,保持简洁和可维护性更重要,而且滥用方法反而损害性能。
3.7 减少布局偏移
在开发调试中,Layout Shift 同样可以使用 Chrome DevTools Performance 进行分析,能查看每一次布局偏移的分数,进行针对性优化。预留空间可减少其他页面元素的偏移,比如出现在最顶部的广告位,在数据还未获取到的时候预先设置好一个容器,避免后续大幅偏移。针对整页动态的内容,使用骨架屏是一种很好的模式,业界已有不少成熟方案可自动生成。设置图片宽高,可保证浏览器在加载图片过程中始终能分配正确的空间大小。
四、总结反思
借助上文中提到的性能测量方式,我们逐步实施优化方案并发布上线,经过近两个月断断续续的时间,最终让性能分数稳定在 80 分以上。' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
性能优化也适用于二八定律,优化方式很多,简单堆砌叠加使用很可能适得其反。不同场景下的优化方案千差万别,关键在于找准最核心的问题。以上仅提供一些思路作为参考,部分方案对特定指标效果很好,部分方案不会反映到指标分数,但有助提升用户体验。再者,指标衡量的是单个页面速度,而作为开发者还应考虑到后续页面,从应用维度去平衡,真正从用户角度考虑。团队招聘信息
我们是携程机票研发团队,负责携程APP/PC端机票业务开发及创新。机票研发在搜索引擎、数据库、深度学习、高并发等方向持续不断地深入探索,持续优化用户体验,提高效率。
在机票研发,你可以和众多技术顶尖大牛一起,真实的让亿万用户享受你的产品和代码,提升全球旅行者的出行体验和幸福指数。
如果你热爱技术,并渴望不断成长,携程机票研发团队期待与你一起腾飞。目前我们前端/后台/数据/测试开发等领域均有开放职位。
简历投递邮箱:tech@ctrip.com,邮件标题:【姓名】-【携程机票】-【投递职位】。
【推荐阅读】