了解前端JavaScript中异步编程的发展
译者:@Jack Chen
原文:https://blog.logrocket.com/evolution-async-programming-javascript/
前言
按照设计,JavaScript是单线程的,这意味着它一次只能处理一个操作。因为我们的程序只有一个执行线程要运行,所以就产生了一个问题:如何在不阻塞执行线程的情况下执行一个长时间运行的操作?欢迎来到异步编程。
JavaScript中的异步编程提供了一种很好的处理操作(I/O)的方法,这些操作不是立即执行的,因此没有立即响应。它们不是等待长时间运行的操作返回,从而阻塞流程中的执行线程,而是被委托给callback,这些回调是在这些操作最终返回时调用的函数。
在这种情况下,执行线程有助于跟踪称为子例程的活动正在运行的操作,以及该子例程在执行后何时应该将控制权返回到其调用子例程。
现在,有很多应用程序需要一种或另一种形式的异步行为。在用JavaScript解释这些概念时,发出网络或AJAX请求提供了一个非常好的用例。
在本文中,我们将使用callback、promise和async/await来说明异步JavaScript的概念,并解释它们是如何工作的。
JavaScript的本质和为什么我们需要异步编程
在前面,我们了解到JavaScript是单线程的,具有全局执行上下文。这意味着,JavaScript本质上是与单个调用堆栈同步的。因此,代码将按照它被调用的顺序执行,通常称为后进先出(LIFO)方法。
例如,假设我们要执行两个函数,A和B,其中B依赖于A的输出来运行。假设函数A需要一些时间来返回函数B开始执行所需的输出,我们最终阻塞了程序的操作线程。这种行为会导致非常慢的应用程序,这对用户体验是有害的。
让我们来看一个JavaScript中的同步或阻塞操作的例子,代码如下:
const fs = require('fs')
const A = (filePath) => {
const data = fs.readFileSync(filePath)
return data.toString()
}
const B = () => {
const result = A('./file.md')
if (result) {
for (i=0; i < result.length; i++) {
console.log(i)
}
}
console.log('Result is back from function A')
}
B()
// output is shown below
0
1
2
3
4
5
6
7
8
9
10
Result is back from function A
在上面的示例中,在继续执行函数B()中的代码逻辑之前,我们在第9行上等待函数A()的输出。现在,这很好–好,直到我们必须读取一个非常大的文件。在那种情况下,在等待函数B()执行所需的输入之前,要花很长时间等待函数A()完成。同样,这是不明智的。
注意1:根据上面的输出,将调用函数B()并将其推入调用堆栈的顶部。完成同步后,将其所有代码逻辑同步执行(包括执行函数A()),将其弹出堆栈,并再次释放线程以供使用。执行结果如下图:
注意2:readFileSync函数是Node.js中fs模块中的内置方法。它从具有指定路径的文件输入中同步读取。因此,对于同步调用或操作,在完成该操作之前,事件循环无法继续执行任何其他JavaScript代码。
异步在JavaScript中解决了什么?
异步编程可以使许多输入/输出操作同时发生。对于JavaScript,这可以通过事件循环,调用堆栈和异步API(例如callback函数)来实现。
让我们看一个异步操作的例子,以更好地理解,代码如下:
const fs = require('fs')
const A = (filePath, callback) => {
return fs.readFile(filePath, (error, result) => {
if (error) {
return callback(error, null)
}
return callback(null, result)
})
}
const B = () => {
// a callback function attached
A('./file.md', (error, result) => {
if (result) {
for (i=0; i < result.length; i++) {
console.log(i)
}
}
})
console.log('Result is not yet back from function A')
}
B()
// output is shown below
Result is not yet back from function A
0
1
2
3
4
5
6
7
8
9
10
这是运行上面代码示例的执行结果,如下图:
如我们所见,我们已经定义了一个异步回调。因此,调用函数B()时不会立即执行函数A()。
相反,它仅在Node.js readFile模块完成解析和读取文件内容之后才这样做。因此,在运行代码时,我们看到第21行上的输出Result尚未从函数A返回的代码立即被执行。
在接下来的部分中,我们将学习有关JavaScript随时间演变的回调和其他异步模式。但在此之前,让我们来谈谈事件循环。
JavaScript事件循环
在前面的讨论中,JavaScript通过基于事件的回调处理非阻塞输入/输出操作。在本节中,我们将通过事件循环,调用堆栈和回调API来了解代码的执行顺序,这是JavaScript中针对浏览器和Node.js的最早的异步API。
根据MDN,通过事件循环使JS中的回调和并发模型成为可能。事件循环负责执行我们的代码,处理诸如回调之类的事件,并安排其他排队执行的任务。让我们使用前面的回调示例来说明事件循环的工作原理。
首先,在执行函数B()之前,调用堆栈和事件循环为空。
当函数B()被执行时,它将被压入调用堆栈。
由于第14行上的第一个函数A()具有附加的回调,因此将其推入回调队列进行处理。
同时,最后一行console.log('Result 尚未从函数A')返回,将被执行并离开调用堆栈。
在完成函数A()并获得响应后,执行将移至事件循环。
此时,调用堆栈为空,因此JS执行上下文会检查事件循环中是否有任何排队的任务。
现在,控制链将函数A()从事件循环移至调用堆栈,然后在其中执行并返回响应(结果)。
此时,结果现在可用,并且调用堆栈再次为空。
然后将for循环移至调用堆栈以执行。
在for循环的每次迭代中,将第17行上的console.log移至调用堆栈以执行直到完成。
最后,由于现在执行已完成,因此将函数B()从调用堆栈中删除,从而结束了典型流程。
事件循环和调用堆栈
事件循环充当桥梁,可跟踪调用堆栈和回调队列。当调用堆栈为空时,JS执行环境有时会检查是否有排队等待执行的内容。如果是,则事件循环从队列(FIFO)中获取第一个任务,并将其移至调用堆栈,然后执行我们的代码。
调用堆栈是一个堆栈数据结构,有助于跟踪程序中当前正在运行或正在执行的功能。对于堆栈数据结构,压入堆栈的最后一项是离开的第一项-更像LIFO。
最后要注意的一点是,虽然回调不是JavaScript引擎实现的一部分,但它们是可供浏览器和Node使用的API。这些API不会将代码执行直接推送到调用堆栈上,因为这可能会干扰已经在执行的代码,因此会导致事件循环。
Callback
Callback是在JavaScript中处理异步行为的最早方法之一。正如我们在前面的异步示例中看到的,回调是作为参数传递给另一个函数的函数,然后用响应执行该函数。
本质上,在异步操作完成后,通过回调或其他类似的异步api(如JavaScript中的promise或async/await)处理错误或返回的响应。
注意:按照惯例,传递给回调的第一个参数是错误,以及错误发生的原因,而第二个参数是响应数据或结果。
同样,创建回调可以像下面的示例一样简单,代码如下:
const callbackExample = (asyncPattern, callback) => {
console.log(`This is an example, with a ${asyncPattern} passed an an argument`)
callback()
}
const testCallbackFunc = () => {
console.log('Again, this is just a simple callback example')
}
// call our function and pass the testCallbackFunction as an argument
callbackExample('callback', testCallbackFunc)
执行结果,如下图:
Callback问题
应该注意的是,由于每个异步行为的结果都发生在其自己的调用堆栈上,因此抛出异常时错误处理程序可能不在调用堆栈上。这可能导致错误无法正确传播到调用函数
此外,还有可怕的“回调地狱”的问题-太多的嵌套回调函数像意大利面条一样纠结在一起。发生这种情况时,错误不会报告给正确的回调,因为我们甚至可能忘记处理每个回调中的所有错误。对于新开发者来说,这尤其令人困惑。
const fs = require('fs')
const callbackHell = () => {
return fs.readFile(filePath, (err, res)=> {
if(res) {
firstCallback(args, (err, res1) => {
if(res1) {
secondCallback(args, (err, res2) => {
if(res2) {
thirdCallback(args, (err, res3) => {
// and so on...
}
}
}
}
}
}
})
}
在上面的例子中显示了一个典型的回调地狱。处理这些问题的一种方法是将回调分解成更小的函数,就像我们在前面的示例中所做的那样。此外, promise和async/await可以解决一些相关的挑战。
将Callback转换为Promise
在本节中,使用前面的基于callback的示例,我们将对它进行promise——重写它以使用promise。代码如下:
const fs = require('fs')
const A = (filePath) => {
const promise = new Promise((resolve, reject) => {
return fs.readFile(filePath, (error, result) => {
if (error) {
reject(error)
}
resolve(result)
})
})
return promise
}
const B = () => {
A('./file.md').then((data)=>{
if(data) {
for (i=0; i < data.length; i++) {
console.log(i)
}
}
}).catch((error)=>{
// handle errors
console.log(error)
})
console.log('Result is not yet back from function A')
}
B()
// output as above
Result is not yet back from function A
0
1
2
3
4
5
6
7
8
9
10
注意:正如上面所看到的,我们已经能够使用promise()构造函数将前面的示例从callback转换成promise。我们将在下一节中深入探讨promise。
在nodejs中,将callback转换为promise甚至更容易,因为通过内建的util.promisify()API改进了对promise的支持。执行结果如下图:
Promise
Promise是表示异步调用最终完成或失败的对象。这意味着,就像callback一样,promise直观地帮助我们处理没有立即执行的操作的错误和成功响应,尽管是以一种更好、更清晰的方式执行。
在ES2015规范中,promise是一个围绕常规回调函数的包装函数。为了构造一个promise,我们使用promise()构造函数,正如我们在前面的例子中看到的,将一个callback转换成一个promise。
Promise()构造函数接受两个参数:resolve和reject,它们都是回调函数。我们可以在回调中运行异步操作,然后解析它是否成功,如果失败则拒绝。下面代码是我们如何使用构造函数声明一个promise:
const promiseExample = new Promise((resolve, reject) => {
// run an async action and check for the success or failure
if (success) {
resolve('success value of async operation')
}
else {
reject(throw new Error('Something happened while executing async action'))
}
})
上面的函数返回一个新的promise,这个promise最初将处于挂起状态。在本例中,resolve和reject充当回调。当使用成功值解决promise时,我们说它现在处于完成状态。另一方面,当它返回一个错误或被拒绝时,我们说它处于被拒绝状态。为了利用上述promise,代码如下:
promiseExample.then((data) => {
console.log(data) // 'success value of async operation'
}).catch((error) => {
console.log(error) // 'Something happened while executing async action'
}).finally(() => {
console.log('I will always run when the promise must have settled')
})
注意:在上面的例子中,finally块帮助处理其他东西——例如,清理逻辑——当promise被解决或完成操作时。它不是要处理一个promise结果,而是要处理任何其他清理代码。
此外,我们可以手动将一个值转换为一个promise,代码如下所示:
const value = 100
const promisifiedValue = Promise.resolve(value)
console.log(promisifiedValue)
promisifiedValue.then(val => console.log(val)).catch(err => console.log(err))
//output below
Promise { 100 }
Promise { <pending> }
100
注意:这也适用于使用Promise.reject(new Error('Rejected'))拒绝promise
Promise.all
Promise.all返回一个promise,该promise等待数组中的所有promise解析,然后解析为这些promise返回的值的数组,通常与原始数组的顺序相同。如果数组中的任何promise被拒绝,则返回promise.all的结果。一切都是被拒绝的。签名如下:
Promise.all([promise1, promise2])
.then(([res1, res2]) => console.log('Results', res1, res2))
在上面的例子中,promise1和promise2是均返回promise的函数。要了解有关Promise.all的更多信息,请查看MDN文档中有关Promise的精选文档。
Promise链式
Promise工作的好处之一就是链式。我们可以使用多个then()方法进行链式调用返回值或一个接一个执行异步动作。使用前面的示例,让我们看看如何衔接promise,代码如下:
const value = 100
const promisifiedValue = Promise.resolve(value)
promisifiedValue.then( (val) => {
console.log(val) // 100
return val + 100
}).then( (val) => {
console.log(val) // 200
})
// and so on
Promise问题
在外界出现的最多的反模式是:
延迟反模式,当无缘无故地创建“延迟”对象时,会使您的代码冗长且难以推理
.then(成功,失败)反模式,使用promise作为荣耀的回调。
Async/await
多年以来,JavaScript从在ES2015中标准化的Callback到Promise发展到在ES2017中标准化的async/await。异步函数使我们可以像编写异步程序一样编写异步程序。特别重要的是,我们在上一节中仅介绍了promise,因为异步函数在幕后使用promise。
因此,了解promise的工作方式是了解async/await的关键。
异步功能的签名在function关键字之前用单词async标记。另外,可以通过在方法名称之前编写异步方法来使其异步。当调用这样的函数或方法时,它将返回promise。一旦返回,promise就被解决;如果抛出异常,则promise将被拒绝。
每个异步函数实际上都是一个AsyncFunction对象。例如,假设我们有一个异步函数返回一个Promise,代码如下:
const asyncFun = () => {
return new Promise( resolve => {
// simulate a promise by waiting for 3 seconds before resolving or returning with a value
setTimeout(() => resolve('Promise value returned'), 3000)
})
}
现在,我们可以使用异步函数包装以上promise,并在函数内部等待promise的结果。该代码段如下所示:
// add async before the func name
async function asyncAwaitExample() {
// await the result of the promise here
const result = await asyncFun()
console.log(result) // 'Promise value returned' after 3 seconds
}
请注意,在上面的示例中,await将暂停promise的执行,直到其解决为止。
Async/await解决了什么
在处理异步行为时,async/await提供了一种更简洁的语法。尽管Promise附带了许多样板,但异步函数在其之上构建了一个抽象。因此,异步功能只是常规promise上的语法糖。总之,对于异步功能:
生成的代码更加简洁,因此更易于调试
错误处理要简单得多,因为它依赖try ... catch,就像在任何其他同步代码中一样,依此类推。
顶层await
顶层await,当前处于ECMAScript规范的第3阶段,允许开发人员在异步函数之外使用await关键字。在此之前,浏览器和Node均不支持该语言。
因此,从前面关于async/await的示例中,如果我们这样做:
// here the returned `asyncFun()`promise is not wrapped in an async
const result = await asyncFun()
console.log(result)
// this would throw a SyntaxError: await is only valid in async function
在此之前,为了模拟这种行为,我们利用了立即调用的函数表达式:
const fetch = require("node-fetch")
(async function() {
const data = await fetch(url)
console.log(data.json())
}())
本质上,由于我们习惯于在代码中进行async/await,因此现在可以单独使用await关键字,想象一个模块可以在后台充当大型异步函数。
有了这项新的顶层await功能,下面的代码段将以您期望async/await功能正常工作的方式工作。在这种情况下,它使ES模块可以充当全局异步功能。
const result = await asyncFun()
console.log(result) // 'Promise value returned'
JavaScript中的异步与并行
如前所述,JavaScript具有基于事件循环和异步API的并发模型。另一方面,受主要浏览器支持的Web worker使得可以在后台线程中并行运行操作,而与操作的主要执行线程分开。
Web Worker API
异步功能有一些限制。如前所述,我们可以使用回调,promise或async / await使代码异步。当我们要计划和处理长时间运行的操作时,这些浏览器和Node API确实派上了用场。
但是,如果我们有一个计算量大的任务需要很长的时间才能解决(例如,很大的for循环)怎么办?在这种情况下,我们可能需要另一个专用线程来处理这些操作,从而释放主线程来执行其他工作。这是Web Worker API发挥作用的地方。它介绍了并行执行我们的代码的可能性。
附带异步功能限制并仅解决与JavaScript单一执行线程相关的一小部分问题。Web工作人员通过为我们的程序引入一个单独的线程来实质上并行运行代码,从而在不阻止事件循环的情况下执行JavaScript代码。
让我们使用一个示例来了解如何创建web worker:
const worker = new Worker('file.js')
从上面的内容,我们用构造函数创建了一个新的worker。我们还指定了要在工作线程中执行的脚本的路径。由于它们在后台的隔离线程中运行,因此要执行的代码包含在单独的JavaScript文件中。
要向专用线程发送消息或从专用线程发送消息,我们可以使用postMessage() API和Worker.onmessage事件处理程序。要终止工作程序,我们可以调用Terminate()方法。
Web Worker局限性
Web workers受到以下方面的限制:
无法访问浏览器DOM
具有不同的全局范围,称为WorkerGlobalScope
强制从相同来源加载文件
总结
在本文中,我们研究了JavaScript中异步编程的发展,从callback到promise到。我们还审查了Web Worker API,如需了解更多相关信息,请参考MDN文档:
https://developer.mozilla.org/zh-CN/docs/Web/API/Worker
我们已经看到,callback是传递给其他函数的简单函数,仅在事件完成时才执行。我们还看到,callback和promise是等效的,因为可以包装callback以公开基于promise的接口,反之亦然。
此外,我们已经看到异步函数在后台独立运行,而不会干扰应用程序的主线程。由于其性质,一旦准备就绪,它们就可以返回响应(数据或错误),从而不会干扰应用程序中其他正在运行的进程。
我们还了解了web worker如何启动与程序执行主线程分开的新线程。
懒人码农(lazycode)