原文链接:blog.bitsrc.io/understandi…
作者主页:blog.bitsrc.io/@Sukhjinder
JavaScript是单线程编程语言,这意味着同一时间只能发生一件事情。也就是说,JavaScript引擎只能在一个线程的同一时间里处理一个语句。
单线程语言简化了我们的编程,因为你不用担心并发问题,但这也意味着在执行像网络请求这样耗时的操作的时候,会堵塞主进程的进行。
想象着从一个API接口请求一些数据,在某些情况下服务器会花费一些时间处理请求,迟迟没有给出响应,这样就阻塞了主进程,让网页变得迟钝。
这就是异步JavaScript发挥作用的地方。用异步的JavaScript(比如回调函数,promise或者async/await),你可以在不阻塞主进程的情况下执行长网络请求。
虽然你没必学习所有的概念去成为一名优秀的JavaScript工程师,但了解这些概念还是很有帮助的。
那么废话不多说了,让我们进入正题吧!
同步的JavaScript是怎么工作的?
在我们深入了解异步JavaScript之前,让我们先理解同步JavaScript在JavaScript引擎里是怎么执行的,举个例子:
const second = () => {
console.log('Hello there!');
}
const first = () => {
console.log('Hi there!');
second();
console.log('The End');
}
first();
为了理解上述代码是怎么在JavaScript引擎里执行的,我们必须了解执行上下文和调用栈(也被称为执行栈)的概念。
执行上下文(Execution Context)
执行上下文是JavaScript代码在一个环境中编译和执行的抽象概念。在JavaScript里运行的任何代码,都会在执行上下文中执行。 函数里的代码在函数执行上下文中执行,全局代码在全局执行上下文中执。每一个函数都有它自己的执行上下文。
执行栈(call stack)
执行栈,顾名思义,就是一个后进先出的栈结构,用来在代码运行的时候存储所有被创建的执行上下文。
JavaScript有一个单独的执行栈,因为它是单线程编程语言。执行栈是后进先出的数据结构就意味着元素只能出栈顶被增加或者移除。
让我们回到刚才提到的代码段,试着去理解一下JavaScript引擎里的代码是怎么运行的。
const second = () => {
console.log('Hello there!');
}
const first = () => {
console.log('Hi there!');
second();
console.log('The End');
}
first();
这里发生了什么?
当这段代码执行的时候,一个全局执行上下文(用main()来表示)就被创建出来并压入栈顶。后面调用first()的时候,又把first()压入栈顶。
之后,console.log('Hi there!')被压入栈顶,当它执行结束的时候,就从栈顶弹出。再之后,我们调用second(),second()函数被压入栈顶。
然后,console.log('Hello there!')被压入栈顶,当它执行结束的时候,就从栈顶弹出。之后,second()函数执行结束,从栈顶弹出。
然后console.log('The End')被压入栈顶,当它结束的时候,从栈顶弹出。再之后,first()函数执行结束,从栈顶弹出。
至此,这段代码就执行完毕了,同时全局执行上下文(main())从栈顶弹出。
异步JavaScript是怎么工作的?
现在我们对执行栈有了一个基本的概念,并且知道同步JavaScript的工作原理,让我们回到异步JavaScript上面。
什么是阻塞(blocking)?
假设我们正在用同步的方式进行图像处理或网络请求:
const processImage = (image) => {
/**
* doing some operations on image
**/
console.log('Image processed');
}
const networkRequest = (url) => {
/**
* requesting network resource
**/
return someData;
}
const greeting = () => {
console.log('Hello World');
}
processImage(logo.jpg);
networkRequest('www.somerandomurl.com');
greeting();
进行图像处理和网络请求需要花费时间。所以当调用processImage()这个函数的时候,花费多少时间取决于要处理图片的大小。
当processImage()函数执行完成之后,它从执行栈里弹出。之后networkRequest()函数被调用,压入执行栈。同样的,需要花费一些时间去结束这个函数的执行。
所以你看,我们必须得等到函数(比如processImage()或networkRequest())执行完了,才能进行下一步动作。这也意味着这些函数会阻塞执行栈或者主进程,我们无法在执行这些函数的同时进行一些其他的操作,这是很不科学的。
如何解决这个问题呢?
最简单的解决办法就是使用异步回调了。我们使用异步的回调函数让代码不再被阻塞,比如:
const networkRequest = () => {
setTimeout(() => {
console.log('Async Code');
}, 2000);
};
console.log('Hello World');
networkRequest();
这里我用一个定时器方法去模仿网络请求。请牢记,setTimeout定时器并不是JavaScript引擎的一部分,而是web APIs(在浏览器中)和C/C++ APIs(在node.js中)的一部分。
为了理解这段代码是如何执行的,我们还得理解一些别的概念,比如事件循环(Event Loop)和回调队列(Message Quene,也被称为任务队列或消息队列)。
现在,让我们回到刚才的代码来看一下它是怎么用异步的方式执行的吧。
const networkRequest = () => {
setTimeout(() => {
console.log('Async Code');
}, 2000);
};
console.log('Hello World');
networkRequest();
console.log('The End');
输出:
Hello World
The End
Async Code
之后执行定时器setTimeout()这个函数,把setTimeout()压入栈顶。这个函数有两个参数,第一个是回调函数,第二个是以毫秒为单位的时间。
setTimeout()函数在web APIs这个环境开启了一个2秒的定时器。这时,setTimeout()函数执行结束了,从栈顶弹出。
之后,console.log('The End')被压入栈顶,执行结束后,从栈顶弹出。
然后,定时器到时间过期了,这个回调函数被压入了消息队列里。但是回调函数没有立即执行,而是回到了事件循环开始的地方。
事件循环(Event Loop)
事件循环的工作就是去查看执行栈是不是空的。如果执行栈是空的,事件循环就去消息队列里查看是否有等待被执行的回调函数。
在本例里,消息队列包含一个等待被执行的回调函数,执行栈是空的。事件循环就会把回调函数压到执行栈栈顶去。
在console.log('Async Code')这段代码被压入执行栈栈顶,执行完毕然后从栈顶弹出之后。之后这个回调函数从栈顶弹出。到这时,这段程序才算真正地结束了。
DOM 事件
消息队列也包含来自DOM事件的回调函数,比如点击事件和键盘事件,例如:
document.querySelector('.btn').addEventListener('click',(event) => {
console.log('Button Clicked');
});
在DOM事件里,事件监听器在web APIs环境里等待一个具体事件发生(在这个例子里是点击事件),当那个事件发生后,回调函数就被放到消息队列里等待被执行。
同样的,事件循环检查执行栈是不是空的,如果是空的,就把回调函数压入执行栈里,让回调函数执行。
现在,我们已经了解到怎么执行异步的回调函数和DOM事件了,显然,他们是被消息队列存起来然后再等待被执行的。
ES6 工作队列/微任务队列
ES6介绍了JavaScript中基于Promises的工作队列/微任务队列的概念。消息队列和微任务队列的区别是,微任务队列执行的优先级比消息队列高,这也意味着在工作队列/微任务队列里的promise任务会比在消息队列的回调函数执行优先级高。例如:
console.log('Script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
new Promise((resolve, reject) => {
resolve('Promise resolved');
}).then(res => console.log(res))
.catch(err => console.log(err));
console.log('Script End');
输出:
Script start
Script End
Promise resolved
setTimeout
我们可以看到promise比setTimeout先执行,因为promise的返回是存储在微任务队列里的,所以它的执行优先级比消息队列高。 让我们看另一个例子,这个例子里有两个promise和setTimeout:
console.log('Script start');
setTimeout(() => {
console.log('setTimeout 1');
}, 0);
setTimeout(() => {
console.log('setTimeout 2');
}, 0);
new Promise((resolve, reject) => {
resolve('Promise 1 resolved');
}).then(res => console.log(res))
.catch(err => console.log(err));
new Promise((resolve, reject) => {
resolve('Promise 2 resolved');
}).then(res => console.log(res))
.catch(err => console.log(err));
console.log('Script End');
输出:
Script start
Script End
Promise 1 resolved
Promise 2 resolved
setTimeout 1
setTimeout 2
我们可以看到两个promise在setTimeout里的回调函数执行之前执行,因为事件循环把微任务队列里的任务排序在消息队列(任务队列)之前。
当事件循环正在微任务队列里执行任务,这时,如果另一个promise返回resolved,它会被加到同一个微任务队列的最后面去,而且它会在消息队列里的回调函数执行之前执行,不管要等多久,回调函数只能等着微任务执行完了才执行。例如:
console.log('Script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
new Promise((resolve, reject) => {
resolve('Promise 1 resolved');
}).then(res => console.log(res));
new Promise((resolve, reject) => {
resolve('Promise 2 resolved');
}).then(res => {
console.log(res);
return new Promise((resolve, reject) => {
resolve('Promise 3 resolved');
})
}).then(res => console.log(res));
console.log('Script End');
输出:
Script start
Script End
Promise 1 resolved
Promise 2 resolved
Promise 3 resolved
setTimeout
由此可见,在微任务队列里的所有任务都会在消息队列里的任务执行完了之后执行。也就是说,事件循环会优先把微任务队列里的任务清空,再去执行消息队列里的回调函数。
总结
这篇文章我们学习了异步的JavaScript的工作原理,以及共同组成JavaScript运行环境的执行栈,事件循环,消息队列(任务队列)和微任务队列(工作队列)的概念。你不必一一掌握所有的概念去成为一个优秀的JavaScript工程师,但了解这些概念还是对你有帮助的。 :)