当事件循环遇到更新渲染

avatar
公众号:转转技术

前言

说到js的执行机制,相信大家都可以说出来:

  1. js引擎在执行过程中,遇到一个异步事件后并不会一直等待其返回结果,而是将其挂起(等异步任务返回结果,就会添加到事件队列中),继续执行执行栈中的任务;
  2. 每次执行栈完毕后,立刻去处理微任务队列;
  3. 微任务队列处理完成后,再去检查事件队列是否有任务,如果有就继续执行...

大家有没有想过一个问题:我们都知道js会操作DOM,然后DOM进行更新渲染,那在event loop每一轮的loop执行完成后,浏览器页面都会更新渲染吗?先不着急做出答案,大家可以感受一下我们提供的题目,然后再继续阅读下面的内容。

浏览器架构

那我们今天就从浏览器的架构和渲染进程说起,重新认识一下event loop

进程和线程

进程(Process)是操作系统资源分配的基本单位,即正在运行的应用实例。启动应用就会创建一个进程,例如:打开一个浏览器就是启动了一个浏览器进程,打开一个VS Code编辑器就是启动了一个VS Code编辑器进程,打开一个Typora就是启动了一个Typora进程...

每个进程都有自己独立的一块内存空间,关闭该进程时,操作系统会释放本进程的内存空间。由于不同进程间是相互独立的,各自拥有自己的内存空间资源,所以两个进程之间的通信,需要通过进程间通信IPC(Inter-Process Communication)来实现。

线程(Thread)是处理器任务调度和执行的基本单位,即存在于进程并执行程序任意部分,每个进程至少做一件事,所以一个进程至少有一个线程,甚至多线程进行工作。

同一进程的线程会共享本进程的内存地址空间,线程之间的通信可以通过全局变量进行通信,所以如果多个线程在操作写变量时将会带来不可预测的后果,也就引入了各种锁(例如互斥锁)的作用。同时,多个线程也是不安全的,当一个线程崩溃了,将会导致整个进程崩溃。但多个进程之间就不会,一个进程崩溃了,另一个进程仍然可以继续运行。

说到线程,就不得不说一下协程(Coroutine):又称微线程,协程是属于线程的,是在线程里面跑的。协程的特点在于是一个线程执行的,协程的调度切换是由用户(程序本身)手动控制的,不存在线程上下文切换的消耗;同时,因为协程是在一个线程执行的,所以不需要多线程的锁机制。所以协程的执行效率比多线程高的多。

浏览器架构模式

单进程架构

早期浏览器的架构设计就是单进程的,浏览器所有的功能模块都是运行在同一个进程里的。浏览器页面行为不当、浏览器错误、浏览器插件错误都会引起整个浏览器或当前运行的标签页关闭;同时通过浏览器插件可以获取操作系统的任意资源,也会引起安全性问题。

多进程架构

我们接下来看下Chrome的多进程架构设计:1个浏览器主进程、1个GPU进程、多个渲染进程和多个插件进程。

进程控制
浏览器控制应用中的 “Chrome” 部分,包括地址栏,书签,回退与前进按钮。以及处理 web 浏览器不可见的特权部分,如网络请求与文件访问。
渲染控制标签页内网站展示。
插件控制站点使用的任意插件,如 Flash。
GPU处理独立于其它进程的 GPU 任务。GPU 被分成不同进程,因为 GPU 处理来自多个不同应用的请求并绘制在相同表面。

多进程的架构模式相较于单进程架构有了很大的提升:

  1. 多个进程的设计,避免了某个进程的崩溃影响其他进程或者整个浏览器的崩溃;
  2. 同样多进程架构可以使用安全沙箱,操作系统提供了限制进程权限的方法,浏览器就可以用沙箱保护某些特定功能的进程。

但是多进程还是不可避免的带来了一些问题:每个进程都会包含公共基础结构的副本(如 V8,它是 Chrome 的 JavaScript 引擎),就会消耗更多的内存;浏览器各模块之间耦合性高、扩展性差等问题。

面向服务的架构

Chrome 服务化

为了解决之前多进程架构带来的一系列问题,Chrome官方团队使用面向服务的架构(Services Oriented Architecture,简称 SOA )的思想设计了新的 Chrome 架构。当Chrome在硬件性能强大设备上,可以将浏览器程序的每一个模块作为一个服务来运行,每个服务都可以运行在独立的进程中(如下图中Network Service),从而提升稳定性;而在资源受限的设备上,又会将这些服务聚合到浏览器进程中从而节省了内存占用。

渲染进程

通过上面的了解,我们知道在Chrome浏览器中,每次新开一个标签页,都会创建一个新的渲染进程,而渲染进程在标签页中又是扮演着重要的角色,负责标签页内发生的所有事情。其核心工作就是将HTML、CSS和JavaScript转换为用户与之交互的网页。

渲染进程包括多个线程工作:

  1. 主线程:运行JavaScript、DOM、CSS、样式布局计算;

  2. 工作线程:运行Web Worker,Service Worker;

  3. 合成线程:将图层分成图块,并发送绘制命令发送给浏览器进程(生成页面,显示在显示器上);

  4. 光栅线程:将图块转换成位图并发送到 GPU。

而在主线程解析HTML时,遇到<script>标记时,就会暂停HTML的解析,开始加载、解析并执行JavaScript代码。这样就造成了HTML解析的阻塞,而JavaScript代码的执行又是为什么会阻塞HTML解析呢?这是因为JavaScript代码里可以通过类似document.write()的方法改写文档,这样就会导致HTML文档整体结构的变化。

事件循环

了解完上面的浏览器内容,我们开始今天的主题内容:event loop。首先我们来看一下在WHATWG规范中是怎么定义事件循环的:

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section.

为了协调事件,用户交互,脚本,渲染,联网等,用户代理必须使用本节中描述的事件循环。

从规定的定义中,我们可以了解事件用户交互脚本渲染网络请求这些东西,都会通过event loop协调运作。那这些在event loop的协调下,又是如何运作呢?我们再来看一下event loop的循环过程

  1. 规范中1~5可以总结为:从task队列中取最老的一个task,执行它(如果没有任务队列,直接跳到微任务步骤);
  2. **Microtasks**:执行microtasks任务[检查点](https://html.spec.whatwg.org/multipage/webappapis.html#perform-a-microtask-checkpoint);
  3. **Update the rendering**更新渲染(**这也是我们今天重点讨论的内容,下面会重点分析**);
  4. 判断是否启动[空闲时间算法](https://w3c.github.io/requestidlecallback/#start-an-idle-period-algorithm)(下面也会介绍`RequestIdleCallback`);
  5. event loop会不断循环上面的步骤(如下图)。

从上面的事件循环流程中,我们可以看到不仅有宏任务队列(task),还有微任务队列(microtask)。那么接下来,我们从不同的任务队列出发,了解一下各自的执行过程。

宏任务

event loop有一个或者多个task队列。每个task又会有指定的task源,例如:浏览器可能为鼠标、键盘事件提供一个task队列,其他事件是另外一个task,这样就可以保证鼠标、键盘事件得到高优先的响应,保证更好的交互体验。

setTimeout

常见的宏任务有:事件回调XHR回调定时器I/O等。那么接下来,主要聊一下setTimeout。说到setTimeout,大家都很熟悉:用来指定某个函数在多少毫秒后执行的定时器,返回一个整数,表示定时器的编号,可以通过这个编号取消这个定时器

在使用setTimeout的过程,大家有没有想过,真的是在多少毫秒后执行回调函数吗?答案是:不是多少毫秒执行,而是多少毫秒推入task队列

因为队列是先进先出的执行机制,所以就会导致一个问题:setTimeout实际延时会比设定值更久。而导致延时更久有这么几个原因:

  1. 最小延时 >=4ms

    在浏览器中,setTimeout()/setInterval() 的每调用一次定时器的最小间隔是4ms,这通常是由于函数嵌套导致(嵌套层级达到一定深度),或者是由于已经执行的setInterval的回调函数阻塞导致的。

    function cb() { f(); setTimeout(cb, 0); } 
    setTimeout(cb, 0);
    

从timeline中,我们看到setTimeout回调在首次执行时大约是从4996ms~5005ms这段时间内执行了4次,从第5次开始就是>=4ms的间隔执行了,说明其在第五次调用被阻塞了。

  1. 未被激活的tabs的定时最小延迟>=1000ms

    为了优化后台tab的加载损耗(以及降低耗电量),在未被激活的tab中定时器的最小延时限制为1S(1000ms)。

    setInterval(() => {
        console.log(+new Date());
    }, 200);
    document.addEventListener('visibilitychange', () => {
        console.log('====================================');
        console.log('document.hidden',document.hidden);
        console.log('====================================');
    })
    

  2. 从截图中,我们可以看到:标签页在激活状态下是以200ms的间隔执行回调,而在标签页后台执行时,回调间隔就变成了1000ms的时间进行调用了。

  3. 超时延迟

     function foo() {
         console.log('timeout callback');
     }
    setTimeout(foo, 0);
    for (let index = 0; index < 10000; index++) {
        console.log(index);
    }
    

  4. setTimeout的回调在最早的时间就推到了任务队列中,但是由于上一次的task任务执行时间过长,而导致了setTimeout的回调超时执行了。

    不过有一点值得提一下:因为我们知道setTimeout是宏任务,每次的回调都会推入任务队列,所以,setTimeout循环调用时,不会阻塞页面的渲染

微任务

说完了上面的宏任务,接下来我们再介绍一下微任务。

MutationObserver

其实微任务最早的引入是为了监听DOM的变化并及时做出响应。在最开始,页面是没有提供对DOM监听的支持的,从DOM4,才开始推荐使用MutationObserver。起初都是通过定时器轮询检测DOM的变化,从上面对setTimeout的介绍可以发现,通过定时器检测,会无法控制轮询检测的时间。

后来引入了Mutation Event,但是其对DOM的监听会带来性能问题和浏览器支持,也正是这样,该特性已经从Web标准中删除。在DOM4中提议用MutationObserver取代mutation事件。

使用方法如下:

  1. 创建一个观察器实例并传入回调函数, `const observer = new MutationObserver(callback)`
  2. 通过observe观察目标节点,options描述为DOM哪些变化应该提供给当前观察者的callback, `observer.observe(targetNode, options)`
  3. 停止观察。 `observer.disconnect()`

其中注册的回调函数,在DOM发生变化时,会被推入微任务队列中的。

Promise

Promise的话,相信大家应该都很熟悉了,Promise.then会被推入微任务队列,而Promise.then在执行的时候,遇到微任务,会将其加入到当前微任务队列中,所以说在循环调用微任务时,就会阻塞页面的渲染,例如:

function fn() {
    Promise.resolve().then(fn)
}

fn()

Generator

上面我们已经对协程做了简单介绍,Generator 函数是协程ES6 的实现,最大特点就是可以交出函数的执行权(即暂停执行)。

使用方式:

  1. 调用生成器函数(*函数),此时并不会执行函数;
  2. 通过`next()`方法执行函数,遇到`yield`关键字,会暂停执行;
  3. 需要继续通过`next()`方法执行函数…
  4. 遇到`return`关键字,便会结束函数的执行,并返回,即使`return` 后面存在`yield`,也不会继续执行。

Async/Await

一个隐式返回promise的异步函数。当函数执行的时候,一旦遇到await就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。

async函数是Generator函数的语法糖,将 Generator 函数和自动执行器,包装在一个函数里,可以使用同步代码的风格来编写异步代码。

更新渲染时机

介绍完宏任务和微任务,接下来到我们今天主要讨论的内容之一:event loop每一轮执行完task,都会进行页面重新渲染吗?

我们可以下面的几个测试用例看一下:

  1. 多个task操作DOM元素,会引发几次页面渲染?

    setTimeout(function settimeout1() {
        btn.innerHTML = '1111111111'
    }, 0);
    setTimeout(function settimeout2() {
        btn.innerHTML = '22222222222'
    }, 0);
    

    分析:

    1. 用户触发`click`事件,执行`DOM点击回调task`,此时可以发现`setTimeout`函数的执行是在`Event:click`的回调task的执行栈中(此时就会通知定时器线程进行监听,什么时候把定时器回调推入任务队列);
    2. `DOM点击回调task`执行完成后,进行`Paint`(更新渲染);
    3. 从任务队列取出新的`task`(settimeout1的回调),执行`settimeout1`;
    4. `settimeout1`执行完成,执行`settimeout2`,进行`Paint`。

    从整个分析过程,会发现:DOM点击回调task执行后,进行Paint,但settimeout1settimeout2这两个task之间并没有进行Paint,而settimeout2执行完后,又进行了Paint

  2. 模拟间隔较长的task,又会引发几次页面渲染?

    setTimeout(function settimeout1() {
        btn.innerHTML = '1111111111'
    }, 0);
    setTimeout(function settimeout2() {
        btn.innerHTML = '22222222222'
    }, 40);
    

    我们知道大多数显示器的刷新频率是60Hz,浏览器也会尽量保持60Hz的刷新率运行,也就是16.7ms刷新一帧,所以如果定时器的延迟时间大于16.7ms时,就会出现多次Paint的结果。

  3. 执行多个微任务

    setTimeout(function settimeout1() {
        let i = 0
        function fn() {
            btn.innerHTML = i
            i++
            if (i > 2000) return
            Promise.resolve().then(fn)
        }
        fn()
    }, 0);
    
    setTimeout(function settimeout2() {
        btn.innerHTML = '22222222222'
    }, 0);
    

    分析:

    1. 从任务队列取出`settimeout1`,开始执行;
    2. `settimeout1`执行过程中产生的微任务会全部在当前task执行过程中执行完;
    3. 微任务队列执行完,进行`Paint`;
    4. 继续从任务队列取出`settimeout2`执行。

    从这个过程中,我们可以发现:

    1. 多个微任务执行,会在当前宏任务阶段执行完,若微任务过多会阻塞页面渲染;
    2. 当微任务个数`>=1000`时,并不会推迟多余微任务到下一个宏任务中执行。

这个时候就不得不提一下requestAnimationFramerequestIdleCallback

requestAnimationFrame

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

特征:

  • 在下次渲染之前调用指定回调;
  • 若想在浏览器下次渲染之前继续更新下一帧动画,回调函数内必须再次调用`requestAnimationFrame`;
  • **task执行后可能不会调用RAF**。

setTimeout(function settimeout1() {
    requestAnimationFrame(function raf1(){
        btn.innerHTML = 'requestAnimationFrame'

        let i = 0
        function fn() {
            btn.innerHTML = i
            if (i++ > 10) return
            Promise.resolve().then(fn)
        }

        fn()
    })
}, 0);

setTimeout(function settimeout2() {
    btn.innerHTML = 'settimeout2'
}, 0);

从timeline中,我们可以看出:

  1. `settimeout1`中的`RAF`会在`settimeout2`后执行,这也是因为`RAF`是在下一帧渲染前执行的,同时也验证了上面特性第三点:**task执行后可能不会调用RAF**;
  2. `RAF`即不是宏任务,也不是微任务,而是跟随渲染过程的,在event loop的[循环过程](https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model)中是包含渲染过程的,而`RAF`的触发是在浏览器`Paint`之前;
  3. 在timeline最后一个task中也可以看到:`raf1`的执行是在`Paint`之前,并同属一个task。

其实在最开始,RAF在浏览器每一帧渲染中的执行时机在不同的浏览器上还是不一样的。jakearchibald在之前提出过这个问题

Chrome、Firefox等符合标准的浏览器会在style/layout/paint之前触发回调,而IE、Edge、Safari则是在style/layout/paint之后触发

后来,也在jakearchibald助攻下:React 放弃了我们接下来要介绍的requestIdleCallback

requestIdleCallback

在介绍event loop的循环过程时,我们有提到判断是否启动空闲时间算法,也就是requestIdleCallback,也被称为幕后任务协作调度 API

**window.requestIdleCallback()**方法将在浏览器的空闲时段内调用的函数排队。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout,则有可能为了在超时前执行函数而打乱执行顺序。

语法使用:

window.requestIdleCallback(callback[, options])

callback的参数是一个deadline对象:

  1. `timeRemaining()` 返回当前帧剩下的毫秒;
  2. `didTimeout` 返回布尔值,表示指定的时间(RIC第二个参数设置的timeout)是否过期。

options. timeout:

如果指定了timeout并具有一个正值,并且尚未通过超时毫秒数调用回调,那么回调会在下一次空闲时期被强制执行。

如何使用Ta?

  • 对非高优先级的任务使用空闲回调;
  • 空闲回调应尽可能不超支分配到的时间;
  • 避免在空闲回调中改变 DOM;
  • 避免运行时间无法预测的任务;
  • 在你需要的时候要用 timeout,但记得只在需要的时候才用。

    const taskLen = 1000 const tasks = Array.from({ length: taskLen }, () => () => { console.log('doing ric task'); }) let currentTaskNo = 0

    const handleTask = (idleDeadline) => { console.log('剩余时间: ', idleDeadline.timeRemaining()); while (currentTaskNo < taskLen && idleDeadline.timeRemaining() > 0) { taskscurrentTaskNo ++currentTaskNo }

    console.log('剩余task数量: ', taskLen - currentTaskNo);
    
    if (taskLen - currentTaskNo) {
        requestIdleCallback(handleTask);
    }
    

    } requestIdleCallback(handleTask)

分析:

  1. 第一次执行`ric`回调时,由于剩余时间少,所以只能执行更少的任务;
  2. 然后在下一帧渲染后,判断是否剩余时间,若有则继续执行剩余的任务,否则继续等待下一帧的剩余时间...;
  3. 有一点特别需要注意的是:**若某个任务执行时间过程,就会超过系统分配给`ric`回调的剩余时间**。

总结

阅读完上面的内容,相信大家会对事件循环和更新渲染有一个不一样的认识,在平时开发中,对浏览器性能优化上也有一定的帮助。通过对WHATWG规范的学习,发现之前自己对事件循环的认识只是片面了解,从规范上出发,了解规范,让开发更规范。本文中,对规范文档的术语可能存在不一样的理解,如有错误之处还请各位小伙伴指出。

  1. event loop每轮的task执行完成后,不一定都会伴随页面的更新渲染;

    Browsing context rendering opportunities are determined based on hardware constraints such as display refresh rates and other factors such as page performance or whether the page is in the background. Rendering opportunities typically occur at regular intervals.

    根据Rendering opportunities来判断每轮event loop是否需要进行更新渲染,会根据浏览器刷新率以及页面性能或是否后台运行等因素判断的。如果hasARenderingOpportunitytrue,需要更新渲染,接下来就需要执行各种渲染所需工作:

    1. 触发`resize`、`scroll`事件,建立 [媒体查询](https://html.spec.whatwg.org/multipage/references.html#refsCSSOMVIEW);
    2. 建立[css动画](https://html.spec.whatwg.org/multipage/references.html#refsWEBANIMATIONS);
    3. 执行动画回调(RAF回调);
    4. 执行[IntersectionObserver](https://html.spec.whatwg.org/multipage/references.html#refsINTERSECTIONOBSERVER) 回调 ;
    5. **更新渲染**;
    6. 判断是否启动`闲时调度算法`。
  2. 类似动画这种需要每一帧实时渲染的操作,可以通过RAF代替setTimeout

  3. taskmicrotaskRAF对应队列的执行:

    1. 每一轮`loop`对应一个`task`;
    2. `microtask`队列则会在每一轮`loop`中全部执行完毕(包含嵌套产生的`microtask`);
    3. `RAF`队列中当前`task`中产生的`RAF`会在每一轮`loop`执行完毕,嵌套的`RAF`则在下一帧之前执行。
  4. RAF回调的执行与taskmicrotask无关,而是与浏览器是否渲染相关联的;

  5. RIC 闲时调度算法的执行是在页面更新渲染之后判断是否需要执行的,若ric回调执行时间过长,也会阻塞主线程的其他task。

福利部分

文章在 “大转转FE” 公众号也会发送,并且公众号有抽奖活动,本文奖品是转转纪念T恤一件或转转随机手办一个,任选其一,欢迎大家关注 ✿✿ヽ(°▽°)ノ✿