如何在現代JavaScript中編寫異步任務

前言

在本文中,我們將探討過去異步執行的 JavaScript 的演變,以及它是怎樣改變我們編寫代碼的方式的。我們將從最早的 Web 開發開始,一直到現代異步模式。

作為編程語言, JavaScript 有兩個主要特征,這兩個特征對於理解我們的代碼如何工作非常重要。首先是它的同步特性,這意味著代碼將逐行運行,其次是單線程,任何時候都僅執行一個命令。

隨著語言的發展,允許異步執行的新工件出現在場景中。開發人員在解決更復雜的算法和數據流時嘗試瞭不同的方法,從而導致新的接口和模式出現。

同步執行和觀察者模式

如簡介中所述,JavaScript 通常會逐行運行你編寫的代碼。即使在最初的幾年中,該語言也有這種規則的例外,盡管很少,你可能已經知道瞭它們:HTTP 請求,DOM 事件和time interval。

如果我們通過添加事件偵聽器去響應用戶對元素的單擊,則無論語言解釋器在運行什麼,它都會停止,然後運行在偵聽器回調中編寫的代碼,之後再返回正常的流程。

與 interval 或網絡請求相同,addEventListener,setTimeout 和 XMLHttpRequest 是 Web 開發人員訪問異步執行的第一批工件。

盡管這些是 JavaScript 中同步執行的例外情況,但重要的是你要瞭解該語言仍然是單線程的。我們可以打破這種同步性,但是解釋器仍然每次運行一行代碼。

例如檢查一個網絡請求。

var request = new XMLHttpRequest();
request.open('GET', '//some.api.at/server', true);

// observe for server response
request.onreadystatechange = function() {
 if (request.readyState === 4 && xhr.status === 200) {
 console.log(request.responseText);
 }
}

11request.send();

不管發生什麼情況,當服務器恢復運行時,分配給 onreadystatechange 的方法都會在取回程序的代碼序列之前被調用。

對用戶交互做出反應時,也會發生類似的情況。

const button = document.querySelector('button');

// observe for user interaction
button.addEventListener('click', function(e) {
 console.log('user click just happened!');
})

你可能會註意到,我們正在連接一個外部事件並傳遞一個回調,告訴代碼當事件發生時應該怎麼做。十多年前,“什麼是回調?”是一個非常受期待的面試問題,因為在很多代碼庫中到處都有這種模式。

在上述每種情況下,我們都在響應外部事件。不管是達到一定的時間間隔、用戶操作還是服務器響應。我們本身無法創建異步任務,我們總是 觀察 發生在我們力所能及范圍之外的事件。

這就是為什麼這種方式的代碼被稱為觀察者模式的原因,在這種情況下,它最好由 addEventListener 接口來表示。很快,暴露這種模式的事件發送器庫或框架開始蓬勃發展。

NODE.JS 和事件發送器

Node.js 是一個很好的例子,它的官網把自己描述為“異步事件驅動的 JavaScript 運行時”,所以事件發送器和回調是一等公民。它甚至已經實現瞭一個 EventEmitter 構造函數。

const EventEmitter = require('events');
const emitter = new EventEmitter();

// respond to events
emitter.on('greeting', (message) => console.log(message));

// send events
emitter.emit('greeting', 'Hi there!');

這不僅是通用的異步執行方法,而且是其生態系統的核心模式和慣例。Node.js 開辟瞭一個在不同環境中甚至在 web 之外編寫 JavaScript 的新時代。當然異步的情況也是可能的,例如創建新目錄或寫文件。

const { mkdir, writeFile } = require('fs');

const styles = 'body { background: #ffdead; }';

mkdir('./assets/', (error) => {
 if (!error) {
 writeFile('assets/main.css', styles, 'utf-8', (error) => {
  if (!error) console.log('stylesheet created');
 })
 }
})

你可能會註意到,回調函數將第一個參數接作為 error ,如果得到瞭預期的響應數據,則將其作為第二個參數。這就是所謂的錯誤優先回調模式,它成為作者和貢獻者為包和庫所做的約定。

Promise 和沒完沒瞭的回調鏈

隨著 Web 開發面臨的更復雜的問題,出現瞭對更好的異步工件的需求。如果我們查看最後一個代碼段,則會看到重復的回調鏈,隨著任務數量的增加,回調鏈的擴展效果不佳。

例如,我們僅添加兩個步驟,即文件讀取和樣式預處理。

const { mkdir, writeFile, readFile } = require('fs');
const less = require('less')

readFile('./main.less', 'utf-8', (error, data) => {
 if (error) throw error
 less.render(data, (lessError, output) => {
 if (lessError) throw lessError
 mkdir('./assets/', (dirError) => {
  if (dirError) throw dirError
  writeFile('assets/main.css', output.css, 'utf-8', (writeError) => {
  if (writeError) throw writeError
  console.log('stylesheet created');
  })
 })
 })
16})

我們可以看到,由於多個回調鏈和重復的錯誤處理,編寫程序變得越來越復雜,代碼變得更加難以理解。

Promise、包裝和鏈模式

當 Promises 最初被宣佈為 JavaScript 語言的新成員時,並沒有引起太多關註,它們並不是一個新概念,因為其他語言在幾十年前就已經實現瞭類似的實現。事實上自從它出現以來,他們就改變瞭我從事的大多數項目的語義和結構。

Promises不僅為開發人員引入瞭用於編寫異步代碼的內置解決方案,,而且還開辟瞭Web 開發的新階段,成為 Web 規范後來的新功能(如 fetch)的構建基礎。

從回調方法遷移到基於 promise 的方法在項目(例如庫和瀏覽器)中變得越來越普遍,甚至 Node.js 也開始緩慢地遷移到它上面。

例如,包裝 Node 的 readFile 方法:

const { readFile } = require('fs');

const asyncReadFile = (path, options) => {
 return new Promise((resolve, reject) => {
  readFile(path, options, (error, data) => {
   if (error) reject(error);
   else resolve(data);
  })
 });
}

在這裡,我們通過在 Promise 構造函數內部執行來隱藏回調,方法成功後調用 resolve,定義錯誤對象時調用reject。

當一個方法返回一個  Promise  對象時,我們可以通過將一個函數傳遞給 then 來遵循其成功的解析,它的參數是 Promise  被解析的值,在這裡是 data。

如果在方法運行期間拋出錯誤,則將調用 catch 函數(如果存在)。

註意:如果你需要更深入地瞭解 Promise 的工作原理,建議你看 Jake Archibald 在 Google 的 web 開發博客上寫的文章“ JavaScript Promises:簡介”。

現在我們可以使用這些新方法並避免回調鏈。

asyncRead('./main.less', 'utf-8')
 .then(data => console.log('file content', data))
 .catch(error => console.error('something went wrong', error))

它具有創建異步任務的原生方法,並以清晰的接口跟蹤其可能的結果,這擺脫瞭觀察者模式。基於 Promise 的代碼似乎可以解決可讀性差且容易出錯的代碼。

在更好的語法突出顯示和更清晰的錯誤提示信息對編碼過程中提供的幫助下,對於開發人員來說,編寫更容易理解的代碼變得更具可預測性,並且執行的情況更好,更容易發現可能的陷阱。

Promises 的采用在社區中非常普遍,以至於 Node.js 迅速發佈其 I/O 方法的內置版本以返回 Promise 對象,例如從 fs.promises 中導入文件操作。

它甚至提供瞭一個 promisify 工具來包裝遵循錯誤優先回調模式的函數,並將其轉換為基於 Promise 的函數。

但是 Promise 在所有情況下都能提供幫助嗎?

讓我們重新評估一下用 Promise 編寫的樣式預處理任務。

const { mkdir, writeFile, readFile } = require('fs').promises;
const less = require('less')

readFile('./main.less', 'utf-8')
 .then(less.render)
 .then(result =>
  mkdir('./assets')
   .then(writeFile('assets/main.css', result.css, 'utf-8'))
 )
 .catch(error => console.error(error))

代碼中的冗餘明顯減少瞭,尤其是在錯誤處理方面,因為我們現在依賴於 catch,但是 Promise 在某種程度上沒能提供直接與動作串聯相關的清晰代碼縮進。

實際上,這是在調用 readFile 之後的第一個 then 語句中實現的。這些代碼行之後發生的事情是需要創建一個新的作用域,我們可以在該作用域中先創建目錄,然後將結果寫入文件中。這會導致縮進節奏的中斷,乍一看就不容易確定指令序列。

註意:請註意,這是一個示例程序,我們可以控制某些方法,它們都遵循行業慣例,但並非總是如此。通過更復雜的串聯或引入不同的庫,我們的代碼風格可以輕松被打破。

令人高興的是,JavaScript 社區再次從其他語言的語法中學到瞭東西,並增加瞭一種表示方法,可以在大多數情況下幫助異步任務串聯,而不是像同步代碼那樣能夠令人輕松的閱讀。

Async 與 Await

Promise 被定義為執行時的未解決的值,創建 Promise 實例是對此工件的“顯式”調用。

const { mkdir, writeFile, readFile } = require('fs').promises;
const less = require('less')

readFile('./main.less', 'utf-8')
 .then(less.render)
 .then(result =>
  mkdir('./assets')
   .then(writeFile('assets/main.css', result.css, 'utf-8'))
 )
 .catch(error => console.error(error))

在異步方法內部,我們可以用 await 保留字來確定 Promise 的解決方案,然後再繼續執行。

讓我們用這種語法重新編寫代碼段。

const { mkdir, writeFile, readFile } = require('fs').promises;
const less = require('less')

async function processLess() {
 const content = await readFile('./main.less', 'utf-8')
 const result = await less.render(content)
 await mkdir('./assets')
 await writeFile('assets/main.css', result.css, 'utf-8')
}

11processLess()

註意:請註意,我們需要將所有代碼移至某個方法中,因為我們無法在 異步函數的作用域之外使用 await 。

每當異步方法找到一個 await 語句時,它將停止執行,直到 promise 被解決為止。

盡管是異步執行,但用 async/await 表示會使代碼看起來好像是同步的,這是容易被開發人員閱讀和理解的東西。

那麼錯誤處理呢?我們可以用在語言中存在瞭很久的try 和 catch。

const { mkdir, writeFile, readFile } = require('fs').promises;
const less = require('less')

async function processLess() {
 const content = await readFile('./main.less', 'utf-8')
 const result = await less.render(content)
 await mkdir('./assets')
 await writeFile('assets/main.css', result.css, 'utf-8')
}

try {
 processLess()
} catch (e) {
 console.error(e)
}

我們大可放心,在過程中拋出的任何錯誤都會由 catch 語句中的代碼處理。現在我們有瞭一個易於閱讀和規范的代碼。

對返回值進行的後續操作無需存儲在不會破壞代碼節奏的 mkdir 之類的變量中;也無需在以後的步驟中創建新的作用域來訪問 result 的值。

可以肯定地說,Promise 是該語言中引入的基本工件,對於在 JavaScript 中啟用 async/await 表示法是必需的,你可以在現代瀏覽器和最新版本的 Node.js 中使用它。

註意:最近在 JSConf 中,Node 的創建者和第一貢獻者 Ryan Dahl, 對在其早期開發中沒有遵守Promises 表示遺憾,主要是因為 Node 的目標是創建事件驅動服務器和文件管理,而 Observer 模式更適合這樣。

結論

將 Promise 引入 Web 開發的目的是改變我們在代碼中順序操作的方式,並改變瞭我們理解代碼的方式以及編寫庫和包的方式。

但是擺脫回調鏈更難解決,我認為在多年來習慣於觀察者模式和采用的方法之後,必須將方法傳遞給 then 並不能幫助我們擺脫原有的思路,例如 Node.js。

正如 Nolan Lawson 在他的出色文章“關於 Promise 級聯的錯誤使用“【https://pouchdb.com/2015/05/18/we-have-a-problem-with-promises.html】 中所述,舊的回調習慣是死硬且頑固的!在文中他解釋瞭如何避免這些陷阱。

我認為 Promise 是中間步驟,它允許以自然的方式生成異步任務,但並沒有幫助我們進一步改進更好的代碼模式,有時你需要更適應改進的語言語法。

當嘗試使用JavaScript解決更復雜的難題時,我們看到瞭對更成熟語言的需求,並且我們嘗試瞭以前不曾在網上看到的體系結構和模式。

我們仍然不知道 ECMAScript 規范在幾年後的樣子,因為我們一直在將 JavaScript 治理擴展到 web 之外,並嘗試解決更復雜的難題。

現在很難說我們需要從語言中真正地將這些難題轉變成更簡單的程序,但是我對 Web 和 JavaScript 本身如何推動技術,試圖適應挑戰和新環境感到滿意。與十年前剛剛開始在瀏覽器中編寫代碼時相比,我覺得現在 JavaScript 是“異步友好”的。

原文:https://www.smashingmagazine.com/2019/10/asynchronous-tasks-modern-javascript/

到此這篇關於如何在現代JavaScript中編寫異步任務的文章就介紹到這瞭,更多相關JavaScript編寫異步任務內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: