阅读 2102

浏览器渲染流程和性能优化【万字长文,超详细】

在上一篇文章中,我们讲了网络过程的优化,从一道面试题,构建性能优化知识体系【网络篇】,这篇文章主要讲渲染过程中的优化。

为了更好地理解渲染过程的优化,我们首先需要了解网页的渲染流程。

渲染流程

在了解网页的渲染流程之前,我们先来看看浏览器中的进程与线程。

浏览器中的进程

现代浏览器是多进程架构,页面的加载、渲染和交互是由多个进程配合完成。以 Chrome 为例,其主要的进程架构如下:

image.png

- 浏览器主进程:
负责界面显示、用户交互、子进程管理,同时提供存储等功能。

- 渲染进程:
核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页。
排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中。
默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。
出于安全考虑,渲染进程都是运行在沙箱模式下,无法访问系统资源。

- GPU 进程:
Chrome 刚开始发布的时候是没有 GPU 进程的。
GPU 的使用初衷是为了实现 3D CSS 的效果。
随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。
最后,Chrome 在其多进程架构上也引入了 GPU 进程。

- 网络进程:
主要负责页面的网络资源加载。
之前是作为一个模块运行在浏览器主进程里面的,直至最近才独立出来,成为一个单独的进程。

- 插件进程:
主要是负责插件的运行。
因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。
复制代码

我们用 Chrome 浏览器打开百度和微博两个页面,点击 Chrome 浏览器右上角的三个点的“选项”菜单,选择 更多工具--任务管理器,可以看到如下图的 Chrome 任务管理器窗口:

image.png 现代浏览器是多进程架构有如下优点:

  • 由于进程是相互隔离的,所以当一个页面或者插件崩溃时,影响到的仅仅是当前的页面进程或者插件进程,并不会影响到浏览器和其他页面,提高了浏览器稳定性。

  • Chrome 把插件进程和渲染进程锁在沙箱里面,这样即使在渲染进程或者插件进程里面执行了恶意程序,恶意程序也无法突破沙箱去获取系统权限,提高了浏览器安全性。

  • 此外,多进程还能充分利用多核优势。

当然,内存等资源消耗也会更大,相当于空间换时间。

渲染进程中的线程

在浏览器的所有进程中,与前端开发最相关就是渲染进程,也就是我们常说的浏览器内核。

渲染进程的主要职责是把从网络下载的 HTML、JavaScript、CSS、图片等资源解析为可以显示和交互的页面。在该过程中渲染进程会开启多个线程协作完成,主要的线程以及作用如下:

1. GUI渲染线程

负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。

当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。

2. JS引擎线程

也称为JS内核,负责解析Javascript脚本,运行代码。(例如V8引擎)

JS引擎一直等待着任务队列中任务的到来,然后加以处理,一个渲染进程中无论什么时候都有一个JS线程在运行JS程序。

需要注意的是: GUI渲染线程与JS引擎线程是相互排斥的。因为JS引擎线程在执行的过程中可能会发生回流和重绘,所以GUI渲染线程执行时候,JS引擎线程会被挂起,等待GUI渲染线程执行完毕之后。同理,当JS引擎执行时GUI线程会被挂起,GUI更新会被保存起来等到JS引擎空闲时立即被执行。所以如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染阻塞。

3. 事件触发线程

归属于浏览器内核而不是JS引擎,用来控制事件循环(可以理解,JS引擎自己都忙不过来,需要浏览器另开线程协助)。

当 JS 引擎执行代码块如 setTimeOut 时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX 异步请求等),会将对应任务添加到事件线程中。

当对应的事件符合触发条件被触发时,该线程会把事件回调函数添加到任务队列的队尾,等待JS引擎的处理。

注意,由于JS的单线程关系,所以这些任务队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行)。

4. 定时器触发线程

传说中的setInterval与setTimeout所在线程。

浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)。

因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)

注意,W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。

5. 异步http请求线程

在XMLHttpRequest在连接后是通过浏览器新开一个线程请求。

将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入任务队列中。再由JavaScript引擎执行。

页面渲染流程

按照渲染的时间顺序,流水线可分为如下几个子阶段:构建 DOM 树、样式计算、布局、绘制和合成。

构建 DOM 树

浏览器无法直接理解和使用 HTML 文本,所以需要将 HTML 转换为浏览器能够理解的结构——DOM 树。并且 JavaScript 查询或修改页面结构也是通过 DOM 树。

image.png

构建 DOM 树、样式计算都是由GUI渲染线程控制完成,但为了提高效率,具体的字节流解析工作,GUI渲染线程会另起子线程完成。比如 HTMLParser线程负责将 HTML 文本转换为 DOM 结构;CSSParser线程将 CSS 文本转换为浏览器可以理解的结构——styleSheets。

样式计算

样式计算的目的是为了计算出 DOM 树中每个元素的具体样式。主要有以下几个步骤:

1、把 CSS 转换为浏览器能够理解的结构styleSheets(CSSOM)。该结构同时具备查询和修改功能,这为后面的样式操作提供基础。在控制台中输入 document.styleSheets, 就可以查看其结构。

image.png

2、转换样式表中的属性值,使其标准化。CSS文本中有一些属性值,如 2em、red、bold等,不容易被渲染引擎理解,需要将其为渲染引擎容易理解的、标准化的计算值。em --> px,red --> rgb(255,0,0),bold --> 700.

3、计算出 DOM 树中每个节点的具体样式。在计算过程中需要遵守 CSS 的继承和层叠两个规则。DOM 元素最终的计算样式,可以通过浏览器的Element->Computed可以查看。

image.png

布局

有了 DOM 树及其对应的样式,接下来就是 创建布局树 和 布局计算 。

DOM 树包含了很多不可见的元素,比如head 标签、使用了 display:none 属性的元素。也有一些不存在 DOM 树中但需要显示在页面上的元素,比如伪类。因此,我们需要在 DOM 树的基础上额外构建一棵布局树(Render Tree),只包含需要显示的可见元素。

有了布局树,下一步就是,根据 DOM 元素的文档结构和样式,计算出每个元素在页面中的具体坐标位置、尺寸等信息,并且,又将这些信息保存在布局树中。 image.png CSS采用了盒子模型来表示每个元素与其他元素之间的距离,盒子模型包括外边距(Margin),内边距(Padding),边框(Border),内容(Content)。页面中的每个元素都是一个个盒子,布局阶段会从布局树的根节点开始遍历,然后确定每个元素盒子在页面中的确切大小与坐标位置的绝对像素值。

绘制

分层

浏览器中的页面通常被分成了很多图层,这些图层按照一定顺序叠加在一起,就形成了最终的页面。

为什么需要分层?主要是为了处理页面中的一些复杂的效果,比如 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,这些效果使得与之对应的 DOM 节点尺寸和坐标等不断更新。如果不分层,则需要重新计算整个布局树中每个元素的位置,而分层后,就只需要计算变换层中的元素位置信息。

因此,为了更方便地实现上述效果,渲染引擎为特定的节点生成专用的图层,最后生成一棵图层树(LayerTree)。

查看一个页面有哪些图层,可以打开开发者工具,选择“Layers”标签,左边是图层列表,对应右边的渲染结果中,每一个黑色线框就是一个图层,选中图层后,右下角可以看到图层的详细信息。

image.png

通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。

那么元素要满足什么条件,渲染引擎才会为它创建新的图层呢?答案是:元素拥有层叠上下文属性,或者需要被剪裁。

层叠上下文参考:MDN image.png

裁剪,假如有一个200*200的div元素,其中文字内容超出了div面积(overflow:auto或scroll),就产生了裁剪,渲染引擎会裁剪文字内容的一部分显示在 div 区域,此时,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。这样,滚动内容就不会重新计算整个文档的布局信息。

绘制列表

图层树的构建完成后,渲染引擎会对图层树中的每个图层进行绘制。对于每一个图层,渲染引擎会把对它的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表。打开“开发者工具”的“Layers”标签,选择“document”层,来实际体验下绘制列表:

image.png 在图层绘制阶段,输出的内容就是这些待绘制列表,用于记录绘制顺序和绘制指令。此时,并没有真正的绘制出页面,实际的绘制操作由合成线程来完成。

进行到这一步的一些关键数据结构如下图所示:

image.png

合成

当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程。

前置知识: 浏览器显示页面内容的屏幕区域,叫做视口(viewport)。

一个页面可能很长,需要滚动显示,每次显示在视口中的只是页面的一小部分。对于这种情况,绘制出一整个长页,会产生太大的开销,而且也没必要。

合成线程会将图层划分为图块(这些图块的大小通常是 256x256 或者 512x512),然后按照视口附近的图块来优先生成位图。

实际生成位图的操作是由栅格化线程来执行的。所谓栅格化,是指将图块转换为位图。而图块是栅格化执行的最小单位image.png

通常,栅格化过程都会使用 GPU 来加速生成。GPU 操作是运行在 GPU 进程中,这就涉及到了跨进程操作。

渲染进程把图块生成位图的指令发送给 GPU 进程,然后在 GPU 中生成图块的位图,并保存在 GPU 的内存中。

一旦所有图块都被生成位图,合成线程就会生成一个绘制图块位图的命令—— DrawQuad,然后将该命令提交给浏览器主进程。

浏览器主进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。

到这里,浏览器就会显示出页面的内容了。整个渲染流程如下图所示:

image.png

性能优化

合理使用css选择器

CSS选择器的匹配是从右向左进行的,这一策略导致了不同的选择器之间的性能也存在差异。

相比于.content-title-span,使用.content .title span时,浏览器计算样式所要花费的时间更多。使用后面一种规则,浏览器必须遍历页面上所有 span 元素,先过滤掉祖先元素不是.title的,再过滤掉.title的祖先不是.content的。嵌套的层级更多,匹配所要花费的时间代价自然更高。

因此,在日常开发中,合理使用css选择器,也是优化页面的一个方面。

  • 避免使用通配符,只对需要用到的元素进行选择。

  • 关注可以通过继承实现的属性,避免重复匹配重复定义。

  • 减少嵌套。后代选择器的开销是最高的,因此我们应该尽量将选择器的深度降到最低(最好不要超过三层),可以使用BEM风格的class来关联元素。

  • 减少用标签选择器和属性选择器。

  • 不要使用class选择器和id选择器修饰元素标签,如h3.title,这样多此一举,还会降低效率,直接使用.title

现代浏览器在这一方面做了很多优化,不同选择器的性能差别不是特别明显,不过知道总比不知道强。

减少 DOM 操作

JS 引擎和渲染引擎是两个独立的线程。当我们用 JS 去操作 DOM 时,本质上是 JS 引擎和渲染引擎之间进行了“跨界交流”,交流依赖了桥接接口作为“桥梁”。 image.png 我们每操作一次 DOM(不管是为了修改还是仅仅为了访问其值),都要过一次桥,每次过桥都有一定开销。因此,过桥的次数一多,就会产生比较明显的性能问题。

所以,使用js修改页面时,应尽量减少 DOM 操作。

<body>
  <div id="container"></div>
</body>
复制代码

假如,要在 container 元素里写 10000 句一样的话。

for(var count=0;count<10000;count++){ 
  document.getElementById('container').innerHTML+='<span>我是一个小测试</span>'
} 
复制代码

上面这样写有两个明显的可优化点:

  • 缓存访问过的元素。

我们每一次循环都调用 DOM 接口重新获取了一次 container 元素,相当于每次循环都有过桥开销。其实,可以通过缓存访问过的元素,来减少后面9999次调用 DOM 接口的操作。

// 只获取一次container,并缓存
let container = document.getElementById('container')
for(let count=0;count<10000;count++){ 
  container.innerHTML += '<span>我是一个小测试</span>'
} 
复制代码
  • 避免频繁修改DOM,尽量一次更新。

上面虽然缓存了访问元素,但在每一次循环里都修改了 DOM 。对 DOM 的修改会引发渲染树的改变,进而导致回流。我们可以将对 DOM 的修改累计起来,然后一次性地应用到 DOM 上。

// 只获取一次container
let container = document.getElementById('container')
let content = ''
for(let count=0;count<10000;count++){ 
  // 累计对DOM的修改操作
  content += '<span>我是一个小测试</span>'
} 
// 将累计的修改操作一次性地应用到 DOM
container.innerHTML = content
复制代码

对于页面中多个元素节点的修改,可以借助 DocumentFragment 完成,具体参考MDN

前端框架,Vue、React等,是通过虚拟节点(Virtual DOM)来搜集更新,然后一次性更新 DOM。

此外,日常开发中比较常见的减少DOM操作的方法还有事件委托。假如有一个很长的列表,点击每个列表项的时候需要响应事件;

<ul class="parent">
  <li>1</li>
  <li>2</li>
  ...
</ul>
复制代码

如果给每个列表项一一绑定一个函数,那对于内存消耗是非常大的,效率上需要消耗很多性能;

比较好的方法就是用事件委托,把这个点击事件绑定到他的父层,也就是 ul 上,然后在执行事件的时候再去匹配判断目标元素;

// 给父层元素绑定事件
document.querySelector('.parent').addEventListener('click', (event) => {
  var target = event.target
  // 判断是否匹配目标元素
  if (target.nodeName.toLocaleLowerCase === 'li') {
    console.log(target.innerHTML + ' clicked');
  }
});
复制代码

事件委托的好处:

1、只绑定一个事件函数,可以减少大量的内存消耗,并且减少了dom操作,提高性能。

2、动态元素绑定事件。通过 AJAX 或者 用户交互 动态的增加或者去除li元素,、在每一次改变的时候都需要重新给新增的元素绑定事件,给将要删去的元素解绑事件;使用了事件委托可以省去这些麻烦,因为事件是绑定在ul的,和li的增减没有关系。

节流和防抖

当网页交互过程中,有一些操作常常会频繁触发,如滚动页面触发的scroll事件,页面缩放触发resize事件、鼠标移动的mousemove\mouseover事件等。

频繁触发这些事件会导致相应回调函数的大量计算,从而引发页面抖动甚至卡顿,为了控制事件回调的触发频率,就需要用事件节流和事件防抖。

  • 事件节流

简单来说,就是从一个时间点开始,在某段时间,无论触发了多少次回调,我都只认第一次,并在计时结束时给予响应。

以scroll事件为例,当用户滚动页面触发了一次scroll事件后,就为这个触发操作开启一个固定时间的计时器。在这个计时器持续时间内,限制后续发生的所有scroll事件对回调函数的触发,当计时器计时结束后,响应执行第一次触发scroll事件的回调函数。

// interval是时间间隔的阈值, fn是我们需要包装的事件回调
function throttle(interval,fn){
  //上次触发回调的时间
  let lastTime = 0
  
  //事件节流操作的闭包返回
  return (params) => {
  
    //记录本次回调触发的时间
    let now = +new Date()
    
    //判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值,若不小于,则执行回调
    if(now - lastTime >= interval){
      lastTime = now
      fn(params)
    }
  }
}

//通过事件节流优化事件回调函数
const throttle_scroll = throttle(1000, () => console.log('页面滚动'))

//绑定事件
document.addEventListener('scroll', throttle_scroll)
复制代码
  • 事件防抖

只响应最后一次触发事件。在一定时间内,不管事件触发了多少次,都只认最后一次。假设设置时间为 1000ms,第一次触发这个事件时会启动一个定时器,后面每次触发这个事件都会清除已有定时器,并重新设置新的定时器,起点为最近一次触发的这个时间。

所以,如果在1000ms内,你再次触发这个事件,之前的定时器被清除,又从此次触发事件的这一刻开始倒计时。

function debounce(time,fn){

 //设置定时器
 let timer = null
 
 //事件防抖的闭包操作
 return (params) => {
 
   //每次事件被触发时,都去清除之前的旧定时器
   if(timer) clearTimeout(timer)
   
   //设置新的定时器
   timer = setTimeout(() => fn(params), time)
 }
}

//通过事件防抖优化事件回调函数
const debounce_scroll = debounce(1000, () => console.log('页面滚动'))

//绑定事件
document.addEventListener('scroll', debounce_scroll)
复制代码

回流、重绘

回流:当我们对 DOM 的修改引发了 DOM 几何尺寸的变化,比如修改元素的宽、高或隐藏元素等,浏览器需要重新计算元素的几何属性进行布局,然后再将计算的结果绘制出来。这个过程就是回流(也叫重排或页面布局)。

重绘:当 DOM 需要更新属性,而这些属性只是影响其外观,风格,并不会影响布局,比如只修改了颜色或背景色,浏览器不需重新计算元素的几何属性,直接为该元素绘制新的样式。这个过程叫做重绘。

重绘不一定导致回流,回流一定会导致重绘。回流比重绘的代价要更高。那么哪些操作可能触发回流呢?

触发回流的操作

1、改变 DOM 元素的几何属性,这些属性包括 width、height、padding、margin、left、top 等。

2、 改变 DOM 树的结构。节点的增减、移动等操作。

这都可以在写样式的时候显式地通过代码效果看出来。对于上面触发回流的操作,浏览器自身也做了优化。浏览器会维护一个队列,把所有会引起回流、重绘的操作放入这个队列,等队列中的操作达到一个阈值或者到了一定的时间间隔,浏览器就会flush队列,进行一个批处理。这样就会让多次的回流/重绘变成一次回流/重绘。但是当你获取某些特定地属性值或者调用某些方法时,浏览器会立刻清空队列。

3、获取下面这些属性值或者调用方法。这些属性和方法有一个共性,就是需要通过即时计算得到,所以浏览器需要立刻重新进行布局计算。

offsetTop、offsetLeft、 offsetWidth、offsetHeight
scrollTop、scrollLeft、scrollWidth、scrollHeight
clientTop、clientLeft、clientWidth、clientHeight
scrollIntoView()、getComputedStyle()、getBoundingClientRect()、scrollTo()
复制代码

优化

对回流、重绘最直观的优化,就是可以减少它的发生次数。

1、避免频繁改变样式。使用class或者cssText合并多次对样式的修改,然后一次更新。

const el = document.querySelector('.test')
el.style.height = '100px'
el.style.width = '200px'
el.style.color = '#333'
复制代码

优化后:

(1)cssText

const el = document.querySelector('.test')
el.style.cssText = "height:100px; width:200px; color: #333;";
复制代码

(2)class

.new-style {
  height:100px;
  width:200px;
  color: #333;
}
复制代码
const el = document.querySelector('.test')
container.classList.add('new-style')
复制代码

2、批量修改DOM。在上面的减少DOM操作中已经写过,这里不再赘述。

3、 避免频繁读取“敏感”属性。上文我们说过,当我们访问元素的一些属性的时候,会导致浏览器强制清空队列。

举个例子,比如说我们想将一组元素的宽度赋值为某一个元素的宽度,我们可能写出这样的代码:

function setWidth() {
  const lis = document.querySelectorAll('li')
  const div = document.querySelector('.test')
  for (let i = 0; i < lis.length; i++) {
     lis[i].style.width = div.offsetWidth + 'px';
  }
}
复制代码

每一次循环的时候,都读取了div的offsetWidth属性值,这就导致了,每一次循环都会强制浏览器刷新队列。作为优化,我们可以将敏感属性通过变量的形式缓存起来。

const width = div.offsetWidth;
function initP() {
  const lis = document.querySelectorAll('li')
  const width = document.querySelector('.test').offsetWidth
  for (let i = 0; i < lis.length; i++) {
     lis[i].style.width = width + 'px';
  }
}
复制代码

分层与合成:动画效果

对于一些复杂的动画效果,比如常见的点击菜单时弹出收回菜单的动画特效,还有一些炫酷的 3D 动画特效,如果没有采用分层机制,会经常引起回流重绘。

关于分层的机制,我们在上面的页面渲染流程中有讲到过。一个网页可以分解很多个图层(分层),每个图层都可以单独地设置大小、位置、透明度、旋转角度等,图层的上下位置可以调整,最后将这些图层叠加在一起后(合成),就能呈现出最终的效果。

在 Chrome 的渲染流水线中,分层体现在生成布局树之后,渲染引擎会根据布局树的特点将其转换为图层树(Layer Tree),合成则是当图层的绘制列表准备好了之后,交由合成线程来完成。

对于需要动画效果的元素,可以将其创建为单独图层,比如使用绝对定位,当它发生改变,不会引起其他元素的频繁回流。

使用css3的一些属性,可以使得动画效果不会引起回流重绘。在了解了渲染流程各部分的功能和作用后,我们知道如果一个动画的实现不经过页面布局和重绘环节,仅在合成阶段就能完成,将会大大提升性能。

符合这一要求的动画属性有:transform和opacity。它们能实现的动画效果:平移、缩放、旋转、倾斜、矩阵变换、透明度。

在使用transform和opacity实现动画效果时,尽量用 will-change 来提前告诉渲染引擎,让它为元素创建独立的层。

.create-layer {
  will-change: transform, opacity;
}
复制代码

参考资料

极客时间:浏览器工作原理与实践

《webkit技术内幕》

掘金小册:前端性能优化原理与实践

CSS的原理,如何解析?

从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理

彻底搞清楚浏览器渲染过程

【春招必备】一名合格的初中级前端工程师需要掌握的浏览器渲染笔记

文章分类
前端