如何在Node.js中编写异步代码

作者选择了开放互联网/言论自由基金作为写作捐赠计划的受益方。

介绍

对于许多 JavaScript 程序,代码是按照开发者编写的顺序逐行执行的。这被称为同步执行,因为这些代码按照编写的顺序一个接一个地执行。然而,并不是每个指令都需要立即执行。例如,如果您发送了一个网络请求,执行您的代码的进程将必须等待数据返回,然后才能处理它。在这种情况下,如果不在等待网络请求完成时执行其他代码,那么时间将被浪费。为了解决这个问题,开发者使用异步编程,其中代码行的执行顺序与编写的顺序不同。使用异步编程,我们可以在等待网络请求等长时间活动完成时执行其他代码。

JavaScript代码在计算机进程中以单线程方式执行。它的代码在该线程上同步处理,每次只运行一条指令。因此,如果我们在此线程上执行长时间运行的任务,所有剩余的代码都会被阻塞,直到任务完成。通过利用JavaScript的异步编程特性,我们可以将长时间运行的任务转移到后台线程中,以避免这个问题。当任务完成时,我们需要处理任务数据的代码会被放回主单线程。

在本教程中,您将学习JavaScript如何利用事件循环来管理异步任务,事件循环是一个JavaScript构造,它在等待另一个任务完成时完成新任务。然后,您将创建一个程序,使用异步编程从吉卜力工作室API请求电影列表,并将数据保存到CSV文件中。异步代码将以三种方式编写:回调、Promise,以及async/await关键字。

注意:截至本文撰写时,异步编程不再仅使用回调进行,但学习这种已过时的方法可以为了解JavaScript社区现在使用Promise提供很好的背景。async/await关键字使我们能够以更简洁的方式使用Promise,因此是撰写本文时JavaScript中进行异步编程的标准方法。

先决条件

事件循环

让我们首先研究JavaScript函数执行的内部工作原理。理解这种行为将使您能够更有意识地编写异步代码,并帮助您在未来解决代码问题。

当JavaScript解释器执行代码时,调用的每个函数都会被添加到JavaScript的调用栈中。调用栈是一个——一种类似列表的数据结构,其中只能将项目添加到顶部,并从顶部移除。栈遵循“后进先出”或LIFO原则。如果您在栈上添加两个项目,最近添加的项目将首先被移除。

让我们用一个使用调用栈的例子来说明。如果JavaScript遇到一个函数functionA()被调用,它会被添加到调用栈中。如果该函数functionA()调用了另一个函数functionB(),那么functionB()被添加到调用栈的顶部。当JavaScript完成一个函数的执行时,它将从调用栈中移除。因此,JavaScript将首先执行functionB(),在完成时将其从栈中移除,然后完成functionA()的执行并将其从调用栈中移除。这就是为什么内部函数总是先于外部函数执行的原因。

当JavaScript遇到异步操作,比如写入文件时,它会将其添加到内存中的一个表中。该表存储了操作、完成条件以及完成时要调用的函数。当操作完成时,JavaScript会将关联的函数添加到消息队列中。队列是另一种类似列表的数据结构,其中的项目只能从底部添加,但可以从顶部移除。在消息队列中,如果两个或多个异步操作准备好执行其函数,则首先完成的异步操作将首先标记其函数以执行。

消息队列中的函数正在等待添加到调用堆栈中。事件循环是一个永久性过程,它检查调用堆栈是否为空。如果为空,则将消息队列中的第一个项目移动到调用堆栈中。JavaScript优先处理消息队列中的函数,而不是在代码中解释的函数调用。调用堆栈、消息队列和事件循环的组合效果允许JavaScript代码在处理异步活动时进行管理。

现在你对事件循环有了高层次的理解,你知道你编写的异步代码将如何执行。有了这些知识,你现在可以使用三种不同的方法创建异步代码:回调、Promise和async/await

使用回调进行异步编程

A callback function is one that is passed as an argument to another function, and then executed when the other function is finished. We use callbacks to ensure that code is executed only after an asynchronous operation is completed.

在很长一段时间里,回调一直是编写异步代码最常见的机制,但现在它们已经基本过时了,因为它们会让代码变得难以阅读。在本步骤,您将编写一个使用回调的异步代码示例,以便您可以将其用作基准,以查看其他策略的效率提升。

在另一个函数中使用回调函数的方法有很多。通常,它们采用如下结构:

function asynchronousFunction([ Function Arguments ], [ Callback Function ]) {
    [ Action ]
}

尽管 JavaScript 或 Node.js 并不在语法上要求将回调函数作为外部函数的最后一个参数,但这是一个通用的做法,可以让回调更容易识别。JavaScript 开发人员通常使用 匿名函数 作为回调。匿名函数是不带名称创建的函数。通常,当函数定义在参数列表的末尾时,可读性会大大提高。

为了演示回调,我们创建一个 Node.js 模块,它将 吉卜力工作室 电影列表写入文件。首先,创建一个文件夹来存储我们的 JavaScript 文件及其输出:

  1. mkdir ghibliMovies

然后进入该文件夹:

  1. cd ghibliMovies

我们将首先向 吉卜力工作室 API 发出 HTTP 请求,我们的回调函数将记录其结果。为此,我们将安装一个库,它允许我们通过回调访问 HTTP 响应的数据。

在您的终端中,初始化 npm,以便我们稍后能够引用我们的软件包:然后,安装 request 库:

  1. npm init -y

然后,安装request库:

  1. npm i request --save

现在打开一个名为callbackMovies.js的新文件,使用nano这样的文本编辑器:

  1. nano callbackMovies.js

在文本编辑器中,输入以下代码。让我们开始使用request模块发送HTTP请求:

callbackMovies.js
const request = require('request');

request('https://ghibliapi.herokuapp.com/films');

在第一行,我们加载了通过npm安装的request模块。该模块返回一个可以进行HTTP请求的函数;然后我们将该函数保存在request常量中。

然后,我们使用request()函数进行HTTP请求。现在让我们通过添加突出显示的更改将HTTP请求的数据打印到控制台:

callbackMovies.js
const request = require('request');

request('https://ghibliapi.herokuapp.com/films', (error, response, body) => {
    if (error) {
        console.error(`Could not send request to API: ${error.message}`);
        return;
    }

    if (response.statusCode != 200) {
        console.error(`Expected status code 200 but received ${response.statusCode}.`);
        return;
    }

    console.log('Processing our list of movies');
    movies = JSON.parse(body);
    movies.forEach(movie => {
        console.log(`${movie['title']}, ${movie['release_date']}`);
    });
});

当我们使用request()函数时,我们给它两个参数:

  • 我们试图请求的网站的URL
  • A callback function that handles any errors or successful responses after the request is complete

我们的回调函数有三个参数:errorresponsebody。当HTTP请求完成时,根据结果自动给参数赋值。如果请求发送失败,那么error将包含一个对象,但responsebody将是null。如果请求成功,那么HTTP响应存储在response中。如果我们的HTTP响应返回数据(在这个例子中我们得到JSON),那么数据就设置在body中。

我们的回调函数首先检查是否收到了错误。最佳做法是首先检查回调中是否有错误,以防止回调的执行在缺少数据的情况下继续进行。在这种情况下,我们记录错误和函数的执行情况。然后,我们检查响应的状态码。我们的服务器可能不总是可用,API 可能会发生变化,导致曾经合理的请求变得不正确。通过检查状态码是否为 200,即表示请求“OK”,我们可以确信我们的响应符合预期。

最后,我们将响应体解析为一个 Array,并循环遍历每部电影以记录其名称和发布年份。

保存并退出文件后,使用以下命令运行此脚本:

  1. node callbackMovies.js

您将获得以下输出:

Output
Castle in the Sky, 1986 Grave of the Fireflies, 1988 My Neighbor Totoro, 1988 Kiki's Delivery Service, 1989 Only Yesterday, 1991 Porco Rosso, 1992 Pom Poko, 1994 Whisper of the Heart, 1995 Princess Mononoke, 1997 My Neighbors the Yamadas, 1999 Spirited Away, 2001 The Cat Returns, 2002 Howl's Moving Castle, 2004 Tales from Earthsea, 2006 Ponyo, 2008 Arrietty, 2010 From Up on Poppy Hill, 2011 The Wind Rises, 2013 The Tale of the Princess Kaguya, 2013 When Marnie Was There, 2014

我们成功收到了一份包含吉卜力工作室电影及其发布年份的列表。现在,让我们通过将当前正在记录的电影列表写入文件来完成此程序。

在文本编辑器中更新 callbackMovies.js 文件,包括以下突出显示的代码,该代码创建了一个包含我们电影数据的 CSV 文件:

callbackMovies.js
const request = require('request');
const fs = require('fs');

request('https://ghibliapi.herokuapp.com/films', (error, response, body) => {
    if (error) {
        console.error(`Could not send request to API: ${error.message}`);
        return;
    }

    if (response.statusCode != 200) {
        console.error(`Expected status code 200 but received ${response.statusCode}.`);
        return;
    }

    console.log('Processing our list of movies');
    movies = JSON.parse(body);
    let movieList = '';
    movies.forEach(movie => {
        movieList += `${movie['title']}, ${movie['release_date']}\n`;
    });

    fs.writeFile('callbackMovies.csv', movieList, (error) => {
        if (error) {
            console.error(`Could not save the Ghibli movies to a file: ${error}`);
            return;
        }

        console.log('Saved our list of movies to callbackMovies.csv');;
    });
});

注意突出显示的更改,我们导入了 fs 模块。该模块在所有 Node.js 安装中都是标准的,并且包含一个可以异步写入文件的 writeFile() 方法。

将数据添加到字符串变量movieList中,而不是将其记录到控制台。然后,我们使用writeFile()movieList的内容保存到新文件callbackMovies.csv中。最后,我们向writeFile()函数提供一个回调,该回调具有一个参数:error。这样我们就能处理无法写入文件的情况,例如当我们在没有权限的用户上运行node进程时。

保存文件并再次运行此Node.js程序:

  1. node callbackMovies.js

在您的ghibliMovies文件夹中,您将看到callbackMovies.csv,其内容如下:

callbackMovies.csv
Castle in the Sky, 1986
Grave of the Fireflies, 1988
My Neighbor Totoro, 1988
Kiki's Delivery Service, 1989
Only Yesterday, 1991
Porco Rosso, 1992
Pom Poko, 1994
Whisper of the Heart, 1995
Princess Mononoke, 1997
My Neighbors the Yamadas, 1999
Spirited Away, 2001
The Cat Returns, 2002
Howl's Moving Castle, 2004
Tales from Earthsea, 2006
Ponyo, 2008
Arrietty, 2010
From Up on Poppy Hill, 2011
The Wind Rises, 2013
The Tale of the Princess Kaguya, 2013
When Marnie Was There, 2014

重要的是要注意,我们在HTTP请求的回调中写入CSV文件。一旦代码位于回调函数中,它将在HTTP请求完成后才写入文件。如果我们想在写入CSV文件后与数据库通信,我们将创建另一个异步函数,该函数将在writeFile()的回调中调用。我们拥有的异步代码越多,嵌套的回调函数就越多。

假设我们想要执行五个异步操作,每个操作只能在另一个操作完成后运行。如果我们要编写此代码,将会是这样:

doSomething1(() => {
    doSomething2(() => {
        doSomething3(() => {
            doSomething4(() => {
                doSomething5(() => {
                    // 最终操作
                });
            });
        }); 
    });
});

当嵌套回调有许多行代码需要执行时,它们变得更加复杂和难以阅读。随着您的JavaScript项目的规模和复杂性增长,这种效果将变得更加显著,直到最终变得难以管理。因此,开发人员不再使用回调来处理异步操作。为了改善我们的异步代码的语法,我们可以使用Promise。

使用Promise进行简洁的异步编程

A promise is a JavaScript object that will return a value at some point in the future. Asynchronous functions can return promise objects instead of concrete values. If we get a value in the future, we say that the promise was fulfilled. If we get an error in the future, we say that the promise was rejected. Otherwise, the promise is still being worked on in a pending state.

Promise通常采用以下形式:

promiseFunction()
    .then([ Callback Function for Fulfilled Promise ])
    .catch([ Callback Function for Rejected Promise ])

如模板所示,Promise也使用回调函数。我们有一个用于then()方法的回调函数,当Promise被执行时执行该函数。我们还有一个用于catch()方法的回调函数,用于处理Promise执行过程中出现的任何错误。

让我们通过重写我们的Studio Ghibli程序以使用Promise来亲身体验一下Promise。

Axios是JavaScript的基于Promise的HTTP客户端,所以让我们继续安装它:

  1. npm i axios --save

现在,使用您选择的文本编辑器,创建一个名为promiseMovies.js的新文件:

  1. nano promiseMovies.js

我们的程序将使用axios发出HTTP请求,然后使用一个特殊的基于Promise的fs版本保存到一个新的CSV文件。

将该代码输入到promiseMovies.js文件中,以便我们加载Axios并发送HTTP请求到电影API:

promiseMovies.js
const axios = require('axios');

axios.get('https://ghibliapi.herokuapp.com/films');

在第一行,我们加载axios模块,将返回的函数存储在名为axios的常量中。然后我们使用axios.get()方法发送HTTP请求到API。

axios.get()方法返回一个Promise。让我们链接该Promise,以便我们可以将吉卜力电影列表打印到控制台:

promiseMovies.js
const axios = require('axios');
const fs = require('fs').promises;


axios.get('https://ghibliapi.herokuapp.com/films')
    .then((response) => {
        console.log('Successfully retrieved our list of movies');
        response.data.forEach(movie => {
            console.log(`${movie['title']}, ${movie['release_date']}`);
        });
    })

让我们来分解一下正在发生的事情。在使用axios.get()进行HTTP GET请求后,我们使用then()函数,该函数仅在Promise被实现时执行。在这种情况下,我们像在回调示例中那样将电影打印到屏幕上。

为了改进这个程序,添加以下突出显示的代码以将HTTP数据写入文件:

promiseMovies.js
const axios = require('axios');
const fs = require('fs').promises;


axios.get('https://ghibliapi.herokuapp.com/films')
    .then((response) => {
        console.log('Successfully retrieved our list of movies');
        let movieList = '';
        response.data.forEach(movie => {
            movieList += `${movie['title']}, ${movie['release_date']}\n`;
        });

        return fs.writeFile('promiseMovies.csv', movieList);
    })
    .then(() => {
        console.log('Saved our list of movies to promiseMovies.csv');
    })

我们另外再次导入fs模块。请注意,在fs导入后,我们有.promises。Node.js包括一个基于Promise的回调fs库的版本,因此不会破坏旧项目的向后兼容性。

现在处理HTTP请求的第一个then()函数调用fs.writeFile()而不是打印到控制台。由于我们导入了基于Promise的fs版本,因此我们的writeFile()函数返回另一个Promise。因此,我们附加另一个then()函数,用于处理writeFile() Promise被实现的情况。

A promise can return a new promise, allowing us to execute promises one after the other. This paves the way for us to perform multiple asynchronous operations. This is called promise chaining, and it is analogous to nesting callbacks. The second then() is only called after we successfully write to the file.

注意:在这个例子中,我们没有像在回调函数示例中那样检查HTTP状态码。默认情况下,axios如果收到表示错误的状态码,就不会实现其承诺。因此,我们不再需要验证它。

要完成这个程序,将承诺与catch()函数链接,如下所示:

promiseMovies.js
const axios = require('axios');
const fs = require('fs').promises;


axios.get('https://ghibliapi.herokuapp.com/films')
    .then((response) => {
        console.log('Successfully retrieved our list of movies');
        let movieList = '';
        response.data.forEach(movie => {
            movieList += `${movie['title']}, ${movie['release_date']}\n`;
        });

        return fs.writeFile('promiseMovies.csv', movieList);
    })
    .then(() => {
        console.log('Saved our list of movies to promiseMovies.csv');
    })
    .catch((error) => {
        console.error(`Could not save the Ghibli movies to a file: ${error}`);
    });

如果在承诺链中有任何承诺未被实现,JavaScript会自动进入catch()函数(如果已定义)。这就是为什么尽管我们有两个异步操作,我们只有一个catch()子句的原因。

让我们通过运行来确认我们的程序产生相同的输出:

  1. node promiseMovies.js

在你的ghibliMovies文件夹中,你会看到promiseMovies.csv文件包含:

promiseMovies.csv
Castle in the Sky, 1986
Grave of the Fireflies, 1988
My Neighbor Totoro, 1988
Kiki's Delivery Service, 1989
Only Yesterday, 1991
Porco Rosso, 1992
Pom Poko, 1994
Whisper of the Heart, 1995
Princess Mononoke, 1997
My Neighbors the Yamadas, 1999
Spirited Away, 2001
The Cat Returns, 2002
Howl's Moving Castle, 2004
Tales from Earthsea, 2006
Ponyo, 2008
Arrietty, 2010
From Up on Poppy Hill, 2011
The Wind Rises, 2013
The Tale of the Princess Kaguya, 2013
When Marnie Was There, 2014

使用承诺,我们可以编写比仅使用回调更简洁的代码。承诺链回调比嵌套回调更清晰。然而,随着我们进行更多的异步调用,我们的承诺链变得越来越长,维护起来越困难。

回调和承诺的冗长来自于在异步任务的结果时需要创建函数的需要。更好的体验是等待异步结果并将其放入函数外的变量中。这样,我们可以在不必创建函数的情况下使用变量中的结果。我们可以使用asyncawait关键字来实现这一点。

使用async/await编写JavaScript

async/await关键字提供了一种在处理Promise时的替代语法。与在then()方法中获得Promise结果不同,结果被返回为值,就像在任何其他函数中一样。我们使用async关键字定义一个函数,告诉JavaScript它是一个返回Promise的异步函数。我们使用await关键字告诉JavaScript在Promise完成时返回Promise的结果,而不是Promise本身。

一般来说,async/await的使用如下:

async function() {
    await [Asynchronous Action]
}

让我们看看如何使用async/await来改进我们的吉卜力工作室程序。使用文本编辑器创建并打开一个新文件asyncAwaitMovies.js

  1. nano asyncAwaitMovies.js

在你新打开的JavaScript文件中,让我们从导入我们在Promise示例中使用的相同模块开始:

asyncAwaitMovies.js
const axios = require('axios');
const fs = require('fs').promises;

导入与promiseMovies.js相同,因为async/await使用了Promise。

现在我们使用async关键字来创建一个包含我们异步代码的函数:

asyncAwaitMovies.js
const axios = require('axios');
const fs = require('fs').promises;

async function saveMovies() {}

我们创建了一个名为saveMovies()的新函数,但在其定义的开头包含了async。这很重要,因为我们只能在异步函数中使用await关键字。

使用await关键字发出HTTP请求,从吉卜力API获取电影列表:

asyncAwaitMovies.js
const axios = require('axios');
const fs = require('fs').promises;

async function saveMovies() {
    let response = await axios.get('https://ghibliapi.herokuapp.com/films');
    let movieList = '';
    response.data.forEach(movie => {
        movieList += `${movie['title']}, ${movie['release_date']}\n`;
    });
}

在我们的saveMovies()函数中,我们使用axios.get()发出HTTP请求,就像以前一样。这一次,我们不再将其与then()函数链接在一起。相反,在调用它之前,我们添加了await。当JavaScript看到await时,它将仅在axios.get()执行完毕并设置了response变量后才执行函数的其余代码。其他代码保存了电影数据,以便我们可以写入文件。

让我们将电影数据写入文件:

asyncAwaitMovies.js
const axios = require('axios');
const fs = require('fs').promises;

async function saveMovies() {
    let response = await axios.get('https://ghibliapi.herokuapp.com/films');
    let movieList = '';
    response.data.forEach(movie => {
        movieList += `${movie['title']}, ${movie['release_date']}\n`;
    });
    await fs.writeFile('asyncAwaitMovies.csv', movieList);
}

当我们使用fs.writeFile()写入文件时,我们也使用await关键字。

为了完成这个函数,我们需要捕获我们的promise可能抛出的错误。让我们通过将我们的代码封装在一个try/catch块中来实现这一点:

asyncAwaitMovies.js
const axios = require('axios');
const fs = require('fs').promises;

async function saveMovies() {
    try {
        let response = await axios.get('https://ghibliapi.herokuapp.com/films');
        let movieList = '';
        response.data.forEach(movie => {
            movieList += `${movie['title']}, ${movie['release_date']}\n`;
        });
        await fs.writeFile('asyncAwaitMovies.csv', movieList);
    } catch (error) {
        console.error(`Could not save the Ghibli movies to a file: ${error}`);
    }
}

由于promise可能会失败,我们用try/catch子句封装了我们的异步代码。这将捕获当HTTP请求或文件写入操作失败时抛出的任何错误。

最后,让我们调用我们的异步函数saveMovies(),这样它将在我们用node运行程序时执行。

asyncAwaitMovies.js
const axios = require('axios');
const fs = require('fs').promises;

async function saveMovies() {
    try {
        let response = await axios.get('https://ghibliapi.herokuapp.com/films');
        let movieList = '';
        response.data.forEach(movie => {
            movieList += `${movie['title']}, ${movie['release_date']}\n`;
        });
        await fs.writeFile('asyncAwaitMovies.csv', movieList);
    } catch (error) {
        console.error(`Could not save the Ghibli movies to a file: ${error}`);
    }
}

saveMovies();

一瞥之下,这看起来像是典型的同步JavaScript代码块。传递的函数较少,看起来更整洁。这些小调整使得使用async/await的异步代码更易于维护。

通过在终端输入以下内容来测试我们程序的这个迭代版本:

  1. node asyncAwaitMovies.js

在你的ghibliMovies文件夹中,将创建一个名为asyncAwaitMovies.csv的新文件,其内容如下:

asyncAwaitMovies.csv
Castle in the Sky, 1986
Grave of the Fireflies, 1988
My Neighbor Totoro, 1988
Kiki's Delivery Service, 1989
Only Yesterday, 1991
Porco Rosso, 1992
Pom Poko, 1994
Whisper of the Heart, 1995
Princess Mononoke, 1997
My Neighbors the Yamadas, 1999
Spirited Away, 2001
The Cat Returns, 2002
Howl's Moving Castle, 2004
Tales from Earthsea, 2006
Ponyo, 2008
Arrietty, 2010
From Up on Poppy Hill, 2011
The Wind Rises, 2013
The Tale of the Princess Kaguya, 2013
When Marnie Was There, 2014

现在你已经使用了JavaScript的async/await特性来管理异步代码。

结论

在本教程中,你学习了JavaScript如何处理执行函数并管理异步操作的事件循环。然后,你编写了使用各种异步编程技术创建CSV文件的程序,该程序在进行HTTP请求获取电影数据后创建CSV文件。首先,你使用了已过时的基于回调的方法。然后,你使用了promises,并最终使用async/await使promise语法更简洁。

通过你对Node.js中异步代码的理解,现在你可以开发受益于异步编程的程序,比如那些依赖于API调用的程序。看一下这个公共API列表。要使用它们,你将需要进行异步HTTP请求,就像我们在这个教程中所做的那样。为了进一步学习,尝试构建一个应用程序,使用这些API来练习你在这里学到的技术。

Source:
https://www.digitalocean.com/community/tutorials/how-to-write-asynchronous-code-in-node-js