跳到主要内容

问题

2023年03月01日
柏拉文
越努力,越幸运

一、布局抖动


还有一种比强制同步布局更坏的情况,那就是布局抖动。所谓布局抖动,是指在一次 JavaScript 执行过程中,多次执行强制布局和抖动操作。为了直观理解,你可以看下面的代码:

function foo() {
let time_li = document.getElementById("time_li")
for (let i = 0; i < 100; i++) {
let main_div = document.getElementById("mian_div")
let new_node = document.createElement("li")
let textnode = document.createTextNode("time.geekbang")
new_node.appendChild(textnode);
new_node.offsetHeight = time_li.offsetHeight;
document.getElementById("mian_div").appendChild(new_node);
}
}

我们在一个 for 循环语句里面不断读取属性值,每次读取属性值之前都要进行计算样式和布局。在 foo 函数内部重复执行计算样式和布局,这会大大影响当前函数的执行效率。这种情况的避免方式和强制同步布局一样,都是尽量不要在修改 DOM 结构时再去查询一些相关值

二、帧 VS 帧率


当你通过滚动条滚动页面,或者通过手势缩放页面时,屏幕上就会产生动画的效果。之所以你能感觉到有动画的效果,是因为在滚动或者缩放操作时,渲染引擎会通过渲染流水线生成新的图片,并发送到显卡的后缓冲区。大多数设备屏幕的更新频率是60 次 / 秒,这也就意味着正常情况下要实现流畅的动画效果,渲染引擎需要每秒更新60张图片到显卡的后缓冲区。我们把渲染流水线生成的每一副图片称为一帧,把渲染流水线每秒更新了多少帧称为帧率,比如滚动过程中 1 秒更新了 60 帧,那么帧率就是 60Hz(或者 60FPS)。由于用户很容易观察到那些丢失的帧,如果在一次动画过程中,渲染引擎生成某些帧的时间过久,那么用户就会感受到卡顿,这会给用户造成非常不好的印象。

要解决卡顿问题,就要解决每帧生成时间过久的问题,为此 Chrome 对浏览器渲染方式做了大量的工作,其中最卓有成效的策略就是引入了分层和合成机制。

三、重排和重绘


页面渲染的流程,简单来说,初次渲染时会经过以下6:

  1. 构建DOM树

  2. 样式计算

  3. 布局定位

  4. 图层分层

  5. 图层绘制

  6. 合成显示

CSS属性改变时,重渲染会分为回流重绘直接合成三种情况,分别对应从布局定位/图层绘制/合成显示开始,再走一遍上面的流程。

元素的CSS具体发生什么改变,则决定属于上面哪种情况:

  • 回流(又叫重排): 元素位置大小发生变化导致其他节点联动,需要重新计算布局

  • 重绘: 修改了一些不影响布局的属性,比如颜色

  • 直接合成: 合成层transformopacity修改,只需要将多个图层再次合并,而后生成位图,最终展示到屏幕上

3.1 重排

重排(reflow): 当DOM的变化影响了页面布局或者几何信息(元素的的位置和尺寸大小),浏览器需要重新计算元素的几何属性,将其安放在界面中的正确位置,这个过程叫做重排重排也叫回流,简单的说就是重新生成布局,重新排列元素。

重排发生的场景:

  • 页面初始渲染: 这是开销最大的一次重排

  • 添加/删除可见的DOM元素

  • 元素的位置发生变化

  • 元素的尺寸发生变化: 比如边距填充边框宽度高度

  • 元素的内容发生变化: 比如文字数量图片大小

  • 改变元素字体大小

  • 浏览器窗口尺寸发生变化: 比如resize事件发生时。因为回流是根据视口的大小来计算元素的位置和大小的

  • 激活CSS伪类:(例如::hover

  • 设置style属性的值: 因为通过设置style属性改变结点样式的话,每一次设置都会触发一次reflow,比如说: displaywidth

  • 查询某些属性或调用某些计算方法: offsetWidthoffsetHeight等,除此之外,当我们调用 getComputedStyle 方法,或者IE里的 currentStyle 时,也会触发重排。这些属性有一个共性,就是需要通过即时计算得到,因此浏览器为了获取这些值,也会进行回流。

    • offsetTopoffsetLeftoffsetWidthoffsetHeight

    • scrollTopscrollLeftscrollWidthscrollHeight

    • clientTopclientLeftclientWidthclientHeight

    • getComputedStylecurrentStyle

重排的影响范围: 由于浏览器渲染界面是基于流式布局模型的,所以触发重排时会对周围DOM重新排列,影响的范围有两种:

  • 全局范围: 从根节点html开始对整个渲染树进行重新布局。
  • 局部范围: 对渲染树的某部分或某一个渲染对象进行重新布局,比如说: 把一个dom的宽高之类的几何信息定死,然后在dom内部触发重排,就只会重新渲染该dom内部的元素,而不会影响到外界

减少重排次数的方案:

  1. 样式集中改变: 不要频繁的操作样式,对于一个静态页面来说,明智且可维护的做法是更改类名而不是修改样式,对于动态改变的样式来说,相较每次微小修改都直接触及元素,更好的办法是统一在 cssText 变量中编辑。虽然现在大部分现代浏览器都会有 Flush 队列进行渲染队列优化,但是有些老版本的浏览器比如IE6的效率依然低下。

  2. 分离读写操作: DOM 的多个读操作(或多个写操作),应该放在一起。不要两个读操作之间,加入一个写操作。原来的操作会导致四次重排,读写分离之后实际上只触发了一次重排,这都得益于浏览器渲染优化机制:

    浏览器渲染优化机制

    由于每次重排都会造成额外的计算消耗,因此大多数浏览器都会通过队列存储重排操作并批量执行来优化重排过程。浏览器会将修改操作放入到队列里,直到过了一段时间或者操作达到了一个阈值,才清空队列。

    当你获取布局信息的操作的时候,会强制队列刷新,例如offsetTop等方法都会返回最新的数据。因此浏览器不得不清空队列,触发回流重绘来返回正确的值

    浏览器的渲染队列机制: 当我们修改了元素的几何属性,导致浏览器触发重排或重绘时。它会把该操作放进渲染队列,等到队列中的操作到了一定的数量或者到了一定的时间间隔时,浏览器就会批量执行这些操作。

  3. 在使用 JavaScript 动态插入多个节点时, 可以使用 DocumentFragment 创建后一次插入

  4. 使用absolutefixed脱离文档流: 使用绝对定位会使的该元素单独成为渲染树中 body 的一个子元素,重排开销比较小,不会对其它节点造成太多影响。当你在这些节点上放置这个元素时,一些其它在这个区域内的节点可能需要重绘,但是不需要重排。

  5. 优化动画:

    • 可以把动画效果应用到 position 属性为 absolutefixed 的元素上: 这样对其他元素影响较小

    • 启用 GPU 加速: GPU 硬件加速是指应用 GPU 的图形性能对浏览器中的一些图形操作交给 GPU 来完成,因为 GPU 是专门为处理图形而设计,所以它在速度和能耗上更有效率,开启GPU加速的方法:

      • opacity

      • filters

      • transform

  6. 离线操作: 通过设置元素属性display: none, 将其从页面上去掉,然后再进行后续操作,这些后续操作也不会触发回流与重绘,这个过程称为离线操作

3.2 重绘

重绘(Repaints): 当一个元素的外观发生改变,但没有改变布局,重新把元素外观绘制出来的过程,叫做重绘

重绘发生的场景: colorborder-stylevisiblitybackgroundbackground-size

  • 颜色的变化

  • 文本方向的变化

  • 阴影的变化

四、什么是硬件加速?


浏览器中的层分为两种: 渲染层合成层

渲染层:

渲染层的概念跟层叠上下文密切相关。简单来说,拥有z-index属性的定位元素会生成一个层叠上下文,一个生成层叠上下文的元素就生成了一个渲染层。

合成层: 只有一些特殊的渲染层才会被提升为合成层,通常来说有这些情况:

  • transform:3D变换:translate3dtranslateZ

  • will-change:opacity | transform | filter

  • opacity | transform | filter 应用了过渡和动画(transition/animation

  • videocanvasiframe

硬件加速:

浏览器为什么要分层呢?答案是硬件加速。就是给HTML元素加上某些CSS属性,比如3D变换,将其提升成一个合成层, 独立渲染

之所以叫硬件加速,就是因为合成层会交给GPU(显卡)去处理,在硬件层面上开外挂,比在主线程(CPU)上效率更高。

利用硬件加速,可以把需要重排/重绘的元素单独拎出来,减少绘制的面积。避免重排/重绘,直接进行合成,合成层的transformopacity的修改都是直接进入合成阶段的;

  • 可以使用transform:translate代替left/top修改元素的位置

  • 使用transform:scale代替宽度、高度的修改

五、如何生成一帧图像


六、生成一帧图像的过程


生成一帧图像,最主要有三种方式: 重排重绘合成。这三种方式的渲染路径是不同的,通常渲染路径越长,生成图像花费的时间就越多

  • 重排

    通过JavaScript或者CSS更新了元素的几何位置属性,例如改变元素的宽度、高度等,那么浏览器会触发重新布局,解析之后的一系列子阶段,这个过程就叫重排。无疑,重排需要更新完整的渲染流水线,所以开销也是最大的。

  • 重绘

    通过JavaScript或者CSS更新了元素的绘制属性,那么布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘。。相较于重排操作,重绘省去了布局分层阶段,所以执行效率会比重排操作要高一些。

  • 合成

    通过JavaScript或者CSS更新了元素既不是几何位置也不是绘制的属性,渲染引擎将跳过布局和绘制,只执行后续的合成操作,我们把这个过程叫做合成。比如通过 CSS 实现一些变形、渐变、动画等特效,这可以避开重排重绘阶段,直接在非主线程上执行合成动画操作。这样的效率是最高的。所以相对于重绘重排合成能大大提升绘制效率。

七、同步布局和布局抖动


同步布局:

通过DOM 接口执行添加元素或者删除元素等操作后,是需要重新计算样式布局的,不过正常情况下这些操作都是在另外的任务中异步完成的,这样做是为了避免当前的任务占用太长的主线程时间。所谓强制同步布局,是指 JavaScript 强制将计算样式和布局操作提前到当前的任务中

比如说: 正常操作如下:

<html>
<body>
<div id="mian_div">
<li id="time_li">time</li>
<li>geekbang</li>
</div>

<p id="demo">强制布局demo</p>
<button onclick="foo()">添加新元素</button>

<script>
function foo() {
let main_div = document.getElementById("mian_div")
let new_node = document.createElement("li")
let textnode = document.createTextNode("time.geekbang")
new_node.appendChild(textnode);
document.getElementById("mian_div").appendChild(new_node);
}
</script>
</body>
</html>

强制同步布局如下: 如果要获取到 main_div 的高度,就需要重新布局,所以这里在获取到 main_div 的高度之前,JavaScript 还需要强制让渲染引擎默认执行一次布局操作。我们把这个操作称为强制同步布局。

<html>
<body>
<div id="mian_div">
<li id="time_li">time</li>
<li>geekbang</li>
</div>

<p id="demo">强制布局demo</p>
<button onclick="foo()">添加新元素</button>

<script>
function foo() {
let main_div = document.getElementById("mian_div")
let new_node = document.createElement("li")
let textnode = document.createTextNode("time.geekbang")
new_node.appendChild(textnode);
document.getElementById("mian_div").appendChild(new_node);
//由于要获取到offsetHeight,但是此时的offsetHeight还是老的数据, 所以需要立即执行布局操作

console.log(main_div.offsetHeight)
}
</script>
</body>
</html>

避免强制同步布局如下:为了避免强制同步布局,我们可以调整策略,在修改 DOM 之前查询相关值。

<html>
<body>
<div id="mian_div">
<li id="time_li">time</li>
<li>geekbang</li>
</div>

<p id="demo">强制布局demo</p>
<button onclick="foo()">添加新元素</button>

<script>
function foo() {
let main_div = document.getElementById("mian_div")
//为了避免强制同步布局,在修改DOM之前查询相关值
console.log(main_div.offsetHeight)
let new_node = document.createElement("li")
let textnode = document.createTextNode("time.geekbang")
new_node.appendChild(textnode);
document.getElementById("mian_div").appendChild(new_node);
}
</script>
</body>
</html>

八、分层、分块和合成机制


通常页面的组成是非常复杂的,有的页面里要实现一些复杂的动画效果,比如点击菜单时弹出菜单的动画特效,滚动鼠标滚轮时页面滚动的动画效果,当然还有一些炫酷的 3D 动画特效。如果没有采用分层机制,从布局树直接生成目标图片的话,那么每次页面有很小的变化时,都会触发重排或者重绘机制,这种“牵一发而动全身”的绘制策略会严重影响页面的渲染效率。

为了提升每帧的渲染效率,Chrome 引入了分层和合成的机制。将素材分解为多个图层的操作就称为分层,最后将这些图层合并到一起的操作就称为合成。所以,分层和合成通常是一起使用的。在Chrome的渲染流水线中,分层体现在生成布局树之后,渲染引擎会根据布局树的特点将其转换为层树(Layer Tree)。层树中的每个节点都对应着一个图层,下一步的绘制阶段就依赖于层树中的节点。绘制阶段会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表。有了绘制列表之后,就需要进入光栅化阶段了,光栅化就是按照绘制列表中的指令生成图片。每一个图层都对应一张图片,合成线程有了这些图片之后,会将这些图片合成为“一张”图片,并最终将生成的图片发送到后缓冲区。这就是一个大致的分层合成流程合成操作是在合成线程上完成的,这也就意味着在执行合成操作时,是不会影响到主线程执行的。

如果说分层是从宏观上提升了渲染效率,那么分块则是从微观层面提升了渲染效率。通常情况下,页面的内容都要比屏幕大得多,显示一个页面时,如果等待所有的图层都生成完毕,再进行合成的话,会产生一些不必要的开销,也会让合成图片的时间变得更久。因此,合成线程会将每个图层分割为大小固定的图块,然后优先绘制靠近视口的图块,这样就可以大大加速页面的显示速度。

九、显示器是怎么显示图像的


每个显示器都有固定的刷新频率,通常是60HZ,也就是每秒更新 60 张图片,更新的图片都来自于显卡中一个叫前缓冲区的地方,显示器所做的任务很简单,就是每秒固定读取 60 次前缓冲区中的图像,并将读取的图像显示到显示器上

那么这里显卡做什么呢?

显卡的职责就是合成新的图像,并将图像保存到后缓冲区中,一旦显卡把合成的图像写到后缓冲区,系统就会让后缓冲区前缓冲区互换,这样就能保证显示器能读取到最新显卡合成的图像。通常情况下,显卡的更新频率和显示器的刷新频率是一致的。但有时候,在一些复杂的场景中,显卡处理一张图片的速度会变慢,这样就会造成视觉上的卡顿。

十、为什么经常主线程卡住了,但是 CSS 动画依然能执行?


完成CSS动画效果的合成操作是在合成线程上完成的,这也就意味着在执行合成操作时,是不会影响到主线程执行的

  • 浏览器是怎么解决渲染分块时纹理上传的问题?

    有时候, 即使只绘制那些优先级最高的图块,也要耗费不少的时间,因为涉及到一个很关键的因素——纹理上传,这是因为从计算机内存上传到 GPU 内存的操作会比较慢。为了解决这个问题,Chrome 又采取了一个策略:在首次合成图块的时候使用一个低分辨率的图片。比如可以是正常分辨率的一半,分辨率减少一半,纹理就减少了四分之三。在首次显示页面内容的时候,将这个低分辨率的图片显示出来,然后合成器继续绘制正常比例的网页内容,当正常比例的网页内容绘制完成后,再替换掉当前显示的低分辨率内容。这种方式尽管会让用户在开始时看到的是低分辨率的内容,但是也比用户在开始时什么都看不到要好。

  • 为什么CSS动画比JavaScript动画高效呢?

    • 从实现动画的复杂度来看: CSS 动画大多数都是补间动画,而 JS 动画是逐帧动画
      • CSS 补间动画: 只需要添加关键帧的位置,其他的未定义的帧会被自动生成
      • JS 逐帧动画: 在时间帧上绘制内容,一帧一帧的,所以他的可再造性很高,几乎可以完成任何你想要的动画形式
    • 编码的高效: 采用 JS 去实现的动画,无论多简单的动画,都需要去控制整个过程
    • 性能的高效: 我们要操作一个元素向右移动,我们可能需要控制 dom.style.left 属性,每次来改变元素的位置,而结合我们所说的,几何属性的改变必然会引起回流,回流必然引起重绘,可想而知如果我们采用 JS 来实现动画,这个代价有多大,这会造成浏览器在不断的计算页面,从而导致浏览器内存堆积。
    • 线程的高效: 由于JavaScript运行在浏览器的主线程中,主线程中还有其他的重要任务在运行,因而可能会受到干扰导致线程阻塞,从而丢帧。CSS 的动画是运行在合成线程中的,不会阻塞主线程,并且在合成线程中完成的动作不会触发回流和重绘
    • 硬件的高效: JavaScript 动画运行在CPU,而CSS动画运行在GPU

    总结: CSS动画的渲染成本小,并且它的执行效率高于JavaScript 动画。所以,只要能用 CSS 实现的动画,就不要采用 JS 去实现

  • 如何利用分层技术优化代码

    在写 Web 应用的时候,你可能经常需要对某个元素做几何形状变换、透明度变换或者一些缩放操作,如果使用 JavaScript 来写这些效果,会牵涉到整个渲染流水线,所以 JavaScript 的绘制效率会非常低下。这时你可以使用will-change来告诉渲染引擎你会对该元素做一些特效变换,CSS 代码如下:

    .box {will-change: transform, opacity;}

    这段代码就是提前告诉渲染引擎 box 元素将要做几何变换和透明度变换操作,这时候渲染引擎会将该元素单独实现一帧,等这些变换发生时,渲染引擎会通过合成线程直接去处理变换,这些变换并没有涉及到主线程,这样就大大提升了渲染的效率。所以,如果涉及到一些可以使用合成线程来处理 CSS 特效或者动画的情况,就尽量使用 will-change来提前告诉渲染引擎,让它为该元素准备独立的层。但是凡事都有两面性,每当渲染引擎为一个元素准备一个独立层的时候,它占用的内存也会大大增加,因为从层树开始,后续每个阶段都会多一个层结构,这些都需要额外的内存,所以你需要恰当地使用will-change

十一、background-image 是怎么加载图片的? 与 img 标签相比, 哪种方式先加载? 哪种方式性能更好?


  • <img />: 是Html标签, 浏览器在解析 HTML 代码时,遇到 <img> 标签会直接请求对应的图片,并将其插入到文档流中。因此,图片会和其他 HTML 元素一起加载,如果图片过大或数量过多,会导致网页加载缓慢。如果引入了一个很大的图片, 那么在这个图片下载完成之前, <img /> 后的内容都不会显示。

    • 加载操作: 可以设置 loading 属性来指定图片的加载方式(如 lazy 可以延迟加载图片)

    • 适用场景:

      • <img /> 标签可以添加alt属性, 能更好的SEO, 有着更好的语义化;也可以被一些读屏软件识别。一般来说,如果图片是周围的文字内容的一部分,比如logo,图表,人等,那么用img

      • <img /> 内容支持打印。所以如果有打印网页的需求,且希望将图片的内容也打印出来, 那么需要使用 <img />

  • background-image: 是Css属性, 会等到DOM结构加载完成后, 具体的话就是当浏览器解析到这个样式时,不会立即请求图片,而是等到元素在文档流中展现时才会请求。如果引入了一个很大的图片, 等到DOM结构加载完成之后,才开始加载背景图片, 所以background-image不会影响网页内容的显示。这样可以减少在页面渲染之前请求的图片数量,提高页面的加载速度。但如果同时加载多张背景图片,也会影响页面性能。

    • 适用场景:

      • background-image 图片内容不是内容的一部分, 属于UI设计装饰性的图片, 所以建议使用background-image

      • background-image 内容不支持打印。所以如果有打印网页的需求,但是不希望将图片的内容也打印出来, 那么需要使用 background-image

参考资料


阿里面试官的”说一下从url输入到返回请求的过程“问的难度就是不一样!