浏览器性能优化实战

作者:rosefang,腾讯 PCG 前端开发工程师

当我们在做性能优化的时候,我们究竟在优化什么?浏览器底层是一个什么架构?浏览器渲染的本质究竟是什么?哪些方面对用户的体验影响才是最大的?有没有业内一些通用的标准或标杆参考?都 1202 年了,雅虎军规还有没有用?性能分析工具都有哪些?我们怎么进行打点分析才是合适的?

本文为你一一讲解这些。了解了这些问题,可能你在做性能优化的时候才能更加得心应手。

1. 性能优化的本质

1.1 展示更快,响应更快

性能优化的目的,就是为了提供给用户更好的体验,这些体验包含这几个方面:展示更快、交互响应快、页面无卡顿情况。

更详细的说,就是指,在用户输入 url 到站点完整把整个页面展示出来的过程中,通过各种优化策略和方法,让页面加载更快;在用户使用过程中,让用户的操作响应更及时,有更好的用户体验。

对于前端工程师来说,要做好性能优化,需要理解浏览器加载和渲染的本质。理解了本质原理,才能更好的去做优化。所以我们先来看看浏览器架构是怎样的。

1.2 理解浏览器多进程架构

从大的方面来说,浏览器是一个多进程架构。

它可以是一个进程包含多个线程,也可以是多个进程中,每个进程有多个线程,线程之间通过 IPC 通讯。每个浏览器有不同的实现细节,并没有标准规定浏览器必须如何去实现。

这里我们只谈论 chrome 架构。

下面这张图是目前 chrome 的多进程架构图。

浏览器性能优化实战_第1张图片 图片引自 Mariko Kosaka 的《Inside look at modern web browser》

我们来看看这些进程分别对应浏览器窗口中的哪一部分:

浏览器性能优化实战_第2张图片 图片引自 Mariko Kosaka 的《Inside look at modern web browser》

那么,怎么看浏览器对应启动了什么进程呢?

chrome 中,我们可以通过更多->More Tools->Task Manager 看到启动的进程。

从 chrome 官网和源码,我们也可以得知,多进程架构中包含这些进程:

  • Browser 进程:打开浏览器后,始终只有一个。该进程有 UI 线程、Network 线程、Storage 线程等。用户输入 url 后,首先是 Browser 进程进行响应和请求服务器获取数据。然后传递给 Renderer 进程。

  • Renderer 进程:每一个 tab 一个,负责 html、css、js 执行的整个过程。前端性能优化也与这个进程有关

  • Plugin 进程:与浏览器插件相关,例如 flash 等。

  • GPU 进程:浏览器共用一个。主要负责把 Renderer 进程中绘制好的 tile 位图作为纹理上传到 GPU,并调用 GPU 相关方法把纹理 draw 到屏幕上。

这里的话只是简单介绍一下浏览器的多进程架构,让大家对浏览器整体架构有个初步认识,其实背后的细节还有很多,这里就不一一展开。有兴趣可以细看这一系列文章和chrome 官网介绍。

1.3 理解页面渲染相关进程

1.3.1 Renderer Process & GPU Process

从以上的多架构,我们了解到,与前端渲染、性能优化相关的,其实主要是 Renderer 进程和 GPU 进程。那么,它们又是什么架构呢?

来看一下这张我们再熟悉不过的图。

浏览器性能优化实战_第3张图片 图片引自 Paul 的 《The Anatomy of a Frame》
  • Renderer 进程:包括 3 个线程。合成线程(Compositor Thread)、主线程(Main Thread)、Compositor Tile Worker。

  • GPU 进程:只有 GPU 线程,负责接收从 Renderer 进程中的 Compositor Thread 传过来的纹理,显示到屏幕上。

1.3.2 Renderer Process 详解

Renderer 进程中 3 个线程的作用为:

  • Compositor Thread:首先接收 vsync 信号(vsync 信号是指操作系统指示浏览器去绘制新的帧),任何事件都会先到达 Compositor 线程。如果主线程没有绑定事件,那么 Compositor 线程将避免进入主线程,并尝试将输入转换为屏幕上的移动。它将更新的图层位置信息作为帧通过 GPU 线程传递给 GPU 进行绘制。

当用户在快速滑动过程中,如果主线程没有绑定事件,Compositor 线程是可以快速响应并绘制的,这是浏览器做的一个优化。

  • Main Thread:主线程就是我们前端工程师熟知的线程,这里会执行解析 Html、样式计算、布局、绘制、合成等动作。所以关于性能的问题,都发生在了这里。所以应该重点关注这里

  • Compositor Tile Worker:由合成线程产生一个或多个 worker 来处理光栅化的工作。

Service Workers 和 Web Workers 可以暂时理解也在 Renderer 进程中,这里不展开讨论。

1.3.2.1 Main Thread
浏览器性能优化实战_第4张图片 main-thread

主线程需要重点讲下。因为这是我们的代码真实存在的环境。

从上一小节 Render 进程和 GPU 进程的图中,我们可以看到有个红色的箭头,从 Recal Styles 和 Layout 指向了 requestAnimationFrame,这意味着有 Forced Synchronous Layout (or Styles)(强制回流和重绘)发生,这一点在性能方面特别要注意。

在 Main Thread 中,有这几个需要注意一下:

  • requestAnimationFrame:因为布局和样式计算是在 rAF 之后,所以在 rAF 是进行元素变更的理想时机。如果在这里对一个元素变更 100 个类,不会进行 100 次计算,它们会分批以后处理。需要注意的是,不能在 rAF 中查询任何计算样式和布局的属性(例如:el.style.backgroundImage 或 el.style.offsetWidth),因为这样会导致重绘和回流。

  • Layout:布局的计算通常是针对整个文档的,并且与 DOM 元素的大小成正比!(这点特别要注意,如果一个页面 DOM 元素太多,也会导致性能问题)

主线程的顺序始终都是:

Input Event Handler->requestAnimationFrame->ParseHtml->ReculateStyles->Layout- >Update Layer Tree->Paint->Composite->commit->requestIdleCallback

只能从前往后,例如,必须先是 ReculateStyles,然后 Layout、然后 Paint。但是,如果它只需要做最后一步 Paint,那么这就是它全部要做的事情,不会再发生前面的 ReculateStyles 和 Layout。

这里其实给了我们一个启示:如果要让 fps 保持 60,即每帧的 js 执行时间少于 16.66ms,那么让这个主线程执行的过程尽可能地少,是我们的性能优化目标

根据主线程的这些步骤,理想的情况下,我们只希望浏览器只发生最后一个步骤:Composite(合成)。

CSS 的属性是我们需要关注一下的模块。这里有描述了哪些CSS 属性会引起重绘、回流和合成。例如,让我们给一个元素进行移动位置时:transformopacity可以直接触发合成,但是lefttop却会触发 Layout、Paint、Composite3 个动作。所以显然用 transform 时更好的方案。

但这并不是说我们不应该用 left 和 top 这些可能引起重绘回流的属性,而是应该关注每个属性在浏览器性能中引起的效果

2. 看看经典:雅虎军规

多年前雅虎的 Nicolas C. Zakas 提出 7 个类别 35 条军规,至今为止很多前端优化准则都是围绕着这个展开。如果严格按照这些规则去做,其实我们有很多优化工作可以做,只要认真践行,性能提升不是问题。

浏览器性能优化实战_第5张图片

我们来看看它 7 个分类都是围绕哪些方面展开:

  • Server:与页面发起请求的相关;

  • Cookie:与页面发起请求相关;

  • Mobile:与页面请求相关;

  • Content:与页面渲染相关;

  • Image:与页面渲染相关;

  • CSS:与页面渲染相关;

  • Javascript:与页面渲染和交互相关。

从上面的描述可以看到,其实雅虎军规,是围绕页面发起请求那一刻,到页面渲染完成,页面开始交互这几个方面来展开,提出的一些原则。

很多原则大家也都耳熟能详,就不全部展开了,有兴趣的同学可以去查看原文。这里主要想提一些忽略但是又值得注意的点:

减少 DOM 节点数量

为什么要减少 DOM 节点的数量?

当遍历查询 500 和 5000 个 DOM 节点,进行事件绑定时,会有所差别。

当一个页面 DOM 节点过多,应该考虑使用无限滚动方案来使视窗节点可控。可以看看google 提的方案。

减少 cookie 大小

cookie 传输会造成带宽浪费,影响响应时间,可以这样做:

消除不必要的 cookies;

静态资源不需要 cookie,可以采用其他的域名,不会主动带上 cookie。

避免图片 src 为空

图片 src 为空时,不同浏览器会有不同的副作用,会重新发起一起请求。

3. 性能指标

3.1 什么样的性能指标才能真正代表用户体验?

要衡量性能,我们必须有一些客观的、可衡量的指标来进行监控。但是客观且定量可衡量的指标不一定能反映用户的真实体验

以前,我们会用 load 事件的触发来衡量一个页面是否加载或显示完成。但是设想会不会有这样的情况:一个页面的 load 事件已经被触发,但是却在 load 事件之后几秒才开始加载内容和渲染页面,所以这个时候,load 事件并不能真实反映用户看到内容的时刻。

在过去几年,google 团队和W3C 性能工作组致力于提供标准的性能 API 来真正衡量用户的体验。主要是从这 4 个方面思考:浏览器性能优化实战_第6张图片

思考点 详细内容
Is it happening? 导航是否成功,服务器是否响应了
Is it useful? 是否已经渲染了足够的内容,让用户可以开始参与其中
Is it usable? 用户是否可以与页面交互,页面是否处于繁忙状态
Is it delightful? 交互是否流畅、自然、没有滞后反映或卡顿

通常有 2 种途径来衡量性能。

  1. 本地实验衡量:本地模拟用户的网络、设备等情况进行测试。通常在开发新功能的时候,实验测量是很重要的,因为我们不知道这个功能发布到线上会有什么性能问题,所以提前进行性能测试,可以进行预防。

  2. 线上衡量:实验测量固然可以反映一些问题,但无法反映在用户那里真实的情况。同样的,在用户那里,性能问题会和用户的设备、网络情况有关,而且还跟用户如何与页面进行交互有关。

有这几个类型与用户感知性能相关。

  • 页面加载时间:页面以多快的速度加载和渲染元素到页面上。

  • 加载后响应时间:页面加载和执行 js 代码后多久能响应用户交互。

  • 运行时响应:页面加载完成后,对用户的交互响应时间。

  • 视觉稳定性:页面元素是否会以用户不期望的方式移动,并干扰用户的交互。

  • 流畅度:过渡和动画是否以一致的帧率渲染,并从一种状态流畅地过渡到另一种状态。

对应上面几种分类,Google 和 W3C 性能工作组提供了对应这几种性能指标:

  • First contentful paint (FCP): 测量页面开始加载到某一块内容显示在页面上的时间。

  • Largest contentful paint (LCP): 测量页面开始加载到最大文本块内容或图片显示在页面中的时间。

  • First input delay (FID): 测量用户首次与网站进行交互(例如点击一个链接、按钮、js 自定义控件)到浏览器真正进行响应的时间。

  • Time to Interactive (TTI): 测量从页面加载到可视化呈现、页面初始化脚本已经加载,并且可以可靠地快速响应用户的时间。

  • Total blocking time (TBT): 测量从 FCP 到 TTI 之间的时间,这个时间内主线程被阻塞无法响应用户输入。

  • Cumulative layout shift (CLS): 测量从页面开始加载到状态变为隐藏过程中,发生不可预期的 layout shifts 的累积分数。

这些指标能从一定程度上衡量页面性能,但不一定都是有效的。举个例子。LCP 指标主要用户衡量页面的主要内容是否完成加载,但会有这样的情况,最大的元素并不是主要内容,那么这个时候 LCP 指标并不是那么重要。

每个不同的站点有自己的特殊性,可以参考以上角度进行衡量,也需要因地制宜。

3.2 Core Web Vitals

在以上列出的指标中,Google 定义了 3 个最核心的指标,作为 Core Web Vitals。它们分别代表着:加载、交互、视觉稳定性。

浏览器性能优化实战_第7张图片 image-20210426192204425
  • Largest Contentful Paint (LCP): 测量加载性能。为了能提供较好的用户体验,LCP 指标建议页面首次加载要在 2.5s 内完成。

  • First Input Delay (FID): 测量交互性能。为了提供较好用户体验,交互时间建议在 100ms 或以内。

  • Cumulative Layout Shift (CLS): 测量视觉稳定性。为了提供较好用户体验,页面应该维持 CLS 在 0.1 或以内。

当页面访问量有 75%的数据达到了以上以上 Good 的标准,则认为性能是不错的了。

Core Web Vitals 是作为核心性能指标,但是其他指标也同样在重要,是做为核心指标的一个辅助。例如,TTFB 和 FCP 都可以用来衡量加载性能(服务器响应时间和渲染时间),它们作为 LCP 的一个问题手段辅助。同样的,TBT 和 TTI 对于衡量交互性能也很重要,是 FID 的一个辅助,但是它们无法在线上进行测量,也无法反映以用户为中心的结果。

Google 官方提供了一个web-vitals库,线上或本地都可以测量上面提到的 3 个指标:

import {getCLS, getFID, getLCP} from 'web-vitals';

function sendToAnalytics(metric) {
  const body = JSON.stringify(metric);
  // Use `navigator.sendBeacon()` if available, falling back to `fetch()`.
  (navigator.sendBeacon && navigator.sendBeacon('/analytics', body)) ||
      fetch('/analytics', {body, method: 'POST', keepalive: true});
}

getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getLCP(sendToAnalytics);

下面,分别讲讲这 3 个指标定义的原因、如何测量、如何优化。

3.2.1 Largest Contentful Paint (LCP)
3.2.1.1 LCP 如何定义
浏览器性能优化实战_第8张图片 图片来自 LCP

LCP 是指页面开始加载到最大文本块内容或图片显示在页面中的时间。那么哪些元素可以被定义为最大元素呢?

  • 标签

  • 在 svg 中的 image 标签

  • video 标签

  • CSS background url()加载的图片

  • 包含内联或文本的块级元素

3.2.1.2 如何测量 LCP

线上测量工具

  • Chrome User Experience Report

  • PageSpeed Insights

  • Search Console (Core Web Vitals report)

  • web-vitals JavaScript library

实验室工具

  • Chrome DevTools

  • Lighthouse

  • WebPageTest

原生的 JS API 测量

LCP 还可以用 JS API 进行测量,主要使用 PerformanceObserver 接口,目前除了 IE 不支持,其他浏览器基本都支持了。

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

我们看一下结果是怎样的:

浏览器性能优化实战_第9张图片 LCP-example

Google 官方 web-vitals 库

Google 官方也提供了一个web-vitals库,底层还是使用这个 API,只是帮我们处理了一些需要测量和不需测量的场景、以及一些细节问题。

3.2.1.3 如何优化 LCP

LCP 可能被这四个因素影响:

  • 服务端响应时间

  • Javascript 和 CSS 引起的渲染卡顿

  • 资源加载时间

  • 客户端渲染

更加详细的优化建议就不展开了,可以参考这里。

3.2.2 First Input Delay (FID)
3.2.2.1 FID 如何定义
浏览器性能优化实战_第10张图片 图片来自 FID

我们都知道第一印象的重要性,比如初次遇到某人形成的印象,会在后续交往中起重要的影响。对于一个网站也是如此。

网站以多快的速度加载完成是其中一项指标,加载后以多快的速度对用户进行响应也同样重要。FID 就是指后者。

可以通过下面的图来更详细了解 FID 处于哪个位置:

浏览器性能优化实战_第11张图片 图片来自 FID

从上图可以看出,当主线程处于繁忙的时候,FID 是指从浏览器接收到了用户输入,到浏览器对用户的输入进行响应的延迟时间。

通常,当我们在写代码的时候,会认为只要用户输入信息,我们的事件回调就会立刻响应,但实际上并不是这样。这是主线程可能处于繁忙,浏览器正忙着解析和执行其他 js。如上图所示的 FID 时间,主线程正在处理其他任务。

当 FID 的时间为 100ms 或以内,则为 Good。

上面的例子中,用户刚好在主线程最繁忙的时刻进行了交互,但是如果用户在主线程空闲的时候交互,那么浏览器可以立刻响应。所以 FID 的值需要重点查看它的分布情况。

FID 实际上测量的是输入事件被感知到到主线程空闲的这段时间。这意味着即使没有输入事件被注册,FID 也可以测量。因为用户的输入相应并不一定需要事件被执行,但一定需要主线程是空闲的。例如,下面这些 HTML 元素都需要在交互响应之前等待主线程上的正在执行的任务完成:

  • 输入框,例如