Event Loop 是 JavaScript 异步编程的核心思想,也是前端进阶必须跨越的一关。同时,它又是面试的必考点,特别是在 Promise 出现以后,各类各样的面试题层出不穷,花样百出。这篇文章从现实生活中的例子入手,让你完全理解 Event Loop 的原理和机制,并能游刃有余的解决此类面试题。javascript
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
async1();
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');
复制代码
咱们都知道 JavaScript 是一门 单线程
语言,也就是说同一时间只能作一件事。这是由于 JavaScript 生来做为浏览器脚本语言,主要用来处理与用户的交互、网络以及操做 DOM。这就决定了它只能是单线程的,不然会带来很复杂的同步问题。html
假设 JavaScript 有两个线程,一个线程在某个 DOM 节点上添加内容,另外一个线程删除了这个节点,这时浏览器应该以哪一个线程为准?前端
既然 Javascript 是单线程的,它就像是只有一个窗口的银行,客户不得不排队一个一个的等待办理。同理 JavaScript 的任务也要一个接一个的执行,若是某个任务(好比加载高清图片)是个耗时任务,那浏览器岂不得一直卡着?为了防止主线程的阻塞,JavaScript 有了 同步
和 异步
的概念。java
若是在一个函数返回的时候,调用者就可以获得预期结果,那么这个函数就是同步的。也就是说同步方法调用一旦开始,调用者必须等到该函数调用返回后,才能继续后续的行为。下面这段段代码首先会弹出 alert 框,若是你不点击 肯定
按钮,全部的页面交互都被锁死,而且后续的 console
语句不会被打印出来。git
alert('Yancey');
console.log('is');
console.log('the');
console.log('best');
复制代码
若是在函数返回的时候,调用者还不可以获得预期结果,而是须要在未来经过必定的手段获得,那么这个函数就是异步的。好比说发一个网络请求,咱们告诉主程序等到接收到数据后再通知我,而后咱们就能够去作其余的事情了。当异步完成后,会通知到咱们,可是此时可能程序正在作其余的事情,因此即便异步完成了也须要在一旁等待,等到程序空闲下来才有时间去看哪些异步已经完成了,再去执行。程序员
这也就是定时器并不能精确在指定时间后输出回调函数结果的缘由。github
setTimeout(() => {
console.log('yancey');
}, 1000);
for (let i = 0; i < 100000000; i += 1) {
// todo
}
复制代码
栈 (stack): 栈是遵循后进先出 (LIFO) 原则的有序集合,新添加或待删除的元素都保存在同一端,称为栈顶,另外一端叫作栈底。在栈里,新元素都靠近栈顶,旧元素都接近栈底。栈在编程语言的编译器和内存中存储基本数据类型和对象的指针、方法调用等.面试
队列 (queue): 队列是遵循先进先出 (FIFO) 原则的有序集合,队列在尾部添加新元素,并在顶部移除元素,最新添加的元素必须排在队列的末尾。在计算机科学中,最多见的例子就是打印队列。编程
堆 (heap): 堆是基于树抽象数据类型的一种特殊的数据结构。promise
如上图所示,JavaScript 中的内存分为 堆内存
和 栈内存
,
JavaScript 中引用类型值的大小是不固定的,所以它们会被存储到 堆内存
中,由系统自动分配存储空间。JavaScript 不容许直接访问堆内存中的位置,所以咱们不能直接操做对象的堆内存空间,而是操做 对象的引用
。
而 JavaScript 中的基础数据类型都有固定的大小,所以它们被存储到 栈内存
中。咱们能够直接操做保存在栈内存空间的值,所以基础数据类型都是 按值访问
。此外,栈内存还会存储 对象的引用 (指针)
以及 函数执行时的运行空间
。
下面比较一下两种存储方式的不一样。
栈内存 | 堆内存 |
---|---|
存储基础数据类型 | 存储引用数据类型 |
按值访问 | 按引用访问 |
存储的值大小固定 | 存储的值大小不定,可动态调整 |
由系统自动分配内存空间 | 由程序员经过代码进行分配 |
主要用来执行程序 | 主要用来存放对象 |
空间小,运行效率高 | 空间大,可是运行效率相对较低 |
先进后出,后进先出 | 无序存储,可根据引用直接获取 |
当咱们调用一个方法的时候,JavaScript 会生成一个与这个方法对应的执行环境,又叫执行上下文(context)。这个执行环境中保存着该方法的私有做用域、上层做用域(做用域链)、方法的参数,以及这个做用域中定义的变量和 this 的指向,而当一系列方法被依次调用的时候。因为 JavaScript 是单线程的,这些方法就会按顺序被排列在一个单独的地方,这个地方就是所谓执行栈。
事件队列是一个存储着 异步任务
的队列,其中的任务严格按照时间前后顺序执行,排在队头的任务将会率先执行,而排在队尾的任务会最后执行。事件队列每次仅执行一个任务,在该任务执行完毕以后,再执行下一个任务。执行栈则是一个相似于函数调用栈的运行容器,当执行栈为空时,JS 引擎便检查事件队列,若是事件队列不为空的话,事件队列便将第一个任务压入执行栈中运行。
咱们注意到,在异步代码完成后仍有可能要在一旁等待,由于此时程序可能在作其余的事情,等到程序空闲下来才有时间去看哪些异步已经完成了。因此 JavaScript 有一套机制去处理同步和异步操做,那就是事件循环 (Event Loop)。
下面就是事件循环的示意图。
用文字描述的话,大体是这样的:
全部同步任务都在主线程上执行,造成一个执行栈 (Execution Context Stack)。
而异步任务会被放置到 Task Table,也就是上图中的异步处理模块,当异步任务有了运行结果,就将该函数移入任务队列。
一旦执行栈中的全部同步任务执行完毕,引擎就会读取任务队列,而后将任务队列中的第一个任务压入执行栈中运行。
主线程不断重复第三步,也就是 只要主线程空了,就会去读取任务队列
,该过程不断重复,这就是所谓的 事件循环
。
微任务、宏任务与 Event-Loop 这篇文章用了颇有趣的例子来解释宏任务和微任务,下面 copy 一下。
仍是以去银行办业务为例,当 5 号窗口柜员处理完当前客户后,开始叫号来接待下一位客户,咱们将每一个客户比做 宏任务
,接待下一位客户
的过程也就是让下一个 宏任务
进入到执行栈。
因此该窗口全部的客户都被放入了一个 任务队列
中。任务队列中的都是 已经完成的异步操做的
,而不是注册一个异步任务就会被放在这个任务队列中(它会被放到 Task Table 中)。就像在银行中排号,若是叫到你的时候你不在,那么你当前的号牌就做废了,柜员会选择直接跳过进行下一个客户的业务处理,等你回来之后还须要从新取号。
在执行宏任务时,是能够穿插一些微任务进去。好比你大爷在办完业务以后,顺便问了下柜员:“最近 P2P 暴雷很严重啊,有没有其余稳妥的投资方式”。柜员暗爽:“又有傻子上钩了”,而后叽里咕噜说了一堆。
咱们分析一下这个过程,虽然大爷已经办完正常的业务,但又咨询了一下理财信息,这时候柜员确定不能说:“您再上后边取个号去,从新排队”。因此只要是柜员可以处理的,都会在响应下一个宏任务以前来作,咱们能够把这些任务理解成是 微任务
。
大爷听罢,扬起 45 度微笑,说:“我就问问。”
柜员 OS:“艹...”
这个例子就说明了:你大爷永远是你大爷 在当前微任务没有执行完成时,是不会执行下一个宏任务的!
总结一下,异步任务分为 宏任务(macrotask)
与 微任务 (microtask)
。宏任务会进入一个队列,而微任务会进入到另外一个不一样的队列,且微任务要优于宏任务执行。
宏任务:script(总体代码)、setTimeout、setInterval、I/O、事件、postMessage、 MessageChannel、setImmediate (Node.js)
微任务:Promise.then、 MutaionObserver、process.nextTick (Node.js)
看看下面这道题你能不能作出来。
setTimeout(() => {
console.log('A');
}, 0);
var obj = {
func: function() {
setTimeout(function() {
console.log('B');
}, 0);
return new Promise(function(resolve) {
console.log('C');
resolve();
});
},
};
obj.func().then(function() {
console.log('D');
});
console.log('E');
复制代码
第一个 setTimeout
放到宏任务队列,此时宏任务队列为 ['A']
接着执行 obj 的 func 方法,将 setTimeout
放到宏任务队列,此时宏任务队列为 ['A', 'B']
函数返回一个 Promise,由于这是一个同步操做,因此先打印出 'C'
接着将 then
放到微任务队列,此时微任务队列为 ['D']
接着执行同步任务 console.log('E');
,打印出 'E'
由于微任务优先执行,因此先输出 'D'
最后依次输出 'A'
和 'B'
再来看一道阮一峰老师出的题目,其实也不难。
let p = new Promise(resolve => {
resolve(1);
Promise.resolve().then(() => console.log(2));
console.log(4);
}).then(t => console.log(t));
console.log(3);
复制代码
首先将 Promise.resolve()
的 then() 方法放到微任务队列,此时微任务队列为 ['2']
而后打印出同步任务 4
接着将 p
的 then() 方法放到微任务队列,此时微任务队列为 ['2', '1']
打印出同步任务 3
最后依次打印微任务 2
和 1
咱们知道,async/await 仅仅是生成器的语法糖,因此不要怕,只要把它转换成 Promise 的形式便可。下面这段代码是 async/await 函数的经典形式。
async function foo() {
// await 前面的代码
await bar();
// await 后面的代码
}
async function bar() {
// do something...
}
foo();
复制代码
其中 await 前面的代码
是同步的,调用此函数时会直接执行;而 await bar();
这句能够被转换成 Promise.resolve(bar())
;await 后面的代码
则会被放到 Promise 的 then() 方法里。所以上面的代码能够被转换成以下形式,这样是否是就很清晰了?
function foo() {
// await 前面的代码
Promise.resolve(bar()).then(() => {
// await 后面的代码
});
}
function bar() {
// do something...
}
foo();
复制代码
回到开篇宇宙条那道烂大街的题目,咱们"重构"一下代码,再作解析,是否是很轻松了?
function async1() {
console.log('async1 start'); // 2
Promise.resolve(async2()).then(() => {
console.log('async1 end'); // 6
});
}
function async2() {
console.log('async2'); // 3
}
console.log('script start'); // 1
setTimeout(function() {
console.log('settimeout'); // 8
}, 0);
async1();
new Promise(function(resolve) {
console.log('promise1'); // 4
resolve();
}).then(function() {
console.log('promise2'); // 7
});
console.log('script end'); // 5
复制代码
首先打印出 script start
接着将 settimeout
添加到宏任务队列,此时宏任务队列为 ['settimeout']
而后执行函数 async1
,先打印出 async1 start
,又由于 Promise.resolve(async2())
是同步任务,因此打印出 async2
,接着将 async1 end
添加到微任务队列,,此时微任务队列为 ['async1 end']
接着打印出 promise1
,将 promise2
添加到微任务队列,,此时微任务队列为 ['async1 end', promise2]
打印出 script end
由于微任务优先级高于宏任务,因此先依次打印出 async1 end
和 promise2
最后打印出宏任务 settimeout
关于这道题的争议:文章发表了大概有两天的时间,陆陆续续收到了小伙伴的评论。大多都是
async1 end
和promise2
的顺序问题。我在Chrome 73.0.3683.103 for MAC
和Node.js v8.15.1
测试是async1 end
先于promise2
,在FireFox 66.0.3 for MAC
测试是async1 end
后于promise2
。
Node.js 在升级到 11.x 后,Event Loop 运行原理发生了变化,一旦执行一个阶段里的一个宏任务(setTimeout,setInterval 和 setImmediate) 就马上执行微任务队列,这点就跟浏览器端一致。
关于 11.x 版本以前 Node.js 与 浏览器环境下事件循环的区别,能够参考 @浪里行舟 大佬的 《浏览器与 Node 的事件循环(Event Loop)有何区别?》,这里就很少废话了。
须要强调的是,Worker 是浏览器 (即宿主环境) 的功能,实际上和 JavaScript 语言自己几乎没有什么关系。也就是说,JavaScript 当前并无任何支持多线程执行的功能。
因此,JavaScript 是一门单线程的语言!JavaScript 是一门单线程的语言!JavaScript 是一门单线程的语言!
浏览器能够提供多个 JavaScript 引擎实例
,各自运行在本身的线程上,这样你能够在每一个线程上运行不一样的程序。程序中每个这样的的独立的多线程部分被称为一个 Worker。这种类型的并行化被称为 任务并行
,由于其重点在于把程序划分为多个块来并发运行。下面是 Worker 的运做流图。
下面用一个阶乘的例子浅谈 Worker 的用法。
首先新建一个 index.html
,直接上代码:
<body>
<fieldset>
<legend>计算阶乘</legend>
<input id="input" type="number" placeholder="请输入一个正整数" />
<button id="btn">计算</button>
<p>计算结果:<span id="result"></span></p>
</fieldset>
<legend></legend>
<script> const input = document.getElementById('input'); const btn = document.getElementById('btn'); const result = document.getElementById('result'); btn.addEventListener('click', () => { const worker = new Worker('./worker.js'); // 向 Worker 发送消息 worker.postMessage(input.value); // 接收来自 Worker 的消息 worker.addEventListener('message', e => { result.innerHTML = e.data; // 使用完 Worker 后记得关闭 worker.terminate(); }); }); </script>
</body>
复制代码
在同目录下新建一个 work.js
,内容以下:
function memorize(f) {
const cache = {};
return function() {
const key = Array.prototype.join.call(arguments, ',');
if (key in cache) {
return cache[key];
} else {
return (cache[key] = f.apply(this, arguments));
}
};
}
const factorial = memorize(n => {
return n <= 1 ? 1 : n * factorial(n - 1);
});
// 监听主线程发过来的消息
self.addEventListener(
'message',
function(e) {
// 响应主线程
self.postMessage(factorial(e.data));
},
false,
);
复制代码
下面的两道题来自 @小美娜娜 的文章 Eventloop 不可怕,可怕的是赶上 Promise。抄一下不会打我吧,嗯。
const p1 = new Promise((resolve, reject) => {
console.log('promise1');
resolve();
})
.then(() => {
console.log('then11');
new Promise((resolve, reject) => {
console.log('promise2');
resolve();
})
.then(() => {
console.log('then21');
})
.then(() => {
console.log('then23');
});
})
.then(() => {
console.log('then12');
});
const p2 = new Promise((resolve, reject) => {
console.log('promise3');
resolve();
}).then(() => {
console.log('then31');
});
复制代码
首先打印出 promise1
接着将 then11
,promise2
添加到微任务队列,此时微任务队列为 ['then11', 'promise2']
打印出 promise3
,将 then31
添加到微任务队列,此时微任务队列为 ['then11', 'promise2', 'then31']
依次打印出 then11
,promise2
,then31
,此时微任务队列为空
将 then21
和 then12
添加到微任务队列,此时微任务队列为 ['then21', 'then12']
依次打印出 then21
,then12
,此时微任务队列为空
将 then23
添加到微任务队列,此时微任务队列为 ['then23']
打印出 then23
这道题实际在考察 Promise 的用法,当在 then() 方法中返回一个 Promise,p1 的第二个完成处理函数就会挂在返回的这个 Promise 的 then() 方法下,所以输出顺序以下。
const p1 = new Promise((resolve, reject) => {
console.log('promise1'); // 1
resolve();
})
.then(() => {
console.log('then11'); // 2
return new Promise((resolve, reject) => {
console.log('promise2'); // 3
resolve();
})
.then(() => {
console.log('then21'); // 4
})
.then(() => {
console.log('then23'); // 5
});
})
.then(() => {
console.log('then12'); //6
});
复制代码
欢迎关注个人微信公众号:进击的前端
《你不知道的 JavaScript (中卷)》—— Kyle Simpson