浏览器渲染机制(三)Event Loop


浏览器渲染机制二入口处

从Event Loop谈JS的运行机制

到此时,已经是属于浏览器页面初次渲染完毕后的事情,JS引擎的一些运行机制分析。主要是结合Event Loop来谈JS代码是如何执行的。

前提你需要知道:
阮一峰参考
我们已经知道了JS引擎是单线程的,知道了JS引擎线程,事件触发线程,定时触发器线程。

然后还需要知道:

  • JS分为同步任务和异步任务
  • 同步任务都在主线程上执行,形成一个执行栈
  • 主线程之外,事件触发线程管理着一个任务队列,只要异步任务有了运行结果,就在任务队列之中放置一个事件(特点是先进先出,后进后出)
  • 一旦执行栈中的所有同步任务执行完毕(此时JS引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到可执行栈,开始执行。

在这里插入图片描述
看到这里,应该就可以理解了:为什么有时候setTimeOut推入的事件不能准时执行?因为可能在它推入到事件列表时,主线程还不空闲,正在执行其它代码,所以就必须等待,自然有误差。

事件循环机制进一步补充:
在这里插入图片描述

上图大致描述就是:

在JS引擎主线程执行过程中:

1、首先执行宏任务的同步任务,在主线程上形成一个执行栈,可理解为函数调用栈。

2、当执行栈中的函数调用到一些异步执行的API(例如异步Ajax,DOM事件,setTimeout等API),则会开启对应的线程(Http异步请求线程,事件触发线程和定时器触发线程)进行监控和控制。

3、当异步任务的事件满足触发条件时,对应的线程则会把该事件的处理函数推进任务队列(task queue)中,等待主线程读取执行。

4、当JS引擎主线程上的任务执行完毕,则会读取任务队列中的事件,将任务队列中的事件任务推进主线程中,按任务队列顺序执行

5、当JS引擎主线程上的任务执行完毕后,则会再次读取任务队列中的事件任务,如此循环,这就是事件循环(Event Loop)的过程。

定时器

上面事件循环机制的核心是:JS引擎线程事件触发线程

但事件上,里面还有一些隐藏细节,譬如调用setTimeout后,是如何等待特定时间后才添加到事件队列中的??

是JS引擎检测的么??

当然不是了。它是由定时器线程控制(因为JS引擎自己都忙不过来,根本无暇分身)

为什么要单独的定时器线程??

调用setTimeout后,是由定时器线程控制等到特定时间后添加到事件队列的,因为JS引擎是单线程的,如果处于阻塞线程状态就会影响计时准确,因此很有必要另开一个线程用来计时。

当使用setTimoutsetInterval时,需要定时器线程计时,计时完成后就会将特定的事件推入事件队列中。

如:

setTimeout(()=>console.log('hello!),1000)
//等1000毫秒计时完毕后(是由定时器线程计时),将回调函数推入事件队列中,等待主线程执行

setTimeout(()=>{
    console.log('hello')
},0)
console.log('begin')

这段代码的效果是最快的时间内将回调函数推入事件队列中,等待主线程执行。

注意:
1. 执行结果是:先begin,后hello
2. 虽然代码的本意是0毫秒就推入事件队列,但是W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms
3. 就算不等待4ms,就算假设0毫秒就推入事件队列,也会先执行begin(因为只能可执行栈内空了后才会主动读取事件队列)

setTimeout而不是setInterval


我们都知道setTimeout和setInterval是异步任务的定时器,需要添加到任务队列等待主线程执行,那么使用setTimeout模拟实现setInterval,会有区别吗???

  1. setTimeout实现setInterval只能通过递归调用。

  2. 每次setTimeout计时到后就会去执行,然后执行一段时间后才会继续setTimeout,中间就多了误差

    setTimeout是在到了指定时间的时候就把事件推到任务队列中,只有当在任务队列中的setTimeout事件被主线程执行后,才会继续再次在到了指定时间的时候把事件推到任务队列,那么setTimeout的事件执行肯定比指定的时间要久,具体相差多少跟代码执行时间有关。

  3. setInterval则是每次都精确的隔一段时间推入一个事件(但是,事件的实际执行时间不一定就准确,还有可能是这个事件还没执行完毕,下一个事件就来了)

    setInterval则是每次都精确的隔一段时间就向任务队列推入一个事件,无论上一个setInterval事件是否已经执行,所以有可能存在setInterval的事件任务累积,导致setInterval的代码重复连续执行多次,影响页面性能。

因此setInterval有一些比较致命的问题:

  • 累积效应,如果setInterval代码在setInterval再次添加到队列之前还没有完成执行,就会导致定时器代码连续运行好几次,而之间没有间隔,就算正常间隔执行,多个setInterval的代码执行时间可能会比预期小(因为代码执行需要一定时间)
  • 比如你ios的webview,或者safari等浏览器中都有一个特点,在滚动的时候是不执行JS的,如果使用了setInterval,会发现在滚动结束后会执行多次由于滚动不执行JS积攒回调,如果回调执行时间过长,就会非常容易造成卡顿问题和一些不可知的错误(setInterval自带的优化,如果当前事件队列中有setInterval的回调,不会重复添加回调)
  • 而且把浏览器最小化显示等操作时,setInterval并不是不执行程序,它会把setInterval的回调函数放在队列中,等浏览器窗口再次打开时,一瞬间全部执行

所以,至于这么问题,一般认为的最佳方案是:用setTimeout模拟setInterval或者特殊场合直接用requestAnimationFrame

Promise时代的microtask与macrotask


promise有一个新的概念microtask.或者可以说JS中分为两种任务:macrotaskmicrotask;

宏任务

macrotask(又叫宏任务)可分为同步任务异步任务
理解如下:

  1. 同步任务指的是在JS引擎主线程上按顺序执行的任务,只有前一个任务执行完毕后,才能执行后一个任务,形成一个执行栈(函数调用栈)。
  2. 异步任务指的是不直接进入JS引擎主线程,而是满足触发条件时,相关的线程将该异步任务推进任务队列(task queue),等待JS引擎主线程上的任务执行完毕,空闲时读取执行的任务,例如异步AjaxDOM事件,setTimeout等。

理解宏任务中同步任务和异步任务的执行顺序,那么就相当于理解了JS异步执行机制–事件循环(Event Loop)参考上方的事件循环。


如果还是不能理解,那么我们再次拿上面的例子进行详细分析,该例子中宏任务的代码部分是:

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

console.log('script end');

代码执行过程如下:

1、JS引擎主线程按代码顺序执行,当执行到console.log('script start');,JS引擎主线程认为该任务是同步任务,所以立刻执行输出script start,然后继续向下执行。

2、JS引擎主线程执行到setTimeout(function() { console.log('setTimeout'); }, 0);,JS引擎主线程认为setTimeout是异步任务API,则向浏览器内核进程申请开启定时器线程进行计时和控制该setTimeout任务。由于W3C在HTML标准中规定setTimeout低于4ms的时间间隔算为4ms,那么当计时到4ms时,定时器线程就把该回调处理函数推进任务队列中等待主线程执行,然后JS引擎主线程继续向下执行。

3、JS引擎主线程执行到console.log('script end');,JS引擎主线程认为该任务是同步任务,所以立刻执行输出script end。

4、JS引擎主线程上的任务执行完毕(输出script startscript end)后,主线程空闲,则开始读取任务队列中的事件任务,将该任务队里的事件任务推进主线程中,按任务队列顺序执行,最终输出setTimeout,所以输出的结果顺序为script start script end setTimeout。

以上便是JS引擎执行宏任务的整个过程。

  • 第一个macrotask会从头到尾将这个任务执行完毕,不会执行其它
  • 浏览器为了能够使得JS内部macrotask与DOM任务能够有序的执行,会在一个macrotask执行结束后,在下一个macrotask执行开始前,对页面进行重新渲染(task->渲染->task->…)

微任务


  • microtask(又叫微任务),Promise,process.nextTick等
  • 可以理解是在当前macrotask执行结束后立即执行的任务
  • 也就是说在当前macrotask任务后,下一个macrotask之前,在渲染之前
  • 所以它的响应速度相比setTimeout(setTimeout是macrotask)会更快因为无需等待渲染
  • 也就是说,在某一个macrotask执行完成后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)

注意:

在Node环境下,process.nextTick的优先级高于promise.也就是:在宏任务结束后会先执行微任务队列中的nextTick部分,然后才会执行微任务中的promise部分。

分别什么样的场景会形成macrotask和microtask呢?

  • macrotask:主代码块,定时器回调(setTimeout,setInterval)、DOM事件的回调、ajax回调(可以看到,事件队列中的每一个事件都是一个macrotask)
  • microtask:Promise,process.nextTick、MutationObserver的回调等

另外,setImmediate则是规定:在下一次Event Loop(宏任务)时触发(所以它是属于优先级较高的宏任务),(Node.js文档中称,setImmediate指定的回调函数,总是排在setTimeout前面),所以setImmediate如果嵌套的话,是需要经过多个Loop才能完成的,而不会像process.nextTick一样没完没了。

再根据线程来理解下:

  • macrotask中的事件都是放在一个事件队列中的,而这个队列由事件触发线程维护.

    宏任务的事件主要有:定时器回调、DOM事件的回调、ajax回调

  • microtask中的所有微任务都是添加到微任务队列中,等待当前macrotask执行完后执行,而这个队列由JS引擎线程维护

所以:

  • 执行一个宏任务(栈中没有就从事件队列中获取)
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
  • 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)
    在这里插入图片描述

栗子一:

console.log('script start');

setTimeout(()=>{
    console.log('setTimeout')
},0);

Promise.resolve()
.then(()=>console.log('promise1'))
.then(()=>console.log('promise2'))

console.log('script end')

//执行结果:
script start
script end
promise1
promise2
setTimeout

执行过程如下:

1、代码块通过语法分析和预编译后,进入执行阶段,当JS引擎主线程执行到console.log('script start');,JS引擎主线程认为该任务是同步任务,所以立刻执行输出script start,然后继续向下执行。

2、JS引擎主线程执行到setTimeout(function() { console.log('setTimeout'); }, 0);,JS引擎主线程认为setTimeout是异步任务API,则向浏览器内核进程申请开启定时器线程进行计时和控制该setTimeout任务。由于W3C在HTML标准中规定setTimeout低于4ms的时间间隔算为4ms,那么当计时到4ms时,定时器线程就把该回调处理函数推进任务队列中等待主线程执行,然后JS引擎主线程继续向下执行。

3、JS引擎主线程执行到Promise.resolve().then(function() { console.log('promise1'); }).then(function() { console.log('promise2'); });,JS引擎主线程认为Promise是一个微任务,这把该任务划分为微任务,等待执行。

4、JS引擎主线程执行到console.log('script end');,JS引擎主线程认为该任务是同步任务,所以立刻执行输出script end

5、主线程上的宏任务执行完毕,则开始检测是否存在可执行的微任务,检测到一个Promise微任务,那么立刻执行,输出promise1promise2

6、微任务执行完毕,主线程开始读取任务队列中的事件任务setTimeout,推入主线程形成新宏任务,然后在主线程中执行,输出setTimeout

栗子二:

在这里插入图片描述

new Promise里的执行器函数是直接执行的算做主程序里,而且.then后面的才会放到微任务中。

另外,请注意下Promise的polyfill与官方版本的区别:

官方版本中,是标准的microtask形式
polyfill,一般都是通过setTimeout模拟的,所以是macrotask形式
请特别注意这两点区别

注意,有一些浏览器执行结果不一样(因为它们可能把microtask当成macrotask来执行了),但是为了简单,这里不描述一些不标准的浏览器下的场景(但记住,有些浏览器可能并不标准)

Mutation Observer可以用来实现microtask(它属于microtask,优先级小于Promise,一般是Promise不支持时才会这样做)

它是HTML5中的新特性,作用是:监听一个DOM变动,当DOM对象树发生任何变动时,Mutation Observer会得到通知

像以前的Vue源码中就是利用它来模拟nextTick的,具体原理是,创建一个TextNode并监听内容变化,然后要nextTick的时候去改一下这个节点的文本内容,如下:(Vue的源码,未修改)

var counter=1
var observer=newMutationObserver(nextTickHandler)
var textNode=document.createTextNode(String(counter))
observer.observe(textNode,{characterData:true})
timerFunc=()=>{
    counter=(counter+1)%2
    textNode.data=String(counter)
}

不过,现在的Vue(2.5+)的nextTick实现移除了Mutation Observer的方式(据说是兼容性原因),取而代之的是使用MessageChannel(当然,默认情况仍然是Promise,不支持才兼容的)。

MessageChannel属于宏任务,优先级是:setImmediate->MessageChannel->setTimeout,所以Vue(2.5+)内部的nextTick与2.4及之前的实现是不一样的,需要注意下。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值