浏览器渲染详细过程:重绘、重排和 composite 只是冰山一角

在以前的一篇文章中:Vue源码详解之nextTick:MutationObserver只是浮云,microtask才是核心!,我说过,偶然在一次对task和microtask的讨论当中,研究到了浏览器在处理完task和microtask以后执行的渲染机制,当时看到这个内容,仍是挺激动的,由于之前历来不知道我在js里更改的样式,浏览器究竟是何时、以怎样的方式渲染到界面上的,因而兴奋的写下了上述文章。javascript

最近深刻研究了这部分,发现这里有一片更广阔的新大陆,在咱们耳熟能详的重排、重绘、composite、合成层提高等概念下还有更深的的东西。这篇文章将会介绍浏览器的详细渲染过程。css

event loop规范和处理过程

这里我在开头说的nexttick详解中说过这部分,可是只是本身看嗨了,并无做为文章重点去详细介绍,当时主要仍是说task和microtask。其实这部分是整个渲染过程的关键,涉及浏览器进行渲染的时机,因此认真说一下:html

请先点连接看html5官方规范: html5 event loop processing modelhtml5

  1. 前1到5步,从多个task队列中里选出一个task队列(浏览器为了区分不一样task的优先级,因此时常有多个task队列),从这个task队列中取出最老的那个task,执行他,而后把他从队列中去除。
  2. 第六步perform a microtask checkpoint执行一个microtask检查点,这个步骤其实包含了多个子步骤,只要microtask queue不空,这一步会一直从microtask queue中取出microtask,执行之。若是microtask执行过程当中又添加了microtask,那么仍然会执行新添加的microtask。( 连接里第七步的return to the microtask queue handling step很关键)
  3. 第七步Update the rendering,更新渲染。到了更新界面的部分了!
    1. 7.1步到7.4步,判断当前的document是否须要渲染,用官方规范的说法就是浏览器会判断这个document是否会从UI Render中获益,由于只须要保持60Hz的刷新率便可,而每轮event loop都是很是快的,因此不必每轮loop都Render UI,而是差很少16ms的时候再Render。同时对于一些比较卡顿的已经不能保证60Hz的页面,若再在此时执行界面渲染会雪上加霜,因此浏览器可能会下调认为document能获益的频率为(好比说)30hz。
    2. run the resize steps,若浏览器resize过,那么这里会在Window上触发’resize’事件。
    3. run the scroll steps。首先,每当咱们在某个target上滚动时(target能够是某个可滚动元素也可能就是document),浏览器就会在target所属的document上的pending scroll event targets里存放这个发生滚动的target。如今, run the scroll steps这一步会从pending scroll event targets里取出target,而后在target上触发scroll事件。
    4. 计算是否触发media query
    5. 7.8和7.9 执行css animation和触发‘animationstart’等animation相关事件. run the fullscreen rendering steps:若是在以前的task或者microtask中执行过 requestFullscreen()等full screen相关api,此处会执行全屏操做。
    6. run the animation frame callbacks,执行requestAnimationFrame的回调,requestAnimationFrame就是在这里执行的!
    7. 执行IntersectionObserver的回调,也许你在图片懒加载的逻辑里用过这个api。
    8. 更新、渲染用户界面
  4. 继续返回第一步

好了,整个流程就介绍完了。前两步task和microtask相关处理再也不赘述。
重要的第三步里有3点值得关注的东西:java

  1. 不是每轮event loop里都会有Update the rendering,只有浏览器判断这个document须要更新界面的时候才会让更新。这意味着两次UI Render之间最小间隔也是16ms,你setInterval去1ms更新一次其实也依然是16ms更新一次。
  2. resize和scroll事件是在渲染流程里触发的。是否是很惊人?这意味着若是你想在scroll事件上绑回调去执行动画,那么根本不须要用requestAnimationFrame去节流,scroll事件自己就是在每帧真正渲染前执行,自带节流效果!固然,滚动图片懒加载、滚动内容无限加载等业务逻辑而非动画逻辑仍是须要throttle的。
  3. 如同mdn、w3c等介绍的:requestAnimationFrame的回调是在重绘前执行的,7.9步是这一逻辑的保证。
  4. UI的重绘是在event loop的结束时执行的。
    页面的重绘居然是跟event loop紧密耦合的,并且是被精肯定义在event loop中的,始料未及。我以前一直不明白我JS修改了DOM样式以后,这样式到底何时呈现。

页面渲染的时机介绍完了,来讲说渲染究竟是怎样一个过程。另,后文讲述的是浏览器详细过程,是实现,前文讲的是规范css3

渲染

这张很经典的图许多人都看过,其中的概念你们应该都很熟悉,也就是这么几个步骤:
js修改dom结构或样式 -> 计算style -> layout(重排) -> paint(重绘) -> composite(合成)git

可是其中有更复杂的内容,咱们从更底层来详细说明这个过程,主要是下面这两幅图:

上图出自GPU Accelerated Compositing in Chrome

上图出自The Anatomy of a Framegithub

这部份内容基于blink、webkit内核,可是其中涉及到的重排、重绘、composite和合成层提高等环节对于各大浏览器都是一致的。web

先说一些概念

  1. 位图

    就是数据结构里常说的位图。你想在绘制出一个图片,你应该怎么作,显然首先是把这个图片表示为一种计算机能理解的数据结构:用一个二维数组,数组的每一个元素记录这个图片中的每个像素的具体颜色。因此浏览器能够用位图来记录他想在某个区域绘制的内容,绘制的过程也就是往数组中具体的下标里填写像素而已。chrome

  2. 纹理
    纹理其实就是GPU中的位图,存储在GPU video RAM中。前面说的位图里的元素存什么你本身定义好就行,是用3字节存256位rgb仍是1个bit存黑白你本身定义便可,可是纹理是GPU专用的,GPU和CPU是分离的,须要有固定格式,便于兼容与处理。因此一方面纹理的格式比较固定,如R5G6B五、A4R4G4B4等像素格式, 另一方面GPU 对纹理的大小有限制,好比长/宽必须是2的幂次方,最大不能超过2048或者4096等。

  3. Rasterize(光栅化)


    在纹理里填充像素不是那么简单的本身去遍历位图里的每一个元素而后填写这个像素的颜色的。就像前面两幅图。光栅化的本质是坐标变换、几何离散化,而后再填充
    同时,光栅化从早期的 Full-screen Rasterization基本都进化到了如今的Tile-Based Rasterization, 也就是否是对整个图像作光栅化,而是把图像分块(tile,亦有翻译为瓦片、贴片、瓷片…)后,再对每一个tile单独光栅化。光栅化好了将像素填充进纹理,再将纹理上传至GPU。
    缘由一方面如上文所说,纹理大小有限制,即便你整屏光栅化也是要填进小块小块的纹理中,不如事先根据纹理大小分块光栅化后再填充进纹理里。另外一方面是为了减小内存占用(整屏光栅化意味着须要准备更大的buffer空间)和下降整体延迟(分块栅格化意味着能够多线程并行处理)。
    看到下图中蓝色的那些青色的矩形了吗?他们就是tiles。

能够想见浏览器的一次绘制过程就是先把想绘制的内容如文字、背景、边框等经过分块Rasterize绘制到不少纹理里,再把纹理上传到gpu的存储空间里,gpu把纹理绘制到屏幕上。

绘制的具体过程

咱们先把计算样式、重排等步骤抽离,单独讲解浏览器是怎么绘制的。

先来看这幅经典的图:

图中一些名词的称呼发生了变化,详见taobaofed的文章:无线性能优化:Composite

Render Object

首先咱们有DOM树,可是DOM树里面的DOM是供给JS/HTML/CSS用的,并不能直接拿过来在页面或者位图里绘制。所以浏览器内部实现了Render Object

每一个Render Object和DOM节点一一对应。Render Object上实现了将其对应的DOM节点绘制进位图的方法,负责绘制这个DOM节点的可见内容如背景、边框、文字内容等等。同时Render Object也是存放在一个树形结构中的。

既然实现了绘制每一个DOM节点的方法,那是否是能够开辟一段位图空间,而后DFS遍历这个新的Render Object树而后执行每一个Render Object的绘制方法就能够将DOM绘制进位图了?就像“盖章”同样,把每一个Render Object的内容一个个的盖到纸上(类比于此时的位图)是否是就完成了绘制。

不,浏览器还有个层叠上下文。就是决定元素间相互覆盖关系(好比z-index)的东西。这使得文档流中位置靠前位置的元素有可能覆盖靠后的元素。上述DFS过程只能无脑让文档流靠后的元素覆盖前面元素。

所以,有了Render Layer。

Render Layer

固然Render Layer的出现并非简单由于层叠上下文等,好比opacity小于一、好比存在mask等等须要先绘制好内容再对绘制出来的内容作一些统一处理的css效果。

总之就是有层叠、半透明等等状况的元素(具体哪些状况请参考无线性能优化:Composite)就会从Render Object提高为Render Layer。不提高为Render Layer的Render Object从属于其父级元素中最近的那个Render Layer。固然根元素HTML本身要提高为Render Layer。

所以如今Render Object树就变成了Render Layer树,每一个Render Layer又包含了属于本身layer的Render Object。

另外:

The children of each RenderLayer are kept into two sorted lists both sorted in ascending order, the negZOrderList containing child layers with negative z-indices (and hence layers that go below the current layer) and the posZOrderList contain child layers with positive z-indices (layers that go above the current layer).
每一个Render Layer的子Render Layer都是按照升序排列存储在两个有序列表当中的:negZOrderList存储了负z-indicices的子layers,posZOrderList存储了正z-indicies的子layers。
— 出自GPU加速的compositing一文

如今浏览器渲染引擎遍历 Layer 树,访问每个 RenderLayer,而后递归遍历negZOrderList里的layer、本身的RenderObject、再递归遍历posZOrderList里的layer。就能够将一颗 Layer树绘制出来。

Layer 树决定了网页绘制的层次顺序,而从属于 RenderLayer 的 RenderObject 决定了这个 Layer 的内容,全部的 RenderLayer 和 RenderObject 一块儿就决定了网页在屏幕上最终呈现出来的内容。

层叠上下文、半透明、mask等等问题经过Render Layer解决了。那么如今:
开辟一个位图空间->不断的绘制Render Layer、覆盖掉较低的Layer->拿给GPU显示出来 是否是就彻底ok了?

不。还有GraphicsLayers和Graphics Context

Graphics Layer(又称Compositing Layer)和Graphics Context

上面的过程能够搞定绘制过程。可是浏览器里面常常有动画、video、canvas、3d的css等东西。这意味着页面在有这些元素时,页面显示会常常变更,也就意味着位图会常常变更。每秒60帧的动效里,每次变更都重绘整个位图是很恐怖的性能开销。

所以浏览器为了优化这一过程。引出了Graphics Layers和Graphics Context,前者就是咱们常说的合成层(Compositing Layer)

某些具备CSS3的3D transform的元素、在opacity、transform属性上具备动画的元素、硬件加速的canvas和video等等,这些元素在上一步会提高为Render Layer,而如今他们会提高为合成层Graphics Layer(你若是查看了前文我给的连接,你当时可能会疑惑为何这些状况也能提高为Render Layer,如今你应该明白了,他们是为提高为Graphics Layer准备的)。每一个Render Layer都属于他祖先中最近的那个Graphics Layer。固然根元素HTML本身要提高为Graphics Layer。

Render Layer提高为Graphics 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

3D transform、will-change设置为 opacity、transform等 以及 包含opacity、transform的CSS过渡和动画 这3个常常遇到的提高合成层的状况请重点记住。

另外除了上述直接致使Render Layer提高为Graphics Layer,还有下面这种由于B被提高,致使A也被隐式提高的状况,详见此文: GPU Animation: Doing It Right

每一个合成层Graphics Layer 都拥有一个 Graphics Context,Graphics Context 会为该Layer开辟一段位图,也就意味着每一个Graphics Layer都拥有一个位图。Graphics Layer负责将本身的Render Layer及其子代所包含的Render Object绘制到位图里。而后将位图做为纹理交给GPU。因此如今GPU收到了HTML元素的Graphics Layer的纹理,也可能还收到某些由于有3d transform之类属性而提高为Graphics Layer的元素的纹理。

如今GPU须要对多层纹理进行合成(composite),同时GPU在纹理合成时对于每一层纹理均可以指定不一样的合成参数,从而实现对纹理进行transform、mask、opacity等等操做以后再合成,并且GPU对于这个过程是底层硬件加速的,性能很好。最终,纹理合成为一幅内容最终draw到屏幕上。

因此在元素存在transform、opacity等属性的css animation或者css transition时,动画处理会很高效,这些属性在动画中不须要重绘,只须要从新合成便可。

上述分层后合并的过程能够用一张图来描述:

绘制的具体实现

系统结构

进程

blink和webkit引擎内部都是使用了两个进程来搞定JS执行、页面渲染之类的核心任务。

  • Renderer进程
    主要的那个进程,每一个tab一个。负责执行JS和页面渲染。包含3个线程:Compositor Thread、Tile Worker、Main thread,后文会介绍这三个线程。
  • GPU进程
    整个浏览器共用一个。主要是负责把Renderer进程中绘制好的tile位图做为纹理上传至GPU,并调用GPU的相关方法把纹理draw到屏幕上(通常的介绍浏览器渲染引擎的文章里都用paint这个词表述把内容光栅化和绘制到位图里,而用draw这个词表示GPU最终把纹理显示到屏幕上),因此这个CPU里的进程更应该称为“负责跟GPU打交道的进程”,不要像我以前同样由于不懂GPU觉得是GPU里的一个进程, mdzz。GPU进程里只有一个线程:GPU Thread。

Renderer进程的三个线程

  • Compositor Thread
    这个线程既负责接收浏览器传来的垂直同步信号(Vsync,水平同步表示画出一行屏幕线,垂直同步就表示从屏幕顶部到底部的绘制已经完成,指示着前一帧的结束,和新一帧的开始), 也负责接收OS传来的用户交互,好比滚动、输入、点击、鼠标移动等等。
    若是可能,Compositor Thread会直接负责处理这些输入,而后转换为对layer的位移和处理,并将新的帧直接commit到GPU Thread,从而直接输出新的页面。不然,好比你在滚动、输入事件等等上注册了回调,又或者当前页面中有动画等状况,那么这个时候Compositor Thread便会唤醒Main Thread,让后者去执行JS、完成重绘、重排等过程,产出新的纹理,而后Compositor Thread再进行相关纹理的commit至GPU Thread,完成输出。
  • Main Thread

    这里你们就很熟悉了,chrome devtools的Timeline里Main那一栏显示的内容就是Main Thread完成的相关任务:某段JS的执行、Recalculate Style、Update Layer Tree、Paint、Composite Layers等等。
  • Compositor Tile Worker(s)
    可能有一个或多个线程,好比PC端的chrome是2个或4个,安卓和safari为1个或2个不等。是由Compositor Thread建立的,专门用来处理tile的Rasterization(前文说过的光栅化)。

能够看到Compositor Thread是一个很核心的东西,后面的俩线程都是由他主要进行控制的。
同时,用户输入是直接进入Compositor Thread的,一方面在那些不须要执行JS或者没有CSS动画、不重绘等的场景时,能够直接对用户输入进行处理和响应,而Main Thread是有很复杂的任务流程的。这使得浏览器能够快速响应用户的滚动、打字等等输入,彻底不用进主线程。这里也有一个很是重要的点,后文会说。
再者,即便你注册了UI交互的回调,进了主线程,或者主线程很卡,可是由于Compositor Thread在他外面拦着,因此Compositor Thread依然能够直接负责将下一帧输出到页面上,所以即便你的主线程可能执行着高耗任务,超过16ms,可是你在滚动页面时浏览器仍是能作出响应的(同步AJAX等特殊任务除外),因此好比你有一个比较卡的动画(动画的预先计算过程或者重绘过程超过16ms每帧),可是你滚动页面是很是流畅的,也就是动画卡而滚动不卡(随便给你个demo本身试试看)。

具体流程

通常咱们在devtools的Timeline里大概会看到以下过程:

也就是JS执行后触发重绘重排等操做。这里着重分析背后的运行过程,即下面这副图:

图里后半部分有两处commit,分别是主线程通知Main Thread能够执行光栅化了,以及光栅化完成、纹理生成完毕,Compositor Thread通知GPU Thread能够将纹理按照指定的参数draw到屏幕上。

总体流程:

  1. Vsync
    接收到Vsync信号,这一帧开始
  2. Input event handlers
    以前Compositor Thread接收到的用户UI交互输入在这一刻会被传入给主线程,触发相关event的回调。

    All input event handlers (touchmove, scroll, click) should fire first, once per frame, but that’s not necessarily the case; a scheduler makes best-effort attempts, the success of which varies between Operating Systems.

    这意味着,尽管Compositor Thread能在16ms内接收到OS传来的屡次输入,可是触发相应事件、传入到主线程被JS感知倒是每帧一次,甚至可能低于每帧一次。也就是说touchmove、mousemove等事件最快也就每帧执行一次,因此自带了相对于动画的节流效果!若是你的主线程有动画之类的卡了一点,事件触发频率很是可能低于16ms。我在最开始关于渲染时机的内容中说了scroll和resize由于和渲染处于同一轮次,因此最快也就每帧执行一次,如今来看,不只仅是scroll和resize!连touchmove、mousemove等事件,因为Compositor Thread的机制缘由,也依然如此
    详见这个jsfiddle,你们能够试试,你能够发现mousemove回调和requestAnimationFrame回调的调用频率是彻底一致的,mousemove的执行次数跟raf执行次数如出一辙,永远没有任何一次出现mousemove执行两次而rAF尚未执行一次的状况发生。另外两次执行间隔在14到20毫秒之间,主要是由于帧的间隔不会精确到16.666毫秒哈,基本是14ms~20ms之间大体波动的,你们能够打开timeline观察。另外有个挺奇怪的现象是每次鼠标从devtool移回页面区域里的时候,会很是快的触发两次mousemove(间隔有时小于5ms),虽然依然每次mousemove后依然紧跟raf,这意味着很是快速的触发了两帧。

  3. requestAnimationFrame
    图中的红线的意思是你可能会在JS里Force Layout,也就是咱们说的访问了scrollWidth、clientHeight、ComputedStyle等触发了强制重排,致使Recalc Styles和Layout前移到代码执行过程中。
  4. parse HTML
    若是有DOM变更,那么会有解析DOM的这一过程。
  5. Recalc Styles
    若是你在JS执行过程当中修改了样式或者改动了DOM,那么便会执行这一步,从新计算指定元素及其子元素的样式。
  6. Layout
    咱们常说的重排reflow。若是有涉及元素位置信息的DOM改动或者样式改动,那么浏览器会从新计算全部元素的位置、尺寸信息。而单纯修改color、background等等则不会触发重排。详见css-triggers
  7. update layer tree
    这一步实际是更新Render Layer的层叠排序关系,也就是咱们以前说的为了搞定层叠上下文搞出的那个东西,由于以前更新了相关样式信息和重排,因此层叠状况也可能变更。
  8. Paint
    其实Paint有两步,第一步是记录要执行哪些绘画调用,第二步才是执行这些绘画调用。第一步只是把所须要进行的操做记录序列化进一个叫作SkPicture的数据结构里:

    The SkPicture is a serializable data structure that can capture and then later replay commands, similar to a display list.

    这个SkPicture其实就一个列表,记录了你的commands。接下来的第二步里会将SkPicture中的操做replay出来,这里才是将这些操做真正执行:光栅化和填充进位图。主线程中和咱们在Timeline中看到的这个Paint实际上是Paint的第一步操做。第二步是后续的Rasterize步骤(见后文)。

  9. Composite
    主线程里的这一步会计算出每一个Graphics Layers的合成时所须要的data,包括位移(Translation)、缩放(Scale)、旋转(Rotation)、Alpha 混合等操做的参数,并把这些内容传给Compositor Thread,而后就是图中咱们看到的第一个commit:Main Thread告诉Compositor Thread,我搞定了,你接手吧。而后主线程此时会去执行requestIdleCallback。这一步并无真正对Graphics Layers完成位图的composite。
  10. Raster Scheduled and Rasterize
    第8步生成的SkPicture records在这个阶段被执行。

    SkPicture records on the compositor thread get turned into bitmaps on the GPU in one of two ways: either painted by Skia’s software rasterizer into a bitmap and uploaded to the GPU as a texture, or painted by Skia’s OpenGL backend (Ganesh) directly into textures on the GPU.

    能够看出Rasterization其实有两种形式:

    • 一种是基于CPU、使用Skia库的Software Rasterization,首先绘制进位图里,而后再做为纹理上传至GPU。这一方式中,Compositor Thread会spawn出一个或多个Compositor Tile Worker Thread,而后多线程并行执行SkPicture records中的绘画操做,以以前介绍的Graphics Layer为单位,绘制Graphics Layer里的Render Object。同时这一过程是将Layer拆分为多个小tile进行光栅化后写入进tile对应的位图中的。
    • 另外一种则是基于GPU的Hardware Rasterization,也是基于Compositor Tile Worker Thread,也是分tile进行,可是这个过程不是像Software Rasterization那样在CPU里绘制到位图里,而后再上传到GPU中做为纹理。而是借助Skia’s OpenGL backend (Ganesh) 直接在GPU中的纹理中进行绘画和光栅化,填充像素。也就是咱们常说的GPU Raster。

    如今基本最新版的几大浏览器都是硬件Rasterization了,可是对于一些移动端基本仍是Software Rasterization较多。打开你的chrome浏览器输入chrome://gpu/ 能够看看你的chrome的GPU加速状况。下图是个人:

    使用Hardware Rasterization的好处在于:以往Software Rasterization的方式,受限于CPU和GPU以前的上传带宽,把位图从RAM里上传到GPU的VRAM里的过程是有不可忽视的性能开销的。若Rasterization的区域较大,那么使用Software Rasterization极可能在这里出现卡顿。下面这个例子是Chrome32和Chrome41的对比,后者的版本实现了Hardware Rasterization。

    不过,对于图片、canvas等状况,我没有查到究竟是怎么处理的,可是我以为绝对是有一个从CPU上传到GPU的过程的,因此应该有一些状况不是纯Hardware Rasterization的,二者应该是结合使用的。另外就是硬件仍是软件Rasterization主要仍是由设备决定的,在这个地方并无咱们手动优化的空间,可是这里涉及到一些后面的内容,因此简单介绍了一下。

  11. commit
    若是是Software Rasterization,全部tile的光栅化完成后Compositor Thread会commit通知GPU Thread,因而全部的tile的位图都会做为纹理都会被GPU Thread上传到GPU里。若是是使用GPU 的Hardware Rasterization,那么此时纹理都已经在GPU中。接下来,GPU Thread会调用平台对应的3D API(windows下是D3D,其余平台都是GL),把全部纹理绘制到最终的一个位图里,从而完成纹理的合并。
    同时,很是关键的一点:在纹理的合并时,借助于3D API的相关合成参数,能够在合并前对纹理transformations(也就是以前提到的位移、旋转、缩放、alpha通道改变等等操做),先变形再合并。合并完成以后就能够将内容呈现到屏幕上了。

并非每次渲染都会执行上述11步的全部步骤,好比Layout、Paint、Rasterize、commit可能一次都没有,可是Layout又可能会不止一次。另外还有利用合成层提高来得到GPU加速的动画等相关技术的原理。接下里就是对上述步骤更加详细的分析。

重排 Layout、强制重排 Force Layout

重排和强制重排是老生常谈的东西了,你们也应该很是熟悉了,但在这里能够结合浏览器机制顺带讲一遍。

首先,若是你改了一个影响元素布局信息的CSS样式,好比width、height、left、top等(transform除外),那么浏览器会将当前的Layout标记为dirty,这会使得浏览器在下一帧执行上述11个步骤的时候执行Layout。由于元素的位置信息变了,将可能会致使整个网页其余元素的位置状况都发生改变,因此须要执行Layout全局从新计算每一个元素的位置。

须要注意到,浏览器是在下一帧、下一次渲染的时候才重排。并非JS执行完这一行改变样式的语句以后当即重排,因此你能够在JS语句里写100行改CSS的语句,可是只会在下一帧的时候重排一次。

若是你在当前Layout被标记为dirty的状况下,访问了offsetTop、scrollHeight等属性,那么,浏览器会当即从新Layout,计算出此时元素正确的位置信息,以保证你在JS里获取到的offsetTop、scrollHeight等是正确的。

会触发重排的属性和方法:

这一过程被称为强制重排 Force Layout,这一过程强制浏览器将原本在上述渲染流程中才执行的Layout过程前提至JS执行过程当中。前提不是问题,问题在于每次你在Layout为dirty时访问会触发重排的属性,都会Force Layout,这极大的延缓了JS的执行效率。

//Layout未dirty 访问domA.offsetWidth不会Force Layout
domA.style.width = (domA.offsetWidth + 1) + 'px' 
//Layout已经dirty, Force Layout
domB.style.width = (domB.offsetWidth + 1) + 'px' 
//Layout已经dirty, Force Layout
domC.style.width = (domC.offsetWidth + 1) + 'px'
复制代码

这三行代码的后两行都致使了Force Layout,Layout一次的时间视DOM数量级从几十微秒到十几毫秒不等,相比于一行JS 1微秒不到的执行时间,这个开销是难以接受的。因此也就有了读写分离、纯用变量存储等避免Force Layout的方法。不然你就会在你Timeline里看到这种10屡次Recalculate Style 和 Layout的画面了。

另外,每次重排或者强制重排后,当前Layout就再也不dirty。因此你再访问offsetWidth之类的属性,并不会再触发重排。

// Layout未dirty 访问多少次都不会触发重排
console.log(domA.offsetWidth) 
console.log(domB.offsetWidth) 

//Layout未dirty 访问domA.offsetWidth不会Force Layout
domA.style.width = (domA.offsetWidth + 1) + 'px' 
//Layout已经dirty, Force Layout
console.log(domC.offsetWidth) 

//Layout再也不dirty,不会触发重排
console.log(domA.offsetWidth) 
//Layout再也不dirty,不会触发重排
console.log(domB.offsetWidth)
复制代码

重绘 Paint

重绘也是类似的,一旦你更改了某个元素的会触发重绘的样式,那么浏览器就会在下一帧的渲染步骤中进行重绘。也即一些介绍重绘机制中说的invalidating(做废),JS更改样式致使某一片区域的样式做废,从而在一下帧中重绘invalidating的区域。

可是,有一个很是关键的行为,就是:重绘是以合成层为单位的。也即 invalidating的既不是整个文档,也不是单个元素,而是这个元素所在的合成层。固然,这也是将渲染过程拆分为Paint和Compositing的初衷之一:

Since painting of the layers is decoupled from compositing, invalidating one of these layers only results in repainting the contents of that layer alone and recompositing.

这里给出两个demo: demo1demo2

两个demo几乎彻底同样,除了第二demo的.ab-right的样式里多了一行,will-change:transform;咱们在前文介绍合成层的时候强调过will-change: transform会让元素强制提高为合成层。

.ab-right {
        will-change: transform; //多了这行
        position: absolute;
        right: 0;
}
复制代码

因而在第二个demo中出现了两个合成层:HTML根元素的合成层和.ab-right所在的合成层。

而后咱们在js中修改了#target元素的样式,因而#target元素在的合成层(即HTML根元素的合成层)被重绘。在demo1中,.ab-right元素没有被提高为合成层,因而.ab-right也被重绘了。而在demo2中,.ab-right元素并无重绘。先看demo1:


明显的看到.ab-right被重绘了。


显然,demo2只重绘了HTML根元素的合成层的内容。

对了,你还能够顺便点到Raster一栏去看看Rasterization的具体过程。前面已经介绍过了,这里真正完成Paint里的操做,将内容绘制进位图或纹理中,且是分tile进行的。

重排和重绘和Compositing

先说点题外的,怎么查看合成层:

修改一些CSS属性如width、float、border、position、font-size、text-align、overflow-y等等会触发重排、重绘和合成,修改另外一些属性如color、background-color、visibility、text-decoration等等则不会触发重排,只会重绘和合成,具体属性列表请自行google。

接下来不少文章里就会说,修改opacity、transform这两个属性仅仅会触发合成,不会触发重绘和合成。因此必定要用这两个属性来实现动画,没有重绘重排,效率很高。

然而事实并非这样。

只有一个元素在被提高为合成层以后,上述状况才成立。

回到咱们以前说的渲染过程的第11步:

同时,很是关键的一点:在纹理的合并时,借助于3D API的相关合成参数,能够在合并前对纹理transformations(也就是以前提到的位移、旋转、缩放、alpha通道改变等等操做),先变形再合并。合并完成以后就能够将内容呈现到屏幕上了。

在合成多个合成层时,确实能够借助3D API的相关参数,从而直接实现合成层的transform、opacity效果。因此若是你将一个元素提高为合成层,而后用JS修改其transform或opacity 或者在 transform或opacity 上施加CSS过渡或动画,确实会避免CPU的Paint过程,由于transform和opacity能够直接基于GPU的合成参数来完成。

可是,这是在合成层总体有transform或opacity才会这么作。对于没有提高为合成层的元素,仅仅是他本身具备transform和opacity,他是做为合成层的内容。而生成合成层的内容和写进位图或纹理是在Paint和Rasterize阶段完成的,所以这个元素的transform和opacity的实现也是在Paint和Rasterize中完成的。因此仍是会重排,也就没有启用咱们常说的GPU加速的动画。

好比这个demo,一个提高为合成层的div#father和一个未提高合成层的div#child,3秒钟后JS更改child和father的transform属性。 接下来渲染的时候流程是怎样的?

  1. Recalc Styles(从新计算样式)
  2. Paint 绘制变更的合成层 即 div#father
    1. Paint 绘制父元素的背景和textNode(即”父元素 提高为合成层”)
    2. Paint 绘制child元素 即div#child
      1. Paint 先translate,完成移动
      2. Paint 再在移动后的区域里绘制子元素的背景和textNode(即”子元素 未提高为合成层”)
  3. Rasterize
  4. Composite 合并合成层,在合成时借助于3D API的相关合成参数完成合成层的位移、旋转等变换,因此div#father的translate在这里实现

因此咱们看到了,对于未提高合成层的元素,他的transform、opacity等是在主线程里Paint和配合Rasterize来实现的(其余的须要重绘的属性更是如此),依然会触发重绘,直接用JS改动这俩属性并不会得到性能提高。而若是元素已提高为合成层,那么他的transform、opacity等样式的实现就是直接由GPU Thread控制在GPU中Compositing来完成的,主线程的Composite步骤只是计算出合成的参数,耗时极小,速度极快,因此所以就有了尽可能使用transform和opacity来完成动画的经验之谈。

借用这篇文章中的例子:

div {
    height: 100px;
    transition: height 1s linear;
}

div:hover {
    height: 200px;
}
复制代码

这段transition的实现过程是这样的:

而若是代码变成了这样

div {
    transform: scale(0.5);
    transition: transform 1s linear;
}

div:hover {
    transform: scale(1.0);
}
复制代码


也就是Main Thread不用重排,不用重绘,Draw也不是他完成的,他的Composite步骤只是计算出具体的Compositing参数而已(示例中其实右边应该是Compositor和GPU Thread,可是做者为了简化概念、便于阐述,直接就没有提GPU Thread,你们不要在此处扣细节)。

另外,第二个例子中div为何提高为合成层,其实就是前文介绍合成层的时候说的:

对 opacity、transform、fliter、backdropfilter 应用了 animation 或者 transition(须要是 active 的 animation 或者 transition,当 animation 或者 transition 效果未开始或结束后,提高合成层也会失效)

括号中的内容也很关键,元素在opacity等属性具备动画时,并非直接就提高为合成层,而是动画或者transition开始时才提高为合成层,而且结束后提高合成层也失效。
同时,元素在提高为合成层或者提高合成层失效时,会触发重绘。这也是上图一开始在动画开始前有Layout the element first timePaint the element into the bitmap两步的缘由:transition开始前,div并未被提高为合成层,transition开始,div立马提高合成层,立马致使其原本所在的合成层重绘(由于要剔除掉提高为合成层的div),而且div由于提高为合成层,也立马重绘,两个重绘好的合成层Rasterize后上传至GPU中。

demo在此,因此在动画开始前看到:

在动画结束后的那一帧则是这样:

这个上述demo中,只有2个dom,因此Paint开销几乎能够忽略,可是若是是dom数量多一些,那么就极可能是下面这样了。

实时上这个状况不止是在动画和过渡时,只要一个元素被提高为合成层,在提高前和合成层失效时都会有这个过程,因此一方面是重绘带来了绘制开销,另外则是纹理上传过程由于CPU到GPU的带宽带来的上传开销(虽然如今已经有Hardware Raster不用上传,可是仍然有不能用Hardware Raster的状况,并且Hardware Raster绘制进纹理的绘制过程自己也是有开销的)。 所以处理很差就可能致使动画开始前和开始后出现一帧卡顿/延迟。

最后,重要的一点,也是通常谈到性能优化的文章中都会介绍的一点,即:

合成层提高并不是银弹。

合成层提高一方面可能会引入纹理生成、上传和重绘的开销,并且合成层提高后会占用GPU VRAM,VRAM可并不会很大。对于移动端,上述两个问题尤甚。并且在介绍合成层时,我还介绍了合成层存在隐式提高的状况。所以请合理使用。

本文主要介绍原理,因此怎么去实现16ms的动画、怎么去提高渲染性能、怎么去优化合成层数量和避免层爆炸等等、以及到底哪些状况会提高合成层、触发重绘等详细内容仍是见文末附录吧。

总结

正文算是比较详细的介绍浏览器的渲染过程,可能须要你事先理解重绘、重排和合成,结合了一些demo,深刻了一些我以前理解错的点。

这里再次强调一下一些颠覆了我认知的内容:

  • 按照HTML5标准,scroll事件是每帧触发一次的,自带requestAnimationFrame节流效果
  • 按照Blink和Webkit引擎实现,touchmove、mousemove等UI input由Compositor线程接收,但传入到主线程是每帧一次,也自带requestAnimationFrame节流效果
  • 重绘是以合成层为单位的
  • 合成层提高先后的Paint步骤

三周前就第一次发布的文章终于在五一节的假期里搞定。呼….

参考资料

chromium官方资料

渲染机制

实操&&性能优化

相关文章
相关标签/搜索