一. 为什么JavaScript是单线程?
JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。那么,为什么JavaScript不能有多个线程呢?这样能提高效率啊。
JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
二、浏览器js运行机制
简介
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。
JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。
于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
(3)一但"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
(4)主线程不断重复上面的第三步。
只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制。这个过程会不断重复。

三. 浏览器的 Event Loop 事件循环
简介
主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。为了更好地理解Event Loop,请看下图

函数入栈,当Stack中执行到异步任务的时候,就将他丢给WebAPIs,接着执行同步任务,直到Stack为空; 在此期间WebAPIs完成这个事件,把回调函数放入CallbackQueue中等待; 当执行栈为空时,Event Loop把Callback Queue中的一个任务放入Stack中,回到第1步。
- Event Loop是由javascript宿主环境(像浏览器)来实现的;
- WebAPIs是由C++实现的浏览器创建的线程,处理诸如DOM事件、http请求、定时器等异步事件;
- JavaScript 的并发模型基于"事件循环";
- Callback Queue(Event Queue 或者 Message Queue) 任务队列,存放异步任务的回调函数
接下来看一个异步函数执行的例子:
var start=new Date();
setTimeout(function cb(){
console.log("时间间隔:",new Date()-start+'ms');
},500);
while(new Date()-start<1000){};
- main(Script) 函数入栈,start变量开始初始化
- setTimeout入栈,出栈,丢给WebAPIs,开始定时500ms;
- while循环入栈,开始阻塞1000ms;
- 500ms过后,WebAPIs把cb()放入任务队列,此时while循环还在栈中,cb()等待;
- 又过了500ms,while循环执行完毕从栈中弹出,main()弹出,此时栈为空,Event Loop,cb()进入栈,log()进栈,输出'时间间隔:1003ms',出栈,cb()出栈
四. 宏任务(Macrotasks)和微任务(Microtasks)
简介
JS的异步有一个机制的,就是会分为宏任务和微任务。宏任务和微任务会放到不同的event queue中,先将所有的宏任务放到一个event queue(macro-task),再将微任务放到一个event queue(micro-task)中。执行完宏任务之后,就会先从微任务中取这个回调函数执行。
讲的详细一点的话
最开始, 执行栈为空, 微任务队列为空, 宏任务队列有一个 script 标签(内含整体代码)
将第一个宏任务出队, 这里即为上述的 script 标签
整体代码执行过程中, 如果是同步代码, 直接执行(函数执行的话会有入栈出栈操作), 如果是异步代码, 会根据任务类型推入不同的任务队列中(宏任务或微任务)
当执行栈执行完为空时, 会去处理微任务队列的任务, 将微任务队列的任务一个个推入调用栈执行完
微任务执行完后,检查是否需要重新渲染 UI。
...往返循环直到宏任务和微任务队列为空
总结一下上述循环机制的特点:
出队一个宏任务 -> 调用栈为空后, 执行一队微任务 -> 更新界面渲染 -> 回到第一步
宏任务 macro-task(Task)
一个event loop有一个或者多个task队列。task任务源非常宽泛,比如ajax的onload,click事件,基本上我们经常绑定的各种事件都是task任务源,还有数据库操作(IndexedDB ),需要注意的是setTimeout、setInterval、setImmediate也是task任务源。总结来说task任务源:
- script
- setTimeout
- setInterval
- setImmediate
- I/O
- requestAnimationFrame
- UI rendering
微任务 micro-task(Job)
microtask 队列和task 队列有些相似,都是先进先出的队列,由指定的任务源去提供任务,不同的是一个 event loop里只有一个microtask 队列。另外microtask执行时机和Macrotasks也有所差异
- process.nextTick
- promises
- Object.observe
- MutationObserver
宏任务和微任务的区别
- 宏队列可以有多个,微任务队列只有一个,所以每创建一个新的settimeout都是一个新的宏任务队列,执行完一个宏任务队列后,都会去checkpoint 微任务。
- 一个事件循环后,微任务队列执行完了,再执行宏任务队列
- 一个事件循环中,在执行完一个宏队列之后,就会去check 微任务队列
宏任务和微任务的运行
下图是一个事件循环的流程

Promise.resolve().then(function promise1 () {
console.log('promise1');
})
setTimeout(function setTimeout1 (){
console.log('setTimeout1')
Promise.resolve().then(function promise2 () {
console.log('promise2');
})
}, 0)
setTimeout(function setTimeout2 (){
console.log('setTimeout2')
}, 0)
运行过程:
script里的代码被列为一个task,放入task队列。
循环1:
-
【task队列:script ;microtask队列:】
- 从task队列中取出script任务,推入栈中执行。
- promise1列为microtask,setTimeout1列为task,setTimeout2列为task。
-
【task队列:setTimeout1 setTimeout2;microtask队列:promise1】
- script任务执行完毕,执行microtask checkpoint,取出microtask队列的promise1执行。
循环2:
-
【task队列:setTimeout1 setTimeout2;microtask队列:】
- 从task队列中取出setTimeout1,推入栈中执行,将promise2列为microtask。
-
【task队列:setTimeout2;microtask队列:promise2】
- 执行microtask checkpoint,取出microtask队列的promise2执行。
(循环2中的 setTimeout2为什么不是跟在setTimeout1的后面输出? 这里我觉得应该是setTimeout1和setTimeout2不是在同一个task队列中, 是两个task队列。在执行完setTimeout1的task队列后, event loop去检查microtask队列是否有事件,并且把事推入到主栈。)

-
【task队列:setTimeout2;microtask队列:】
- 从task队列中取出setTimeout2,推入栈中执行。
- setTimeout2任务执行完毕,执行microtask checkpoint。
-
【task队列:;microtask队列:】

注:有些文章说的一个事件循环的开始是先执行微任务再执行宏任务,有有些说的是先执行宏任务再执行微任务,我个人觉得这两种只是看法的角度不一致
- 如果把script载入到主堆栈这一过程看成是执行了宏任务,那么就是宏任务先开始。
- 如果不把这个script的运行当做是宏任务,只看异步函数中的宏任务(setTimeout)那么就是微任务先开始。
宏任务与微任务示例
EXP1 在主线程上添加宏任务与微任务
console.log('-------start--------');
setTimeout(() => {
console.log('setTimeout'); // 将回调代码放入另一个宏任务队列
}, 0);
new Promise((resolve, reject) => {
for (let i = 0; i < 5; i++) {
console.log(i);
}
resolve()
}).then(()=>{
console.log('Promise'); // 将回调代码放入微任务队列
})
console.log('-------end--------');
运行结果:
-------start--------
0
1
2
3
4
-------end--------
Promise
setTimeout
由EXP1,我们可以看出,当JS执行完主线程上的代码,会去检查在主线程上创建的微任务队列,执行完微任务队列之后才会执行宏任务队列上的代码
运行顺序:
主线程 => 主线程上创建的微任务 => 主线程上创建的宏任务
script里的代码被列为一个task,放入task队列。
循环1:
-
【task队列:script ;microtask队列:】
- 从task队列中取出script任务,推入栈中执行。
- promise列为microtask,setTimeout列为task。
-
【task队列:setTimeout ;microtask队列:promise】
- script任务执行完毕,执行microtask checkpoint,取出microtask队列的promise执行。
循环2:
-
【task队列:setTimeout ;microtask队列:】
- 从task队列中取出setTimeout,推入栈中执行
- setTimeout任务执行完毕,执行microtask checkpoint。
-
【task队列:;microtask队列:】
EXP2 在微任务中创建微任务
setTimeout(_ => console.log('setTimeout4'))
new Promise(resolve => {
resolve()
console.log('Promise1')
}).then(_ => {
console.log('Promise3')
Promise.resolve().then(_ => {
console.log('before timeout')
}).then(_ => {
Promise.resolve().then(_ => {
console.log('also before timeout')
})
})
})
console.log(2)
运行结果:
Promise1
2
Promise3
before timeout
also before timeout
setTimeout4
由EXP2,我们可以看出,在微任务队列执行时创建的微任务,还是会排在主线程上创建出的宏任务之前执行(因为微任务只有一条,自增链不断的话 会一直往下执行微任务,不会被中断)
运行顺序:
主线程 => 主线程上创建的微任务1 => 微任务1上创建的微任务2 => 主线程上创建的宏任务
script里的代码被列为一个task,放入task队列。
循环1:
-
【task队列:script ;microtask队列:】
- 从task队列中取出script任务,推入栈中执行。
- promise3 列为microtask,setTimeout4 列为task。
-
【task队列:setTimeout4;microtask队列:promise3】
- script任务执行完毕,执行microtask checkpoint,取出microtask队列的promise3执行。
- 将before timeout 列为 microtask。
-
【task队列:setTimeout4;microtask队列:before timeout】
- before timeout 执行
- 将also before timeout 列为microtask
-
【task队列:setTimeout4;microtask队列:also before timeout】
- also before timeout 执行
循环2:
-
【task队列:setTimeout4 ;microtask队列:before timeout】
- 从task队列中取出setTimeout4,推入栈中执行
- setTimeout4任务执行完毕,执行microtask checkpoint。
-
【task队列:;microtask队列:】
EXP3: 宏任务中创建微任务
// 宏任务队列 1
setTimeout(() => {
// 宏任务队列 1.1
console.log('timer_1');
setTimeout(() => {
// 宏任务队列 3
console.log('timer_3')
}, 0)
new Promise(resolve => {
resolve()
console.log('new promise')
}).then(() => {
// 微任务队列 1
console.log('promise then')
})
}, 0)
setTimeout(() => {
// 宏任务队列 2.2
console.log('timer_2')
}, 0)
console.log('========== Sync queue ==========')
运行结果:
========== Sync queue ==========
timer_1
new promise
promise then
timer_2
timer_3
运行顺序:
主线程(宏任务队列 1)=> 宏任务队列 1.1 => 微任务队列 1 => 宏任务队列 3=>宏任务队列2.2
循环1:
-
【task队列:script ;microtask队列:】
- 从task队列中取出script任务,推入栈中执行。
- timer_1 列为task,timer_2 列为task。
-
【task队列:timer_1,timer_2;microtask队列:】
- script任务执行完毕,执行microtask checkpoint,无microtask队列可执行。
循环2
-
【task队列::timer_1,timer_2;microtask队列:】
- 从task队列中取出 timer_1 推入栈中执行。
- 将 timer_3 列为task,promise then 列为microtask
-
【task队列:timer_2,timer_3;microtask队列:promise then】
- 执行microtask checkpoint,取出microtask队列的promise then执行
循环3
-
【task队列:timer_2,timer_3;microtask队列:】
- 从task队列中取出timer_2,推入栈中执行
-
【task队列:timer_3;microtask队列:】
- 执行microtask checkpoint,无microtask队列可执行
循环4
-
【task队列:timer_3;microtask队列:】
- 从task队列中取出timer_3,推入栈中执行
-
【task队列:;microtask队列:】
- 执行microtask checkpoint,无microtask队列可执行
EXP4:微任务队列中创建的宏任务
// 宏任务1
new Promise((resolve) => {
console.log('new Promise(macro task 1)');
resolve();
}).then(() => {
// 微任务1
console.log('micro task 1');
setTimeout(() => {
// 宏任务3
console.log('macro task 3');
}, 0)
})
setTimeout(() => {
// 宏任务2
console.log('macro task 2');
}, 0)
console.log('========== Sync queue(macro task 1) ==========');
运行结果:
new Promise(macro task 1)
========== Sync queue(macro task 1) ==========
micro task 1
macro task 2
macro task 3
异步宏任务队列只有一个,当在微任务中创建一个宏任务之后,他会被追加到异步宏任务队列上(跟主线程创建的异步宏任务队列是同一个队列)
运行顺序:
主线程 => 主线程上创建的微任务 => 主线程上创建的宏任务 => 微任务中创建的宏任务
循环1:
-
【task队列:script ;microtask队列:】
- 从task队列中取出script任务,推入栈中执行。
- macro task 2 列为task,micro task 1 列为microtask。
-
【task队列:macro task 2;microtask队列:micro task 1】
- script任务执行完毕,执行microtask checkpoint,microtask队列中取出micro task 1 执行。
- 执行micro task 1 的时候,把macro task 3列为task
循环2
- 【task队列:macro task 2,macro task 3;microtask队列:】
-
- 从task队列中取出 micro task2,推入栈中执行。
- 执行microtask checkpoint,无microtask队列可执行
循环2
-
【task队列:macro task 3;microtask队列:】
- 从task队列中取出 micro task3,推入栈中执行。
- 执行microtask checkpoint,无microtask队列可执行
-
【task队列:;microtask队列:】
小总结
- 微任务队列优先于宏任务队列执行,
- 微任务队列上创建的宏任务会被后添加到当前宏任务队列的尾端,微任务队列中创建的微任务会被添加到微任务队列的尾端。
- 只要微任务队列中还有任务,宏任务队列就只会等待微任务队列执行完毕后再执行。
五. 当 Event Loop 遇上事件冒泡
手动触发
代码
<div class="outer">
<div class="inner"></div>
</div>
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');
function onClick() {
console.log('click');
setTimeout(function() {
console.log('timeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise');
});
}
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);
点击 inner,最终打印结果为:
click
promise
click
promise
timeout
timeout
分析
为什么打印结果是这样的呢?我们来分析一下: (0)将 script 标签内的代码(宏任务)放入执行栈执行,执行完后,宏任务微任务队列皆空。
(1)点击 inner,onClick 函数入执行栈执行,打印 "click"。执行完后执行栈为空,因为事件冒泡的缘故,事件触发线程会将向上派发事件的任务放入宏任务队列。
(2)遇到 setTimeout,在最小延迟时间后,将回调放入宏任务队列。遇到 promise,将 then 的任务放进微任务队列
(3)此时,执行栈再次为空。开始清空微任务,打印 "promise"
(4)此时,执行栈再次为空。从宏任务队列拿出一个任务执行,即前面提到的派发事件的任务,也就是冒泡。
(5)事件冒泡到 outer,执行回调,重复上述 "click"、"promise" 的打印过程。
(6)从宏任务队列取任务执行,这时我们的宏任务队列已经累计了两个 setTimeout 的回调了,所以他们会在两个 Event Loop 周期里先后得到执行。
可以看成是:
function onClick() {
//模拟outer click事件
setTimeout(function(){onClick1()},0)
console.log('click');
setTimeout(function() {
console.log('timeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise');
});
}
function onClick1() {
console.log('click1');
setTimeout(function() {
console.log('timeout1');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
});
}
//模拟inner click事件
onClick()
代码触发
代码
inner.click()
打印结果为:
click
click
promise
promise
timeout
timeout
分析
依旧分析一下:
(0)将 script(宏任务)放入执行栈执行,执行到 inner.click() 的时候,执行 onClick 函数,打印 "click"
(1)当执行完 onClick 后,此时的 script(宏任务)还没返回,执行栈不为空,不会去清空微任务,而是会将事件往上冒泡派发
...(关键步骤分析完后,续步骤就不分析了)
可以看成是:
function onClick() {
console.log('click');
setTimeout(function() {
console.log('timeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise');
});
}
onClick();
onClick();
总结
在一般情况下,微任务的优先级是更高的,是会优先于事件冒泡的,但如果手动 .click() 会使得在 script代码块 还没弹出执行栈的时候,触发事件派发。
Event Loop总结
浏览器进行事件循环工作方式
-
选择当前要执行的任务队列,选择任务队列中最先进入的任务,如果任务队列为空即null,则执行跳转到微任务(MicroTask)的执行步骤。
-
将事件循环中的任务设置为已选择任务。
-
执行任务。
-
将事件循环中当前运行任务设置为null。
-
将已经运行完成的任务从任务队列中删除。
-
microtasks步骤:进入microtask检查点。
-
更新界面渲染。
-
返回第一步
【执行进入microtask检查点时,浏览器会执行以下步骤:】
-
设置microtask检查点标志为true。
-
当事件循环microtask执行不为空时:选择一个最先进入的microtask队列的microtask,将事件循环的microtask设置为已选择的microtask,运行microtask,将已经执行完成的microtask为null,移出microtask中的microtask。
-
清理IndexDB事务
-
设置进入microtask检查点的标志为false。
重点
总结以上规则为一条通俗好理解的:
- 顺序执行先执行同步方法,碰到MacroTask直接执行,并且把回调函数放入MacroTask执行队列中(下次事件循环执行);碰到microtask直接执行。把回调函数放入microtask执行队列中(本次事件循环执行)
- 当同步任务执行完毕后,去执行微任务microtask。(microtask队列清空)
- 由此进入下一轮事件循环:执行宏任务 MacroTask (setTimeout,setInterval,callback)
[总结]所有的异步都是为了按照一定的规则转换为同步方式执行。
以上是本人参考以下资料后的理解,如果有错误的地方,请各位大牛帮忙纠正,谢谢。
JavaScript 运行机制详解:再谈Event Loop