同步与异步 JavaScript – 调用堆栈、承诺等
让我从问“什么是 JavaScript”开始这篇文章?好吧,这是我迄今为止找到的最令人困惑但又直截了当的答案:
JavaScript 是一种单线程、非阻塞、异步、并发的编程语言,具有很大的灵活性。
等一下——它是不是同时说单线程和异步?如果您了解单线程的含义,您很可能会将它与同步操作联系起来。那么 JavaScript 怎么能异步呢?
在本文中,我们将了解 JavaScript 的同步和异步部分。您几乎每天都在 Web 编程中使用它们。
如果你也喜欢从视频内容中学习,这篇文章也可以作为视频教程在这里获得:🙂
在本文中,您将了解:
- JavaScript 是如何同步的。
- JavaScript 是单线程时异步操作是如何发生的。
- 理解同步与异步如何帮助你更好地理解 JavaScript 承诺。
- 许多简单但功能强大的示例详细介绍了这些概念。
JavaScript 函数是一等公民
在 JavaScript 中,您可以创建和修改函数,将其用作参数,从另一个函数返回它,并将其分配给变量。所有这些能力让我们可以在任何地方使用函数来逻辑地放置一堆代码。
逻辑上组织成函数的代码行
我们需要告诉 JavaScript 引擎通过调用它们来执行函数。它看起来像这样:
// Define a function
function f1() {
// Do something
// Do something again
// Again
// So on...
}
// Invoke the function
f1();
默认情况下,函数中的每一行都按顺序执行,一次一行。即使您在代码中调用多个函数,这同样适用。再次,一行一行。
同步 JavaScript - 函数执行堆栈的工作原理
那么当你定义一个函数然后调用它时会发生什么呢?JavaScript 引擎维护一个stack
名为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
都是顺序的。这是Synchronous
JavaScript的一部分。JavaScript 的main
线程确保它在开始查看任何内容之前处理堆栈中的所有内容elsewhere
。
伟大的!现在我们了解了synchronous
JavaScript 中的操作是如何工作的,现在让我们抛硬币看看它的asynchronous
一面。你准备好了吗?
异步 JavaScript——浏览器 API 和 Promise 的工作原理
这个词的asynchronous
意思是不同时发生。它在 JavaScript 的上下文中是什么意思?
通常,按顺序执行事情效果很好。但有时您可能需要从服务器获取数据或延迟执行函数,这是您没有预料到的NOW
。因此,您希望代码执行asynchronously
。
在这些情况下,您可能不希望 JavaScript 引擎停止其他顺序代码的执行。因此,在这种情况下,JavaScript 引擎需要更有效地管理事物。
我们可以使用两个主要触发器对大多数异步 JavaScript 操作进行分类:
- 浏览器 API/Web API事件或函数。这些包括像 的方法
setTimeout
,或像单击、鼠标悬停、滚动等事件处理程序。 - 承诺。一个独特的 JavaScript 对象,它允许我们执行异步操作。
如果您不熟悉 Promise,请不要担心。您无需了解更多内容即可阅读本文。在文章的最后,我提供了一些链接,以便您可以以对初学者最友好的方式开始学习 Promise。
如何处理浏览器 API/Web API
浏览器 APIsetTimeout
和事件处理程序依赖于callback
函数。回调函数在异步操作完成时执行。下面是一个setTimeout
函数如何工作的例子:
function printMe() {
console.log('print me');
}
setTimeout(printMe, 2000);
该etTimeout
函数在经过一定时间后执行函数。在上面的代码中,文本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
aside的回调函数并继续其他执行?所以输出可能是这样的,也许:
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()
也从堆栈中出来。 - 此时,堆栈和队列中没有其他东西可以进一步执行。
我希望您现在已经清楚asynchronous
JavaScript的部分是如何在内部工作的。但是,这还不是全部。我们要看看promises
。
JavaScript 引擎如何处理 Promise
在 JavaScript 中,promise 是帮助您执行异步操作的特殊对象。
您可以使用Promise
构造函数创建承诺。您需要向它传递一个executor
函数。在 executor 函数中,您可以定义当 Promise 成功返回或抛出错误时要执行的操作。您可以通过分别调用resolve
和reject
方法来做到这一点。
以下是 JavaScript 中的 promise 示例:
const promise = new Promise((resolve, reject) =>
resolve('I am a resolved promise');
);
执行 promise 后,我们可以使用该.then()
方法处理结果以及该方法的任何错误.catch()
。
promise.then(result => console.log(result))
每次使用该fetch()
方法从存储中获取一些数据时,都使用 promise 。
这里的重点是 JavaScript 引擎不使用callback queue
我们之前在浏览器 API 中看到的相同内容。它使用另一个称为Job Queue
.
JavaScript 中的作业队列是什么?
每次在代码中出现承诺时,执行器函数都会进入作业队列。事件循环像往常一样工作以查看队列,但在空闲时优先考虑job queue
项目而不是callback queue
项目stack
。
回调队列中macro task
的项目称为 a ,而作业队列中的项目称为 a 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()
和以前一样的函数,但我们在它之后引入了一个 promise。现在记住我们所学的所有内容并猜测输出。
如果您的答案与此相符,则您是正确的:
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 引擎使用堆栈数据结构来跟踪当前执行的函数。该堆栈称为函数执行堆栈。
- 函数执行堆栈(又名调用堆栈)按顺序、一行一行、一行一行地执行函数。
- 当异步操作/延迟完成时,浏览器/Web API 使用回调函数来完成任务。回调函数放在回调队列中。
- 承诺执行器函数放置在作业队列中。
- 对于事件循环的每个循环,从回调队列中完成一个宏任务。
- 一旦该任务完成,事件循环就会访问作业队列。它在寻找下一件事情之前完成作业队列中的所有微任务。
- 如果两个队列在同一时间点获得条目,则作业队列优先于回调队列。
在我们结束之前...
目前为止就这样了。我希望您发现这篇文章很有见地,它可以帮助您更好地理解 JavaScript 的同步与异步概念。
让我们连接起来。您可以在Twitter(@tapasadhikary)、My Youtube 频道和GitHub(atapas)上关注我。
正如之前所承诺的,这里有一些您可能会觉得有用的文章,