一、前言:
在本文中我们将介绍到如下内容:
1、js单线程
2、js的同步异步和事件循环
3、宏任务和微任务
4、案例分析
二、JS单线程
JavaScript(JS)在1995年由Netscape公司的Brendan Eich,在网景导航者浏览器上首次设计实现而成。js的发明用途是面向浏览器的;js的功能除了包含ES的语法外,更重要的是操作DOM和BOM。所以在设计之初,它就被设计为一种单线程的语言;因为多线程设计会带来很多同步上的问题;比如:我们现在假设了两个线程,a和b,a线程要给DOM元素div赋值,b线程要获取元素div的值;如果按照顺序去做a->b可能符合我们的预期,但是b->a我们就无法得到最新的值。这里的多线程执行次序和同步上的问题就很难操作;所以基于浏览器的特性,把js设计为一种单线程的语言。
单线程不存在上面的多线程同步和执行次序问题,它执行起来像一个队列,排队执行,执行完一个任务,执行下一个任务。这对于浏览器的DOM和BOM来说完全是可控的;但是如果有些任务执行的时间比较久,这对于用户体验是致命的,比如我们执行一个js计算操作,需要10秒钟,那么整个程序就需要等待这10秒才能继续执行,浏览器上就出现白屏加载10秒钟的现象;这种情况下我们的网页就完全没有用户体验了。
所以,对于js单线程面临的任务执行排队问题,js还设计有同步任务和异步任务。
三、JS的同步异步和事件循环
我们在了解js同步和异步之前,首先要了解什么是同步,什么是异步。
同步,事情要一件一件做,饭要一口一口吃;就是这个道理;js在做一个事情的时候是专注的,无法做其他任何事情,必须要做完这个事情才能继续执行其他。
异步,我们早上起床后,可以去烧水,烧水过程中我们可以洗漱,洗漱完毕等水烧做饭。烧水和洗漱两个事情的执行是不冲突,不需要相互等待的,这就是异步。比如js中的定时器,ajax请求都是异步操作。
图一
图一是js执行任务的过程,js是单线程的执行机制,所以维护的执行栈为左侧同步执行。至于右侧的异步操作,js维护了一个事件队列,队列里面的异步任务(比如定时器或ajax)在执行完毕后,通过注册回调函数的形式让主线程读取回调函数并执行。
我们看到主线程在和Event Queue(事件队列)之间是双向的,事件队列中有异步任务执行完毕就会通知主线程,主线程一旦有空闲就会去读取事件队列;所以他们是双向的。需要注意的是,只有主线程空闲下来后才会去读取事件队列。
这个过程是往复循环的。主线程从“任务队列”中读取事件,这个过程是循环不断的,所以整个过程的这种运行机制又称为Event Loop(事件循环)。
执行过程:
(1)所有的同步任务都在主线程上执行,形成一个执行栈(比如有10次console.log,它们需要按照顺序执行)
(2)主线程之外,还存在一个“事件队列”。异步任务的执行都会进入这个队列(可以认为主线程暂时不会管这些异步任务)。只要异步任务有了运行结果(比如,定时器到时,ajax请求反馈了结果),就在“任务队列”之中放置一个事件。
(3)一旦“执行栈”中的所有同步任务执行完毕(主线程有了空闲),主线程就会读取“事件队列”,将可执行的任务放在主线程执行。事件队列是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。
(4)主线程不断重复上面的第三步。
只要主线程空闲了(主线程执行栈没有任务了),就会去读取“事件队列”
所以,我们在使用js的过程中,发现总是同步代码先执行,异步代码的执行要靠后一些;比如下面代码:
setTimeout(() => {
console.log("时间到");
}, 0);
console.log("开始");
执行结果:开始 时间到
setTimeout是一个异步任务被放到异步的事件队列中,当主线程工作结束后才会去读取执行。
再比如:
setTimeout(() => {
console.log("时间到");
}, 0);
for (let i = 0; i < 1000000000; i++) {}
console.log("开始");
执行结果:开始 时间到
for循环也是主线程需要执行的代码,目前来看主线程有两个工作要做:1、for循环;2、console打印;只有这两个事情都完毕后才会去事件队列去读取setTimeout注册的回调函数执行;
此时也看到另外一个问题,定时器不是那么精准的。
四、宏任务和微任务
除了广义的同步任务和异步任务,我们对任务有更精细的定义:
· macro-task(宏任务)
· micro-task(微任务)
宏任务列表:
微任务列表:
上面类别分别列出了宏任务和微任务的情况,也列出了浏览器和node的对应情况(不熟悉node的同学可以略过,只看浏览器即可);
任务执行次序是这样的:
执行一个宏任务(先执行同步代码)-->执行所有微任务-->UI render-->执行下一个宏任务-->执行所有微任务-->UI render-->......
备注:
根据HTML 标准,一轮事件循环执行结束之后,下轮事件循环执行之前开始进行UI render。即:macro-task任务执行完毕,接着执行完所有的micro-task任务后,此时本轮循环结束,开始执行UI render。UI render完毕之后接着下一轮循环。但是UI render不一定会执行,因为需要考虑ui渲染消耗的性能和有没有ui变动。
五、案例分析
console.log("1");
setTimeout(function() {
console.log("2");
new Promise(function(resolve) {
console.log("4");
resolve();
}).then(function() {
console.log("5");
});
});
new Promise(function(resolve) {
console.log("7");
resolve();
}).then(function() {
console.log("8");
});
setTimeout(function() {
console.log("9");
new Promise(function(resolve) {
console.log("11");
resolve();
}).then(function() {
console.log("12");
});
});
1、第一轮事件循环流程:
· 整体script作为第一个宏任务进入主线程,遇到console.log,输出1。
· 遇到setTimeout,其回调函数被分发到宏任务Event Queue中。我们暂且记为setTimeout1。
· 遇到Promise,new Promise直接执行,输出7。then被分发到微任务Event Queue中。我们记为then1。
· 又遇到了setTimeout,其回调函数被分发到宏任务Event Queue中,我们记为setTimeout2。
宏任务Event Queue | 微任务Event Queue |
setTimeout1 | then1 |
setTimeout2 |
上表是第一轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了1和7。同时我们发现了then1这个微任务,执行then1,输出8。
第一轮事件循环结束,这一轮输出1,7,8。
2、第二轮事件循环从setTimeout1宏任务开始:
· 首先输出2。
· new Promise立即执行输出4,
· then也分发到微任务Event Queue中,记为then2。
宏任务Event Queue | 微任务Event Queue |
setTimeout2 | then2 |
第二轮事件循环宏任务结束,我们then2这个微任务可以执行,输出5。
第二轮事件循环结束,第二轮输出2,4,3,5。
3、第三轮事件循环从setTimeout2执行:
· 直接输出9。
· 直接执行new Promise,输出11。
· 将then分发到微任务Event Queue中,记为then3。
宏任务Event Queue | 微任务Event Queue |
then3 |
第三轮事件循环宏任务执行结束,执行微任务then3,输出12。
第三轮事件循环结束,第三轮输出9,11,12。
整段代码,共进行了三次事件循环,完整的输出为1,7,8,2,4,5,9,11,12。