让我在这篇文章的开头问一句:"什么是JavaScript"?好吧,这是我到目前为止发现的最令人困惑但又最有意义的答案:
JavaScript是一种单线程的、非阻塞的、异步的、并发的编程语言,具有很大的灵活性。
等一下--它同时说了单线程和异步吗?如果你了解单线程的含义,你很可能会把它与同步操作联系起来。那么,JavaScript怎么能是异步的呢?
在这篇文章中,我们将学习所有关于JavaScript的同步和异步部分。你几乎每天都会在网络编程中使用这两部分。
如果你也喜欢从视频内容中学习,这篇文章也可以作为视频教程在这里提供:
在这篇文章中,你会学到
- JavaScript是如何同步的。
- 当JavaScript是单线程时,如何发生异步操作。
- 了解同步与异步如何帮助你更好地理解JavaScript的承诺。
- 大量简单而有力的例子,详细介绍这些概念。
JavaScript函数是一流的公民
在JavaScript中,你可以创建和修改一个函数,把它作为一个参数,从另一个函数中返回,并把它分配给一个变量。所有这些能力使我们可以在任何地方使用函数,以逻辑地放置一堆代码。

将代码行有逻辑地组织到函数中
我们需要通过调用函数来告诉JavaScript引擎执行这些函数。它看起来会像这样。
// Define a function
function f1() {
// Do something
// Do something again
// Again
// So on...
}
// Invoke the function
f1();
默认情况下,函数中的每一行都是按顺序执行的,一次一行。即使你在代码中调用了多个函数,也同样适用。还是那句话,逐行执行。
同步的JavaScript - 函数执行栈是如何工作的
那么,当你定义一个函数,然后调用它时,会发生什么?stack JavaScript引擎维护着一个名为function execution stack 的数据结构。堆栈的目的是跟踪当前正在执行的函数。它的作用如下:
- 当JavaScript引擎调用一个函数时,它会把它添加到堆栈中,然后开始执行。
- 如果当前执行的函数调用了另一个函数,引擎会将第二个函数添加到堆栈中并开始执行。
- 一旦执行完第二个函数,引擎就把它从堆栈中取出。
- 控件从上次离开的地方回去恢复第一个函数的执行。
- 一旦第一个函数的执行结束,引擎将其从堆栈中取出。
- 以同样的方式继续下去,直到没有什么东西可以放进堆栈。
函数执行堆栈也被称为Call Stack :

函数执行堆栈
让我们看一个三个函数逐一执行的例子:
function f1() {
// some code
}
function f2() {
// some code
}
function f3() {
// some code
}
// Invoke the functions one by one
f1();
f2();
f3();
现在我们来看看函数执行栈会发生什么:

一个逐步的流程显示了执行的顺序
你看到那里发生了什么吗?首先,f1() 进入堆栈,执行,然后跳出。然后是f2() ,最后是f3() 。在这之后,堆栈是空的,没有其他东西可以执行。
好的,现在让我们通过一个更复杂的例子。这里有一个函数f3() ,它调用了另一个函数f2() ,而后者又调用了另一个函数f1():
function f1() {
// Some code
}
function f2() {
f1();
}
function f3() {
f2();
}
f3();
让我们看看函数执行栈是怎么回事:

一个逐步的流程显示了执行的顺序
注意,首先f3() 进入堆栈,调用另一个函数,f2() 。所以现在f2() 进入了,而f3() 仍然在栈中。f2() 函数调用了f1() 。所以,是时候让f1() 进入堆栈了,而f2() 和f3() 仍然在堆栈中。
首先,f1() 执行完毕,从堆栈中出来。紧接着,f2() 完成,最后是f3() 。
底线是,在function execution stack 里面发生的所有事情都是有顺序的。这就是JavaScript的Synchronous 部分。JavaScript的main 线程确保在开始研究任何elsewhere 之前,它已经处理了堆栈中的一切。
很好!现在我们明白了synchronous 操作在JavaScript中是如何工作的,现在让我们翻转硬币,看看它的asynchronous 面。你准备好了吗?
异步JavaScript--浏览器API和承诺如何工作
asynchronous 这个词意味着不在同一时间发生。在JavaScript的上下文中,它是什么意思?
通常情况下,依次执行的事情效果很好。但有时你可能需要从服务器获取数据或执行一个有延迟的函数,这是你预计不会发生的事情NOW 。因此,你希望代码能够执行asynchronously 。
在这些情况下,你可能不希望JavaScript引擎停止其他顺序代码的执行。所以,在这种情况下,JavaScript引擎需要更有效地管理一些事情。
我们可以用两个主要的触发器来分类大多数异步JavaScript操作:
- 浏览器API/网络API事件或函数:这些包括方法,如
setTimeout,或事件处理程序,如点击、鼠标移动、滚动等等。 - 诺言:一个独特的JavaScript对象,允许我们进行异步操作。
如果你是诺言的新手,不要担心。你不需要知道比这更多的东西来学习这篇文章。在文章的最后,我提供了一些链接,这样你就可以以最适合初学者的方式开始学习承诺。
如何处理浏览器API/Web APIs
浏览器API如setTimeout 和事件处理程序依赖于callback 函数。当一个异步操作完成时,回调函数就会执行。下面是一个关于setTimeout 函数如何工作的例子:
function printMe() {
console.log('print me');
}
setTimeout(printMe, 2000);
setTimeout 函数在一定时间过后执行一个函数。在上面的代码中,文本print me ,在延迟2秒后记录到控制台。
现在假设我们在setTimeout 函数之后还有几行代码,就像这样:
function printMe() {
console.log('print me');
}
function test() {
console.log('test');
}
setTimeout(printMe, 2000);
test();
那么,我们期望在这里发生什么?你认为输出会是什么?
JavaScript引擎是否会等待2秒钟去调用test() 函数并输出这个。
printMe
test
还是会设法把setTimeout 的回调函数放在一边,继续它的其他执行?所以输出可能是这样的,也许:
test
printMe
如果你猜是后者,你是对的。这就是异步机制起作用的地方。
JavaScript回调队列如何工作(又称任务队列)
JavaScript维护着一个回调函数的队列。它被称为回调队列或任务队列。一个队列的数据结构是First-In-First-Out(FIFO) 。所以,首先进入队列的回调函数有机会先出去。但问题是:
- JavaScript引擎什么时候把它放进队列?
- JavaScript引擎什么时候把它从队列中取出来?
- 当它从队列中出来的时候,它又去了哪里?
- 最重要的是,所有这些东西与JavaScript的异步部分有什么关系?
哇,很多问题!让我们来看看答案。让我们在下面这张图片的帮助下弄清楚答案。

上图显示了我们已经看到的常规call stack 。还有两个额外的部分,用于跟踪浏览器API(如setTimeout)是否启动,以及queue该API的回调函数。
JavaScript引擎继续执行调用栈中的函数。由于它没有把回调函数直接放到堆栈中,所以不存在任何代码在堆栈中等待/阻断执行的问题。
引擎创建了一个loop ,定期查看队列,以找到它需要从那里提取的东西。当堆栈为空时,它从队列中拉出一个回调函数到调用堆栈中。现在回调函数的执行与堆栈中的其他函数一样。循环继续。这个循环就是著名的Event Loop 。
所以,这个故事的寓意是:
- 当浏览器API发生时,把回调函数放在队列中。
- 像往常一样在堆栈中继续执行代码。
- 事件循环检查队列中是否有一个回调函数。
- 如果有,把回调函数从队列中拉到堆栈中并执行。
- 继续这个循环。
好了,让我们看看下面的代码是如何工作的:
function f1() {
console.log('f1');
}
function f2() {
console.log('f2');
}
function main() {
console.log('main');
setTimeout(f1, 0);
f2();
}
main();
该代码执行了一个带有回调函数的setTimeout 函数f1() 。请注意,我们给它的延迟时间为零。这意味着我们希望函数f1() 立即执行。在setTimeout之后,我们执行另一个函数,f2() 。
那么,你认为输出会是什么?是这样的:
main
f2
f1
但是,你可能认为f1 应该在f2 之前打印,因为我们没有延迟f1的执行。但不,情况并非如此。还记得我们上面讨论的event loop 机制吗?现在,让我们看看它在上述代码中的一步步流程。

事件循环--看一步步的执行
下面是写出来的步骤:
main()函数进入调用栈内部。- 它有一个控制台日志来打印main这个词。
console.log('main')执行并走出堆栈。 - setTimeout浏览器API发生。
- 回调函数把它放到回调队列中。
- 在堆栈中,执行照常发生,所以
f2()进入堆栈。f2()的控制台日志执行。两者都从堆栈中出去。 main()也从堆栈中跳出。- 事件循环认识到调用栈是空的,队列中有一个回调函数。
- 回调函数
f1(),然后进入堆栈。执行开始。控制台日志执行,f1()也从堆栈中出来。 - 在这一点上,堆栈和队列中没有其他东西可以进一步执行。
我希望现在你已经很清楚JavaScript的asynchronous 部分是如何在内部工作的。但是,这还不是全部。我们还要看一下promises 。
JavaScript引擎是如何处理承诺的
在JavaScript中,承诺是帮助你执行异步操作的特殊对象。
你可以使用Promise 构造函数来创建一个承诺。你需要向它传递一个executor 函数。在执行函数中,你要定义当一个承诺成功返回或抛出一个错误时你要做什么。你可以通过分别调用resolve 和reject 方法来做到这一点。
下面是一个JavaScript中承诺的例子:
const promise = new Promise((resolve, reject) =>
resolve('I am a resolved promise');
);
在承诺执行后,我们可以用.then() 方法处理结果,用.catch() 方法处理任何错误:
promise.then(result => console.log(result))
每当你使用fetch() 方法从一个商店获得一些数据时,你都会使用承诺。
这里的重点是,JavaScript引擎并没有使用我们之前看到的用于浏览器API的callback queue 。它使用另一个特殊的队列,叫做Job Queue 。
什么是JavaScript中的作业队列?
每当代码中出现一个承诺时,执行函数就会进入作业队列。事件循环像往常一样工作,查看队列,但当stack 空闲时,优先考虑job queue 的项目,而不是callback queue 的项目。
回调队列中的项目被称为macro task ,而工作队列中的项目被称为micro task 。
所以整个流程是这样的:
- 对于
event loop的每个循环,一个任务从callback queue中完成。 - 一旦该任务完成,事件循环就会访问
job queue。在它查看下一件事之前,它会完成工作队列中所有的micro tasks。 - 如果两个队列在同一时间点都有条目,那么
job queue会比callback queue更加优先。
下面的图片显示了工作队列与其他预先存在的项目的包含情况。

现在,让我们看一个例子来更好地理解这个序列:
function f1() {
console.log('f1');
}
function f2() {
console.log('f2');
}
function main() {
console.log('main');
setTimeout(f1, 0);
new Promise((resolve, reject) =>
resolve('I am a promise')
).then(resolve => console.log(resolve))
f2();
}
main();
在上面的代码中,我们和以前一样有一个setTimeout() 函数,但是我们在它后面紧接着引入了一个承诺。现在请记住我们所学到的一切,并猜测输出。
如果你的答案与此相符,你就是正确的:
main
f2
I am a promise
f1
现在让我们来看看行动的流程:

回调队列与工作队列
流程几乎和上面一样,但关键是要注意来自作业队列的项目如何优先于来自任务队列的项目。还要注意的是,如果setTimeout 的延迟为零,这甚至并不重要。它总是关于工作队列在回调队列之前。
好了,我们已经学到了理解JavaScript中同步和异步执行所需的一切。
这里有一个小测验给你
让我们通过测验来测试你的理解。猜测以下代码的输出,并应用我们到目前为止所学到的所有知识:
function f1() {
console.log('f1');
}
function f2() {
console.log('f2');
}
function f3() {
console.log('f3');
}
function main() {
console.log('main');
setTimeout(f1, 50);
setTimeout(f3, 30);
new Promise((resolve, reject) =>
resolve('I am a Promise, right after f1 and f3! Really?')
).then(resolve => console.log(resolve));
new Promise((resolve, reject) =>
resolve('I am a Promise after Promise!')
).then(resolve => console.log(resolve));
f2();
}
main();
下面是预期的输出:
main
f2
I am a Promise, right after f1 and f3! Really?
I am a Promise after Promise!
f3
f1
你想要更多这样的测验吗?请到这个资源库来练习更多的练习。
如果你被卡住或需要任何澄清,我的DM在Twitter上随时开放。
总结
总结一下:
- JavaScript引擎使用堆栈数据结构来跟踪当前执行的函数。这个堆栈被称为函数执行堆栈。
- 函数执行堆栈(又称调用堆栈)按顺序逐行逐条地执行函数。
- 浏览器/网络API使用回调函数来完成异步操作/延迟时的任务。回调函数被放置在回调队列中。
- 承诺执行函数被放置在工作队列中。
- 对于事件循环的每个循环,一个宏任务从回调队列中完成。
- 一旦该任务完成,事件循环就会访问工作队列。在寻找下一件事之前,它完成了工作队列中所有的微观任务。
- 如果两个队列在同一时间点上都有条目,工作队列会比回调队列更优先。
在我们结束之前...
这就是目前的全部内容。我希望你觉得这篇文章很有见地,并能帮助你更好地理解JavaScript的同步与异步概念。
让我们联系起来。你可以在Twitter(@tapasadhikary)、我的Youtube频道和GitHub(atapas)上关注我。
正如之前承诺的那样,这里有几篇文章你可能会觉得很有用: