一:什么是单线程
你一定听说过一句话:JavaScript是单线程的语言。单线程就是每一个当下,只有一件事情能被处理。相反地,作为人类,我们能够一边写代码,一边听歌,这是因为我们的大脑不是单线程的。假如把【写代码】和【听歌】看做是我们的JS代码,对应我们人类大脑的就是JS引擎,那么在每一个特定的时间点,JS引擎只能执行【写代码】或者【听歌】,不能2者同时进行,这就是JS的单线程。

二:单线程怎样保证不被阻塞 - Event Loop
想象一个场景:我们通过发送一个HTTP请求去拿到用户的名字,然后再用这个拿到的名字去render我们的页面。下面是一段伪代码:

const name = HTTP.get('/user/{id}').reponse.name;
renderPage(name);

因为JS是单线程的,那就意味着我们在执行第一行代码(http请求)的时候,别的什么事情都不能做,现在整个页面都是卡住的,不能响应用户的任何操作。这势必是一个非常不好的体验。那么怎样保证我们的事件处理不被阻塞呢?答案就是Event Loop(事件轮询)

什么是Event Loop呢?可以类比为你每天上班,早上你一到公司你的领导就给你安排了10件不同的任务。虽然前面我说我们人类是可以一边听歌,一边写代码的。但是一般工作上的事情,还是得一件一件地做。所以,你把这10件任务写进你今天的【to do list】。每做完一个任务,你就把它划掉,然后马上做下一个任务。

三:JS Stack和Task Queue
我们都知道代码执行,会有一个入栈和出栈的过程,在JS的这个语境下,我们暂且把这个栈叫做JS Stack。我们前面说了,JS是单线程的,这就意味着我们只有一个JS Stack。所以将要被执行的代码,都得进入这个唯一的JS Stack,执行完了的代码,则从JS Stack移除掉。

我们的一段JS代码,包含着多个不同的task,就如一个to do list,包含着多个不同的任务一样。我们的任务有轻重缓急之分,有着不同的优先级。我们的JS task也一样,有着不同的类别,一般来说JS Task可以分为以下2类:
MacroTask:

script(整体代码), setTimeout, setInterval, setImmediate, requestAnimationFrame, I/O, UI Rendering

MicroTask:

Promise, queueMicrotask, process.nextTick, Object.observe, MutationObserver

当我们的JS引擎去遇到多个task的时候,就会把这些task进行排队,从而就形成了task queue。前面我们说了task可以分为2类,自然地就会有2个类型的task queue:MacroTask queueMicroTask queue

JS的同步代码会按照代码的出现顺序,放入JS Stack被执行。只有当前的JS Stack空了的时候,MacroTask queue或者MicroTask queue被推入JS Stack,继而被执行

我们马上来看一下例子,加深对上面这段加粗的文字的理解:

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

上面代码的输出结果是1,2,而不是2,1。要理解这个问题,我们先来认识一下setTimeout()和Web API的概念。

四:ECMAScript和Web API
我们经常在我们的JS代码里面使用setTimeout(),可能自然地把setTimeout算作了ECMAScript的一部分。但是,其实并不是这样的。我们常常使用的setTimeout(),setInterval()等其实是属于Web API的范畴。举个例子:

setTimeout(function f1(){}, 5000);

以上代码的执行过程可以被理解为:Web API拿到setTimeout()的2个参数:回调函数f1()和延时5000毫秒。Web API开始一个5000毫秒的记时,在5000毫秒到来之前,f1()会被Web API一直拿着,一旦5000毫秒时间到,这个f1()就被推入MacroTask queue。 如下图所示:

Screen Shot 2020-02-21 at 10.09.55 PM.png

理解了Web API的工作过程之后,我们再来回顾之前的例子:

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

现在来分析一下上面这段代码的执行过程:
step1: setTimeout()方法进入JS Stack被执行,但是实际上是调用Web API去执行。我们的延时是0秒,就意味着Web API会立即把回调函数推入MacroTask queue。再次强调:进入task queue并不会被执行,要进入JS Stack才会被执行。

step2: 因为setTimeout()执行完了(注意是setTimeout()这个函数本身被执行了,而不是它的callback函数被执行了。),当前JS Stack为空,又因为console.log('1');是同步代码,所以会被立即放入JS Stack且执行,于是得到打印的值:1。

step3: 上一步执行完之后,当前JS Stack为空,所以从MacroTask queue里面取出f1()函数放入JS Stack执行,得到打印值: 2。

以上就是打印值1,2的由来。现在你明白了,为什么明明setTimeout()在前面,但是它的callback打印值却在后面。

五:UI Rendering
在我们的这个单线程里面,浏览器不仅要处理所有的JS代码,还包括页面的渲染(UI Rendering)。

通常情况下,我们的浏览器会每16.6ms渲染一次页面,也就是一秒之内渲染60次,我们常常说的60FPS(Frames Per Second)就是这么来的。

一次UI Rendering通常包括三个步骤:计算CSS的数值->布局(layout)->绘制(paint)。但是也并不是每次渲染都需要这三个步骤。下面是一个示意图:

Screen Shot 2020-02-21 at 9.23.17 PM.png

到了应该渲染页面的时刻,页面渲染的优先级是高于task queue的。但是,只有JS Stack为空时,才可以执行渲染任务。

Screen Shot 2020-02-22 at 10.25.25 PM.png

下面看一段伪代码:

button.addEventListener('click', () => {  
  box.style.display = 'none';  
  box.style.display = 'block';  
  box.style.display = 'none';  
  box.style.display = 'block';  
  box.style.display = 'none'; 
  box.style.display = 'block';  
});

大家觉得上面的一段代码会造成页面渲染很大的开销吗?box元素会在页面上频繁地出现又消失吗?答案是:不会。

因为callback里面的所有代码执行完了,才会进行页面渲染。所以,callback的执行结果,会得到display='block', 后再render。

理想情况下,在每一帧(Frame)开始的时候,总是最先开始做页面渲染的工作。但是,我们的其他event(比如用户点击事件的处理函数,或者其他的JS逻辑代码等)却可以发生在任何时候,而且他们有的执行的时间长,有的执行的时间短。如下图所示:黄色部分代表除了页面渲染之外的其他事件处理。

Screen Shot 2020-02-20 at 2.58.08 PM.png

设想一个情况,新的一帧时间点到了,但是在上一帧里发生的一个事件需要处理的时间太长了,到此刻都还没有执行完,也就是JS Stack不为空,这就会导致本来应该开始的新一轮页面渲染被推迟,如下图所示:

Screen Shot 2020-02-20 at 2.57.18 PM.png

当我们的JS事件所需要执行的时间太长以至于在一秒内不能渲染足够数量的帧数,也就是常说的掉帧,从视觉上反应出来,就是页面卡顿。

一个最经典的例子,就是pageScroll事件。如果你在pageScroll的处理函数里有大量的计算,就会导致JS Stack长时间被占用,从而导致页面渲染被阻塞,而用户看到的情况就是页面卡顿。因为对于pageScroll事件来说,只要有1PX的变化,就是一次事件的触发。在一次随意地滑动过程中,其实已经触发了N多个callback函数,而这多个callback函数都要依次被执行,JS Stack就会一直被占用,从而导致页面的渲染无法按照正常的频率渲染。为了解决这个问题,我们一般要采用函数节流和防抖,最终目的都是为了防止触发太多callback的发生。

六:requestAnimationFrame vs setTimeout( )

requestAnimationFrame的语法如下所示:

window.requestAnimationFrame(_callback_);

我们可以看到requestAnimationFrame()的参数只有一个callback,没有延时参数。setTimeout()的callback可以发生在任何时候,但是requestAnimationFrame, 永远发生在render的前面,如下图所示:黄色表示requestAnimationFrame()

Screen Shot 2020-02-20 at 3.00.35 PM.png

所以,不要使用setTimeout()来做动画,而是采用requestAnimationFrame。前面我们已经讲到,setTimeout()的callback执行的时间是不确定的,所以采用setTimeout()来做动画,可能会出现闪烁,跳帧的情况。

接下来通过一段代码来加深一下二者执行时机的理解。

requestAnimationFrame(()=>{console.log('rAF')});  
setTimeout(() => console.log('timeout'));  
console.log('console');

以上代码在Chrome的输出结果是:console, rAF, timeout。但是在Firefox和Safari的结果为:console, timeout, rAF。

按照EcmaScript的标准,Chrome是正确的,因为requestAnimationFrame在render的前面,但是在Firefox和Safari里面,requestAnimationFrame被实现为在render的后面,希望他们早日fix这个bug。。。。

七:MicroTask

终于迎来了我们的MicroTask! 前面我们说到,如果有多个MicroTask,也会建立起一个MicroTask queue。他们俩的执行机制是:

1:取出MacroTask queue中的第一个任务执行完
2:取出MicroTask queue中的所有任务执行完
3:重复以上步骤

看下面的一段代码:

setTimeout(() => console.log('timeout1'));  
Promise.resolve().then(()=>{console.log('promise1')});  
setTimeout(() => console.log('timeout2'));  
Promise.resolve().then(()=>{console.log('promise2')});  
console.log('console');  
requestAnimationFrame(()=>{console.log('rAF')});

打印结果为:console, promise1, promise2,rAF, timeout1, timeout2

MircoTask是会阻塞页面渲染。看下面一段代码:

function loop(){  
    Promise.resolve().then(loop);  
}  
loop();

上面这段代码,持续地往MicroTask queue里面塞任务,这些任务会持续地被推到JS Stack执行,JS Stack就会一直被占用,所以页面的渲染就被阻塞了。

以上就是JS Event Loop的一些最基本最核心的内容,还有很多内容可以讲,但是这篇文章已经太长了,就放到下一篇文章再说把。


nanaistaken
583 声望43 粉丝