Javascript高级语法之深入理解同步与异步(译文)

189 阅读11分钟

「这是我参与2022首次更文挑战的第8天,活动详情查看:2022首次更文挑战」。

在本文中,您将学习:

  • JavaScript 是如何同步的。
  • 当 JavaScript 是单线程时,异步操作是如何发生的。
  • 了解同步与异步如何帮助您更好地理解 JavaScript 承诺。
  • 许多简单但功能强大的示例详细介绍了这些概念。

JavaScript 函数是一等公民

在 JavaScript 中,你可以创建和修改函数,将其用作参数,从另一个函数返回,并将其分配给变量。所有这些能力使我们能够在任何地方使用函数来逻辑地放置一堆代码。

image.png 逻辑组织成函数的代码行

我们需要告诉 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.

image.png 函数执行栈

我们来看一个一个一个执行的三个函数的例子:

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都是顺序的。这是SynchronousJavaScript的一部分。JavaScript 的main线程确保它在开始查看任何内容之前会处理堆栈中的所有内容elsewhere

现在我们了解了synchronousJavaScript 中的操作是如何工作的,现在让我们看看它的asynchronous

异步 JavaScript – 浏览器 API 和 Promise 的工作原理

这个词的asynchronous意思是不同时发生。它在 JavaScript 的上下文中意味着什么?

通常,按顺序执行事情效果很好。但是你有时可能需要从服务器获取数据或延迟执行功能,这是你没有预料到的NOW。因此,你希望代码执行asynchronously

在这些情况下,你可能不希望 JavaScript 引擎停止执行其他顺序代码。因此,在这种情况下,JavaScript 引擎需要更有效地管理事物。

我们可以使用两个主要触发器对大多数异步 JavaScript 操作进行分类:

  1. 浏览器 API/Web API事件或函数。这些包括方法setTimeout,或事件处理程序,如单击、鼠标悬停、滚动等等。
  2. 承诺。一个独特的 JavaScript 对象,允许我们执行异步操作。

如果你不熟悉 Promise,请不要担心。你无需了解更多信息即可阅读本文。在文章的最后,我提供了一些链接,以便你以最适合初学者的方式开始学习 Promise。

如何处理浏览器 API/Web API

浏览器 APIsetTimeout和事件处理程序依赖于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 的异步部分有什么关系?

让我们借助下图找出答案:

image.png 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我们上面讨论的机制吗?现在,让我们逐步了解上述代码的流程。

第三流

事件循环 - 查看分步执行

以下是写出来的步骤:

  1. main()函数进入调用堆栈。
  2. 它有一个控制台日志来打印单词 main。执行console.log('main')并出栈。
  3. 发生 setTimeout 浏览器 API。
  4. 回调函数将其放入回调队列。
  5. 在堆栈中,执行照常进行,因此f2()进入堆栈。执行的控制台日志f2()。两者都出栈。
  6. 也从堆栈中main()弹出。
  7. 事件循环识别调用栈为空,队列中有回调函数。
  8. 然后回调函数f1()进入堆栈。执行开始。控制台日志执行,f1()也从堆栈中出来。
  9. 此时,堆栈和队列中没有其他内容可以进一步执行。 我希望你现在已经清楚asynchronousJavaScript 部分是如何在内部工作的。但是,这还不是全部。我们得看看promises

JavaScript 引擎如何处理 Promise

在 JavaScript 中,promise 是帮助您执行异步操作的特殊对象。

你可以使用Promise构造函数创建一个 Promise 。您需要将一个executor函数传递给它。在 executor 函数中,您可以定义当 Promise 成功返回或抛出错误时要执行的操作。您可以通过分别调用resolvereject方法来做到这一点。

下面是一个 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 中的作业队列是什么?

每次代码中出现 promise 时,executor 函数都会进入作业队列。事件循环像往常一样工作以查看队列,但在空闲时优先于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

总结

  • JavaScript 引擎使用堆栈数据结构来跟踪当前执行的函数。该堆栈称为函数执行堆栈。
  • 函数执行堆栈(又名调用堆栈)按顺序、逐行、逐个地执行函数。
  • 当异步操作/延迟完成时,浏览器/Web API 使用回调函数来完成任务。回调函数被放置在回调队列中。
  • 承诺执行器函数被放置在作业队列中。
  • 对于事件循环的每个循环,在回调队列之外完成一个宏任务。
  • 一旦该任务完成,事件循环就会访问作业队列。它在寻找下一件事之前完成了作业队列中的所有微任务。
  • 如果两个队列在同一时间点获得条目,则作业队列优先于回调队列。