面向初学者的同步和异步 JavaScript
本文由我们的开发人员 Bartosz 撰写。它也是这个更大系列的下一篇,所有之前的文章都可以在这里找到:)。
自从我上次发表关于Javascript 的文章以来,已经过去了好久。很难找到时间写下一篇。但是……我成功了,并且想分享一些困扰我很久的事情。
如果你读过我的帖子,你可能还记得我提到过 JavaScript 是同步运行的,而且是一门单线程语言。那么异步性呢?AJAX 请求呢?异步从外部 API 获取数据呢?我决定把这些都讲一遍,分解成最小的部分,以一种最通俗易懂的方式呈现给大家。以一种我乐于学习的方式。让我们开始吧。
单线程同步
我知道我已经写过关于这方面的文章了,但我希望一切都触手可及。我希望读完我的内容后,你不会觉得需要去其他地方寻找 JavaScript 同步和异步方面的基础知识。
那么,JavaScript 的单线程和同步性又如何呢?归根结底,这要归功于该语言的两个非常重要的功能。也就是说,无论我们的代码有多少行,我们编写的所有内容都会一行一行地、一次一个命令地执行。JavaScript 只有一个线程,我们编写的命令会在这个线程中执行,只有前一个命令完成后才会执行下一个命令。就这样结束了。嘿,但是异步性……——你可能会问。我知道,我知道,但请稍等片刻。现在轮到我最喜欢的 console.log 了。
即使在这种情况下,当我们不执行复杂的任务时,console.log('World')命令也只会在第一个命令——console.log('Hello')完成时执行。单线程,同步。然而,我最喜欢的例子如下,虽然看起来它应该返回 true,但实际上并没有。
这是因为 JavaScript 引擎不会将这个表达式(3 > 2 > 1)作为一个整体来看待。当然,在第一步中,它会估算3 > 2,这得益于基本的数学运算,因此返回true 。由于我们不知道 true 是否大于 1,因此这个true被解析为数字 1。最终,引擎会判断1 > 1,结果为 false,因此整个表达式的结果为 false。
所以,再次概括一下——单线程,同步。
经过这段激动人心的介绍(其实只是个提醒),我们将进入……再次重复我在之前文章中已经写过的内容(执行上下文:执行阶段、提升、执行上下文:创建阶段、调用、执行堆栈)。执行上下文和执行堆栈(也称为调用堆栈)。执行上下文每次调用函数时都会出现,其中包含变量等信息。执行堆栈只是一个堆栈,被调用函数的执行上下文会被推送到该堆栈上。然而,在这种情况下,任何文字都比几行代码更能描述它。
为什么要有这么多控制台日志?嗯,我认为没有什么比记录当前发生的事情更好的了。当我们运行脚本时,所有变量和函数语句都会存储在内存中(女士们先生们,提升),然后代码就会开始执行(执行阶段)。我会使用 Chrome 开发工具并设置几个断点,这样我们就可以随时暂停脚本的执行。
如你所见,我将它们分别放在每个函数的开头和结尾,这样日志就会告诉我们函数代码何时开始执行,何时结束。
由于代码是从上到下执行的,所以直到第 23 行才发生任何事情……然后,轰隆隆,第一个 console.log 出现了。
在函数 one 开头设置的断点(注意!断点在标记行之前暂停,在本例中是在 console.log 之前!)告诉我们 console.log('Im about to invoke one function!') 和 one(myNumber) 已执行。另一方面,console.log('I just finish everything!') 却没有显示,尽管它在下面一行。为什么?为什么它看起来像是在第四行?因为被调用函数的执行上下文已被压入堆栈,此后的所有内容目前对 JavaScript 引擎都无关紧要。
这里有一个蓝色箭头,指示当前正在执行代码的执行上下文。好的,现在我们来设置下一个断点。它会在第 7 行吗?
好吧,事实证明我们已经在函数二中了,并且调用该函数之后的任何代码都没有被调用。所以……
...必须等待。另一方面,函数二的执行上下文落入了堆栈。
其中,函数三被调用,一切工作与第一种情况相同。最后的 console.log 必须等待,因为我们已经到达了函数三的执行上下文。
事情很简单。我们没有调用任何东西(在函数三中),所以整个过程现在结束了。在控制台中,我们得到:
好的,剩下的代码怎么办?我们把它忘了吗?当然不行。由于我们目前不会创建新的执行上下文,所以当所有操作完成后,它将自动从堆栈中弹出……
...我们会回来的...
所以我们回到了函数二的执行上下文,发现还有一些事情要做。打印了结束的 console.log,然后像上面一样,我们从堆栈中弹出了执行上下文。只剩下最后一个了。
现在,所有剩余的事情都已完成。
既然一切都完成了,栈就空了!呼,后面还有好多照片呢,所以现在是不是该拍点儿不重复的了?!
我上面提到过,我希望把所有内容都放在一篇文章里,但我决定做这个“小”提醒还有一个原因。现在想象一下,当你访问各种网站时,上面讨论的示例是网站运行的唯一方式。必须先做一些事情才能启动下一个功能。你可能会同意,从用户的角度来看,这会非常繁琐。一个很好的例子就是这样。
waitFiveSeconds 函数的作用正如其名——等待五秒钟。由于 JavaScript 是单线程且同步的,因此在前五秒钟内点击鼠标的次数并不重要。最终结果如下:
相信我,我已经很努力了。正如我上面写的——这会非常繁琐。不过,幸运的是,浏览器中的 JavaScript 活动更加复杂,而且在主线程(也是唯一的 JavaScript 线程)中发生的事情并不是浏览器中实际发生的唯一事情。那样会不会太无聊了?
然而,上面的例子告诉我们什么呢?主要是,阻塞 JavaScript 主线程中应该发生的事情非常危险。如果某个需要一些时间执行的操作进入堆栈,很容易破坏用户访问我们网站的体验。既然我们已经知道不应该这样做,那么我们可以做什么呢?答案很简单——异步回调。
在上面的例子中,我们有两个函数。一个在点击时(onClick)调用,并调用 setTimeout()。在我们的例子中,setTimeout 接受两个参数。第一个是我们要调用的函数(也称为回调函数)。第二个参数指示调用传递的回调需要多长时间。这次,点击浏览器窗口将导致:
在上面的例子中,我们有两个函数。一个在点击时调用(onClick),这意味着 setTimeout()。在我们的例子中,setTimeout 接受两个参数。第一个是我们要调用的函数(也称为回调函数)。第二个参数指示调用传递的回调需要多长时间。这次,点击浏览器窗口将获得如下内容:
正如我上面提到的,浏览器中可能发生很多事情,其中一些是事件循环、任务队列和 Web API。后者,例如 Ajax 请求、setTimeout 或 DOM(文档本身)与堆栈和任务队列通信。让我们以最后一段代码为例。有些人可能会感到惊讶,代码似乎无需等待 waitFiveSeconds 函数就可以运行。这是因为 setTimeout 使用了计时器 API。整个操作从堆栈中移出的时间相当于我们作为第二个参数给出的毫秒数。此外,setTimeout 回调是异步的。当然,并非 JavaScript 中的所有回调都是这样运行的。更重要的是,它们中的大多数是同步的。例如,我们传递给数组方法(如 map() 或 filter())的回调。但是,有些回调是异步的。示例中最简单且最常用的是 setTimeout。它让我们模拟从服务器获取数据。
由于 waitFiveSeconds 函数被暂时从堆栈中释放,控制台中出现了“I was clicked!”。5 秒后,waitFiveSeconds 函数将从 Web API 推送到任务队列。
任务队列只不过是一个推送任务的队列。没有什么可以阻止您将多个任务排队。当然,我们不希望异步函数被遗忘,因此我们必须以某种方式将它们重定向回堆栈。幸运的是,我们不必亲自解决这个问题——如果我们的堆栈为空(意味着没有要执行的操作,没有创建执行上下文),并且我们的任务队列不为空,则第一个任务将被推送出任务队列。由于函数 waitFiveSeconds 被暂时从堆栈中释放,控制台中出现了“我被点击了!”。5 秒后,函数 waitFiveSeconds 将从 Web API 推送到任务队列。
使用setTimeout最常见的例子就是我们将第二个参数设置为0,那么在最后的代码稍作改动之后,你认为会发生什么呢?
确切地说,结果与前一个非常相似。
这是因为 waitZeroSeconds 函数被推迟了,只有当堆栈为空时才会执行。考虑到点击后,堆栈不可能为空,因为 onClick 函数的执行上下文位于其顶部。只有当所有已初始化的内容都从堆栈中弹出后(在我们的例子中是 console.log('I was clicked')),waitZeroSeconds 函数才会执行。
由于我们已经了解浏览器中的 JavaScript 异步性以及同步 JavaScript 如何处理这个问题,让我们看一个例子,其中我们对外部 API 有两个简单的查询,以及它们外面的两个 console.log。
Fetch 是 XMLHttpRequest 的现代替代品,并且是异步执行的。它用于向 API 发送请求以检索数据。
事情很简单。一开始我们在控制台中得到:
由于我们知道 fetch 是异步进行的,因此在清除堆栈后,我们会收到 console.logs。然而,问题是,按照什么顺序执行?(提示:检查指示已提取数据量的数字)。
所以?
一切都很简单,对吧?差不多。在上面的例子中,我们想到了一个问题——如果我们想先获取数据,然后在第二个查询中使用它,该怎么办?让我们看看另一种情况。
请注意,我们不知道帖子的 ID,因此将请求发送到端点 .../posts/1 来实际获取它。然后,我们希望使用保存的 ID 并检索属于此 ID 的所有评论。
不幸的是,我们没有成功。这是因为进入堆栈的查询没有包含 ID 信息。现在,我们将稍微修改一下上面的代码。
巩固知识是基础,所以我再说一遍顺序。
- 在 getComments 之前
- getId 内部
- getComments 函数中的 id
- getComments 之后
- 所有评论
我们该如何正确获取评论数据?解决方案有几个,但最新/最流行的是使用 async / await。
是不是很简单,令人愉悦?我们只需要添加两个关键字——async/await。然而,究竟发生了什么?这里,我们必须进入 JavaScript 中名为 Promise 的领域。
什么是 Promise?首先,它们是对象;其次,它们是非常具体的对象。
最重要的是,它们只是一些与日常生活中类似的承诺。我们每个人都在人生的某个阶段承诺过一些事情。对父母,我们会打扫房间;对雇主,我们会准时到办公室。每个承诺都有两个阶段。第一个阶段,我喜欢称之为“瞬态”阶段。看起来,我们承诺在一天结束前倒垃圾。由于我们还有时间,所以我们的承诺处于
那就是等待最终结果。在这种情况下,值将是未定义的。下一阶段将告诉我们是否能够在一天结束前清理掉这些不幸的垃圾。如果是,并且我们的任务成功了,那么状态就很容易猜了——
例如,这里的值只是一个空字符串。但是,如果我们忘记了,并且未能履行承诺,那么状态将是(也使用空字符串)。
根据状态的不同,可能会发生不同的情况。让我们从一个简单的例子开始。
你可能会问:“这到底是怎么回事?” 好吧,通过 new Promise,我们创建了一个新对象,它接受一个带有两个参数的回调函数——resolve 和 rejection,这两个参数稍后会根据年龄是否在 18 岁来使用。你很容易就能猜到,resolve 函数用于处理 Promise 履行时的变体,而 rejection 函数用于处理 Promise 未履行时的变体。这看起来很简单,对吧?现在我们继续。如果你承诺了某件事,那么……那么,然后呢?这里出现了一个关键字“then()”。已履行的 Promise 将是一个已解析的 Promise 对象。“then()”接收这个值,并使用回调函数以某种方式对其进行处理。让我们稍微修改一下代码,然后使用 then()。
瞧。在 then() 中,我们使用了一个带有 msg 参数的回调函数。因为我们已经知道这个 Promise 会被 fulfilled,所以 msg 会接受我们在 resolve() 中传入的参数值。在我们的例子中,它将是一个字符串——“我已经足够大了……”。因此,我们将得到
现在让我们看看当我们将变量中的年龄改为小于 18 岁(比如说 17 岁)时会发生什么。
我们遇到了一个错误。这是因为 then() 用于处理已解决的 Promise。另一方面,如果我们想捕获一个尚未履行(被拒绝)的 Promise,我们会使用 catch()。所以,让我们在代码中添加 catch(),并将年龄保留为 17。
它看起来会像这样。当然,原理是一样的。在错误参数中,我们传入一个参数,但这次是reject()字符串“What a shame”。没错,结果将是:
正如我建议的那样。一切都相当简单透明。好的,我们来添加一个承诺……
如你所见,我们创建了 order 函数,它将返回一个 Promise。它与前一个 Promise 相同,唯一的区别在于,在本例中它始终处于解析状态,因此它的值是 resolve() 函数的参数。但更有趣的是代码底部的内容。在第 17 行,我们将该函数称为 order 函数。由于在 then() 函数中返回了 Promise,因此我们可以再次使用 then() 函数(在最后返回 Promise),但这次 Promise 的执行结果将是 resolve() 函数中给出的参数,该参数位于 order 函数中。
那么这一切意味着什么?这给我们带来了什么?嗯,多亏了它,我们可以串联 Promises,并将前一个 Promises 的结果传递给下一个 Promises。而且,结果(已解析或已拒绝的 Promises)总是会被传递,这对于与外部 API 通信非常有帮助。我将稍微修改一下上面的代码,添加一些返回 Promises 的函数并串联它们。
通过捕获回调函数参数中的值,我们可以将所有值传递下去。另外,值得一提的是,我们应该始终使用 catch() 来确保某些操作不会意外失败。由于使用了 Promises,我们不必为每个函数调用 catch() 或 then()。只需在任何 Promises 链的末尾添加一个 catch() 即可。
好的,在简要介绍完 Promises 之后,让我们回到 async/await,正是借助它,我们才能首先获取帖子的 ID,然后使用它从 API 中获取更多信息。那么 async/await 的作用是什么呢?让我们回到前面的例子。
它为什么有效?我们得到了预期的结果,因为 await 会暂停函数后续部分的执行,直到我们收到响应为止。因此,只有当 getId 函数能够返回 id、console.log(从第 11 行开始)以及函数中的所有内容时,它才会完成。这一点非常重要。只有当我们知道操作会阻塞主 JavaScript 线程,从而阻止用户与应用程序交互时,才应该使用它。好的,但它还能给我们带来什么呢?嗯,async/await 引入了一些功能,使代码更具可读性。
这里我们修改了之前的一段代码。我们删除了所有的 then() 函数,代码看起来更美观了,对吧?使用 await 函数,我们不仅停止了函数代码的进一步执行,还能立即访问 Promise 从 fetch 返回的结果。
如你所见,我兑现了我的承诺。我试图将所有相关信息都包含在一篇文章中。当然,你也可以添加更多内容,但最终,我认为,如果一个新手开发者想了解更多关于 JavaScript 异步工作原理的信息,那么这里几乎已经包含了所有需要的信息。
文章来源:https://dev.to/2nit/synchronous-and-asynchronous-javascript-for-beginners-5f9p