毫无疑问,虽然JavaScript的历史比较悠久,但这并不妨碍它成为当今最受欢迎的编程语言之一。对刚接触该语言的人来说,JavaScript的异步特性可能会有一些挑战。在本文中,我们将了解和使用Promise
和async/await
来编写小型异步程序。通过这些示例,你将了解一些可以在自己程序中使用的异步技巧。
本文中的所有代码示例都是基于Node环境编写的,因此建议安装Node以后运行。虽然所有程序都是为Node编写的,但类似的语法在浏览器中也能同样运行,它们的异步编程的写法和原理是通用的。
序言
不管你是否相信JavaScript是一门真正的编程语言,事实是它现在非常的流行。如果你是Web开发人员,你就更应该花一些时间来学习它的优缺点。
JavaScript是单线程的,并且相当于是非阻塞异步流。如果是刚开始使用JavaScript进行异步编程,那么在调试异步代码时,可能会产生很多烦恼。相比常见的同步编程,异步编程需要更多的耐心和不同的思维方式。
在同步模式中,所有操作都发生在一个队列(或者中,更易于对程序进行推理;但是在异步模式中,操作可以在任何时间点以任何顺序开始或结束,每个函数执行结束的时间是不可预测。因此,仅仅依靠运行的顺序序列是不够的。异步编程需要在程序流程和设计方面进行更多思考。
在本文中,我们会尝试几个简单的异步程序,从简单到复杂。我们将编写实现这两个场景功能:
- 将文件内容写入另一个文件。
- 将多个文件的内容写入新文件。
Promises 和 async/await
让我们花点时间快速回顾一下promise和async / await的基础知识。
Promises
Promise
是代表异步操作结果的对象。Promise
上有两个回调:resolve(成功之后的回调函数)
和reject(失败后的回调函数)
。一般而言,
resolve
的结果可以通过then
获取。而reject的结果可以通过catch
来获取。可以通过
new
关键字来使用Promise
构造函数创建Promise。例如:const p = new Promise((r, j) => {});
这里
r
回调在 resolve时调用,j
回调在reject时调用。
另外Promise
对象有一些实用的静态方法如all
,race
,resolve
和reject
。
all
方法可以将多个Promise
实例包装成一个新的Promise
实例,全部的Promise
都resolve
的时候返回的是一个结果数组,有任何reject
都会使最后的结果变为reject
。race
就是赛跑的意思,意思就是说,Promise.race([p1, p2, p3])
里面哪个结果获得的快,就返回那个结果,不管结果本身是resolve
状态还是reject
状态。resolve
方法创建一个Promise
实例并调用resolve
方法处理给定参数。reject
方法创建一个Promise
实例并调用reject
方法处理给定参数。
async/await
async/await
的目的是简化同时使用多个Promise
时的行为,避免了大量使用回调(而带来的回调地狱)。- 正如
Promise
类似于结构化回调,async/await
结合了生成器
和Promise
的特点简化了异步程序的编写。 - 可以使用
async
关键字将函数标记为异步函数。即:async function hello() {}
或const hello = async() => {};
。 async
函数返回的永远是一个Promise
对象。只要是async
函数的返回值,必然会被包含在Promise
对象中。- 如果
async
函数内部存在未捕捉到的异常,则通过Promise
的reject
返回异常。 - 可以在
async
函数内部返回Promise
对象的语句前使用await
。这种情况下,函数的执行将被“暂停”,直到await
的Promise
语句执行完毕,并且返回值不再是一个Promise
对象而是其resolve
的返回结果。 await
只在async
方法内部的有效。
读写单个文件
本节中,我们将编写一个脚本来读取单个文件的内容,并将其写入一个新文件。
首先,我们将为程序的入口创建一个async
方法:
async
然后,我们需要创建两个Promise
,一个代表文件的内容,另一个代表将内容写入另一个文件的操作结果:
async
在上面的代码段中,readFile
和writeFile
都是异步的,并且都返回一个Promise
。因此,需要使用await
来确保readFile
有返回值,以便在writeFile
中使用它:
async
最后,可以考虑一下要在main
函数中返回什么。在这里,我们打算返回要写入的新文件的名称。要注意的是,返回值将被自动包装在Promise
对象中。但是我们需要使用await
来确保在函数执行完之前得到了writeFile
的结果:
async
现在,我们可以调用main函数并将结果或任何未捕获的异常打印出来:
main()
.then(r => console.log("Result:", r))
.catch(err => console.log("An error occurred", err);
为了使程序更加完整,我们需要使用fs
模块并将fs.readFile
和fs.writeFile
Promise化,即promisify。完整的脚本如下所示:
const util =
在上面的代码段中,我们Promise
化了fs.writeFile
和fs.readFile
。promisify函数可以将任何遵循Node.js回调风格的函数,转换为基于Promise
的函数。
接下来我们聊聊异常处理。你可以通过好几种方法来处理异常,具体取决于你想处理到什么程度。例如,在上面的代码片段中,我们在catch
块里基本上捕获了main
函数中可能发生的任何异常。不过它只在async
方法内有用,未捕获的异常会通过该函数的reject
返回。
但是,假设你想做更多的控制,并且希望根据每个async
方法的错误来做不同的操作。在这种情况下,你可以在每个异步操作中使用try-catch
或catch
。
使用try-catch
的情况
我们先来看一下用try-catch
的情况。
async
在上面的代码段中,我们添加了两个try-catch
块。另外,我们在第一个程序块try-catch
之外创建了fileContent
变量,以便在整个main
函数中可用。注意,在每个try-catch
中,如果出现异常,我们返回的是一个对象。错误对象包含一个消息字段和错误的详细信息。如果发生任何错误,函数会立即返回我们自定义的错误对象。请记住,返回的对象会被自动包含在Promise
中。我们可以像以前一样调用main
函数,不过这次可以在then()
中检查错误对象:
main()
.then(r => {
if(r.error) {
return console.log(
"An error occurred, recover here. Details:", r);
}
return console.log("Done, no error. Result:", r);
})
.catch(err => console.log("An error occurred", err));
注意,在then()
中,我们会检查resolve
对象是否存在错误。如果有,那么我们在这里进行错误处理;否则,我们只需将结果打印到日志。另一个catch
块将捕获运行时错误或程序未处理的其他错误。
使用catch
的情况
除了try-catch
,我们也可以通过给每个Promise
绑定一个catch
来处理异常:
async
这里你可能注意到了,我们给每个Promise
都加了catch
方法,并返回一个自定义错误对象,类似于前面的示例。如果其中一个步骤有错误,将只返回这一步的结果,该结果仅包含我们的自定义错误对象。
但是,对于第二个操作,如果写操作成功,我们将明确地返回一个空对象。这是因为writeFile
操作成功时传递给resolve
的是undefined
,而我们无法访问undefined
值的error
字段。所以如果写入成功,我们要返回一个resolve
空对象的Promise
。
我们还可以写两个辅助函数,减少一些重复代码:
const call =
call
函数接受一个Promise
,并返回一个Promise
。如果结果为null或未定义,Promise
将使用空对象进行处理;或者是操作的结果。如果有错误,将解析为一个包含错误信息的error
对象。
error
辅助函数需要result和msg两个参数,它将返回包含错误结果和自定义消息的对象。
添加这两个函数后,我们可以更新main
函数:
async
这里,我们将每个操作传递给call
函数。然后检查是否有错误,如果有,那么只需调用error
函数以返回带有自定义错误消息的自定义错误。完整的代码如下所示:
const util =
为了更多地减少重复代码并使它变得更加模块化,我们还可以做两件事:
- 使用
fs-extra
并删除所有对util.promisify
的调用。 - 将这两个辅助函数放到它们自己的文件中。
之后,我们将得到以下内容:
const fs =
注意,由于我们正在使用fs-extra
,如果不将回调传递给方法,则该函数默认将返回一个Promise
。这就是为什么我们删除所有promisify调用,并直接在fs
变量上转换所有fs
调用的原因。另外,我们将两个辅助函数放到了他们自己的call.js
文件中。
读写多个文件
在本节中,我们将编写一个脚本,该脚本读取多个文件的内容并将结果写入新文件。此示例的设置与上一节非常相似:
const fs =
在上面的代码段中,首先我们需要fs-extra
具有所有基于Promise
的方法版本的模块fs
。然后,我们将main async
函数定义为程序的入口点。我们还定义了一个数组,其中包含要读取的文件的硬编码路径。
接下来,我们将编写一个遍历文件路径的for循环,并读取每个文件的内容:
const fs =
在A行上,我们定义了for循环。在B行await
上,我们根据的结果,fs.readFile
并将其分配给content
变量。最后,在C行中,我们将内容记录到控制台。让我们用实际的写文件操作替换log语句:
const fs =
在上面的代码段中,我们首先在A行中定义文件的路径。然后在B行中,将结果写入新路径,并确保await
在其上也是如此。我们需要在await
这里,因为我们要确保在移至下一个文件之前完成写入。最后在C行,我们返回输入文件路径。
现在,上面的实现还可以,但是我们可以做得更好。在上面的实现中,我们一次处理一个文件。也就是说,我们等待每个文件的读写操作完成,然后再移动到下一个文件。实际上,我们可以通过创建一个Promise
数组并发地运行每个读写过程,其中的每个Promise
表示对文件的读写操作。最后,我们可以用来Promise.all(Promise[])
方法同时处理所有Promise
:
const fs =
在上面的代码段中,我们在A行上定义了一个数组来保存读写Promise
。在行B上,我们开始遍历每个文件路径的for循环。在C行上,我们将自调用async
函数推入readWrites
数组。在每个async
函数的主体内,我们读取每个文件的内容并写入一个新文件。在F行上,我们返回的结果fs.writeFile
是一个Promise
对象。最后,在G行中,我们用于Promise.all
同时处理所有Promise
。我们还await
对结果进行解析,该结果解析为保存写入结果的单个数组。如果写操作成功,我们应该得到一个未定义值的数组。这是因为write方法解析为undefined
没有发生错误。
即使上面的实现完成了工作,我们也可以做得更好。我们可以在files
数组上使用带有async
函数的map
方法,而无需使用自调用async
函数。它也将更容易理解:
const fs =
在上面的代码的A行中我们对files
数组执行map
方法,把它传递给一个async
函数。在async
函数内部,我们仅执行读写操作。最后在D行,我们调用Promise.all
并传递readWrites
数组。该readWrites
数组保存了多个Promise
,其中每个Promise
代表每次读取和写入的结果。
现在,让我们扩展上面的示例。让我们创建一个文件夹,并将所有新文件放入其中。async
在进行读写操作之前,我们将需要创建一个函数来为我们创建输出文件夹:
async
在上面的代码段中,我们首先创建一个async
名为的函数prepare
。在A行,首先,output
如果文件夹已经存在,则将其删除。我们还等待Promise
完成,然后再移至B行。在B行上,我们创建了output
文件夹,我们也等待完成。现在,在开始读写操作之前,我们可以在prepare
函数内部使用该函数main
:
const fs =
在A行上,我们等待prepare
函数完成,然后再进行读写操作。我们还在行B上更新了输出文件路径。脚本的其余部分几乎相同。我们还将files
and output
变量移到了main函数之外。如果运行上面的脚本,应该会看到一个output
包含每个输入文件副本的文件夹。
结论
JavaScript从诞生到现在,已经演化为一个非常先进易用的语言,并且Promise
以及async/await
使异步程序变得更易写也更易读。现在我们已经到了文章的结尾,让我们回顾一些其它的要点:
- 我们可以
Promise.all
与数组的map
方法一起使用来创建Promise
并同时处理它们。我们也可以在Promise.all
等待所有Promise
被完成之前使用await
运算符,即:await Promise.all(inputs.map(async v => {}));
- 如果要在
async
函数内部使用try-catch
块,则需要在返回Promise
的任何Promise
值或函数之前使用await
运算符。
JavaScript是一个功能强大的全栈语言,不仅可以开发Web前端,也使用Node.js开发后端,使用Electron开发桌面应用。同时也可以结合CukeTest、LeanRunner等工具开发自动化测试及RPA,应了那句老话"学好JavaScript,走遍天下都不怕"。学好异步编程是掌握JavaScript的关键,希望这篇文章对你有所帮助。