admin 管理员组

文章数量: 887032

目录

什么是异步任务

Event Loop和消息队列

JS代码的执行环境

延时队列

微任务队列

requestAnimationFrame和setTimeout

 event loop处理模型

 浏览器渲染流程

生成DOM

HTML和JS、JS和CSS之间存在的互斥的关系

布局阶段(生成布局树)

分层阶段(生成分层树)

图层绘制

栅格化

合成

性能分析

重绘、重排、合成

关于DOM操作


什么是异步任务

 异步任务,其实也就是非 “立即执行” 的任务。与之相对的,就是正在执行的同步任务。常见的异步任务又被分为了微任务与宏任务

微任务(microTask):Promise, MutationObserver, queueMicrotask;

宏任务(task):SetTimeout, setInterval, RequestAnimationFrame, RequestIdleCallback, 事件回调, Ajax等;

其中,宏任务(非官方叫法),即通过轮询机制调度的任务;而微任务相较于宏任务时间颗粒度比较细,也可以简单理解为执行优先级较高的异步任务。

Event Loop和消息队列

在单线程的执行环境中,任意一个任务的阻塞都会影响到它之后待执行的任务。因此,合理的调度就显得尤为重要,而这就是消息队列和event loop所做的事情。

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. Each agent has an associated event loop, which is unique to that agent.

——from whatwg


为了协调事件、用户交互、脚本、渲染、网络等等,user agents必须使用event loop。每个agent都有一个关联的event loop,并且该event loop对应agent是唯一的。

消息轮询可以简单理解为下面的抽象模型:

浏览器自动会将html的解析一开始就默认添加至消息队列之中,这也就是为什么页面不需要额外操作就能默认渲染出来。当首屏渲染完成之后,此时就会从队列中找寻下一个任务执行,比如此时setTimeout了一个任务,那么此时就会将其拿出,然后执行。 

JS代码的执行环境

我们都听说过栈空间和堆空间这两种不同的存储类型。而其中的栈空间,其实就是JS代码执行所在。每一段代码的运行,都会处于一个执行上下文栈,又或者叫做函数调用栈。JS就是通过栈的特性来管理函数的上下文关系的。

  • 栈空间就是调用栈所在的空间,用来存放执行上下文和基本类型变量;
  • 栈空间一般不会设置太大,用来存放一些原始类型的小数据;
  • 栈空间主要用来管理函数调用,栈式内存中连续的一块空间,同时栈结构也是先进后出的策略,符合函数调用的状态保存;
  • 每一个调用的函数,都会创建一个执行上下文活动对象,在其执行完毕时销毁(不考虑闭包) ,JS通过一个栈的数据结构来管理多个执行上下文;

JS首次执行代码时,会创建一个全局上下文,而函数执行时,存在函数相对应的执行上下文。当某个上下文的代码执行完毕时,该上下文就会弹出栈,而之后又有任务时就会创建新的执行上下文,并基于该上下文执行代码。一切处于正在执行的上下文中的代码,都属于同步代码。

为什么使用栈结构来管理函数调用?

1,函数可以被函数调用。因此需要通过栈来储存父函数的执行状态,等到子函数执行结束之后,会将代码执行控制权返还给父函数;

2,函数具有作用域机制。函数内部定义的变量又被称为临时变量,临时变量只能在该函数中被访问,外部函数无权访问,而函数执行结束时,存放在内存中的临时变量也随之被销毁;

现在我们将栈空间结合事件轮询机制一起来看:

延时队列

< ​​​​​​参考:​《setTimeout是如何实现的》

除了正常使用的消息队列之外,浏览器还会维护另一个需要延迟执行的任务列表。包括了定时器和浏览器内部一些需要延迟执行的任务。当JS创建定时器时,渲染引进就会将该定时器的回调任务添加至延迟队列中。

setTimeout的实现,就是在JS调用setTimeout方法,执行到当前代码的时候,同步的创建一个延时任务。该任务包含回调函数、当前发起时间、延迟执行时间。创建好任务,再将该任务添加至延迟队列中。在浏览器的底层代码中会存在一个processDelayTask的函数。每一次任务轮询,处理完消息队列的任务,就会调用processDelayTask函数。该函数会根据发起时间和延迟时间计算出到期的任务,然后依次将这些任务添加至消息队列当中。全部都处理完成之后,才会开始下一次轮询。

clearTimeout的实现,就是根据Id从延迟队列hashMap中删除该定时器任务。

所以上面的流程图,更准确来说应该是这样的:

whatwg规范规定,消息队列本质上并不是一个queue,并且,也不仅仅只有一个。 

Task queues An event loop has one or more task queues. A task queue is a set of tasks.

Task queues are sets, not queues, because the event loop processing model grabs the first runnable task from the chosen queue, instead of dequeuing the first task.

—— from whatwg

但是V8实现上,目前只维护了两个“队列”,即消息队列和延时队列。< 出处

微任务队列

event loop中存在微任务队列(microtask queue)。而该微任务队列,就是我们常用到的微任务的执行环境。

microtask queue:

Each event loop has a microtask queue, which is a queue of microtasks, initially empty. A microtask is a colloquial way of referring to a task that was created via the queue a microtask algorithm.

——from whatwg


每个event loop都有一个微任务队列,即一个最初为空存放microtasks的queue结构。微任务是一种口语化方式表示通过queue a microtask算法创建的任务。

微任务的执行依赖于事件轮询中微任务的“检测点”(microtask checkpoint)。检测点作为执行微任务的时间点,同时也作为标识防止重复执行。

微任务的检测点  通常情况下,当前宏任务快执行完之时,也就是在JS引擎准备退出当前执行上下文的时候,JS引擎会检测微任务队列,然后按照入栈顺序执行这些微任务。

微任务的执行时机为:同步代码执行结束之后、当前宏任务结束之前。  < 出处

 

requestAnimationFrame和setTimeout

为何推荐使用rAF(requestaAnimationFrame缩写)而不是setTimeout?

const div = document.querySelector('div')

setTimeout(() => {
  div.style.backgroundColor = 'red'
  
  useTimer()
  // useRAF()
},500)

function useRAF(){
  requestAnimationFrame(() => {
    for(let i = 0; i <= 2000;i++){
      console.log(i)
    }
  div.style.backgroundColor = 'yellow'
  })
}

function useTimer(){
  setTimeout(() => {
    for(let i = 0; i <= 2000;i++){
      console.log(i)
    }
  div.style.backgroundColor = 'yellow'
  })
}

使用rAF:

 使用setTimeout:

简化版的performance对比如下:

rAF一定会在paint之前执行。或许其task的占用时间会很长,但是导致的现象为原本应该出现在第二帧的内容,被推迟到第三帧才绘制完成,最终展示;而setTimeout则不会有这种特性。当setTimeout的task时间过长时,浏览器会优先将这一帧的内容paint完成,而setTimeout的任务则被推到paint之后执行。

二者的区别就在于:rAF是将所有的效果都推迟了一帧,最终展示bgColor为yellow;setTimeout则是会在第二帧展示red,第二帧以后展示yellow。

而视觉上,我们对推迟执行的感知不大,但是对“一闪而过”感知极大。当然,并不是每个setTimeout都会出现这种现象,只是对于rAF来说,setTimeout更加不可控。

而其根本原因就在于,在event loop中有一个 "timing"(rendering opportunity的指标之一,更新渲染的时间点)的概念 。并不是每一轮轮询都存在timing,也只有当timing到达时,才会Update the rendering(更新渲染)rAF的执行时机,发生在Update the rendering之前。因此当前这一轮的渲染更新,是一定会反映rAF中的样式更新的;但是setTimeout的执行时机和Update the rendering并无联系,这也就意味着,可能setTimeout的回调是和前一个任务在一个timing内,也有可能是在两个不同的timing当中。若处于两个timing,那么回调中的更新,会等到下一个Update the rendering中呈现。

Timing:

if the browser is attempting to achieve a 60Hz refresh rate, then rendering opportunities occur at a maximum of every 60th of a second (about 16.7ms). If the browser finds that a browsing context is not able to sustain this rate, it might drop to a more sustainable 30 rendering opportunities per second for that browsing context, rather than occasionally dropping frames. Similarly, if a browsing context is not visible, the user agent might decide to drop that page to a much slower 4 rendering opportunities per second, or even less.

——from whatwg


如果浏览器试图实现60Hz的刷新率,那么呈现机会最多每60秒(约16.7ms)出现一次。如果浏览器发现不能维持这个速率,它可能会降低到每秒30次刷新,以此来避免掉帧。类似地,如果浏览上下文不可见,user agent 可能会决定将该页面刷新率降低到每秒4次,甚至更少。

 rendering opportunity:

browsing context has a rendering opportunity if the user agent is currently able to present the contents of the browsing context to the user, accounting for hardware refresh rate constraints and user agent throttling for performance reasons, but considering content presentable even if it's outside the viewport.

browsing context has no rendering opportunities if its active document is render-blocked; otherwise, rendering opportunities are determined based on hardware constraints such as display refresh rates and other factors such as page performance or whether the document's visibility state is "visible". Rendering opportunities typically occur at regular intervals.

——from whatwg


如果user agent当前能够将当前浏览上下文的内容呈现给用户(有些内容即使在视口之外,其变化也是可见),并且需要满足硬件刷新率的限制和user agent出于性能原因的节流,那么此时就有rendering opportunity

如果一个浏览上下文的活动文档是render-blocked的,那么它就没有rendering opportunity; 否则,rendering opportunity是基于硬件约束(如显示刷新率)和其他因素(如页面性能或文档的可见性状态是否“visible”)来确定的。Rendering opportunities通常定期出现。

注:并不是所有浏览器的rAF都是符合规范实现的。

 event loop处理模型

< 参考:whatwg规范 & V8实现 & 深入解析你不知道的 EventLoop

 

 更详细的event loop流程参考:whatwg规范 或 深入解析你不知道的 EventLoop

rendering opportunity:

browsing context has a rendering opportunity if the user agent is currently able to present the contents of the browsing context to the user, accounting for hardware refresh rate constraints and user agent throttling for performance reasons, but considering content presentable even if it's outside the viewport.

browsing context has no rendering opportunities if its active document is render-blocked; otherwise, rendering opportunities are determined based on hardware constraints such as display refresh rates and other factors such as page performance or whether the document's visibility state is "visible". Rendering opportunities typically occur at regular intervals.

——from whatwg


如果user agent当前能够将当前浏览上下文的内容呈现给用户(有些内容即使在视口之外,其变化也是可见),并且需要满足硬件刷新率的限制和user agent出于性能原因的节流,那么此时就有rendering opportunity

如果一个浏览上下文的活动文档是render-blocked的,那么它就没有rendering opportunity; 否则,rendering opportunity是基于硬件约束(如显示刷新率)和其他因素(如页面性能或文档的可见性状态是否“visible”)来确定的。Rendering opportunities通常定期出现。

Unnecessary rendering:

  • The user agent believes that updating the rendering of the Document's browsing context would have no visible effect, and
  • The Document's map of animation frame callbacks is empty.

 ——from whatwg


  • user agent 认为更新 Document 的浏览上下文不会产生可见的效果
  • 未设置requestAnimationFrame

 浏览器渲染流程

<参考:《渲染流程》 & 博客

以下是浏览器渲染的流程图:
​​​​​​​

 

初次渲染可以分为如下几个主要环节:

生成DOM

浏览器会首先解析HTML,在遇到CSS或JS资源的时候,会进行加载执行。

对于HTML、CSS、JS文件的解析和执行,存在如下两种说法:

1. HTML、CSS、JS的解析执行都在主线程内解析执行。但是在JS解析执行的时候,会开辟一个新的线程去解析剩余的资源(如其他JS资源、CSS资源、图片资源等)。

 The rendering engine is single threaded. Almost everything, except network operations, happens in a single thread. In Firefox and Safari this is the main thread of the browser. In Chrome it's the tab process main thread.

While executing scripts, another thread parses the rest of the document and finds out what other resources need to be loaded from the network and loads them. In this way, resources can be loaded on parallel connections and overall speed is improved. Note: the speculative parser only parses references to external resources like external scripts, style sheets and images.<出处

 

2.  CSS的加载和解析会单独开辟一个异步请求线程,其可以与HTML解析的渲染引擎线程并行;而JS资源虽然也是在其独立的JS引擎线程中进行解析执行,但是其会与渲染引擎线程相斥,在加载和解析执行JS的时候,会中止HTML的解析,直到JS执行完毕。不过虽然CSS的辅助线程和渲染引擎线程(HTML解析线程)可以并行执行,但是其与JS引擎线程互斥,并且CSS的解析是会中止JS引擎线程执行的。<出处

其中第一种说法可信度较高,参考文献和依据也有迹可循;第二种仅供参考。

HTML和JS、JS和CSS之间的阻塞关系

HTML和JS:当HTML文档解析的过程中,遇到了<script>标签(没有设置async或defer),那么HTML的解析就会被中止,转而加载、解析、执行JS。这或许是因为DOM提供给JS脚本可以操作修改文档的接口,从而导致DOM结构会被JS所影响。但无论是何种理由,从最初的设定来说,没有设置async或者defer的scripts都是同步的、立即执行的

The model of the web is synchronous. Authors expect scripts to be parsed and executed immediately when the parser reaches a <script> tag. The parsing of the document halts until the script has been executed. If the script is external then the resource must first be fetched from the network - this is also done synchronously, and parsing halts until the resource is fetched. 

  ——from 《how browser work》

当然,你也可以用defer或者async属性来改变这种行为:

Authors can add the "defer" attribute to a script, in which case it will not halt document parsing and will execute after the document is parsed. HTML5 adds an option to mark the script as asynchronous so it will be parsed and executed by a different thread.

  ——from 《how browser work》

JS和CSS:JS具有操作样式的权限。若在JS执行的时候,CSS并未解析执行完成,那么此时通过JS去访问style information是会报错的。因此,当CSS正在解析执行的时候,JS资源将会被阻塞。对于Firefox来说,整个JS的解析执行都会被阻塞;而WebKit只会在脚本试图访问style的时候进行阻塞。

scripts asking for style information during the document parsing stage. If the style is not loaded and parsed yet, the script will get wrong answers and apparently this caused lots of problems. It seems to be an edge case but is quite common. Firefox blocks all scripts when there is a style sheet that is still being loaded and parsed. WebKit blocks scripts only when they try to access certain style properties that may be affected by unloaded style sheets.

 ——from 《how browser work》

布局阶段(生成布局树)

计算出DOM树中可见元素的几何位置,这个计算过程就叫布局。主线程会遍历 DOM 树和样式,然后构造出一颗布局树。 这棵树上的节点都带有 x、y 坐标和边界框大小之类的信息。布局树和 DOM 树的结构类似,但是树上只包含页面可见元素的信息。如果元素被设置了 display: none,那么布局树就不会包含这个元素。同样的,如果一个内容是通过伪类(比如 p::before { content: 'Hi!' })添加进来的,那么这个元素会被包含在布局树中,但是 DOM 树中没有。

分层阶段(生成分层树)

针对页面种不同的层叠层级,渲染引擎会为特定的节点生成专用的图层,并生成一颗图层树。 浏览器的页面实际被分成了很多图层,这些图层叠后合成了最终的页面。 并不是每一个节点都包含一个图层,如果一个节点没有对应的层,则这个节点就从属于父节点的图层。

层叠层级:

1,拥有层叠上下文的元素会被提升为单独的一层(position + z-index);

2,需要剪裁(clip)的地方会被创建为图层。比如div种溢出的文字,如果内容超出了div大小,这个时候就会形成剪裁,渲染引擎会把剪裁的文字内容的一部分用于显示在div区域;出现这种剪裁的情况,渲染引擎会为文字部分单独创建一个图层,如果出现滚动条,则滚动条也会被提升为单独的层;

图层绘制

在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制。 渲染引擎会把一个图层的绘制拆分成很多个小的绘制指令,然后再将这些指令按照顺序组成一个待绘制列表。绘制列表中的指令,就是让其执行一个简单的绘制操作。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景,前景,边框都需要单独的指令去绘制。因此,在图层绘制阶段,并非真实的绘制,而是输出这些待绘制列表。

栅格化

当图层的绘制列表准备好之后,主线程就会把该绘制列表提交给合成线程。合成线程会将内容划分成图块后发送给栅格线程进行栅格化操作。所谓栅格化,是指将图块转换为位图。实际生成位图的操作是由栅格化来执行的,所处理位图的视图优先级决定了栅格化线程的优先级。渲染进程维护了许多栅格化线程,所有图块的栅格化都是在线程池内执行的。

图块:

一个图层或许会很大,比如有些页面需要拖动滚动条很久才能滚动到底部。但是通过视口,用户只能看到页面的很小一部分。在这种情况下,要绘制出所有图层内容的话,就会产生很大没必要的开销。 基于这个原因,合成线程会将图层划分为图块。这些图块的大小通常是256x256,或者512x512

  

合成

合成线程会根据栅格线程不同的优先级处理图块,比如它会优先处理视窗(及附近)的图块。并且图块还具有不同分辨率的图块,以便在用户放大、缩放时使用。合成线程会收集这些图块的信息(绘制图块)来创建合成帧。之后会生成一个绘制图块的指令,提交给浏览器进程。 浏览器进程接收到指令后,根据该指令将其合成帧内容发送到到显卡的前缓冲区中,然后再由浏览器主进程读取并将内容显示在屏幕上。如果发生了滚动,合成线程会创建另一个合成帧发送给显卡。

性能分析

<参考:《how browsers work》

重绘、重排、合成

重排(reflow):如果修改了元素的几何位置属性,浏览器会触发重新布局,解析之后的一系列子阶段。这个过程就叫重排。 重排需要更新较完整的渲染流水线,所以开销会很大;

重绘(repaint):如果只是改变了某些和几何属性无关的属性,如颜色等,那么布局阶段就不会被执行。因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段。这个过程就叫重绘。 重绘省去了布局和分层阶段,所以执行效率会比重排操作高一些;

合成:如果使用了CSS的transform来实现动画效果,可以直接跳布局和绘制,避开重排和重绘,直接在非主线程上执行合成动画操作。这样的效率是最高的,因为在非主线程上合成,并没有占用主线程的资源,也比开了布局和绘制两个子阶段,因此,相对于重绘和重排,合成能大大提升绘制效率。

详解参考:浏览器渲染详细过程 的 重排和重绘和Compositing 部分

可以用以下两个例子在while(true){} 的影响下观察合成和重排(重绘同理)的差异:

      @keyframes test1 {
        from {
          transform: translate(0px, 0px);
        }
        to {
          transform: translate(1000px, 1000px);
        }
      }
      @keyframes test2 {
        from {
          width: 0px;
        }
        to {
          width: 100px;
        }
      }

test1的动画不受影响,而test2动画被阻塞。这是因为合成的进行是处于合成线程,与主线程互不干扰;

关于DOM操作

在交互过程中,绝大部分的呈现使用对DOM的操作来实现。比如更改某个DOM的样式,或者删除一些DOM,或者新增一些DOM。对于我们的框架(vue / react / angular)来说,底层也同样也需要通过对DOM的操作来最终更新页面。

DOM操作真的很奢侈吗?或许我们经常会听到,操作DOM是一个性能不友好的行为。但事实并非如此。如果我们不关心过程,只关心结果的话,直接通过原生JS来操作DOM实现页面更新,一定是开销最小的行为。就像上面说到的,我们的框架依旧是通过操作DOM来实现页面更新,而其中复杂的监听/检测数据更新的逻辑实现,一定是会带来额外的开销的。只是说,这个开销,相对于框架对编写代码的体验提升来说,显得微不足道。

当然,DOM不等于页面,JS也没有直接操作页面的功能。JS可操作DOM,而DOM是生成页面的源。DOM更新,和页面更新是两回事。

统一DOM操作,真的节省性能吗?网络上流传着一种说法:将对DOM的操作统一起来(类似节流),会比直接随意的操作DOM要更节省性能。伴随这种说法的论据就是 虚拟DOM 以及 fragment 。但事实上这也是不正确的。浏览器对DOM操作的响应(rerender),本身就是一个异步的过程。

Incremental layout is done asynchronously.

——from 《how browser work》

总结来说,操作DOM是同步的,但是页面的更新是异步的

因此,你可以放心大胆的去操作DOM。当然,也存在例外:如强制同步布局

Scripts asking for style information, like "offsetHeight" can trigger incremental layout synchronously.

 ——from 《how browser work》

对于一些布局信息相关的属性(HTMLElement.offset[ Width | Height | Top | Left | Parent ]),会导致浏览器的强制同步布局,也就是将异步的render强制为同步实现,从而带来性能浪费。

for(let i = 0; i <=1000; i++){
  div.style.width = i + 'px'
  div.offsetWidth
}

如上述的例子,是否注释掉 "div.offsetWidth" 带来的代码性能差距极大:

注释div.offsetWidth:

整体在近1ms内完成,并且reflow(紫色部分)异步执行;

保留div.offsetWidth:

 总共耗时20ms以上,并且reflow同步执行多次(最下面密密麻麻的紫色部分)。

当然,强制同步布局并不意味着会同步绘制到页面上,更准确的说应该是生成布局树的部分被提前了,而最终的paint依旧是异步执行的(最末尾有个小小的绿色部分)。现象上来讲,页面上能感知到的依旧是盒子从1px宽度一瞬间变成1000p。只是时间上来说会延后一些,并不能观察到逐渐增加的过程。

此时,使用到 “节流手段” 统一插入DOM确实是会节省性能:

  const fragment = document.createDocumentFragment()
  const son = document.createElement('div')

  fragment.appendChild(son)
  for(let i = 0; i <=1000; i++){
    son.style.width = i + 'px'
    console.log(son.offsetParent,son.offsetHeight)
  }
  
  document.body.insertBefore(fragment,div)
  document.body.removeChild(div)

因为本身fragment在for循环之时还未插入DOM文档中,因此即使访问了位置信息也不会导致重新布局。当然,此时打印出来的offset相关的属性也都是无效值(null, 0)。

参考:

how browsers work: https://web.dev/howbrowserswork/

whatwg: https://html.spec.whatwg/multipage/webappapis.html

浏览器工作原理与实践:https://time.geekbang/column/intro/100033601

Event loop: https://www.youtube/watch?v=cCOL7MC4Pl0 

v8事件轮询源码:https://source.chromium/chromium/chromium/src/+/main:base/task/sequence_manager/README.md

Tasks microTasks queues schedules:https://jakearchibald/2015/tasks-microtasks-queues-and-schedules/

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

深入解析你不知道的 EventLoop 和浏览器渲染、帧动画、空闲回调(动图演示) - 掘金

V8 Promise源码全面解读 - 掘金

setTimeout精确度问题

本文标签: 浏览器