在現代 JavaScript 中編寫異步任務
已發表: 2022-03-10JavaScript 作為一種編程語言有兩個主要特徵,這兩個特徵對於理解我們的代碼如何工作都很重要。 首先是它的同步特性,這意味著代碼將逐行運行,幾乎就像您閱讀它一樣,其次它是單線程的,任何時候都只執行一個命令。
隨著語言的發展,新的工件出現在場景中以允許異步執行; 開發人員在解決更複雜的算法和數據流時嘗試了不同的方法,這導致了圍繞它們的新接口和模式的出現。
同步執行和觀察者模式
正如介紹中提到的,JavaScript 大部分時間都在逐行運行您編寫的代碼。 即使在最初的幾年裡,該語言也有這個規則的例外,儘管它們是少數並且你可能已經知道它們:HTTP 請求、DOM 事件和時間間隔。
const button = document.querySelector('button'); // observe for user interaction button.addEventListener('click', function(e) { console.log('user click just happened!'); })
如果我們添加一個事件偵聽器,例如單擊一個元素並且用戶觸發了此交互,JavaScript 引擎將為事件偵聽器回調排隊一個任務,但將繼續執行當前堆棧中存在的內容。 完成那裡的調用後,它現在將運行偵聽器的回調。
這種行為類似於網絡請求和計時器所發生的情況,它們是 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 && request.status === 200) { console.log(request.responseText); } } request.send();
當服務器返回時,分配給onreadystatechange
的方法的任務將排隊(代碼在主線程中繼續執行)。
注意:解釋 JavaScript 引擎如何排隊任務和處理執行線程是一個複雜的話題,可能值得單獨寫一篇文章。 儘管如此,我還是建議您觀看“事件循環到底是什麼?” 菲利普·羅伯茨 (Phillip Roberts) 幫助您更好地理解。
在提到的每種情況下,我們都在響應外部事件。 達到一定的時間間隔,用戶操作或服務器響應。 我們本身無法創建異步任務,我們總是觀察到發生在我們力所能及之外的事件。
這就是為什麼以這種方式形成的代碼被稱為觀察者模式的原因,在這種情況下,它更好地由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 開啟了在不同環境中編寫 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'); }) }) }) })
我們可以看到,隨著我們編寫的程序變得越來越複雜,由於多個回調鏈接和重複的錯誤處理,代碼變得難以用肉眼理解。
承諾、包裝和鍊式模式
當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
函數(如果存在)。
注意:如果您需要更深入地了解 Promises 的工作原理,我推薦 Jake Archibald 的“JavaScript Promises: An Introduction”文章,該文章是他在 Google 的 Web 開發博客上寫的。
現在我們可以使用這些新方法並避免回調鏈。
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 的函數。
但是 Promises 在所有情況下都有幫助嗎?
讓我們重新想像一下使用 Promises 編寫的樣式預處理任務。
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
的錯誤處理方面,但 Promises 不知何故未能提供與動作串聯直接相關的清晰代碼縮進。
這實際上是在調用readFile
之後的第一個then
語句上實現的。 在這些行之後發生的事情是需要創建一個新的範圍,我們可以首先在其中創建目錄,然後將結果寫入文件中。 這會導致縮進節奏中斷,乍一看不容易確定指令順序。
解決此問題的一種方法是預烘焙一個自定義方法來處理此問題並允許正確連接該方法,但我們將向似乎已經具備完成任務所需的代碼引入更複雜的深度我們想要。
注意:考慮到這是一個示例程序,我們可以控制一些方法,它們都遵循行業慣例,但情況並非總是如此。 隨著更複雜的連接或引入不同形狀的庫,我們的代碼風格很容易被打破。
令人高興的是,JavaScript 社區再次從其他語言語法中學習並添加了一個符號,這對解決異步任務連接不像同步代碼那樣令人愉快或直接閱讀的情況有很大幫助。
異步並等待
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') } processLess()
注意:請注意,我們需要將所有代碼移動到一個方法中,因為我們今天不能在異步函數範圍之外使用await
。
每次異步方法找到await
語句時,它都會停止執行,直到處理的值或承諾得到解決。
使用 async/await 表示法有一個明顯的後果,儘管它是異步執行的,但代碼看起來好像是同步的,這是我們開發人員更習慣於看到和推理的東西。
錯誤處理呢? 為此,我們使用語言中已經存在很長時間的語句try
和catch
。
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less'); async function processLess() { try { 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') } catch(e) { console.error(e) } } processLess()
我們放心,過程中拋出的任何錯誤都將由catch
語句中的代碼處理。 我們有一個處理錯誤處理的中心位置,但現在我們有一個更易於閱讀和遵循的代碼。
具有返回值的後續操作不需要存儲在不會破壞代碼節奏的mkdir
等變量中; 也無需在後續步驟中創建新範圍來訪問result
的值。
可以肯定地說,Promises 是語言中引入的基本工件,是在 JavaScript 中啟用 async/await 表示法所必需的,您可以在現代瀏覽器和最新版本的 Node.js 上使用它。
注意:最近在 JSConf 中,Node 的創建者和第一個貢獻者 Ryan Dahl很遺憾沒有在早期開發中堅持 Promises ,主要是因為 Node 的目標是創建事件驅動的服務器和文件管理,而觀察者模式更適合。
結論
將 Promises 引入 Web 開發世界改變了我們在代碼中排隊操作的方式,改變了我們對代碼執行的推理方式以及我們編寫庫和包的方式。
但是擺脫回調鏈更難解決,我認為必須將方法傳遞給then
並沒有幫助我們擺脫多年來習慣於主要供應商採用的觀察者模式和方法的思路在 Node.js 等社區中。
正如 Nolan Lawson 在他關於 Promise 連接中錯誤使用的優秀文章中所說,舊的回調習慣很難改掉! 他後來解釋瞭如何擺脫其中一些陷阱。
我相信 Promise 需要作為一個中間步驟,以允許一種自然的方式來生成異步任務,但並沒有幫助我們在更好的代碼模式上前進,有時你實際上需要一種更具適應性和改進的語言語法。
當我們嘗試使用 JavaScript 解決更複雜的難題時,我們看到了對更成熟語言的需求,並且我們嘗試了以前在 Web 上不習慣看到的架構和模式。
“
我們仍然不知道 ECMAScript 規範幾年後會是什麼樣子,因為我們一直在將 JavaScript 治理擴展到 Web 之外並嘗試解決更複雜的難題。
現在很難說我們究竟需要什麼語言才能讓這些難題變成更簡單的程序,但我很高興 web 和 JavaScript 本身如何移動事物,試圖適應挑戰和新環境。 與十多年前我開始在瀏覽器中編寫代碼時相比,我現在覺得 JavaScript 是一個更加異步友好的地方。
延伸閱讀
- “JavaScript Promises:簡介”, Jake Archibald
- “Promise Anti-Patterns”, Bluebird 庫文檔
- “我們的承諾有問題,”諾蘭·勞森