在 Node.js 中撰寫非同步代碼的方法

作者選擇了開放互聯網/言論自由基金作為撰寫捐贈計劃的受贈方。

介紹

對於許多JavaScript程序,代碼是按照開發者編寫的順序逐行執行的。這被稱為同步執行,因為這些代碼行按照編寫的順序依次執行。然而,您給計算機的每個指令都不需要立即處理。例如,如果您發送一個網絡請求,執行您代碼的進程將不得不等待數據返回,然後才能處理它。在這種情況下,如果在等待網絡請求完成時不執行其他代碼,將會浪費時間。為了解決這個問題,開發者使用異步編程,其中代碼行的執行順序與編寫的順序不同。使用異步編程,我們可以在等待像網絡請求這樣的長時間活動完成時執行其他代碼。

JavaScript程式碼在電腦進程中以單線程執行。其程式碼在該線程上同步處理,每次僅運行一條指令。因此,如果我們在此線程上執行一個耗時的任務,則所有其餘的程式碼都會被阻塞,直到該任務完成。通過利用JavaScript的異步編程功能,我們可以將耗時的任務卸載到後台線程,以避免這個問題。當任務完成時,我們需要處理該任務數據的程式碼將被放回到主單線程。

在本教程中,您將學習JavaScript如何通過事件循環管理異步任務,事件循環是一個JavaScript構造,它在等待另一個任務完成時完成一個新任務。然後,您將創建一個程序,使用異步編程從Studio Ghibli 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模塊,將一系列的Studio Ghibli電影寫入文件。首先,創建一個文件夾,用於存儲我們的JavaScript文件及其輸出:

  1. mkdir ghibliMovies

然後進入該文件夾:

  1. cd ghibliMovies

我們將從向Studio Ghibli 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 項目增長並變得更加複雜時,這種影響將變得更加明顯,直到最終變得無法管理。因此,開發人員不再使用回調來處理異步操作。為了改善我們異步代碼的語法,我們可以改用 promises。

使用 Promises 進行簡潔的異步編程

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 ])

如此模板所示,promises 也使用回調函數。我們有一個用於 then() 方法的回調函數,在 promise 實現時執行。我們還有一個用於 catch() 方法的回調函數,用於處理 promise 執行時出現的任何錯誤。

讓我們透過重寫我們的 Studio Ghibli 程式以使用 promises 來第一手體驗它們。

Axios 是一個基於 promises 的 JavaScript HTTP 客戶端,所以讓我們繼續安裝它:

  1. npm i axios --save

現在,使用您喜歡的文本編輯器,創建一個名為 promiseMovies.js 的新文件:

  1. nano promiseMovies.js

我們的程序將使用 axios 發送一個 HTTP 請求,然後使用一個特殊的基於 promises 的版本 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關鍵字提供了一種替代語法,用於處理 promises。與將 promise 的結果在then()方法中可用不同,結果會像在任何其他函數中一樣作為值返回。我們使用async關鍵字定義一個函數,告訴JavaScript這是一個返回 promise 的異步函數。我們使用await關鍵字告訴JavaScript,在滿足條件時返回 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使用 promises。

現在,我們使用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關鍵字。

為了完成這個函數,我們需要捕獲我們的承諾可能拋出的錯誤。讓我們通過將代碼封裝在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}`);
    }
}

由於承諾可能失敗,我們將我們的異步代碼封裝在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 如何處理執行函數並使用事件循環管理異步操作。然後,您編寫了使用各種異步編程技術通過對電影數據進行 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