前端能能优化篇

299 阅读1小时+

前端开发中的性能优化

前言:现在前端开发中面试也好,还是实际项目中,前端性能优化都是必不可少的,本文将从所有需要的点出发,来讲解一下关于前端中的性能优化。

归纳篇

常见的性能优化方案

对于前端应用来说,网络耗时、页面加载耗时、脚本执行耗时、渲染耗时等耗时情况会影响用户的等待时长,而 CPU 占用、内存占用、本地缓存占用等则可能会导致页面卡顿甚至卡死。

因此,性能优化可以分别从耗时和资源占用两方面来解决,我个人也比较喜欢将其称为“时间”和“空间”两个维度。

#时间角度优化:减少耗时

我们知道浏览器在页面加载过程中,会进行以下的步骤:

  • 网络请求相关(发起 HTTP 请求从服务端获取页面资源,包括 HTML/CSS/JS/图片资源等)
  • 浏览器解析 HTML 和渲染页面
  • 加载 Javascript 代码时会暂停页面渲染(包括解析到外部资源,会发起 HTTP 请求获取并加载)

在浏览器的首次加载和渲染完成之后,不代表用户就可以马上交互和操作。根据业务代码加载过程,页面还会分别进入页面开始渲染、渲染完成、用户可交互等阶段。除此之外,页面交互过程中,会根据业务逻辑进行逻辑运算、页面更新。

题外话:为什么我们常常说要理解原理呢?性能优化便是个很好的例子,如果你不知道这个过程具体发生了什么,就很难找到地方下手去进行优化。

根据这个过程,我们可以从四个方面进行耗时优化:

  1. 网络请求优化。
  2. 首屏加载优化。
  3. 渲染过程优化。
  4. 计算/逻辑运行提速。

在前端性能优化实践中,网络请求优化和首屏加载优化方案使用频率最高,因为不管项目规模如何、各个模块和逻辑是否复杂,这两个方向的耗时优化方案都是比较通用的。相比之下,对于页面内容较多、交互逻辑/运算逻辑复杂的项目,才需要针对性地进行渲染过程优化和计算/逻辑运行提速。

一起来看看~

#1. 网络请求优化

网络请求优化的目标在于减少网络资源的请求和加载耗时,如果考虑 HTTP 请求过程,显然我们可以从几个角度来进行优化:

  1. 请求链路:DNS 查询、部署 CDN 节点、缓存等。
  2. 数据大小:代码大小、图片资源等。

对于请求链路,核心的方案常常包括使用缓存,比如 DNS 缓存、CDN 缓存、HTTP 缓存、后台缓存等等,前端的话还可以考虑使用 Service Worker、PWA 等技术。使用缓存并非万能药,很多使用由于缓存的存在,我们在功能更新修复的时候还需要考虑缓存的情况。除此之外,还可以考虑使用 HTTP/2、HTTP/3 等提升资源请求速度,以及对多个请求进行合并,减少通信次数;对请求进行域名拆分,提升并发请求数量。

数据大小则主要考对请求资源进行合理的拆分(CSS、Javascript 脚本、图片/音频/视频等)和压缩,减少请求资源的体积,比如使用 Tree-shaking、代码分割、移除用不上的依赖项等。

在请求资源返回后,浏览器会进行解析和加载,这个过程会影响页面的可见时间,通过对首屏加载的优化,可有效地提升用户体验。

#2. 首屏加载优化

首屏加载优化核心点在于两部分:

  1. 将页面内容尽快地展示给用户,减少页面白屏时间。
  2. 将用户可操作的时间尽量提前,避免用户无法操作的卡顿体验。

减少白屏时间除了我们常说的首屏加载耗时优化,还可以考虑使用一些过渡的动画,让用户感知到页面正在顺利加载,从而避免用户对于白屏页面或是静止页面产生烦躁和困惑。除了技术侧的优化,很多时候产品策略的调整,给用户带来的体验优化效果不低于技术手段优化,因此我们也需要重视。

整体的优化思路包括:尽可能提前页面可见,以及将用户可交互的时间提前。一般来说,我们需要尽可能地降低首屏需要的代码量和执行耗时,可以通过以下方式进行:

  • 对页面的内容进行分片/分屏加载
  • 仅加载需要的资源,通过异步或是懒加载的方式加载剩余资源
  • 使用骨架屏进行预渲染
  • 使用差异化服务,比如读写分离,对于不同场景按需加载所需要的模块
  • 使用服务端直出渲染,减少页面二次请求和渲染的耗时

有些时候,我们的页面也需要在客户端进行展示,此时可充分利用客户端的优势:

  • 配合客户端进行资源预请求和预加载,比如使用预热 Web 容器
  • 配合客户端将资源和数据进行离线,可用于下一次页面的快速渲染
  • 使用秒看技术,通过生成预览图片的方式提前将页面内容提供给用户

除了首屏渲染以外,用户在浏览器页面过程中,也会触发页面的二次运算和渲染,此时需要进行渲染过程的优化。

#3. 渲染过程优化

渲染过程的优化要怎么定义呢?我们可以将其理解为首屏加载完成后,用户的操作交互触发的二次渲染。

主要思路是减少用户的操作等待时间,以及通过将页面渲染帧率保持在 60FPS 左右,提升页面交互和渲染的流畅度。包括但不限于以下方案:

  • 使用资源预加载,提升空闲时间的资源利用率
  • 减少/合并 DOM 操作,减少浏览器渲染过程中的计算耗时
  • 使用离屏渲染,在页面不可见的地方提前进行渲染(比如 Canvas 离屏渲染)
  • 通过合理使用浏览器 GPU 能力,提升浏览器渲染效率(比如使用 css transform 代替 Canvas 缩放绘制)

以上这些,是对常见的 Web 页面渲染优化方案。对于运算逻辑复杂、计算量较大的业务逻辑,我们还需要进行计算/逻辑运行的提速。

#4. 计算/逻辑运行提速

计算/逻辑运行速度优化的主要思路是“拆大为小、多路并行”,方式包括但不限于:

  • 通过将 Javscript 大任务进行拆解,结合异步任务的管理,避免出现长时间计算导致页面卡顿的情况
  • 将耗时长且非关键逻辑的计算拆离,比如使用 Web Worker
  • 通过使用运行效率更高的方式,减少计算耗时,比如使用 Webassembly
  • 通过将计算过程提前,减少计算等待时长,比如使用 AOT 技术
  • 通过使用更优的算法或是存储结构,提升计算效率,比如 VSCode 使用红黑树优化文本缓冲区的计算
  • 通过将计算结果缓存的方式,减少运算次数

以上便是时间维度的性能优化思路,还有空间维度的资源优化情况。

#空间角度优化:降低资源占用

提到性能优化,大多数我们都在针对页面加载耗时进行优化,对资源占用的优化会更少,因为资源占用常常会直接受到用户设备性能和适应场景的影响,大多数情况下优化效果会比耗时优化局限,因此这里也只能说一些大概的思路。

资源占用常见的优化方式包括:

  • 合理使用缓存,不滥用用户的缓存资源(比如浏览器缓存、IndexDB),及时进行缓存清理
  • 避免存在内存泄露,比如尽量避免全局变量的使用、及时解除引用等
  • 避免复杂/异常的递归调用,导致调用栈的溢出
  • 通过使用数据结构享元的方式,减少对象的创建,从而减少内存占用

说到底,我们在做性能优化的时候,其实很多情况下会依赖时间换空间、空间换时间等方式。性能优化没有银弹,只能根据自己项目的实际情况做出取舍,选择相对合适的一种方案去进行优化。

对于页面耗时和资源占用的性能优化分析,大部分情况都可以使用 Chrome 开发者工具进行针对性的分析和优化。

 加载流程篇

  

常见的页面加载流程

其实我们在性能优化的归纳篇有简单说过,页面加载的过程其实跟我们常常提起的浏览器页面渲染流程几乎一致:

  1. 网络请求,服务端返回 HTML 内容。
  2. 浏览器一边解析 HTML,一边进行页面渲染。
  3. 解析到外部资源,会发起 HTTP 请求获取,加载 Javascript 代码时会暂停页面渲染。
  4. 根据业务代码加载过程,会分别进入页面开始渲染、渲染完成、用户可交互等阶段。
  5. 页面交互过程中,会根据业务逻辑进行逻辑运算、页面更新。

那么,我们可以针对其中的每个步骤做优化,主要包括:资源获取、资源加载、页面可见、页面可交互。

#资源获取

资源获取主要可以围绕两个角度做优化:

  • 资源大小
  • 资源缓存

#资源大小

一般来说,前端都会在打包的时候做资源大小的优化,资源类型包括 HTML、JavaScript、CSS、图片等。优化的方向包括:

(1) 合理的对资源进行分包。

首次渲染时只保留当前页面渲染需要的资源,将可以异步加载、延迟加载的资源拆离。通常我们会在代码编译打包的时候做处理,比如使用 Webpack 将代码拆到不同的 bundle 包中 (opens new window)。

(2) 移除不需要的代码。

我们项目中常常会引入许多开源代码,同时我们自己也会实现很多的工具方法,但是实际上并不是全部相关的代码都是最终需要执行的代码,所以我们可以在打包的时候移除不需要的代码。现在基本大多数的打包工具都提供了类似的能力,比如 Tree-shaking。

除此之外,如果我们的项目较大,使用和依赖了多个不同的仓库。如果在不同的代码仓库里,都依赖了同样的 npm 代码包,那么我们可能会遇到打包时引入多次同样的 npm 包的情况。一般来说,我们在管理依赖包的时候,可以使用peerDependency来进行管理,避免多次安装依赖、以及版本不一致导致的多次打包和安装等情况。

(3) 资源压缩和合并。

代码压缩也常常是在打包阶段进行的,包括 JavaScript 和 CSS 等代码,在一些情况下也可以使用图片合并(雪碧图的生成)。通常也是使用的打包工具以及插件自带的压缩能力,开启压缩后的代码可能比较难定位,可以配合 Sorce Mapping 来进行问题定位。

除了打包时的压缩,我们在页面加载的时候也可以启用 HTTP 的 gzip 压缩,可以减少资源 HTTP 请求的耗时。

#资源缓存

资源缓存的优化,其实更多时候跟我们的资源获取的链路有关,包括:

  • 减少 DNS 查询时间,比如使用浏览器 DNS 缓存、计算机 DNS 缓存、服务器 DNS 缓存
  • 合理地使用 CDN 资源,有效地减少网络请求耗时
  • 对请求资源进行缓存,包括但不限于使用浏览器缓存、HTTP 缓存、后台缓存,比如使用 Service Worker、PWA 等技术

其实,我们观察资源获取的链路,获取除了大小和缓存的角度以外,还可以做更多的优化,比如:

  • 使用 HTTP/2、HTTP/3,提升资源请求速度
  • 对请求进行优化,比如对多个请求进行合并,减少通信次数
  • 对请求进行域名拆分,提升并发请求数量

#资源加载

资源加载步骤中,我们一般也有以下的优化角度:

  • 加载流程拆分
  • 资源懒加载
  • 资源预加载

#加载流程拆分

页面的加载过程,常常分为两个阶段:页面可见、页面可交互。

前面我们讲了对资源做拆分,在页面启动加载的时候仅加需要的资源,拆分的过程则可以结合上述的两个阶段来做处理。

(1) 页面可见。

页面可见可以分为部分可见以及内容完全可见。

对于部分可见,一般来说可以做 loading 的展示或是直出,让用户知道页面正在加载中,而非无响应。

对于内容完全可见,则是用户可视区域内的内容完全渲染完毕。除此之外,当前可视范围以外的内容,则可以拆离出首屏的分包,通过预加载或是懒加载的方式进行异步加载。

(2) 页面可交互。

同样的,页面可交互也可以分为部分可交互以及完全可交互。

一般来说,组件的样式渲染仅需要 HTML 和 CSS 加载完成即可,而组件的功能则可能需要加载具体的功能代码。对于复杂或是依赖资源较多的功能,加载的耗时可能相对较长。在这样的情况下,我们可以选择将该部分的资源做异步加载。

在初始的内容加载完毕之后,剩下的资源需要延迟加载。对于页面功能完全可交互,同样依赖于分包资源延迟加载。加载流程的优化,不管是页面可见,还是页面可交互,都离不开延迟加载。

延迟加载可分为两种方式进行加载:懒加载和预加载。因此,资源懒加载和预加载也是加载流程中很重要的一部分。

#资源懒加载

我们常说的懒加载其实又被称为按需加载,顾名思义就是需要用到的时候才会进行加载。通过将非必要功能进行懒加载的方式,可以有效地减少页面的初始加载速度,提升页面加载的性能。

常见的场景比如某些组件在渲染时不具备完整的功能,当用户点击的时候,才进行对应逻辑的获取和加载。遇到点击时未加载完成的情况下,可以通过适当的方式提示用户功能正在加载中。

资源懒加载常常也是跟资源分包一起进行,大多数前端框架(比如 Vue、React、Angular)也都提供了懒加载的能力,也可以配合 Webpack 打包 (opens new window)做处理。

#资源预加载

资源预加载也称为闲时加载,很多时候我们可以在页面空闲的时候,对一些用户可能会用到的资源做提前加载,以加快后续渲染或者操作的时间。

仔细一看,资源预加载和资源懒加载都比较相似,都会通过将资源拆离的方式做成异步延迟的方式加载。两者的区别在于:

  • 懒加载的功能只会在需要的时候才进行加载,因为一些功能用户可能不会使用到,比如帮助中心、反馈功能等等
  • 预加载的功能则是在不阻塞核心功能的时候,尽可能利用空闲的资源提前加载,这部分的功能则是用户很可能会使用到,比如获取下一屏页面的内容数据

#复杂场景下的加载流程

在页面到达可交互状态之后,后续的加载流程也可以根据业务场景做后续的优化。对于一些复杂的业务,我们可以结合业务的特点做更进一步的性能优化。

#复杂加载流程管理

对于页面初始化流程过于复杂的应用来说,我们可以对加载流程做任务的拆分,分阶段地进行加载。

举个例子,假设我们需要在 Web 端加载 VsCode,那么我们可能需要考虑以下各个功能的加载:

- 整体页面框架
- 顶部菜单栏
- 左侧工具栏
- 底部状态栏
- 文件目录栏
- 文件详情
  - 内容展示
  - 编辑功能
  - 菜单功能
- 搜索功能
- 插件功能

以上只是我按照自己想法粗略拆分的功能,我们可以简单分成几个加载阶段:

  1. 页面整体框架加载完成。此时可以看到各个功能区域的分布,包括顶部菜单栏、左侧工具栏、底部状态栏、项目内容区域等等,但这些区域的内容未必都完全加载完成。
  2. 通用功能加载完成。比如顶部菜单栏、左侧工具栏、底部状态栏等等,一些具体的菜单或是工具的功能可以做按需加载和预加载,比如搜索功能。
  3. 项目内容相关框架加载完成。此时可以看到项目相关的内容区域,比如文件目录、当前文件的内容详情等等。
  4. 插件功能。用户安装的插件,在核心功能都加载完成之后再获取和加载。

当我们根据项目的具体加载过程做了阶段划分之后,则可以将我们的代码做任务拆分,可以拆分成串行和并行的任务。串行的任务比如按照阶段划分的大任务,并行的任务则可以是某个阶段内的小任务,其中也可以包括一些异步执行的任务,或是延迟加载的任务。

#长耗时任务的拆离

如果我们的应用中会有耗时较长的计算任务,比如拉取回来的数据需要计算处理后才能渲染,那么我们可以对这些耗时较长的任务做任务拆分。

同样的,我们还是回到 Web 端加载 VsCode 的场景。假设我们在加载某个特别大的文件,则可以考虑分别对该文件的内容获取、数据转换做任务拆分,比如分片获取该文件的内容,根据分片的内容做渲染的计算,计算过程如果耗时较长,也可以做异步任务的拆分,甚至可以结合 Web Worker 和 WebAssembly 等技术做更多的优化。

#读写分离

对于交互复杂、需要加载的资源较多的情况下,如果用户的权限只是可读,那么对于编辑相关的功能可以做资源拆离,对于有权限的用户才进行编辑能力的加载。

读写分离其实属于资源拆分的一种具体场景,我们可以结合业务的具体场景做具体的功能拆分,比如管理员权限相关的管理功能,也是类似的优化场景。

渲染篇

首屏渲染

说到页面渲染,首屏的渲染显然是最首要的。其实前面在归纳篇也有介绍,首屏加载优化核心点在于:将页面内容尽快展示给用户,减少页面白屏时间。

首屏渲染包括了首屏内容的加载和渲染两个过程。

#首屏内容加载

对于首屏加载过程,我们可以通过以下方式进行优化:

  • 使用骨架屏进行预渲染
  • 对页面进行分片/分屏加载,将页面可见/可交互时间提前
  • 优化资源加载的顺序和粒度,仅加载需要的资源,通过异步加载方式加载剩余资源
  • 使用差异化服务,比如读写分离,对于不同场景按需加载所需要的模块
  • 使用服务端直出渲染,减少页面二次请求和渲染的耗时
  • 使用秒看技术,通过预览的方式(比如图片)提前将页面内容提供给用户
  • 配合客户端进行资源预请求和预加载,比如使用预热 Web 容器
  • 配合客户端将资源和数据进行离线,可用于下一次页面的快速渲染

这里提到了很多的方向,但是否每个优化点都适用于自身的项目中,需要结合项目本身做调研和验证。举个简单的例子,最后两条优化点明显是基于有自研客户端的前提下,需要配合客户端一起做优化才可以实现。

实际上,对于首屏内容的优化,前端开发在项目中更常用的点是骨架屏、数据分片/分屏加载、SSR DOM 直出渲染这几种,因为这几个优化点相对来说方向明确、效果明确、实现相对简单。如果是想要对项目做差异化服务、做资源的拆分和优化,则可能随着项目的复杂度增加,方案难度提升、实现成本也增长。

#首屏内容渲染

对于首屏内容渲染的过程,更多时候我们是指浏览器渲染 HTML 的过程。该过程可以优化的点也是我们常常提及的,浏览器渲染页面的优化过程,比如:

  • 将 CSS 放在<head>里,可用来避免浏览器渲染的重复计算
  • 将 JavaScript 脚本放在<body>的最后面,避免资源阻塞页面渲染
  • 减少 DOM 数量,减少浏览器渲染过程中的计算耗时
  • 通过合理使用浏览器 GPU 合成,提升浏览器渲染效率

以上这些,是我们在做首屏渲染时考虑渲染过程的优化点。虽然这些优化点属于前端基础和共识,也常常会出现在基础面试中。

很多时候我们为了准备面试而学习了很多的知识和原理,却容易在将知识和实践结合的过程中忘记。越是基础和简单的点,反而往往会在实际写代码的时候被忽略,直到性能出现了问题,这些基础的优化点才会被注意到。

当然,首屏性能的提升,除了渲染相关的,也还有上一篇我们提到的加载流程相关的优化。

#页面更新

除了首屏内容需要尽快加载和渲染以外,当页面内容需要更新的时候,我们也需要尽可能地减少更新内容渲染的耗时。

一般来说,页面更新场景我们常常会关注用户操作和页面渲染。

#用户操作

页面内容的更新,一般有两种情况:

  1. 用户自身操作(点击、输入、拖拽等)的页面响应。
  2. 实时内容的变更(比如聊天室的消息提醒、弹幕等等)。

如果是用户自身的操作,则我们需要及时地更新页面内容,让用户感受到操作生效了。该过程应该是优先级最高的,一般需要同步进行。因为如果有别的任务在执行而导致主线程阻塞,就容易造成页面卡顿的体验。关于卡顿相关的,我会另外再起一篇文章介绍,这里就不过多展开啦。

至于实时内容的变更,优先级更多会比用户操作稍微低一些,也基本上都是异步进行的。我们还可以考虑对变更内容做合并、批量更新,也可以考虑定时拉取最新内容更新的方式。

#事件委托

对于用户交互频繁的场景,我们还得注意事件的绑定。相信很多人都了解过事件委托,如果在列表数量内容较大的时候,对成千上万节点进行事件监听,也是不小的性能消耗。使用事件委托的方式,通过将事件绑定在父元素上,我们可以大量减少浏览器对元素的监听,也是在前端性能优化中比较简单和基础的一个做法。

事件委托是很常见的优化方式,需要注意的是,如果我们直接在document.body上进行事件委托,可能会带来额外的问题。由于浏览器在进行页面渲染的时候会有合成的步骤,合成的过程会先将页面分成不同的合成层,而用户与浏览器进行交互的时候需要接收事件。

如果我们在document.body上被绑定了事件,这时候整个页面都会被标记。即使我们的页面不关心某些部分的用户交互,合成器线程也必须与主线程进行通信,并在每次事件发生时进行等待。此时可以使用passive: true选项来解决。

#页面渲染

我们在页面内容更新的时候,一般也可以考虑以下优化点:

  • 减少/合并 DOM 操作,减少页面更新的内容范围,减少浏览器渲染过程中的计算耗时
  • 对于页面动画,可以使用 CSS transition 能力,减少 DOM 属性的修改
  • 使用资源预加载,在空闲时间,提前将用户可能需要用到的资源进行获取并加载(比如下一页的内容)

#DOM 操作合并

说到 DOM 操作的合并和减少,目前大多数前端框架都提供了虚拟 DOM 的能力(比如 Vue 和 React)。虚拟 DOM 本身就有对 DOM 操作和更新做优化,通过使用 JavaScript 对象模拟 DOM 元素,并在页面需要更新时对更新的部分做 DOM Diff,尽可能地减少内容的更新频率和范围。

虽然现在大多数前端项目都离不开前端框架,也正因为这些框架本身已经做了很多的优化,所以我们常常会忘记和忽略掉这些注意事项。

但也从侧面论证了,即使是很基础的优化点也需要重视,即使是简单的优化点也可以做出很棒的设计。

#页面滚动渲染

考虑到页面滚动的场景,可能会出现性能问题的地方常常是长列表/页面的渲染。

由于页面内容过多,页面的 DOM 元素数量也很多,容易造成页面渲染的卡顿。在这样的情况下,我们可以考虑仅渲染可见区域的部分,比如页面内容超出滚动范围之外,就可以进行销毁,将页面的 DOM 数量保持在一定范围内。

卡顿篇

卡顿优化

还是那句话,对于大多数的渲染场景,我们都可以使用浏览器的 Performance 来录制和分析性能问题,Performance 适用于针对某个具体、可复现的问题做分析。

卡顿问题同样也是,我们可以在火焰图中看到一些长耗时的任务,然后再逐个分析具体的耗时问题出现在哪里,逐一解决。

这里介绍一些耗时任务的优化方案。

#赋值和取值

其实大多数情况下,我们都很少会去在意一些变量的取值和赋值。

但是在一些复杂的计算场景下,比如深层次的遍历中,需要考虑的点就很多很细,比如:

  • 尽量将不需要执行的逻辑前置,提前判断做return
  • 减少window对象或是深层次对象上的取值,可以将其保存为临时变量使用
  • 减少不必要的遍历,Array.filter()这种语法也是一次遍历,需要注意
  • 对复杂数据结构的数据查询,可以考虑优化数据结构

一些简单的问题,在重复上百万次的计算之后,都会被无数放大。即使是从window对象上获取某个值,然后做计算生成 DOM 这样的操作,如果将它放在多层遍历的最里层去做,同样会造成性能问题。

如果你的项目中有使用 Canvas,且重度依赖画布绘制,你会发现 ctx 的上下文切换开销也不低,后面也会单独对 Canvas 的一些性能问题做补充说明。

这也告诉我们,平时的代码习惯也要好,比如副作用、全局对象等,都可以考虑做更好的设计。

#优化计算性能/内存

除了上面提到的一些基础场景(比如取值赋值),很多时候我们提升计算性能,还依赖于使用更好的算法和数据结构。

其实大多数时候,前端都很少涉及到算法和数据结构相关的设计,但是在极端复杂的场景下,也需要考虑做一些优化。

讲一个经典例子,在 VSCode 的 1.21 发布版本中包含了一项重大改进:全新的文本缓冲区实现 (opens new window),在内存和速度方面都有大幅的性能提升。

在这次优化中,VSCode 引入了红黑树的数据结构,替代了原有的线性阵列,优化了内存避免了内存爆炸,同时也优化了查询的时间复杂度。

其实,除了计算耗时过长,如果出现内存占用过多的情况下,同样会造成浏览器频繁的 GC。如果你有仔细观察 Performance,便会发现浏览器的 GC 本身也需要不小的耗时。

所以,我们还需要时常关注内存情况,考虑:

  • 使用享元的方式来优化数据存储,减少内存占用
  • 及时地清理不用的资源,比如定时器
  • 避免内存泄露等问题

#大任务拆解

对于一些计算耗时较长的任务,我们可以考虑将任务做拆解,分成一个个的小任务,做异步执行。

比如,考虑将任务执行耗时控制在 50 ms 左右。每执行完一个任务,如果耗时超过 50 ms,将剩余任务设为异步,放到下一次执行,给到页面响应用户操作和更新渲染的时间。

我们都知道 React 框架有使用虚拟 DOM 的设计。实际上,虽然虚拟 DOM 解决了页面被频繁更新和渲染带来的性能问题,但传统虚拟 DOM 依然有以下性能瓶颈:

  • 在单个组件内部依然需要遍历该组件的整个虚拟 DOM 树
  • 在一些组件整个模版内只有少量动态节点的情况下,这些遍历都是性能的浪费
  • 递归遍历和更新逻辑容易导致 UI 渲染被阻塞,用户体验下降

对此,React 中还设计了协调器(Reconciler)与渲染器(Renderer)来优化页面的渲染性能。而在 React16 中,还新增了调度器(Scheduler)。

调度器能够把可中断的任务切片处理,能够调整优先级,重置并复用任务。调度器会根据任务的优先级去分配各自的过期时间,在过期时间之前按照优先级执行任务,可以在不影响用户体验的情况下去进行计算和更新。通过这样的方式,React 可在浏览器空闲的时候进行调度并执行任务。

这便是将大任务做拆解方案中,很好的一个例子。

#其他计算优化

除了上述的一些优化方案,我们还可以考虑:

(1) 使用 Web Worker。

如今 Web Worker 已经是前端应用中比较常用的一个能力了,对于一些耗时较长、相对独立的计算任务,我们可以使用 Web Worker 来进行计算。

当然,由于这些计算任务已经不在主线程了,那么通信的耗时、数据的同步、Worker 兼容性等问题也需要考虑,做好兜底和兼容方案,保证核心能力的使用。

(2) 使用 WebAssembly。

WebAssembly 的运行性能接近原生,因此在许多计算耗时的场景上会被使用来优化,比如文件上传、文件/视频内容识别等等。

(3) 使用 AOT 技术。

使用 AOT 技术,通过将计算过程提前,减少计算等待时长。

举个例子,在 Angular 框架中,提供了预编译(AOT)能力,无须等待应用首次编译,以及通过预编译的方式移除不需要的库代码、减少体积,还可以提早检测模板错误。

#卡顿的监控和定位

出现卡顿问题的时候,往往难以定位,因为这个时候页面常常已经卡死,无法做更多的调试操作。

#Performance

定位一个页面的运行是否有卡顿,最简单又直接的方式是录制 Performance。Performance 会把耗时长的任务直接标记为红色,我们可以根据这些任务,查找和分析具体产生耗时的脚本是哪些,然后去做优化。

但是,Performance 仅对开发者来说比较方便,在真实用户的使用场景里,未必有条件能提供 Performance 的录制。更多的时候,我们只能粗略地监控用户的卡顿情况,发现这样的场景,并尝试去解决。

#requestAnimationFrame

一般来说我们监控卡顿,可以考虑使用window.requestAnimationFrame方法。该方法会在绘制下一帧绘制前被调用,这意味着当前的同步计算任务即将结束。

前面也有说到,卡顿大多数是因为长耗时的计算任务导致的。那么,我们就可以考虑在某个函数执行之前记下时间戳,而在window.requestAnimationFrame的时候再取其中的时间差,判断当前函数的执行耗时是否合理。

当然,该方案并不是完全准确,因为我们常常会在一个函数中间调用另外一个函数,还可能会同步抛出事件通知,执行其他的计算任务。

不过,考虑到真实的线上用户里无法直接使用 Performance,这也算是一个能做卡顿监控的方案。我们可以配合日志、其他不同的监控和上报等,来做更多的问题定位。

Canvas篇

Canvas 性能优化

其实对于 Canvas 的优化,WDN (opens new window)上也有一些介绍。如果你在网上搜索相关内容,或许有许多的优化方向都和本文有些相像。

这是当然的,因为我们在做 Canvas 优化的时候,也同样会去找业界的方案做调研,结合自身项目的情况再做方案设计。

那么,这里整理下我了解到以及实践中的一些 Canvas 优化方案吧。

#Canvas 上下文切换

Canvas 绘制 API 都是在上下文context上进行调用,context不是一个普通的对象,当我们对其赋值的时候,性能开销远大于普通对象。我们可以尝试将每个赋值操作执行一百万次,来看看其耗时:

赋值属性耗时(ms)耗时(非法赋值)(ms)
font200+1500+
fillStyle80+800+
strokeStyle50+800+
lineWidth30+500+

可见,频繁对 Canvas 上下文属性修改赋值是有一定的性能开销的。这是因为当我们调用context.lineWidth = 2时,浏览器会需要立刻地做一些事情,这样在下一次绘制的时候才能以最新的状态绘制。这意味着,在绘制两段不同字体大小的文本的时候,需要设置两次不同的字体,也就是需要进行两次context上下文状态的切换。

在大多数情况下,我们的 Canvas 绘制内容的样式不会太多。但是在绘制内容数量大、样式多的场景下,我们应该考虑如何减少上下文context的切换。

可以考虑使用先将相同样式的绘制内容收集起来,结合享元的方式将其维护起来。在绘制的时候,则可以针对每种样式做切换,切换后批量绘制相同样式的所有内容。

举个例子,我们绘制俄罗斯方块,可以考虑所有方块的信息收集起来,相同样式的放在一个数据中,切换上下文后遍历绘制。比如,边框信息放在一个数组中,背景色相同的放在一个数组中。

#Canvas 拆分

一般来说,我们在 Canvas 里绘制的内容,都可以根据变更频率来拆分,简称动静分离。

Canvas 拆分的关键点在于:尽量避免进行不必要的渲染,减少频繁变更的渲染范围。

比如在游戏中,状态栏(血条、当前关卡说明等)相对动作/动画内容来说,这部分内容的变更不会太频繁,可以将其拆出到一个单独的 Canvas 来做绘制。再假设该游戏有个静态的复杂背景,如果我们每次更新内容都需要重新将这个背景再绘制一遍,显然开销也是不小的,那么这个背景我们也可以用单独的 Canvas 来绘制。

Canvas 拆分的前提是更新频率的内容分离,而在拆分的时候也有两个小技巧:

  1. 根据绘制范围拆分。
  2. 根据堆叠层次关系拆分。

#绘制范围的拆分

绘制范围的拆分要怎么理解呢?简单说就是将画布划分不同的区域,然后根据不同的区域更新频率,来进行 Canvas 拆分。

举个例子,假设我们现在需要实现 Web 端 VsCode,而整个界面都是由 Canvas 绘制(当然这样不大合理,这里假设只是为了更好地举例)。

我们可以简单地将 VsCode 拆分成几个区域:顶部栏、左侧栏、底部栏、编辑区。显然这个几个区域的变更频率、触发变更的前提都不一致,我们可以将其做拆分。

#堆叠层次的拆分

如果说绘制范围的拆分是二维角度,那么堆叠层次更像是三维的 y 轴方向的拆分。

前面提到的游戏画布拆分,其实背景图片便是堆叠在其余内容的下面。我们可以考虑更复杂的场景,比如我们要实现 Web 版的 Excel/Word,那么我们也可考虑按照堆叠顺序来做拆分:背景色、文字、边框线等等。

对于有堆叠顺序的绘制来说,Canvas 拆分的优化效果更好。因为如果是二维角度的内容,我们可以只擦除和重绘某个 x/y 轴范围的内容就可以。

但是涉及到绘制内容的堆叠,如果不做 Canvas 的拆分,意味着我们其中任何一个层级的内容变更,都需要将所有层级的内容擦除并且重绘。比如在 Excel 场景下,某个区域的格子背景颜色变更,我们需要将该区域的格子全部擦除,再重新分别绘制背景色、文字、边框线、其他内容等等。

实际上,结合前面提到的context上下文的性能开销可知,我们在绘制的时候,很可能并不是以单个格子为单位来进行顺序堆叠的绘制,而是整个画布所有格子一起做顺序绘制(意思是,先绘制所有格子的背景色,再绘制所有格子的文字和边框线等等)。

在这样的情况下,如果没有做 Canvas 堆叠顺序的拆分,意味着每一个小的变更,我们都需要将整个表格的内容进行重绘。

#Canvas 拆分的开销

需要注意的是,Canvas 本身的维护也会存在一定的开销,并不是说我们拆的越多越好。

可以根据项目的实际情况,结合 Canvas 拆离后的效果,确定 Canvas 拆分的最终方案。

#离屏渲染

对于离屏渲染的概念,大多数情况是指:使用一个不可见(或是屏幕外)的 Canvas 对即将渲染的内容的某部分进行提前绘制,然后频繁地将屏幕外图像渲染到主画布上,避免重复生成该部分内容的步骤。

比如,提前绘制好某个图像,在画布更新的时候直接使用该图像:

// 在离屏 canvas 上绘制
var canvasOffscreen = document.createElement("canvas");
canvasOffscreen.width = dw;
canvasOffscreen.height = dh;
canvasOffscreen
  .getContext("2d")
  .drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh);

// 在绘制每一帧的时候,绘制这个图形
context.drawImage(canvasOffscreen, x, y);

#各种离屏渲染场景

关于离屏渲染,其实结合不同的使用场景,还可以达到不同的效果。比如:

(1) 使用离屏 Canvas 提前绘制特定内容。

这就是前面说到的提前绘制好需要的内容,避免每次重复生成的开销。

(2) 使用双 Canvas 交替绘制。

考虑 Canvas 滚动的场景,比如分页绘制,离屏 Canvas 可以提前绘制下一页/下一屏的内容,在切换的时候可以直接使用提前绘制好的内容。

通过这样的方式,可以加快 Canvas 的绘制,可以理解为预渲染的效果。

(3) 使用 OffscreenCanvas 达到真正的离屏。

通过 OffscreenCanvas API,真正地将离屏 Canvas 完整地运行在 worker 线程,有效减少主线程的性能开销。

#OffscreenCanvas API 能力

要达到将 Canvas 运行在 web worker 线程中,需要依赖 OffscreenCanvas API (opens new window)提供的能力。

需要注意的是,该 API 同样可以运行在主线程中。即使是在主线程中运行,其开销也比普通 Canvas 要小。

OffscreenCanvas提供了一个可以脱离屏幕渲染的 Canvas 对象,可运行在在窗口环境和 web worker 环境。但是该 API 已知具有兼容性问题(比如 Safari 和 IE,以及部分安卓 Webview),需要考虑不兼容情况下的降级方案。关于此能力现有的技术方案和文档较少,可参考:

  • OffscreenCanvas - 概念说明及使用解析(opens new window)
  • OffscreenCanvas — Speed up Your Canvas Operations with a Web Worker(opens new window)

对于该 API,核心的优势在于:当主线程繁忙时,依然可以通过 OffscreenCanvas 在 worker 中更新画布内容,避免给用户造成页面卡顿的体验。

除此之外,还可以进一步考虑在兼容性支持的情况下,通过将局部计算运行在 worker 中,减少渲染层的计算耗时,提升渲染层的渲染性能。

#其他 Canvas 优化方式

上面介绍了几种较大的 Canvas 优化方案,实际上我们在项目中还需要考虑:

  • 做内容的增量更新渲染,避免频繁地绘制大范围的内容
  • 避免浮点数的坐标点,浏览器为了达到抗锯齿的效果会做额外的运算,建议用整数取而代之
  • 使用 CSS transform 代替 Canvas 计算缩放(CSS transforms 使用 GPU,因此速度更快)
  • 过于复杂的计算逻辑,可以考虑做任务的拆分,避免长时间计算造成页面卡顿

这里简单提一下增量渲染。

#增量渲染

增量渲染需要对内容的变更做计算,将变更的内容局限在某个特定范围,从而避免频繁地绘制大范围的内容。

举个例子,假设我们的画布内容支持向下滚动,那么我们在滚动的时候可以考虑:

  • 根据滚动的距离,将上一帧可复用的内容做裁剪保存
  • 在下一帧绘制中,先将上一帧中重复的内容在新的位置绘制
  • 原有内容绘制完成后,新增的部分内容再进行重新绘制

通过这样的方式,可以节省掉一部分的内容绘制和生成过程,提升每次渲染的速度。

容器篇

容器性能优化

由于 Web 应用本身只运行在 WebView 中,而 WebView 的能力又依赖于宿主容器,因此 Web 应用本身很多能力都比较局限。如果宿主容器能配合一起做一些优化,效果要远胜于我们自身做的很多优化效果。

从性能优化的角度来说,宿主容器主要能提供的能力包括:

  • 加速页面打开
  • 加速页面切换

#加速页面打开

对前端项目来说,我们常常会对首屏打开做很多的优化,包括尽量减少首屏需要的代码、对首屏渲染的内容进行分片等等(参考《前端性能优化--归纳篇》)。

即使前端本身优化到极致,对于资源获取、请求数据等这些耗时占比较大的部分,还是存在的。但是如果容器能提供类似的能力,我们就可以将这部分的耗时做优化了,比如:

  • 提前下载并缓存 Web 相关资源,页面打开时直接获取缓存,比如 HTML/JavaScript/CSS
  • 提前获取和缓存页面渲染相关的请求资源,页面请求时直接返回,或是直接从缓存中获取
  • 提前启动 WebView 页面,并加载基础资源

#资源准备

我们可以在客户端即将打开某个 WebView 页面之前,提前将该页面资源下载下来,由此加快 WebView 页面加载的速度。

由于资源请求本身也会消耗一定的资源,一般来说会在比较明确使用的场景下才会使用。也就是说用户很可能会点进去该 WebView 页面,基于这样的前提来做资源准备,比如列表页进入详情页,比如底部 TAB 进入的页面等等。

这些提前下载并临时缓存的资源,可以包括:

  • 页面加载资源,包括 HTML/CSS/JavaScript 等
  • 首屏页面内容的请求数据,比如分片数据的首片数据等

资源预下载要做的时候相对简单,需要注意的是下载后的资源的管理问题,在使用完毕或是不需要的情况下需要及时的清理,如果过多的缓存会占用用户机器的资源。

其实除了依赖客户端,前端本身也有相关的技术方案,比如说可以使用 PWA 提前请求和缓存页面需要的资源。

#预加载

在需要的资源已经准备好的前提下,容器还可以提供预加载的能力,包括:

  • 容器预热:提前准备好 WebView 资源
  • 资源加载:将已下载的 Web 资源进行加载,比如基础的 HTML/CSS/JavaScript 等资源

举个例子,小程序中也有对资源预加载做处理。在小程序启动时,微信会为小程序展示一个固定的启动界面,界面内包含小程序的图标、名称和加载提示图标。此时,微信会在背后完成几项工作:下载小程序代码包、加载小程序代码包、初始化小程序首页。

小程序的启动过程也分了两个步骤:

图片

  1. 页面预渲染。这是准备 WebView 页面的过程,由于小程序里是双线程的设计,因此渲染层和逻辑层都会分别进行初始化以及公共库的注入。逻辑层和渲染层是并行进行的,并不会相互依赖和阻塞。
  2. 小程序启动。当用户打开小程序后,小程序开始下载业务代码,同时会在本地创建基础 UI(内置组件)。准备完成后,就会开始注入业务代码,启动运行业务逻辑。

显然,小程序基础库和环境初始化相关的资源,都被提前内置在 APP 中了,并提前准备好相关的资源,使得用户打开小程序的时候,可以快速地加载页面。除此之外,小程序还提供了预加载的能力,业务方只需要配置提前拉取的资源,微信则可以在启动的过程中,提前将相关的资源拉取回来。

很多宿主预加载的方案也类似,比如对 WebView 页面做前置的资源下载和加载,当用户点击时尽快地给到用户体验。

#加速页面切换

除了首次打开页面的加速,在页面切换时我们也可以做很多提速的事情。

#容器预热

前面讲到,在打开小程序前,其实微信已经提前准备好了一个 WebView 层,由此减少小程序的加载耗时。

而当这个预备的 WebView 层被使用之后,一个新的 WebView 层同样地会被提前准备好。这样当开发者跳转到新页面时,就可以快速渲染页面了。这个过程也可以理解为容器的前置预热。

在这个例子中,小程序针对不同的页面使用了不同的 WebView 进行渲染,因此不管是首次打开,还是跳转/切换新页面,都会准备多一个 WebView 用来快速加载。

但多准备一个 WebView 本身也是对客户端的一种资源消耗,所以其实我们还可以考虑另外一种方案:容器切换。

#容器切换

容器切换方案指当页面切换时复用同一个 WebView 资源,可以理解为前端单应用类似的方式在 APP 中做资源切换。

由于需要复用同一个 WebView,因此该方案对资源的管理要求较高,包括:

  • 对页面应用的生命周期管理完善,自顶向下实现初始化、更新和销毁的能力
  • 页面切换时,需要及时清理原有逻辑和资源,比如定时器、页面遗留的 UI 和事件监听等
  • 资源占用、内存泄露等问题,会随着 WebView 复用次数而积累

要达到不同页面和前端应用之间的资源复用,要求比直接准备一个新的 WebView 容器要高很多。即使是不同的页面,也需要有统一的生命周期管理,约定好页面的一些销毁行为,并能执行到每个模块和组件中。

但如果项目架构和设计做得好,效果要远胜于容器预热,因为在进行页面切换的时候,很多资源可以直接复用,比如:

  • 通用的框架库,比如使用了 Vue/React 等前端框架、Antd 等组件库,就可以免去获取和加载这些资源的耗时
  • 公共库的复用,项目中自行封装的一些工具库,也可以直接复用
  • 模块复用,通用的模块比如顶部栏、底部栏、工具栏、菜单栏等功能,可以在页面切换时选择性保留,直接省略这部分模块的加载和页面渲染

看到这里或许有些人会疑惑,如果是这样的话为什么不直接用单页面呢?要知道我们讨论的场景是客户端打开的场景,也就是说 WebView 页面的退出,大多数情况下是会先回到 APP 原生页面中。当用户进入到另外一个 WebView 页面时,才会重新打开 WebView,此时才考虑是用新预热的 WebView,还是直接复用刚才的 WebView。

总的来说,容器切换是一个设计要求高、副作用强、但优化效果好的方案。

#客户端直出渲染

在有容器提供资源的基础上,我们还可以在 WebView 页面关闭前,对当前页面做截屏或是 HTML 保存处理。

在下一次用户进入到相同的页面中时,可以先使用上一次浏览的图片或是页面片段先预览,当页面加载完成后,再将预览部分移除。这种预加载(预览)的方案,由于是客户端提供的直出渲染能力,因此也被称为客户端直出渲染。

当然,相对于在页面关闭前保存,其实也可以直接实现直出渲染的能力,这样不管是否已经打开过某个页面,都可以通过容器预热时提前计算出直出渲染的内容,当页面打开时直接进行渲染。

这种方案有一个比较麻烦的地方:当缓存的页面内容发生变化时,需要及时更新直出渲染的内容。

因此,及时用户并不在页面内,也需要定期去获取最新的资源,并生成直出渲染的内容。当需要预渲染的页面多了,维护这些页面的实时性也需要消耗不少的资源,因此更适用于维护成本较低的页面。

SSR篇

SSR 性能优化

首先,我们来看一下 SSR 方案主要优化了哪些地方的性能。

#SSR 渲染方案

一般来说,我们页面加载会分为好几个步骤:

  1. 请求域名,服务器返回 HTML 资源。
  2. 浏览器加载 HTML 片段,识别到有 CSS/JavaScript 资源时,获取资源并加载。

现在大多数前端页面都是单页面应用,使用了一些前端框架来渲染页面,因此还会有以下的流程:

  1. 加载并初始化前端框架、路由库。
  2. 根据当前页面路由配置,命中对应的页面组件并进行渲染。
  3. 页面组件如果有依赖的资源,则发起请求获取数据后,再进行渲染。

到这里,用户才完整地可见到当前页面的内容,并进行操作。可见,页面启动时的加载流程比较长,对应的耗时也都无法避免。

使用 SSR 服务端渲染,可以在第 1 步中直接返回当前页面的内容,浏览器可以直接进行渲染,再加载剩余的其他资源,因此优化效果是十分明显的。除了性能上的优化,SSR 还可以带来更好的 SEO 效果,因为搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。

那一般来说 SSR 技术方案要怎么做呢?其实从上面的过程中,我们也可以推导出,需要根据页面路由和页面内容生成对应的 HTML 内容,用于首次获取 HTML 的时候直接返回。

#框架自带 SSR 渲染

现在我们大多数前端项目都会使用框架,而许多开源框架也提供了 SSR 能力。由于前端框架本身就负责动态拼接和渲染 HTML 的工作,因此实现 SSR 有天然的便利性。

以 Vue 为例子,Vue 提供了 vue-server-renderer (opens new window)服务端能力,基本思想基本也是前面说过的:浏览器请求服务端时,服务端完成动态拼接 HTML 的能力,将拼接好的 HTML 直接返回给浏览器,浏览器可以直接渲染页面:

// 省略,可直接查看官网例子:https://ssr.vuejs.org/zh/guide/#%E5%AE%8C%E6%95%B4%E5%AE%9E%E4%BE%8B%E4%BB%A3%E7%A0%81

// 服务端收到请求时,生成 HTML 内容并返回
server.get("*", (req, res) => {
  // 使用 Vue 实例
  const app = new Vue({
    data: {
      url: req.url,
    },
    template: `<div>访问的 URL 是:{{ url }}</div>`,
  });

  // 使用 vue-server-renderer 将 Vue 实例生成最终的 HTML 内容
  renderer.renderToString(app, context, (err, html) => {
    console.log(html);
    if (err) {
      res.status(500).end("Internal Server Error");
      return;
    }
    res.end(html);
  });
});

server.listen(8080);

当服务端收到请求时,生成 Vue 实例并依赖vue-server-renderer的能力,将 Vue 实例生成最终的 HTML 内容。该例子中,服务端直接使用现有资源就可以完成直出 HTML 的拼接.

但是在更多的前端应用场景下,通常还需要服务端动态获取其他的数据,才能完整地拼接出首屏需要的内容。一般来说,我们可以在服务端接到浏览器请求时,同时获取对应的数据,使用这些数据完成 HTML 拼接后再返回给浏览器。

在 Vue SSR 能力中,可以依赖createApp的能力,引入Vuex提前获取对应的数据并更新到 Store 中(参考数据预取和状态 (opens new window)),然后在服务端收到请求时,创建完整的 Vue 应用的能力:

const createApp = require("/path/to/built-server-bundle.js");

server.get("*", (req, res) => {
  const context = { url: req.url };

  createApp(context).then((app) => {
    renderer.renderToString(app, (err, html) => {
      if (err) {
        if (err.code === 404) {
          res.status(404).end("Page not found");
        } else {
          res.status(500).end("Internal Server Error");
        }
      } else {
        res.end(html);
      }
    });
  });
});

#同构 SSR 渲染

前面我们讲到,Vue 提供了 SSR 的能力,这意味着我们可以使用 Vue 来完成客户端和服务端渲染,因此大部分的代码都可以复用。对于这种一份代码可分别在服务器和客户端上运行,我们成为“同构”。

对比自行实现 SSR 渲染,依赖开源框架提供的同构能力,一套代码可以分别实现 CSR 和 SSR,可大大节省维护成本。

还是以 Vue 为例,使用 Vue 框架实现同构,大概的逻辑如图:

图片

不管是路由能力,还是组件渲染的能力,要保持同一套代码能分别运行在浏览器和服务端环境(Node.js)中,对于代码的编写则有一定的要求,比如 DOM 操作、window/document 对象等都需要谨慎,这些 Vue 官方指引 (opens new window)也有介绍。

除此之外,服务端的入口逻辑显然会和客户端有差异,比如资源的获取方式、依赖的公共资源有所不一样等等。因此,在打包构建时会区分出两端的入口文件,并对通用逻辑做整合打包。这些内容也都在上面的图中有所体现。

#非同构 SSR 渲染

如果我们并没有强依赖前端框架,或是我们的项目过于复杂,此时可能要实现同构需要的成本比较大(抽离通用模块、移除环境依赖代码等)。考虑到项目的确需要 SSR 来加速页面可见,此时我们可以针对首屏渲染内容,自行实现 SSR 渲染。

SSR 核心思想前面也讲过好几遍了,因此要做的事情也比较明确:根据不同的路由,提供对于的页面首屏拼接的能力。由于不强依赖于同构,因此可以直接使用其他语言或是 ejs 来实现首屏 HTML 内容的拼接。

显然,非同构的方案实现 SSR 的成本,比同构的方案成本要高不少,并且还存在代码一致性、可维护性等一系列问题。因此,即使首屏直出的内容无法使用框架同构,大多数情况下,我们也会考虑尽量复用现有的代码,抽离核心的通用代码,并提供 SSR 服务代码编译打包的能力。

举个例子,假设我们的页面完全由 Canvas 进行渲染,显然 Canvas 是无法直出的。但正因为 Canvas 渲染前,需要加载的代码、计算渲染内容等各种流程过长,耗时较多,想要实现 SSR 渲染则可能只能考虑,针对首屏内容做一套 DOM/SVG 渲染用于 SSR。

基于这样的情况下,我们需要尽量复用计算部分的能力,抽离出通用的 Canvas/DOM/SVG 渲染接口,以尽可能实现对接口编程而不是对实现编程。

#SSR 利弊

上面主要围绕 SSR 的实现思想,介绍了开源框架 SSR、同构/非同构等 SSR 方案。

其实除了代码实现的部分以外,一个完整的 SSR 方案,还需要考虑:

  • 代码构建/部署:代码发布流程中,如何确保 SSR 部分代码的有效性,即不会因为非 SSR 部分代码的变更导致 SSR 服务异常
  • 是否使用 Serverless:是否使用 Serverless 来部署 SSR 服务
  • 是否使用缓存:是否可以将 SSR 部分或是最终生成的 HTML 结果进行缓存,节约服务端计算和拼接成本

我们在选择一个技术方案的时候,不能只看它能带来什么收益,同时还需要评估一并带来的风险以及弊端。

对于 SSR 来说,收益是显而易见的,前面也有提到:

  • 实现更快的内容到达时间 (time-to-content)
  • 更好的 SEO

而其弊端也是客观存在的,包括:

  • 服务端资源消耗
  • 方案需要开发成本和维护成本
  • 可能会影响页面最终的完全可交互时间

对于最后一点,有时候也会被我们忽略。因为 SSR 在最开始就提供了首屏完整的 HTML 内容,用户可见时间极大地提前了,我们常常会忘了关注页面所有功能加载完成、页面可交互的时间点。显然,由于浏览器需要在首屏时渲染完整的 HTML 内容,该过程也是需要一定的耗时的,所以后面的其他步骤完成的时间点都会有所延迟。如果首屏 HTML 内容很多/复杂的情况下,这种情况会更明显。

项目管理篇

性能优化通常需要投入不少的人力和成本来完成,因此更多的时候我们可以将其当作是一个项目的方式来进行管理。从项目管理的角度来讲,我们的性能优化工作会拆解为以下部分内容:

  1. 确定优化的目标和预期。
  2. 确定技术方案。
  3. 项目排期和执行。
  4. 进行项目复盘。

#1. 确定优化的目标和预期

性能优化的第一步,就是要确定优化的目标和预期。在给出具体的数据之前,我们首先需要对一些性能数据进行定义,常见包括:

  • 网络资源请求时间
  • Time To Start Render(TTSR):浏览器开始渲染的时间
  • Dom Ready:页面解析完成的时间
  • Time To Interact(TTI)):页面可交互时间
  • Total Blocking Time (TBT):总阻塞时间,代表页面处于不可交互状态的耗时
  • First Input Delay(FID):从用户首次交互,到浏览器响应的时间

要选择合适有效的指标进行定义,比如由于前端框架的出现,Page Load 耗时(window.onload事件触发的时间)已经难以用来作为页面可见时间的关键点,因此可以使用框架提供的生命周期,或者是使用 Largest Contentful Paint (LCP,关键内容加载的时间点)更为合适。

对需要关注的性能数据进行定义完成后,可以对它们进行目标和预期的确定,一般来说有两种方式:

  1. 对比原先数据优化一定比例,比如 TTI 耗时减少 30%。
  2. 通过对竞品进行分析确定目标,比如比竞品耗时减少 20%。

在确定了目标和预期之后,我们便可以根据预期来确定优化的方向、技术方案。

#2. 确定技术方案

根据确定的目标和预期,我们就可以选择合适的优化方案。

为什么不能将前面提到的全部技术方案都做一遍呢?显然这是不合理的。主要原因有两个:

  1. 性价比。项目开发最看重的便是投入产出比,对于不同的项目来说,不同的技术优化方案需要投入人力不一样,很可能需要的投入较多但是优化效果可能不明显。
  2. 不适用,比如有些业务并不具备差异化服务。

举个例子,阿猪的预期目标是客户端内打开应用 TTI 耗时减少 30%,因此他可以选择的优化方案包括:

  1. 对首页数据进行分片/分屏加载。
  2. 首屏仅加载需要的资源,通过异步加载方式加载剩余资源。
  3. 使用服务端直出渲染。
  4. 使用 Tree-shaking 移除代码中无用的部分。
  5. 配合客户端进行资源预请求和预加载,比如使用预热 Web 容器。
  6. 配合客户端将资源和数据进行离线,可用于下一次页面的快速渲染。

其中,5-6 需要客户端小伙伴进行支持,那么阿猪可以根据对方可以投入人力进行配合,来确定这两个优化点是否在本次方案中。

为了达成目标,对合适的技术优化点进行罗列之后,需要对每个优化点进行简单的调研,确定它们的优化效果。比如针对对首页数据进行分屏加载,可以通过简单的模拟测试,对比完整数据的 TTI 耗时,与首屏数据的 TTI 耗时,预估该技术点的优化效果如何。

最后,根据每个优化点的优化效果,以及相应的工作量评估,以预期为目标,选择性价比最优的技术方案。

在技术方案确定后,则需要对工作内容进行排期,并按计划执行。优化完成后,还需要结合目标和预期,对优化效果进行复盘,同时还可以提出未来优化的规划。

#3. 项目排期和执行

这个步骤主要是排期实现,耗时最多。一般来说,需要注意的有两点:

  1. 进行合理的分工排期。
  2. 对项目风险进行把控。

#进行合理的分工排期

进行工作量评估的过程可以分为三步:

  1. 确认技术方案,以及分工合作方式。
  2. 拆分具体功能模块,分别进行工作量评估,输出具体的排期时间表。
  3. 标注资源依赖情况和协作存在的风险,进行延期风险评估。

当我们确认好技术方案之后,可以针对实现细节拆分具体的功能模块,分别进行工作量的预估和分工排期。具体的分工排期在多人协作的时候是必不可少的,否则可能面临分工不明确、接口协议未对齐就匆忙开工、最终因为各种问题而返工这些问题。

进行工作量评估的时候,可以精确到半天的工作量预期。对独自开发的项目来说,同样可以通过拆解功能模块这个过程,来思考具体的实现方式,也能提前发现一些可能存在的问题,并相应地进行规避。

提供完整的工作量评估和排期计划表(精确到具体的日期),可以帮助我们有计划地推进项目。在开发过程中,我们可以及时更新计划的执行情况,团队的其他人也可以了解我们的工作情况。

工作量评估和排期计划表的另外一个重要作用,是通过时间线去严格约束我们的工作效率、及时发现问题,并在项目结束后可针对时间维度进行项目复盘。

#对项目风险进行把控

我们在项目开发过程中,经常会遇到这样的情况:

  • 因为方案设计考虑不周,部分工作需要返工,导致项目延期
  • 在项目进行过程中,常常会遇到依赖资源无法及时给到、依赖方因为种种原因无法按时支援等问题,导致项目无法按计划进行
  • 团队协作方式未对齐,开发过程中出现矛盾,反复的争执和调整协作方式导致项目延期

一个项目能按照预期计划进行,技术方案设计、分工和协作方式、依赖资源是否确定等,任何一各环节出现问题都可能导致整体的计划出现延误,这是我们不想出现的结果。

因此,我们需要主动把控各个环节的情况,及时推动和解决出现的一些多方协作的问题。

#4. 进行项目复盘

很多开发习惯了当代码开发完成、发布上线之后就结束了这个项目,其实他们遗漏了一个很重要的环节:复盘。

我换过好多个团队,发现大多数团队和个人,都没有养成复盘的习惯。复盘是一个特别好的习惯,对于我们个人的成长也好,项目的优化和发展也好,都有很好的作用。

当然,也有一些人会把复盘当做背锅和甩锅,这是不对的。当我们在项目过程中,常常因为有 Deadline 而不断地赶节奏,大多数情况下都只能发现一个问题解决一个问题。而在项目结束之后,我们才可以跳出项目,做更加广视角下的回顾和思考。

有效的复盘,可以达到以下的效果:

  1. 及时发现自己的问题并改进,避免掉进同一个坑。
  2. 让团队成员知道每个人都在做什么,团队管理不混乱。
  3. 整理沉淀和分享项目经验,让整个团队都得到成长。

对于大多数开发来说,很多时候都不屑于主动邀功,觉得自己做了些什么老板肯定都看在眼里,写什么总结和复盘都是刷存在感的表现。实际上老板们每天的事情很多,根本没法关注到每一个人,我以前也曾经跟老板们问过这样一个问题:做和说到底哪个重要?

答案是两个都重要。把一件事做好是必须的,但将这件事分享出来,可以同样给团队带来更多的成长。

通过对项目进行复盘,除了可以让团队其他人和老板知道我们做了些什么,更重要的是,我们可以及时发现自身的一些问题并改进。

项目复盘最好可以结合数据来说话,性能优化的工作可以用具体的耗时和 CPU 资源占用这些指标来做总结,工具的开发可以用接入使用的用户数量来说明效果。甚至是普普通通的项目上线,也都可以使用对比排期和实际开发,复盘各个环节的耗时和质量。

卡顿监控篇

卡顿大概是前端遇到的问题的最棘手的一个,尤其是卡顿产生的时候常常无法进行其他操作,甚至控制台也打开不了。

但是这活落到了咱们头上,老板说啥就得做啥。能本地复现的我们还能打开控制台,打个断点或者录制 Performance 来看看到底哪些地方占用较大的耗时。如果没法本地复现呢?

#卡顿检测

首先,我们来看看可以怎么主动检测卡顿的出现。

卡顿,顾名思义则是代码执行产生长耗时,导致浏览器无法及时响应用户的操作。那么,我们可以基于不同的方案,来监测当前页面响应的延迟。

#Worker 心跳方案

对应浏览器来说,由于 JavaScript 是单线程的设计,当卡顿发生的时候,往往是由于 JavaScript 在执行过长的逻辑,常见于大量数据的遍历操作,甚至是进入死循环。

利用这个特效,我们可以在页面打开的时候,就启动一个 Worker 线程,使用心跳的方式与主线程进行同步。假设我们希望能监测 1s 以上的卡顿,我们可以设置主线程每间隔 1s 向 Worker 发送心跳消息。(当然,线程通讯本身需要一些耗时,且 JavaScript 的计时器也未必是准时的,因此心跳需要给予一定的冗余范围)

由于页面发生卡顿的时候,主线程往往是忙碌状态,我们可以通过 Worker 里丢失心跳的时候进行上报,就能及时发现卡顿的产生。

但是其实 Worker 更多时候用于检测网页崩溃,用来检测卡顿的效果其实还不如使用window.requestAnimationFrame,因为线程通信的耗时和延迟导致该方案不大准确。

#window.requestAnimationFrame 方案

前面前端性能优化--卡顿篇有简单提到一些卡顿的检测方案,市面上大多数的方案也是基于window.requestAnimationFrame方法来检测是否有卡顿出现。

window.requestAnimationFrame()会在浏览器下次重绘之前调用,常常用来更新动画。这是因为setTimeout/setInterval计时器只能保证将回调添加至浏览器的回调队列(宏任务)的时间,不能保证回调队列的运行时间,因此使用window.requestAnimationFrame会更合适。

通常来说,大多数电脑显示器的刷新频率是 60Hz,也就是说每秒钟window.requestAnimationFrame会被执行 60 次。因此可以使用window.requestAnimationFrame来监控卡顿,具体的方案会依赖于我们项目的要求。

比如,有些人会认为连续出现 3 个低于 20 的 FPS 即可认为网页存在卡顿 (opens new window),这种情况下我们则针对这个数值进行上报。

除此之外,假设我们认为页面中存在超过特定时间(比如 1s)的长耗时任务即存在明显卡顿,则我们可以判断两次window.requestAnimationFrame执行间超过一定时间,则发生了卡顿。

使用window.requestAnimationFrame监测卡顿需要注意的是,他是一个被十分频繁执行的代码,不应该处理过多的逻辑。

#Long Tasks API 方案

熟悉前端性能优化的开发都知道,阻塞主线程达 50 毫秒或以上的任务会导致以下问题:

  • 可交互时间(TTI)延迟
  • 严重不稳定的交互行为 (轻击、单击、滚动、滚轮等) 延迟
  • 严重不稳定的事件回调延迟
  • 紊乱的动画和滚动

因此,W3C 推出 Long Tasks API (opens new window)。长任务(Long task)定义了任何连续不间断的且主 UI 线程繁忙 50 毫秒及以上的时间区间。比如以下常规场景:

  • 长耗时的事件回调
  • 代价高昂的回流和其他重绘
  • 浏览器在超过 50 毫秒的事件循环的相邻循环之间所做的工作

参考 Long Tasks API -- MDN(opens new window)

我们可以使用PerformanceObserver这样简单地获取到长任务:

var observer = new PerformanceObserver(function (list) {
  var perfEntries = list.getEntries();
  for (var i = 0; i < perfEntries.length; i++) {
    // 分析和上报关键卡顿信息
  }
});
// 注册长任务的观察
observer.observe({ entryTypes: ["longtask"] });

相比requestAnimationFrame,使用 Long Tasks API 可避免调用过于频繁的问题,并且performance timeline的任务优先级较低,会尽可能在空闲时进行,可避免影响页面其他任务的执行。但需要注意的是,该 API 还处于实验性阶段,兼容性还有待完善,而我们卡顿常常发生在版本较落后、性能较差的机器上,因此兜底方案也是十分需要的。

#PerformanceObserver 卡顿检测

前面也提到,卡顿产生于用户操作后网页无法及时响应。根据这个原理,我们可以使用PerformanceObserver监听用户操作,检测是否产生卡顿:

new PerformanceObserver((list) => {
  list.getEntries().forEach((entry) => {
    const duration = entry.duration;

    const delay = entry.processingStart - entry.startTime;
    const eventHandlerTime = entry.processingEnd - entry.processingStart;

    console.log(`Total duration: ${duration}`);
    console.log(`Event delay: ${delay}`);
    console.log(`Event handler duration: ${eventHandlerTime}`);
  });
}).observe({ type: "event" });

这种方式的好处是避免频繁在requestAnimationFrame中执行任务,这也是官方鼓励开发者使用的方式,它避免了轮询,且被设计为低优先级任务,甚至可以从缓存中取出过往数据。

但该方式仅能发现卡顿,至于具体的定位还是得配合埋点和心跳进行会更有效。

#卡顿埋点上报

不管是哪种卡顿监控方式,我们使用检测卡顿的方案发现了卡顿之后,需要将卡顿进行上报才能及时发现问题。但如果我们仅仅上报了卡顿的发生,是不足以定位和解决问题的。

#卡顿打点

那么,我们可以通过打点的方式来大概获取卡顿发生的位置。

举个例子,假设我们一个网页中,关键的点和容易产生长耗时的操作包括:

  1. 加载数据。
  2. 计算。
  3. 渲染。
  4. 批量操作。
  5. 数据提交。

那么,我们可以在这些操作的地方进行打点。假设我们卡顿工具的能力主要有两个:

interface IJank {
  _jankLogs: Array<IJankLogInfo & { logTime: number }>;
  // 打点
  log(jankLogInfo: IJankLogInfo): void;
  // 心跳
  _heartbeat(): void;
}

那么,当我们在页面加载的时候分别进行打点,我们的堆栈可能是这样的:

_jankLogs = [
  {
    module: "数据层",
    action: "加载数据",
    logTime: xxxxx,
  },
  {
    module: "渲染层",
    action: "计算",
    logTime: xxxxx,
  },
  {
    module: "渲染层",
    action: "渲染",
    logTime: xxxxx,
  },
  {
    module: "数据层",
    action: "批量操作计算",
    logTime: xxxxx,
  },
  {
    module: "数据层",
    action: "数据提交",
    logTime: xxxxx,
  },
];

当卡顿心跳发现卡顿产生时,我们可以拿到堆栈的数据,比如当用户在批量操作之后发生卡顿,假设此时我们拿到堆栈:

_jankLogs = [
  {
    module: "数据层",
    action: "加载数据",
    logTime: xxxxx,
  },
  {
    module: "渲染层",
    action: "计算",
    logTime: xxxxx,
  },
  {
    module: "渲染层",
    action: "渲染",
    logTime: xxxxx,
  },
  {
    module: "数据层",
    action: "批量操作计算",
    logTime: xxxxx,
  },
];

这意味着卡顿发生时,最后一次操作是数据层--批量操作计算,则我们可以认为是该操作产生了卡顿。

我们可以将module/action以及具体的卡顿耗时一起上报,这样就方便我们监控用户的大盘卡顿数据了,也较容易地定位到具体卡顿产生的位置。

#心跳打点

当然,上述方案如果能达到最优效果,则我们需要在代码中关键的位置进行打点,常见的比如数据加载、计算、事件触发、JavaScript 加载等。

我们可以将打点方法做成装饰器,自动给class中的方法进行打点。如果埋点数据过少,可能会产生误报,那么我们可以增加心跳的打点:

IJank._heartbeat = () => {
  IJank.log({
    module: "Jank",
    action: "heartbeat",
    logTime: xxxxx,
  });
};

当我们心跳产生的时候,会更新堆栈数据。假设发生卡顿的时候,我们拿到这样的堆栈信息:

_jankLogs = [
  {
    module: "数据层",
    action: "加载数据",
    logTime: xxxxx,
  },
  {
    module: "Jank",
    action: "heartbeat",
    logTime: xxxxx,
  },
  {
    module: "Jank",
    action: "heartbeat",
    logTime: xxxxx,
  },
  {
    module: "渲染层",
    action: "计算",
    logTime: xxxxx,
  },
  {
    module: "Jank",
    action: "heartbeat",
    logTime: xxxxx,
  },
  {
    module: "渲染层",
    action: "渲染",
    logTime: xxxxx,
  },
  {
    module: "Jank",
    action: "heartbeat",
    logTime: xxxxx,
  },
  {
    module: "数据层",
    action: "批量操作计算",
    logTime: xxxxx,
  },
  {
    module: "Jank",
    action: "heartbeat",
    logTime: xxxxx,
  },
];

显然,卡顿发生时最后一次打点为Jank--heartbeat,这意味着卡顿并不是产生于数据层---批量操作计算,而是产生于该逻辑后的一个不知名逻辑。在这种情况下,我们可能还需要再在可疑的地方增加打点,再继续观察。

#JavaScript 加载打点

有一个用于监控一些懒加载的 JavaScript 代码的小技巧,我们可以使用PerformanceObserver获取到 JavaScript 代码资源拉取回来后的时机,然后进行打点:

performanceObserver = new PerformanceObserver((resource) => {
  const entries = resource.getEntries();

  entries.forEach((entry: PerformanceResourceTiming) => {
    // 获取 JavaScript 资源
    if (entry.initiatorType !== "script") return;

    // 打点
    this.log({
      moduleValue: "compileScript",
      actionValue: entry.name,
    });
  });
});

// 监测 resource 资源
performanceObserver.observe({ entryTypes: ["resource"] });

当卡顿产生时,堆栈的最后一个日志如果为compileScript--bundle_xxxx之类的,则可以认为该 JavaScript 资源在加载的时候耗时较久,导致卡顿的产生。

通过这样的方式,我们可以有效监控用户卡顿的发生,以及卡顿产生较多的逻辑,然后进行相应的问题定位和优化。

前端性能分析工具篇

前端性能分析工具(Chrome DevTools)

一般来说,前端的性能分析通常可以从时间和空间两个角度来进行:

  • 时间:常见耗时,如页面加载耗时、渲染耗时、网络耗时、脚本执行耗时等
  • 空间:资源占用,包括 CPU 占用、内存占用、本地缓存占用等

那么,下面来看看有哪些常见的工具可以借来用用。由于我们的网页基本上跑在浏览器中,所以基本上大多数的工具都来源于浏览器自身提供,首当其冲的当然是 Chrome DevTools (opens new window)。本文我们也主要围绕 Chrome DevTools 来进行说明。

#Lighthouse

Lighthouse (opens new window)的前身是 Chrome DevTools 面板中的 Audits。在 Chrome 60 之前的版本中, 这个面板只包含网络使用率和页面性能两个测量类别,从 Chrome 60 版本开始, Audits 面板已经被 Lighthouse 的集成版取代。而在最新版本的 Chrome 中,则需要单独安装 Lighthouse 拓展程序来使用,也可以通过脚本来使用。

#架构

图片

下面是 Lighthouse 的组成部分:

  • 驱动(Driver):和 Chrome Debugging Protocol (opens new window)进行交互的接口
  • 收集器(Gatherers):使用驱动程序收集页面的信息,收集器的输出结果被称为 Artifact
  • 审查器(Audits):将 Artifact 作为输入,审查器会对其运行测试,然后分配通过/失败/得分的结果
  • 报告(Report):将审查的结果分组到面向用户的报告中(如最佳实践),对该部分应用加权和总体然后得出评分

#主要功能

Lighthouse 会在一系列的测试下运行网页,比如不同尺寸的设备和不同的网络速度。它还会检查页面对辅助功能指南的一致性,例如颜色对比度和 ARIA 最佳实践。

在比较短的时间内,Lighthouse 可以给出这样一份报告(可将报告生成为 JSON 或 HTML):

图片

这份报告从 5 个方面来分析页面: 性能、辅助功能、最佳实践、搜索引擎优化和 PWA。像性能方面,会给出一些常见的耗时统计。除此以外,还会给到一些详细的优化方向。

如果你希望短时间内对你的网站进行较全面的评估,可以使用 Lighthouse 来跑一下分数,确定大致的优化方向。

#Performance 面板

Performance (opens new window)面板同样有个前身,叫 Timeline (opens new window)。该面板用于记录和分析运行时性能,运行时性能是页面运行时(而不是加载)的性能。

#使用步骤

Performance 面板功能特别多,具体的分析也可以单独讲一篇了。这里我们简单说一下使用的步骤:

  1. 在隐身模式下打开 Chrome。隐身模式可确保 Chrome 以干净状态运行,例如浏览器的扩展可能会在性能评估中产生影响。
  2. 在 DevTools 中,单击“Performance”选项卡,并进行一些基础配置(更多参考官方说明 (opens new window))。
  3. 按照提示单击记录,开始记录。进行完相应的操作之后,点击停止。
  4. 当页面运行时,DevTools 捕获性能指标。停止记录后,DevTools 处理数据,然后在 Performance 面板上显示结果。

#主要功能

关于 Performance 怎么使用的文章特别多,大家网上随便搜一下就能搜到。一般来说,主要使用以下功能:

图片

  • 查看 FPS 图表:当在 FPS 上方看到红色条形时,表示帧速率下降得太低,以至于可能损害用户体验。通常,绿色条越高,FPS 越高
  • 查看 CPU 图表:CPU 图表在 FPS 图表下方。CPU 图表的颜色对应于性能板的底部的 Summary 选项卡
  • 查看 火焰图:火焰图直观地表示出了内部的 CPU 分析,横轴是时间,纵轴是调用指针,调用栈最顶端的函数在最下方。启用 JS 分析器后,火焰图会显示调用的每个 JavaScript 函数,可用于分析具体函数
  • 查看 Buttom-up:此视图可以看到某些函数对性能影响最大,并能够检查这些函数的调用路径

具体要怎么定位某些性能瓶颈,可以参考官方文档系列文章 (opens new window),这里就不详细介绍啦。

#Performance Monitor

打开 Chrome 控制台后,按组合键ctrl + p(Mac 快捷键为command + p),输入> Show Performance Monitor,就可以打开 Performance Monitor 性能监视器。主要的监控指标包括:

  • CPU usage:CPU 占用率
  • JS head size:JS 内存使用大小
  • DOM Nodes:内存中挂载的 DOM 节点个数
  • JS event listeners:事件监听数
  • ...:其他等等

大多数情况下,我们在进行性能优化的时候,使用上面一些工具也足以确定大致的优化方向。更多的细节和案例,就不在这里详述了。

#前端性能监控

除了具体的性能分析和定位,我们也经常需要对业务进行性能监控。前端性能监控包括两种方式:合成监控(Synthetic Monitoring,SYN)、真实用户监控(Real User Monitoring,RUM)。

#合成监控

合成监控就是在一个模拟场景里,去提交一个需要做性能审计的页面,通过一系列的工具、规则去运行你的页面,提取一些性能指标,得出一个审计报告。例如上面介绍的 Lighthouse 就是合成监控。

合成监控的使用场景不多,一般可能出现在开发和测试的过程中,例如结合流水线跑性能报告、定位性能问题时本地跑的一些简单任务分析等。该方式的优点显而易见:

  • 可采集更丰富的数据指标,例如结合 Chrome Debugging Protocol (opens new window)获取到的数据
  • 较成熟的解决方案和工具,实现成本低
  • 不影响真实用户的性能体验

#真实用户监控

真实用户监控,就是用户在我们的页面上访问,访问之后就会产生各种各样的性能指标。我们在用户访问结束的时候,把这些性能指标上传到我们的日志服务器上,进行数据的提取清洗加工,最后在我们的监控平台上进行展示的一个过程。

我们提及前端监控的时候,大多数都包括了真实用户监控。常见的一些性能监控包括加载耗时、DOM 渲染耗时、接口耗时统计等,而对于页面加载过程,可以看到它被定义成了很多个阶段:

图片

而我们要做的,则是在力所能及的地方进行打点、计算、采集、上报,该过程常常需要借助 Performance Timeline API。将需要的数据发送到服务端,然后再对这些数据进行处理,最终通过可视化等方式进行监控。因此,真实用户监控往往需要结合业务本身的前后端架构设计来建设,其优点也比较容易理解:

  • 完全还原真实场景,减去模拟成本
  • 数据样本足够抹平个体的差异
  • 采集数据可用于更多场景的分析和优化

对比合成监控,真实用户监控在有些场景下无法拿到更多的性能分析数据(例如具体哪里 CPU 占用、内存占用高),因此更多情况下作为优化效果来参考。这些情况下,具体的分析和定位可能还是得依赖合成监控。

但真实用户监控也有自身的优势,例如 TCP、DNS 连接耗时过高,在各种环境下的一些运行耗时问题,合成监控是很难发现的。

#性能分析自动化

我们在开发过程中,也常常需要进行性能分析。而前端的性能分析上手成本也不低,除了基本的页面加载耗时、网络耗时,更具体的定位往往需要结合前面介绍的 Performance 面板、FPS、CPU、火焰图等一点点来分析。

如果这一块想要往自动化方向发展,我们可以怎么做呢?

#使用 Lighthouse

前面也有介绍 Lighthouse,它提供了脚本的方式使用。因此,我们可以通过自动化任务跑脚本的方式,使用 Lighthouse 跑分析报告,通过对比以往的数据来进行功能变更、性能优化等场景的性能回归。

使用 Lighthouse 的优势在于开发成本低,只需要按照官方提供的配置 (opens new window)来调整、获取自己需要的一些数据,就可以快速接入较全面的 Lighthouse 拥有的性能分析能力。

不过由于 Lighthouse 同样基于 CDP(Chrome DevTools Protocol),因此除了实现成本降低了,CDP 缺失的一些能力它也一样会缺失。

#Chrome DevTools Protocol

Chrome DevTools Protocol (opens new window)允许第三方对基于 Chrome 的 Web 应用程序进行检测、检查、调试、分析等。有了这个协议,我们就可以自己开发工具获取 Chrome 的数据了。

#认识 Chrome DevTools 协议

Chrome DevTools 协议基于 WebSocket,利用 WebSocket 建立连接 DevTools 和浏览器内核的快速数据通道。

我们使用的 Chrome DevTools 其实也是一个 Web 应用。我们使用 DevTools 的时候,浏览器内核 Chromium 本身会作为一个服务端,我们看到的浏览器调试工具界面,通过 Websocket 和 Chromium 进行通信。建立过程如下:

  1. DevTools 将作为 Web 应用程序,Chromium 作为服务端提供连接。
  2. 通过 HTTP 提取 HTML、JavaScript 和 CSS 文件。
  3. 资源加载后,DevTools 会建立与浏览器的 Websocket 连接,并开始交换 JSON 消息。

同样的,当我们通过 DevTools 从 Windows、Mac 或 Linux 计算机远程调试 Android 设备上的实时内容时,使用的也是该协议。当 Chromium 以一个--remote-debugging-port=0标志启动时,它将启动 Chrome DevTools 协议服务器。

#Chrome DevTools 协议域划分

Chrome DevTools协议具有与浏览器的许多不同部分(例如页面、Service Worker 和扩展程序)进行交互的 API。该协议把不同的操作划分为了不同的域(domain),每个域负责不同的功能模块。比如DOMDebuggerNetworkConsolePerformance等,可以理解为 DevTools 中的不同功能模块。

使用该协议我们可以:

  • 获取 JS 的 Runtime 数据,常用的如window.performancewindow.chrome.loadTimes()
  • 获取NetworkPerformance数据,进行自动性能分析
  • 使用 Puppeteer (opens new window)的 CDPSession (opens new window),与浏览器的协议通信会变得更加简单

#与性能相关的域

本文讲性能分析相关,因此这里我们只关注和性能相关的域。

  1. Performance。 从Performance域中Performance.getMetrics()可以拿到获取运行时性能指标包括:
  • Timestamp: 采取度量样本的时间戳
  • Documents: 页面中的文档数
  • Frames: 页面中的帧数
  • JSEventListeners: 页面中的事件数
  • Nodes: 页面中的 DOM 节点数
  • LayoutCount: 全部或部分页面布局的总数
  • RecalcStyleCount: 页面样式重新计算的总数
  • LayoutDuration: 所有页面布局的合并持续时间
  • RecalcStyleDuration: 所有页面样式重新计算的总持续时间
  • ScriptDuration: JavaScript 执行的持续时间
  • TaskDuration: 浏览器执行的所有任务的合并持续时间
  • JSHeapUsedSize: 使用的 JavaScript 栈大小
  • JSHeapTotalSize: JavaScript 栈总大小
  1. Tracing。 Tracing域可获取页面加载的 DevTools 性能跟踪。可以使用Tracing.startTracing.stop创建可在 Chrome DevTools 或时间轴查看器中打开的跟踪文件。

我们能看到生成的 JSON 文件长这样: 图片

这样的 JSON 文件,我们可以丢到 DevTools Timeline Viewer (opens new window)中,可以看到对应的时间轴和火焰图:

图片

  1. Runtime。 Runtime域通过远程评估和镜像对象暴露 JavaScript 的运行时。可以通过Runtime.getHeapUsage获取 JavaScript 栈的使用情况,通过Runtime.evaluate计算全局对象的表达式,通过Runtime.queryObjects迭代 JavaScript 栈并查找具有给定原型的所有对象(可用于计算原型链中某处具有相同原型的所有对象,衡量 JavaScript 内存泄漏)。

除了上面介绍的这些,还有Network可以分析网络相关的性能,以及其他可能涉及 DOM 节点、JS 执行等各种各样的数据分析,更多的可能需要大家自己去研究了。

#自动化性能分析

通过使用 Chrome DevTools 协议,我们可以获取 DevTools 提供的很多数据,包括网络数据、性能数据、运行时数据。

对于如何使用该协议,其实已经有很多大神针对这个协议封装出不同语言的库,包括 Node.js、Python、Java等,可以根据需要在 awesome-chrome-devtools (opens new window)这个项目中找到。

至于我们到底能拿到怎样的数据,可以做到怎样的自动化程度,就不在本文里讲述啦,后面有机会再开篇文章详细讲讲。

之前在研究小伙伴遗留代码的时候,发现了PerformanceObserver这玩意,不看不知道,越看越有意思。

其实这个 API 出了挺久了,机缘巧合下一直没有接触到,直到最近开始深入研究前端性能情况。

#PerformanceObserver

其实单看PerformanceObserver的官方描述 (opens new window),好像没什么特别的:

PerformanceObserver()构造函数使用给定的观察者callback生成一个新的PerformanceObserver对象。当通过observe()方法注册的条目类型的性能条目事件被记录下来时,调用该观察者回调。

乍一看,好像跟我们网页开发和性能数据没什么太大关系。

#常见的性能指标数据获取

在很早的时候,前端开发的性能数据很多都是从Performance (opens new window)里获取:

Performance接口可以获取到当前页面中与性能相关的信息。它是 High Resolution Time API 的一部分,同时也融合了 Performance Timeline API、Navigation Timing API、User Timing API 和 Resource Timing API。

提到页面加载耗时,还是得祭出这张熟悉的图(来自PerformanceNavigationTiming API (opens new window)):

上述图中的数据都可以从window.performance中获取到。

一般来说,我们可以在页面加载的某个结点(比如onload)的时候获取,并进行上报。

但这仅包含页面打开过程的性能数据,而近年来除了网页打开,网页使用过程中的用户体验也逐渐开始被重视了起来。

2024 年 3 月起,INP (Interaction to Next Paint) 将替代 First Input Delay (FID) 加入 Largest Contentful Paint (LCP) 和 Cumulative Layout Shift (CLS),作为三项稳定的核心网页指标。尽管第一印象很重要,但首次互动(FID)不一定代表网页生命周期内的所有互动(INP)。

这意味着我们还需要关注整个网页生命周期内的用户体验,PerformanceObserver的设计正是为了提供用户体验相关性能数据,它鼓励开发人员尽可能使用。

#PerformanceObserver 对象

[PerformanceObserver]{developer.mozilla.org/zh-CN/docs/… 对象为性能监测对象,用于监测性能度量事件,在浏览器的性能时间轴记录新的 performance entry 的时候将会被通知。

研究过前端性能的人,或许还有些对PerformanceObserver不大熟悉(比如我),但是所有大概都知道 Chrome 浏览器的 Performance 性能时间轴:

作为 Performance 面板的老用户,我们常常会从时间轴上捞取出存在性能问题的操作,然后细细分析和研究对应的代码执行情况。而这个时间轴上记录下 performance entry 时,我们可以当通过observe()方法获取到对应的内容和数据。

前面提到,如果我们需要关注网页在整个生命周期中的性能情况,意味着需要定期轮询、埋点等方式做上报。通过使用PerformanceObserver接口,我们可以:

  • 避免轮询时间线来检测新指标
  • 避免新增删除重复数据逻辑来识别新指标
  • 避免与其他可能想要操纵缓冲区的消费者的竞争条件

#PageSpeed Insights (PSI) 前端性能指标

之前给大家讲过前端性能数据指标体系,我们能看到核心网页指标包括 FID、LCP 和 CLS,他们都可以从使用PerformanceObserver直接拿到:

// FID
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    const delay = entry.processingStart - entry.startTime;
    console.log("FID candidate:", delay, entry);
  }
}).observe({ type: "first-input", buffered: true });
// LCP
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    console.log("LCP candidate:", entry.startTime, entry);
  }
}).observe({ type: "largest-contentful-paint", buffered: true });

此外,web-vitals JavaScript 库 (opens new window)可用来测量真实用户的所有 Web Vitals 指标,其方式准确匹配 Chrome 的测量方式。他提供了 PSI 中的各种指标数据:CLS、FID、LCP、INP、FCP、TTFB,如果你仔细研究它的实现,便是使用PerformanceObserver的能力。

比如,INP 需要监控整个网页生命周期中的交互体验,我们可以看到其实现 (opens new window)基于PerformanceEventTiming的监测实现:

new PerformanceObserver((list) => {
  list.getEntries().forEach((entry) => {
    // Full duration
    const duration = entry.duration;

    // Input delay (before processing event)
    const delay = entry.processingStart - entry.startTime;

    // Synchronous event processing time
    // (between start and end dispatch)
    const eventHandlerTime = entry.processingEnd - entry.processingStart;
    console.log(`Total duration: ${duration}`);
    console.log(`Event delay: ${delay}`);
    console.log(`Event handler duration: ${eventHandlerTime}`);
  });
}).observe({ type: "event" });

Event Timing API中包括的用户交互事件几乎是很全的,但该方式可用于检测用户交互的流畅性,并不能作为出现卡顿时的定位方案。具体卡顿的定位,可参考《前端性能卡顿的监控和定位方案》一文。

#resource observe 获取资源加载时机

《前端性能卡顿的监控和定位方案》这篇文章中,我们还发现一个有意思的使用方式:

new PerformanceObserver((resource) => {
  const entries = resource.getEntries();

  entries.forEach((entry: PerformanceResourceTiming) => {
    // 获取 JavaScript 资源
    if (entry.initiatorType !== "script") return;
    const startTime = new Date().getTime();

    window.requestAnimationFrame(() => {
      // JavaScript 资源加载完成
      const endTime = new Date().getTime();
    });
  });
}).observe({ entryTypes: ["resource"] });

除了使用performanceObserver监测resource资源获取性能数据,我们还可以在回调触发时开始计数,以此计算该 JavaScript 资源加载耗时,从而考虑是否需要对资源进行更合理的分包。

#自定义性能指标

配合PerformanceObserver,我们还可以使用User Timing API (opens new window)进行自定义打点:

// Record the time immediately before running a task.
performance.mark("myTask:start");
await doMyTask();
// Record the time immediately after running a task.
performance.mark("myTask:end");

// Measure the delta between the start and end of the task
performance.measure("myTask", "myTask:start", "myTask:end");

然后使用PerformanceObserver获取相关指标数据:

// 有兼容性,需要处理异常
try {
  const po = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      console.log(entry.toJSON());
    }
  });
  // 监测 measure entry
  po.observe({ type: "measure", buffered: true });
} catch (e) {}

更多的使用方式,可以参考自定义指标 (opens new window)一文。

#参考

常常进行前端性能优化的小伙伴们会发现,实际开发中性能优化总是阶段性的:页面加载很慢/卡顿 -> 性能优化 -> 堆叠需求 -> 加载慢/卡顿 -> 性能优化。

这是因为我们的项目往往也是阶段性的:快速功能开发 -> 出现性能问题 -> 优化性能 -> 快速功能开发。

建立一个完善的性能指标体系,便可以在需求开发阶段发现页面性能的下降,及时进行修复。

#前端性能指标体系

为什么需要进行性能优化呢?这是因为一个快速响应的网页可以有效降低用户访问的跳出率,提升网页的留存率,从而收获更多的用户。参考《经济时报》如何超越核心网页指标阈值,并使跳出率总体提高了 43% (opens new window),这个例子中主要优化了两个指标:Largest Contentful Paint (LCP) 和 Cumulative Layout Shift (CLS)。

除此之外,页面速度是一个重要的搜索引擎排名因素,它影响到你的网页是否能被更多用户访问。

#常见的前端性能指标

我们来看下常见的前端性能指标,由于网页的响应速度往往包含很多方面(页面内容出现、用户可操作、流畅度等等),因此性能数据也由不同角度的指标组成:

这些是 User-centric performance metrics (opens new window)中介绍到的指标,其中 FCP、LCP、FID、INP/TTI 在我们常见的前端开发中会比较经常用到。

最简单的,一般前端应用都会关心以下几个指标:

  1. FCP/LCP,该指标影响内容呈现给用户的体验,对页面跳出率影响最大。
  2. FID/INP,该指标影响用户与网页交互的体验,对功能转化率和网页留存率影响较大。
  3. TTI,该指标也为前端网页常用指标,页面可交互即用户可进行操作了。

除了这些简单的指标外,我们要如何建立起对网页完整的性能指标呢?一套成熟又完善的解决方案为 Google 的 PageSpeed Insights (PSI)  (opens new window)

#PageSpeed Insights (PSI)

PageSpeed Insights (PSI) 是一项免费的 Google 服务,可报告网页在移动设备和桌面设备上的用户体验,并提供关于如何改进网页的建议。

前面在《补齐Web前端性能分析的工具盲点》 (opens new window)一文中,我们简单介绍过 Google 的另外一个服务--Lighthouse (opens new window)

PageSpeed Insights 和 Lighthouse 的区别主要为:

特征PageSpeed InsightsLighthouse
如何访问pagespeed.web.dev/ (opens new…(浏览器访问;无需登录)Google Chrome 浏览器扩展 (opens new window)(推荐非开发人员使用) Chrome DevTools(opens new window) Node CLI 工具(opens new window) Lighthouse CI(opens new window)
数据来源Chrome 用户体验报告(真实数据) Lighthouse API(模拟实验室数据)Lighthouse API
评估一次一页一次一页或一次多页
指标核心网络生命、页面速度性能指标(首次内容绘制、速度指数、最大内容绘制、交互时间、总阻塞时间、累积布局偏移)性能(包括页面速度指标)、可访问性、最佳实践、SEO、渐进式 Web 应用程序(如果适用)
建议标有Opportunities and Diagnostics的部分提供了提高页面速度的具体建议。标有Opportunities and Diagnostics的部分提供了提高页面速度的具体建议。堆栈包可用于定制改进建议。

简单来说,PageSpeed Insights 可同时获取实验室性能数据和用户实测数据,而 Lighthouse 则可获取实验室性能数据以及网页整体优化建议(包括但不限于性能建议)。

我们之前提到过 (opens new window),前端性能监控包括两种方式:合成监控(Synthetic Monitoring,SYN)、真实用户监控(Real User Monitoring,RUM)。这两种监控的性能数据,便是分别对应着实验室数据和用户实测数据。

实测数据是通过监控访问网页的所有用户,并针对其中每个用户的各自的体验,衡量一组给定的性能指标来确定的。和实验室数据不同,由于现场数据基于真实用户访问数据,因此它反映了用户的实际设备、网络条件和用户的地理位置。

当然,实测数据也可以由用户真实访问页面时进行上报收集,稍微大一点的前端应用都会这么做。但在此之前,如果你的前端网页没有做数据上报监控,也可以使用 PageSpeed Insights 工具进行简单的测试。但考虑到 PageSpeed Insights 收集的用户皆基于 Chrome 浏览器(CrUX),且需要登录的应用无法有效地获取真实数据,那么自行搭建一套性能指标体系则是最好的。

虽然实际上 PageSpeed Insights 服务并不能解决我们所有的问题,但是我们可以参考它的性能指标,来搭建自己的性能体系呀。

#核心网页指标

参考 Google 的 PageSpeed Insights (opens new window),我们知道 PSI 会报告真实用户在上一个 28 天收集期内的 First Contentful Paint (FCP)、First Input Delay (FID)、Largest Contentful Paint (LCP)、Cumulative Layout Shift (CLS) 和 Interaction to Next Paint (INP) 体验,同时 PSI 还报告了实验性指标首字节时间 (TTFB) 的体验。

其中,核心网页指标包括 FID/INP、LCP 和 CLS。

#FID

First Input Delay (FID) (opens new window)衡量的是从用户首次与网页互动(即,点击链接、点按按钮或使用由 JavaScript 提供支持的自定义控件)到浏览器能够实际开始处理事件处理脚本以响应该互动的时间。

我们可以使用 Event Timing API (opens new window)在 JavaScript 中衡量 FID:

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    const delay = entry.processingStart - entry.startTime;
    console.log('FID candidate:', delay, entry);
  }
}).observe({type: 'first-input', buffered: true});

实际上,从 2024 年 3 月开始,FID 将替换为 Interaction to Next Paint (INP),后面我们会着重介绍。

#LCP

Largest Contentful Paint (LCP) (opens new window)指标会报告视口内可见的最大图片或文本块的呈现时间(相对于用户首次导航到页面的时间)。

我们可以使用 Largest Contentful Paint API (opens new window)在 JavaScript 中测量 LCP:

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    console.log('LCP candidate:', entry.startTime, entry);
  }
}).observe({type: 'largest-contentful-paint', buffered: true});

#CLS

许多网站都面临布局不稳定的问题:DOM 元素由于内容异步加载而发生移动。

Cumulative Layout Shift (CLS) (opens new window)指标便是用来衡量在网页的整个生命周期内发生的每次意外布局偏移的最大突发布局偏移分数。我们可以从Layout Instability方法中获得布局偏移:

addEventListener("load", () => {
    let DCLS = 0;
    new PerformanceObserver((list) => {
        list.getEntries().forEach((entry) => {
            if (entry.hadRecentInput)
                return;  // Ignore shifts after recent input.
            DCLS += entry.value;
        });
    }).observe({type: "layout-shift", buffered: true});
});

布局偏移分数是该移动两个测量的乘积:影响比例和距离比例。

layout shift score = impact fraction * distance fraction

#Interaction to Next Paint (INP)

FID 仅在用户首次与网页互动时报告响应情况。尽管第一印象很重要,但首次互动不一定代表网页生命周期内的所有互动。此外,FID 仅测量首次互动的“输入延迟”部分,即浏览器在开始处理互动之前必须等待的时间(由于主线程繁忙)。

Interaction to Next Paint (INP) (opens new window)用于通过观察用户在访问网页期间发生的所有符合条件的互动的延迟时间,评估网页对用户互动的总体响应情况。

INP 不仅会衡量首次互动,还会考虑所有互动,并报告网页整个生命周期内最慢的互动。此外,INP 不仅会测量延迟部分,还会测量从互动开始,一直到事件处理脚本,再到浏览器能够绘制下一帧的完整时长。因此是 Interaction to Next Paint。这些实现细节使得 INP 能够比 FID 更全面地衡量用户感知的响应能力。

从 2024 年 3 月开始,INP 将替代 FID 加入 Largest Contentful Paint (LCP) 和 Cumulative Layout Shift (CLS),作为三项稳定的核心网页指标。

INP 的计算方法是观察用户与网页进行的所有互动,而互动是指在同一逻辑用户手势触发的一组事件处理脚本。例如,触摸屏设备上的“点按”互动包括多个事件,如pointeruppointerdownclick。互动可由 JavaScript、CSS、内置浏览器控件(例如表单元素)或由以上各项驱动。

我们同样可以使用 Event Timing API (opens new window)在 JavaScript 中衡量 FID:

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    const delay = entry.processingStart - entry.startTime;
  }
}).observe({type: 'event', buffered: true});

关于 INP 的优化,可以参考 Optimize Interaction to Next Paint (opens new window)

#web-vitals JavaScript 库

web-vitals JavaScript 库 (opens new window)使用PerformanceObserver,用于测量真实用户的所有 Web Vitals 指标,其方式准确匹配 Chrome 的测量方式,提供了上述提到的各种指标数据:CLS、FID、LCP、INP、FCP、TTFB。

我们可以使用 web-vitals 库来收集到所需的数据。

#评估体验质量

PSI 根据网页指标计划设置了阈值,将用户体验质量分为三类:良好、需要改进或较差,具体可参考 PageSpeed Insights 简介 (opens new window)

值得注意的是,PSI 报告所有指标的第 75 百分位。

为便于开发者了解其网站上最令人沮丧的用户体验,选择第 75 百分位。通过应用上述相同阈值,这些字段指标值被归类为良好/需要改进/欠佳。

这与我们常见的前端性能指标监控不大一样,因为一般来说大家会取平均值来评估指标。而取 75 百分位这种方式,值得我们去好好思考哪种计算方式更能真实反应用户的体验。

当然,上述 PSI 的性能指标体系,也未必完全适合我们网页使用,我们还可以针对网页的实际情况做出调整。举个例子,网页的 FCP/LCP 虽然十分影响用户的留存,但如果是对于专注服务于老用户、操作频繁、使用时长长的应用来说,网页运行过程中的流畅性更值得关注。

#参考