彻彻底底讲明白浏览器渲染面试细节

1,566 阅读16分钟

引文

这篇文章能给你带来什么

在常规文章中,我们一般会看到DOM数的构建过程,稍微深入一点,还能看到Render树的构建过程。本文却不只是停留在表面,它会深入到浏览器底层的渲染过程,探讨一些有价值,有意义的东西。

SLP

浏览器渲染过程与三个最重要的阶段,简称为SLP

  • S(Sturcture) 构建DOM树结构
  • L(Layout) 排版,确认每个DOM的位置
  • P(Paint) 绘制,绘制每个DOM的具体内容

具体细节

一个元素从加入到呈现经历了什么?

这里只讨论Main Thread的行为,具体什么是Main Thread,下文会提到

这是devtools performance的性能报告,我们可以看到,一个元素从加入DOM到呈现到页面上会经历以下五个步骤:

  1. Recalculate style
  2. Layout (reflow)
  3. Update Layer Tree
  4. Paint (repaint)
  5. Composite Layers
Recalculate Style

这是一个复杂的步骤,浏览器如何把CSS样式表转化为可以认识的对象呢?

一个哲学问题:css文本 -------------> stylesheet

  1. 标准化css文本

    css存在于很多地方:

    • link
    • inline
    • style

    css存在很多写法:

#test {
	font: 2em;
}

#test {
	font: 32px;
}

​ 浏览器做的第一步就是标准化css文本,当然这个过程很简单,无非就是一些细节处理与转换,我们重点不是 这里。

  1. 如何形成CSSOM tree

    最终会形成这样的CSSOM tree

我们来分析一下:

p标签继承了bodyfont-size属性,并且自己拥有一个color属性,div同样如此,只不过它还多拥有一个font-weight属性。如果div有自己的font-size标签,那它就会覆盖body标签的font-size,这就是层叠上下文。

当然,分析它的行为没有丝毫用处,我们应该知道CSSOM tree是怎么绘制出来的。

简单的来说,递归遍历节点并且跟随css文本动态构建

如何根据css文本把样式放在dom节点上是个难题,因此,我们引出一个概念

选择器 CRP

CRP(关键渲染路径)意味着很多方面,我们这里只讲关于选择器的

考虑以下情况

p {
  color:red;
}

div p {
  color:blue;
}

谁会更快呢?

  • 第一种情况:在找到p节点时,直接附着样式
  • 第二种情况:在找到p节点时,向上遍历是否有div作为父级

所以,不要再滥用一些选择器。

​ 至此,CSSOM树绘制完毕。

update layer tree

这一个操作主要是dom之间更新层叠上下文的关系

Paint

Paint分为两个过程:

  1. 记录重绘的信息
  2. 重绘到页面

第二步实际是后续我们提到的rasterize(栅格化)的过程,我们提到的重绘只是第一步

Composite Layers

这是后续我们讨论的重点,所以这里只做一个引入。它并非在主进程执行。

从图形学的角度看看浏览器的绘制优化

是的,你可能一脸懵逼

如果你熟悉chrome devtools的performance工具,你还可能认得主线程进行的几个步骤,也就是对应上面章节提到的 Recalculate style -> Paint

  • DOM 构建或者改变dom tree
  • Style and Layout 这两个可以一起说,整体做了样式计算+排版的工作
  • Layerize 图层化,遍历DOM树,寻找每个节点所属图层
  • Paint 调用绘制API,输出DisplayList

最后一步Paint其实没有把DOM绘制到页面上,它只做了一个绘制信息的输出,接下来 合成线性做的就是Composite Layers(真实绘制)

请注意,不管是reflow还是repaint,始终都会执行Composite Layers这一步,否则DOM不会被渲染到页面上,不信你可以去performance看看

raster(栅栏化)

概念:什么是光栅化?

首先,光栅化(Rasterize/rasteriztion)。 这个词儿Adobe官方翻译成栅格化或者像素化。没错,就是把矢量图形转化成像素点儿的过程。我们屏幕上显示的画面都是由像素组成,而三维物体都是点线面构成的。要让点线面,变成能在屏幕上显示的像素,就需要Rasterize这个过程。就是从矢量的点线面的描述,变成像素的描述。 如下图,这是一个放大了1200%的屏幕,前面是告诉计算机我有一个圆形,后面就是计算机把圆形转换成可以显示的像素点。这个过程就是Rasterize。

img
img

分块

聊这个之前,我们先想象一下,IPhoneXR的ViewPort只有414 x 896,而一个界面可能非常的长,我们可以针对优先级预处理一下这个页面,避免无用的一些部分被渲染而浪费时间

我们可以对页面进行分块:

  1. 靠近ViewPort的称作visible tiles(可见图块)
  2. 其他部分统一叫做tile(图块)
  3. 图块和图块组成位图

进/线程通信

一般来说,raster操作会放在gpu进程,生成的位图放在gpu内存里。浏览器需要从gpu内存里取出位图,所以必不可少的涉及到进程通信。

涉及到两个进程

  1. Render

    设计多个线程

    1. Compositor Thread
    2. Main Thread
    3. Raster Threads(Compositor Tile Workers) 这是一个线程池
  2. GPU

    GPU进程通信简单提一下,主要是使用IPC技术进行通信,当然IPC技术很多种,可以自行了解细节。

我们主要分析一下Render进程内部

Compositor Thread

对于浏览器来说,这个线程比Main Thread更为重要,它承载了所有用户与浏览器的交互动作(滚动,输入,点击)。如果不是必要的情况,浏览器会通过这个线程处理交互行为,并且位移layer,然后通知GPU进程,并输出新frame

当然,如果JS需要介入到这些交互情况:

比如点击绑定事件,滚动被绑定动画etc...

此时会通知Main Thread进行js处理

Compositor Thread即使在主线程高度阻塞(动画繁忙,不是同步计算的情况)的情况下也会工作,只是渲染很慢,导致卡顿的样子出现

除此之外,这倒霉线程还用于接收浏览器发来的VSync

VSync

它是一种垂直同步信号,你可以理解为浏览器告知需要进行下一帧的刷新了。

Input event handlers

有一个很特别的地方,Compositor Thread在一帧时间内可以接受多次输入,但只上报给Main Thread一次,也就是说,像mousemove这样的东西,根本不需要进行raf节流,它绝对跟raf触发时间一致

测试时,不要开启devtools,这东西会影响发送频率

Non-Fast Scrollable Region

Since running JavaScript is the main thread's job, when a page is composited, the compositor thread marks a region of the page that has event handlers attached as "Non-Fast Scrollable Region". By having this information, the compositor thread can make sure to send input event to the main thread if the event occurs in that region. If input event comes from outside of this region, then the compositor thread carries on compositing new frame without waiting for the main thread.

简而言之,当新帧含有需要js处理的区域,将会被compositor thread发送给main thread

Minimizing event dispatches to the main thread

上文提到了Input event handles,但对其行为描述的有些暴力,下面会详细的讲它的特性

To minimize excessive calls to the main thread, Chrome coalesces continuous events (such as wheel, mousewheel, mousemove, pointermove, touchmove ) and delays dispatching until right before the next requestAnimationFrame.

Main Thread

主线程在之前的章节已经提到过,它是js发挥的战场,它执行js处理回调/动画/anything

同时它也是浏览器渲染(并非绘制)的战场:

  1. Recalculate style
  2. Layout
  3. Update Layer Tree
  4. Paint
  5. Composite Layers
Compositor Tile Workers

即使是高P大佬,只要他不专耕浏览器引擎或者图形学,绝对不会问到你这里。

你只需要知道它是栅栏化的线程池就行了。

Reflow和Repaint

我知道这是大家最想了解的一块,因为面试官经常问起你

但是,这是浏览器渲染过程中最简单的一块,能聊的很少...

Reflow

####定义reflow

全局图层出现变化,进行re-layout

什么会reflow

这里只讨论行为上的,原理后续会深入,下同

  • 该属性不止影响自身元素,而且可能影响其他元素布局(任何文本流的属性:height,width,etc)

  • js显式获得的属性(height,width,clientWidth,offsetWidth etc,这些东西都需要浏览器重新计算布局才能获得),大概有以下几种:

    1. 相对计算的: Element.offsetTop/clientTop
  1. 针对自身的: Element.clientHeight/style.height 3. 针对整体的: Element.scrollTop ..
  2. Js API: getComputedStyle/getBoundingRect etc... 5. 修改css样式表

Force Layout

一般来说,上面提到的第二点,也就是js进行css属性访问会触发Force Layout(强制重排)

当然,我们应该了解一下它的原理

// not layout
$('#A').style.width = '1px'
// layout
$('#B').style.width = '2px'
// layout
$('#C').style.width = '3px'

这其实只会layout两次,因为第一次设置属性,浏览器会把dom设置一个dirty标志,在下一帧未渲染之前,又有其他js来读取css属性,一旦发现dirty = true,则浏览器马上relayout,避免js读取到脏数据

Repaint

定义repaint

浏览器记录重新绘制当前图层的信息

记录和绘制是两个概念,上面已经解答的很清楚了,不妨可以回去再复习一下。

什么会repaint

  • 绝对只会影响自身的属性(也就是脱离文本流的属性):

    1. transformX(Y) (这也是为什么transfrom百分比是针对自身的)
    2. visibility
    3. background
  • 通过新建图层进行的本该是reflow的行为

    1. transfromZ
    2. will-change
    3. z-index 配合其他属性(下文会讲)

区分repaint和reflow

究其根本

某个复合层(通常我们叫做compositor layer)的纹理出现破坏时,我们只需要重绘和栅栏化被破坏的层级纹理,大面积的破坏纹理,将会导致多个层的重绘制和栅栏化。也就是说,浏览器识别到导致纹理破坏的属性属于拥有自己复合层的元素,它会触发repaint,否则会触发reflow

其实我们可能已经明白,要把reflow 优化为 repaint,只需要把更改全局图层的操作移动到当前图层进行即可。

图层?

是的,上述讨论已经找到了问题的关键,图层

你需要了解的是以下两点:

  • 不是每一个dom节点都有自己对应的图层
  • 它必须从属于某个图层

这意味着,一个span标签不会存在自己的图层,但它可能属于父级标签的图层,也可能属于全局图层

什么样的节点才会拥有自己的图层呢?

首先必须提升至Render Layer

一种是拥有层叠上下文的元素,那就简单了 层叠上下文

  • position == absolute | relative && z-index != auto

当然,也有其他方式可以新建图层:

  • will-change
  • opacity < 1
  • contain
  • video/webgl/canvas 等自带硬件加速的元素
  • 隐式合成

除此之外,通过gpu加速绘制或clip的元素也会拥有自己的图层(下面章节会介绍)

细节

我们详细聊聊细节

概念:着色器(shader)
  • vertex shader(顶点着色器):画顶点坐标,纹理坐标等
  • fragment shader(片段着色器):可以理解为pixel shader像素着色器
绘制阶段向GPU发送了什么

我们考虑到ajax里前后端通信的数据格式是json,而前后端通信延迟时间是可接受的,但是在渲染领域,与gpu通信的ms级延迟都会造成卡顿,所以我们必须要减少通信载体的大小,为最小化载体信息,你可以理解为这样一个通信过程

  • 将每个复合层绘制成一个单独的图像;
  • 准备层数据(尺寸、偏移量、透明度等);
  • 准备动画着色器(如果适用)
  • 发送上述数据到GPU

你需要注意的是:一旦合成层过多,发送给GPU的数据也会变大,cpu/gpu带宽问题将会成为瓶颈,同时合成层带来的内存开销也应该被计入代价之内

对于内存的开销,你可以在chrome devtools Layers里找到

所以你不应该一味的将元素放在新的合成层内,应该综合考虑后谨慎实现

隐式合成

考虑这样的情况

A {
 	z-index: 2
}

B {
	z-index: 1;
  will-change:xxx;
}

我们在B上做动画,而A不动,此时会有几个图层?

会有三个:

  1. 背景图层
  2. B图层
  3. A图层

你可能会很疑惑,A为什么被单独提取出来了一个图层?

这就是隐式合成:

一个或多个没有自己复合层的元素要出现在有复合层元素的上方,它就会拥有自己的复合层;这种情况被称为隐式合成。

优化

浏览器优化

  • 浏览器对导致重渲染的行为会做batch render,也就是说会对在某个时间内reflow/repaint的行为进行合并。
  • 浏览器只能优化render带来的reflow,而不能优化js计算带来的reflow,这意味着要尽量少在代码里获得元素的属性,若一定要获取,请善用缓存。
优化带来的问题

考虑下面一种场景:

box.style.transform = 'translateX(1000px)'
box.style.tranition = 'transform 1s ease'
box.style.transform = 'translateX(500px)'

动画会如你想象的一般执行吗?

事实上是不会的,上文已经提到过浏览器会做batch rendertransform属性会立刻变化到最终结果

解决
  • 中止优化

    上面提到过,浏览器会对一些导致重渲染(不仅是reflow)的行为进行,并且浏览器无法优化js带来的reflow,那么可以利用此终止浏览器优化

    box.style.transform = 'translateX(1000px)'
    getBoundingClientRect(box)
    box.style.tranition = 'transform 1s ease'
    box.style.transform = 'translateX(500px)'
    

    浏览器傻了

  • raf

    在这之前,我假设你知道raf的执行时机是在SLP之前的

    box.style.transform = 'translateX(1000px)'
    requestAnimationFrame(() => {
      box.style.tranition = 'transform 1s ease'
      requestAnimationFrame(() => {
        box.style.transform = 'translateX(500px)'
      })
    })
    

    浏览器疯了

    当然,你可能觉得ref嵌套很奇怪。如果不嵌套,那么跟最开始的情况没什么两样,因为在进行SLP之前,raf就会执行,那么最终浏览器还是会做batch render

主动优化

  • 缓存会导致reflow的元素的属性

  • 缓存多次reflow至一次修改:

    • 不要修改css样式表,采取cssText的修改方法

      eg: e.cssText += ';height:300px;width:300px;'

    • 采取className的方式切换样式

      eg: e.classList += 'newClassName'

    ps:浏览器本身会进行batch render,所以这样优化的效果不是很大

  • 下线元素后进行操作:

    • display:none
    • createFragment

不得不提的一些优化副作用

前面提到过,优化reflow最重要的手段是新建合成层,也提到了一些副作用,下面是一些总结:

  1. 新建合成层和导致浏览器重绘所有图层
  2. 内存开销增大
  3. GPU通信数据/次数增大

Composite

基础知识

浏览器把整个页面渲染分为了几个层:

  1. Dom
  2. Render Object 记录node内容,向GraphicsContext(绘图上下文)发起绘制请求
  3. RenderLayers 复制处理dom子树 CPU绘制
  4. GraphicsLayers 储存该层的GraphicsContext GPU绘制

该层级中,后层级的处理前一层级

聊聊细节

Render Object OR Render Layer

来源

上图是DOM TreeRenderObject Tree的对应关系

对于浏览器而言,它没办法理解DOM,所以把dom转化为了一堆Render Object并一一对应。(它包含了所有DOM的信息,包括css信息)

cssom treedom tree的交汇处

这是一个很有意思的话题,cssom treedom tree究竟是在哪个阶段merge的呢?

cssom treedom tree形成完毕之后,调用StyleResolver类为DOM节点匹配样式,形成RenderStyle Object,该对象由Render Object使用。

这也就是我们常说的css与dom的合并。其实有点误解,因为这不是合并的过程,而是cssom根据形成完毕的dom tree新形成一个RenderStyle Object的过程,cssom treedom tree两者本身不发生变化。

Render Layer

而dom节点之间有层叠上下文关系,没办法直接对Render Object做DFS完成位图的绘制,所以引入一个抽象中间层RenderLayer。这样高层级的layer就可以覆盖低层级的layer了,渲染起来更轻松了。当然RenderLayer不只解决层叠上下文的事情,它也解决透明度这类相关的事情。

所以,Render Layer存在的目的是为了处理Render Object`在不同图层之间的差异。

Composite Layer

抽象出该层纯粹是为了减少位图的绘制,毕竟一些动画会高频改变位图,每次重新绘制全部位图带来的开销无法估量也无法接受。

你需要注意的是,要想成为Composite Layer,必须是Render Layer

提升的方法很多:

  • 3D 或透视变换(perspective、transform) CSS 属性
  • 使用加速视频解码的 元素
  • 拥有 3D (WebGL) 上下文或加速的 2D 上下文的 元素
  • 混合插件(如 Flash)
  • 对 opacity、transform、fliter、backdropfilter 应用了 animation 或者 transition(需要是 active 的 animation 或者 transition,当 animation 或者 transition 效果未开始或结束后,提升合成层也会失效)
  • will-change 设置为 opacity、transform、top、left、bottom、right(其中 top、left 等需要设置明确的定位属性,如 relative 等)
  • 拥有加速 CSS 过滤器的元素
  • 元素有一个 z-index 较低且包含一个复合层的兄弟元素(换句话说就是该元素在复合层上面渲染)
  • ….. 所有情况的详细列表参见淘宝fed文章:无线性能优化:Composite

有一下几个重点:

  • GraphicsLayers由GPU绘制,速度远胜于RenderLayers
  • 一旦开启硬件加速,则会提升为GraphicsLayers
  • 若非经过profiling,不要滥用硬件加速

Composite

Composite Layer拥有自己的位图,Composite的过程也就是合成位图的过程

借用一张图:

Main Thread将计算上面这样的过程,旋转,位移,缩放等操作都会在这里被计算完全。最后把信息发送给Compositor Thread,再后面的流程之前已经提过。

Commit

其实没必要在这里提到commit,但为了叙述一个完整的浏览器渲染过程,还是提及一下。

当所有tile都栅栏化完成后,GPU储存了全部的纹理,此时调用操作系统的API,把它们输出到一张位图上,就是我们看见的页面了。

参考