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

来自菜鸟教程
跳转至:导航、​搜索

作为 Write for DOnations 计划的一部分,作者选择了 Open Internet/Free Speech Fund 来接受捐赠。

介绍

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

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

在本教程中,您将了解 JavaScript 如何在 Event Loop 的帮助下管理异步任务,这是一个 JavaScript 构造,可以在等待另一个任务的同时完成一个新任务。 然后,您将创建一个程序,该程序使用异步编程从 Studio Ghibli API 请求电影列表,并将数据保存到 CSV 文件。 异步代码将以三种方式编写:回调、承诺和使用 async/await 关键字。

注意: 在撰写本文时,异步编程不再仅使用回调来完成,但是学习这个过时的方法可以为 JavaScript 社区现在使用 Promise 的原因提供很好的背景信息。 async/await 关键字使我们能够以不那么冗长的方式使用 Promise,因此是撰写本文时在 JavaScript 中进行异步编程的标准方式。


先决条件

事件循环

让我们从研究 JavaScript 函数执行的内部工作原理开始。 了解它的行为方式将使您能够更谨慎地编写异步代码,并将帮助您在将来对代码进行故障排除。

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

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

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

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

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

带回调的异步编程

回调函数 是作为参数传递给另一个函数,然后在另一个函数完成时执行的回调函数。 我们使用回调来确保代码仅在异步操作完成后执行。

长期以来,回调是编写异步代码最常用的机制,但现在它们已基本过时,因为它们会使代码难以阅读。 在这一步中,您将编写一个使用回调的异步代码示例,以便您可以将其用作基线,以了解其他策略提高的效率。

有很多方法可以在另一个函数中使用回调函数。 通常,他们采用这种结构:

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

虽然 JavaScript 或 Node.js 在语法上不要求将回调函数作为外部函数的最后一个参数,但这是一种常见做法,可以让回调更容易识别。 JavaScript 开发人员使用 匿名函数 作为回调也很常见。 匿名函数是那些没有名字的函数。 当在参数列表的末尾定义函数时,它通常更具可读性。

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

mkdir ghibliMovies

然后进入那个文件夹:

cd ghibliMovies

我们将首先向 Studio Ghibli API 发出 HTTP 请求,我们的回调函数将记录其结果。 为此,我们将安装一个库,允许我们在回调中访问 HTTP 响应的数据。

在您的终端中,初始化 npm,以便我们稍后可以参考我们的包:

npm init -y

然后,安装 request 库:

npm i request --save

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

nano callbackMovies.js

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

回调电影.js

const request = require('request');

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

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

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

回调电影.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
  • 在请求完成后处理任何错误或成功响应的回调函数

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

我们的回调函数首先检查我们是否收到错误。 最佳实践是首先检查回调中的错误,这样回调的执行就不会继续丢失数据。 在这种情况下,我们记录错误和函数的执行。 然后我们检查响应的状态码。 我们的服务器可能并不总是可用,API 可能会发生变化,导致曾经合理的请求变得不正确。 通过检查状态码是 200,这意味着请求是“OK”,我们可以确信我们的响应是我们所期望的。

最后,我们将响应正文解析为 Array 并遍历每部电影以记录其名称和发行年份。

保存并退出文件后,运行此脚本:

node callbackMovies.js

您将获得以下输出:

OutputCastle 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 文件:

回调电影.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 程序:

node callbackMovies.js

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

回调电影.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(() => {
                    // final action
                });
            });
        }); 
    });
});

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

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

promise 是一个 JavaScript 对象,它将在未来的某个时间点返回一个值。 异步函数可以返回 Promise 对象而不是具体值。 如果我们在未来得到一个值,我们就说这个承诺已经实现。 如果我们在未来得到一个错误,我们说这个承诺被拒绝了。 否则,promise 仍处于待处理状态。

Promise 通常采用以下形式:

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

如本模板所示,promise 也使用回调函数。 我们有一个用于 then() 方法的回调函数,它在一个 Promise 被实现时执行。 我们还为 catch() 方法提供了一个回调函数,用于处理在执行 Promise 时出现的任何错误。

让我们通过重写我们的 Studio Ghibli 程序来使用 Promise 来获得 Promise 的第一手经验。

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

npm i axios --save

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

nano promiseMovies.js

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

promiseMovies.js 中输入此代码,以便我们可以加载 Axios 并向电影 API 发送 HTTP 请求:

承诺电影.js

const axios = require('axios');

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

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

axios.get() 方法返回一个承诺。 让我们链接这个承诺,以便我们可以将 Ghibli 电影列表打印到控制台:

承诺电影.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 数据写入文件:

承诺电影.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 包含基于回调的 fs 库的基于承诺的版本,因此在遗留项目中不会破坏向后兼容性。

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

一个promise可以返回一个新的promise,允许我们一个接一个地执行promise。 这为我们执行多个异步操作铺平了道路。 这称为 承诺链 ,它类似于嵌套回调。 第二个 then() 只有在我们成功写入文件后才会调用。

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


为了完成这个程序,将 Promise 与 catch() 函数链接起来,如下所示:

承诺电影.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}`);
    });

如果 Promise 链中的任何 Promise 未实现,JavaScript 会自动转到 catch() 函数(如果已定义)。 这就是为什么我们只有一个 catch() 子句,即使我们有两个异步操作。

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

node promiseMovies.js

在您的 ghibliMovies 文件夹中,您将看到 promiseMovies.csv 文件,其中包含:

承诺电影.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

使用 Promise,我们可以编写比仅使用回调更简洁的代码。 回调的承诺链是比嵌套回调更简洁的选择。 然而,随着我们进行更多的异步调用,我们的 Promise 链变得更长且更难维护。

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

使用 async/await 编写 JavaScript

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

通常,async/await 的用法如下所示:

async function() {
    await [Asynchronous Action]
}

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

nano asyncAwaitMovies.js

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

asyncAwaitMovies.js

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

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

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

asyncAwaitMovies.js

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

async function saveMovies() {}

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

使用 await 关键字发出 HTTP 请求,从 Ghibli 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 的异步代码更易于维护。

通过在终端中输入以下代码来测试我们程序的迭代:

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 如何使用事件循环处理执行函数和管理异步操作。 然后,您编写的程序在使用各种异步编程技术对电影数据发出 HTTP 请求后创建了一个 CSV 文件。 首先,您使用了过时的基于回调的方法。 然后,您使用了 Promise,最后使用 async/await 使 Promise 语法更简洁。

随着您对 Node.js 异步代码的理解,您现在可以开发受益于异步编程的程序,例如那些依赖 API 调用的程序。 看看这个公共API列表。 要使用它们,您必须像我们在本教程中所做的那样发出异步 HTTP 请求。 为了进一步学习,请尝试构建一个使用这些 API 的应用程序来练习您在此处学到的技术。