引文
这篇文章能给你带来什么
在常规文章中,我们一般会看到DOM数的构建过程,稍微深入一点,还能看到Render树的构建过程。本文却不只是停留在表面,它会深入到浏览器底层的渲染过程,探讨一些有价值,有意义的东西。
SLP
浏览器渲染过程与三个最重要的阶段,简称为SLP
- S(Sturcture) 构建DOM树结构
- L(Layout) 排版,确认每个DOM的位置
- P(Paint) 绘制,绘制每个DOM的具体内容
具体细节
一个元素从加入到呈现经历了什么?
这里只讨论
Main Thread
的行为,具体什么是Main Thread
,下文会提到
这是devtools performance
的性能报告,我们可以看到,一个元素从加入DOM到呈现到页面上会经历以下五个步骤:
- Recalculate style
- Layout (reflow)
- Update Layer Tree
- Paint (repaint)
- Composite Layers
Recalculate Style
这是一个复杂的步骤,浏览器如何把CSS样式表转化为可以认识的对象呢?
一个哲学问题:css文本 -------------> stylesheet
-
标准化css文本
css存在于很多地方:
- link
- inline
- style
css存在很多写法:
#test {
font: 2em;
}
#test {
font: 32px;
}
浏览器做的第一步就是标准化css文本,当然这个过程很简单,无非就是一些细节处理与转换,我们重点不是 这里。
-
如何形成CSSOM tree
最终会形成这样的CSSOM tree
我们来分析一下:
p
标签继承了body
的font-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分为两个过程:
- 记录重绘的信息
- 重绘到页面
第二步实际是后续我们提到的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。
分块
聊这个之前,我们先想象一下,IPhoneXR的ViewPort
只有414 x 896
,而一个界面可能非常的长,我们可以针对优先级预处理一下这个页面,避免无用的一些部分被渲染而浪费时间
我们可以对页面进行分块:
- 靠近
ViewPort
的称作visible tiles(可见图块)
- 其他部分统一叫做
tile(图块)
- 图块和图块组成位图
进/线程通信
一般来说,raster
操作会放在gpu进程,生成的位图放在gpu内存里。浏览器需要从gpu内存里取出位图,所以必不可少的涉及到进程通信。
涉及到两个进程
-
Render
设计多个线程
- Compositor Thread
- Main Thread
- Raster Threads(Compositor Tile Workers) 这是一个线程池
-
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
同时它也是浏览器渲染(并非绘制)的战场:
- Recalculate style
- Layout
- Update Layer Tree
- Paint
- Composite Layers
Compositor Tile Workers
即使是高P大佬,只要他不专耕浏览器引擎或者图形学,绝对不会问到你这里。
你只需要知道它是栅栏化的线程池就行了。
Reflow和Repaint
我知道这是大家最想了解的一块,因为面试官经常问起你
但是,这是浏览器渲染过程中最简单的一块,能聊的很少...
Reflow
####定义reflow
全局图层出现变化,进行re-layout
什么会reflow
这里只讨论行为上的,原理后续会深入,下同
-
该属性不止影响自身元素,而且可能影响其他元素布局(任何文本流的属性:height,width,etc)
-
js显式获得的属性(height,width,clientWidth,offsetWidth etc,这些东西都需要浏览器重新计算布局才能获得),大概有以下几种:
- 相对计算的: Element.offsetTop/clientTop
- 针对自身的: Element.clientHeight/style.height 3. 针对整体的: Element.scrollTop ..
- 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
-
绝对只会影响自身的属性(也就是脱离文本流的属性):
- transformX(Y) (这也是为什么transfrom百分比是针对自身的)
- visibility
- background
-
通过新建图层进行的本该是
reflow
的行为- transfromZ
- will-change
- 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不动,此时会有几个图层?
会有三个:
- 背景图层
- B图层
- 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 render
,transform
属性会立刻变化到最终结果
解决
-
中止优化
上面提到过,浏览器会对一些导致重渲染(不仅是
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
最重要的手段是新建合成层,也提到了一些副作用,下面是一些总结:
- 新建合成层和导致浏览器重绘所有图层
- 内存开销增大
- GPU通信数据/次数增大
Composite
基础知识
浏览器把整个页面渲染分为了几个层:
- Dom
- Render Object 记录node内容,向GraphicsContext(绘图上下文)发起绘制请求
- RenderLayers 复制处理dom子树 CPU绘制
- GraphicsLayers 储存该层的GraphicsContext GPU绘制
该层级中,后层级的处理前一层级
聊聊细节
Render Object OR Render Layer
来源
上图是
DOM Tree
与RenderObject Tree
的对应关系
对于浏览器而言,它没办法理解DOM,所以把dom转化为了一堆Render Object
并一一对应。(它包含了所有DOM的信息,包括css信息)
cssom tree
与 dom tree
的交汇处
这是一个很有意思的话题,cssom tree
与dom tree
究竟是在哪个阶段merge
的呢?
cssom tree
在dom tree
形成完毕之后,调用StyleResolver
类为DOM节点匹配样式,形成RenderStyle Object
,该对象由Render Object
使用。
这也就是我们常说的css与dom的合并。其实有点误解,因为这不是合并的过程,而是cssom
根据形成完毕的dom tree
新形成一个RenderStyle Object
的过程,cssom tree
和dom 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,把它们输出到一张位图上,就是我们看见的页面了。