浏览器渲染机制二入口处
从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引擎是单线程的,如果处于阻塞线程状态就会影响计时准确,因此很有必要另开一个线程用来计时。
当使用setTimout
或setInterval
时,需要定时器线程计时,计时完成后就会将特定的事件推入事件队列中。
如:
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,会有区别吗???
-
setTimeout
实现setInterval
只能通过递归调用。 -
每次
setTimeout
计时到后就会去执行,然后执行一段时间后才会继续setTimeout
,中间就多了误差setTimeout是在到了指定时间的时候就把事件推到任务队列中,只有当在任务队列中的setTimeout事件被主线程执行后,才会继续再次在到了指定时间的时候把事件推到任务队列,那么setTimeout的事件执行肯定比指定的时间要久,具体相差多少跟代码执行时间有关。
-
而
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中分为两种任务:macrotask和microtask;
宏任务
macrotask(又叫宏任务)可分为同步任务和异步任务:
理解如下:
- 同步任务指的是在JS引擎主线程上按顺序执行的任务,只有前一个任务执行完毕后,才能执行后一个任务,形成一个执行栈(函数调用栈)。
- 异步任务指的是不直接进入JS引擎主线程,而是满足触发条件时,相关的线程将该异步任务推进任务队列(task queue),等待JS引擎主线程上的任务执行完毕,空闲时读取执行的任务,例如异步
Ajax
,DOM事件,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 start
和script 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
微任务,那么立刻执行,输出promise1
和promise2
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及之前的实现是不一样的,需要注意下。