分块的程序
JavaScript程序是由多个块构成的。这些块中只有一个是现在执行,其余的则会在将来执行。最常见的块单位是函数。
我们来看一个例子:
function foo() {
console.log('foo');
}
function bar(){
console.log('bar');
}
setTimeout(bar, 1000);
foo();
上面这段代码中有两个块,现在执行的部分:
function foo() {
console.log('foo');
}
setTimeout(bar, 1000);
foo();
将来执行的部分:
console.log('bar');
现在的块在程序运行之后就会立即执行。
setTimeout(..)设置了一个事件(定时)在将来执行,所以函数bar()的内容会在将来执行。
把代码片段封装成函数,指定它在响应某个事件(定时器、鼠标点击、Ajax等)时执行,其实就是在代码中创建了一个将来执行的块,也由此在这个程序中引入了异步机制。
但是JavaScript本身不支持异步,在ES6之前它甚至没有异步的概念。它是依赖宿主环境(比如浏览器)来实现对异步的支持的。
浏览器中时怎么实现异步的呢?答案是事件循环。
执行栈和事件循环
先来看一下执行栈。
栈是一种数据结构,具有后进先出的原则。
当JavaScript引擎第一次遇到 JS 代码时,会产生一个全局执行上下文并压入栈,每遇到一个函数调用,就会往栈中压入一个新的上下文。引擎执行栈顶的函数,执行完毕,弹出当前执行上下文。这个过程反复进行,直到栈中的代码全部执行完毕。这里的栈就是执行栈。
看一段代码:
function foo(b) {
var a = 1;
return a + b;
}
function bar(x) {
let y = 2;
return foo(x * y);
}
bar(3); // 7
当调用 bar 时,创建了一个包含了 bar 的参数和局部变量执行上下文,放入栈中。
当 bar 调用 foo 时,第二个上下文被创建并被压入栈中,放在第一个上下文 之上,上下文中包含 foo 的参数和局部变量。
当 foo 执行完毕然后返回时,第二个上下文就被弹出栈(剩下 bar 函数的执行上下文 )。
当 bar 也执行完毕然后返回时,第一个上下文也被弹出,栈就被清空了。
执行栈是无法区分代码是同步还是异步的,它之负责代码的具体执行过程。异步代码的执行主要依赖宿主环境的事件循环机制。
先通过一段伪代码了解一下事件循环:
//callbackQueue是一个用作队列的数组//(先进,先出)
var callbackQueue = [];
//execStack是一个用作栈的数组//(后进,先出)
var execStack = [];
//“永远”执行
while (true) {
//一次tick
if(execStack.isEmpty() && callbackQueue.length > 0){
//拿到队列中的下一个事件, 压入执行栈中
execStack.push(callbackQueue.shift());
}
}
这里用while循环实现的持续运行的循环,循环的每一轮称为一个tick。
对每个tick而言,如果在队列中有等待事件并且当前执行栈为空,那么就会从队列中摘下一个事件并压入执行栈中。这些事件就是异步代码的回调函数。
我们可以用一张图来展示这个过程:
图中stack代表执行栈,web api会产出一些异步事件,callback queue代表事件队列,event loop代表事件循环。
程序通常分成了很多小块,在事件循环队列中一个接一个地放入执行栈中去执行。
就拿我们的第一个示例来说, setTimeout(..) 并没有把你的回调函数挂在事件循环队列中。它所做的是设定一个定时器。当定时器到时后,环境会把你的回调函数放在事件循环中,它会在未来的某个tick中被从事件队列中取出放入执行栈中执行。
任务队列
在 ES6 中,有一个新的概念建立在事件循环队列之上,叫作任务队列(job queue) 。
它是挂在事件循环队列的每个 tick 之后的一个队列。在事件循环的每个 tick 中,可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前 tick 的任务队列末尾添加一个任务。
ES6添加了对promise的支持,promise 的异步特性是基于任务的 ,我们来看一下它对事件循环的影响。
看一个示例:
console.log( "a" );
setTimeout(function() {
console.log('b')
}, 0);
new Promise(function(resolve) {
console.log('c')
resolve();
}).then(function() {
console.log('d')
});
//运行结果: 'a'、'c'、'd'、'b'
可能你认为这里会输出a、b、c、d,实际结果为a、c、d、b。因为任务处理是在当前事件循环 tick 结尾处,且定时器触发是为了调度下一个事件循环 tick 。
我们来看一下这段代码具体做了什么:
- JavaScript引擎将现在执行的代码块压入执行栈
- 执行 console.log("a"),打印 'a'
- 执行setTimeout,定时器触发,一个回调函数被放入事件队列中
- new Promise构造了一个promise实例,这个过程中执行传入的匿名函数,打印 'c',并且resolve被触发,promise.then中注册的回调函数被添加到任务队列
- 同步代码执行完毕,开始执行任务队列
- 任务队列的第一个(promise.then的回调函数)被取出执行,打印 'd'
- 任务队列执行完,本次tick完成,开始下一个tick的执行
- 执行栈为空,从事件队列中取出第一个(setTimeout的callback)事件并执行,打印 'b';
所以最终的打印结果是:'a'、'c'、'd'、'b'。
总结:
这篇文章简单介绍了事件循环机制,理解它可以帮我们对一段异步代码的执行顺序有一个更清晰的认识,从而减少代码运行的不确定性。你要能掌控自己的代码,这非常重要。
作者水平有限,写文也参考了很多资料,因为无法实际调试浏览器去运行上述过程,写文的时候只能尽量参考一些资料,辅以一些代码用例,总有种隔纱看物的感觉。如文章有误,或者你对这方面的内容有更深的理解,欢迎指正。