JavaScript 異步編程指南 — 事件與回調函數 Callback

這是一個系列文章,你可以關注公眾號「五月君」訂閱話題《JavaScript 異步編程指南》獲取最新信息。
JavaScript 異步編程中回調是最常用和最基礎的實現(xiàn)模式。回調就是函數,一般我們也會稱它為 Callback,相信這對于 JavaScript 開發(fā)者不會陌生,而函數在 JavaScript 中屬于一等公民,可以將函數傳遞給方法作為實參調用。
這種編程模式對于習慣同步思維的人來說很難理解,一般我們的大腦對事物的理解是同步的、線性的,在異步編程中它是一種相反的模式,你會看到代碼的編寫順序與實際執(zhí)行順序并不是我們預期的,因為它們的編寫與實際執(zhí)行順序也許沒有什么直接的關系,特別是在處理一些復雜的業(yè)務場景時,掌握不好異步編程,通常也會寫出糟糕的代碼。
在筆者組建的技術交流群中,有時候大家提問一些問題,當看到一大堆 Callback 嵌套的代碼時,感覺就很糟糕,頓時很難讓人在有耐心去看它,這種模式它不會給予我們很友好的閱讀體驗,有時看到了我會說你先把代碼書寫邏輯整理下,也許問題就出在這里!
談回調也少不了一個概念 “事件”,在使用 JavaScript 操作 DOM、網絡請求或在 Node.js 中更多的是一種事件驅動的模型,由事件觸發(fā)執(zhí)行我們的回調。
定時器
例如,我們?yōu)?定時器 API 其傳入一個函數,讓其在將來某個時間之后執(zhí)行。我們可以通過 setTimeout 或 setInterval 實現(xiàn),前一個 setTimeout 是僅執(zhí)行一次,后一個 setInterval 是間隔指定時間后重復執(zhí)行。
這兩個 API 在瀏覽器、Node.js 環(huán)境中使用都是一樣的。
function fn() {
// do something...
}
setTimeout(fn, 1000);
setInterval(fn, 1000);
網絡事件
發(fā)起一個請求從另一端獲取數據,這也是異步中很常見的一個操作,在客戶端早期我們可以使用 XMLHttpRequest發(fā)起 HTTP 請求并異步處理服務器返回的響應。
const httpRequest = new XMLHttpRequest();
httpRequest.open('GET', 'http://openapi.xxx.com/api');
httpRequest.send();
httpRequest.onreadystatechange = function() {
if (httpRequest.readyState === XMLHttpRequest.DONE) {
if (httpRequest.status === 200) {
alert(httpRequest.responseText);
} else {
alert('There was a problem with the request.');
}
}
};
現(xiàn)在瀏覽器端有了一個新的 API fetch() 取代了復雜且名字容易誤導人的 XMLHttpRequest,因為這個雖然名字帶了 XML 但和 XML 沒關系,fetch() API 完全基于 Promise 可以方便的讓你編寫代碼從網絡獲取數據,簡單看一下:
fetch('http://example.com/movies.json')
.then(function(response) {
return response.json();
})
.then(function(myJson) {
console.log(myJson);
});
Node.js 中也定義了一些網絡相關的 API,Node.js 提供的 HTTP/HTTPS 模塊可以幫助我們在 Node.js 客戶端向服務端請求數據
const http = require('http');
function sendRequest() {
const req = http.request({
method: 'GET',
host: '127.0.0.1',
port: 3010,
path: '/api'
}, res => {
let data = '';
res.on('data', chunk => data += chunk.toString());
res.on('end', () => {
console.log('response body: ', data);
});
});
req.on('error', console.error);
req.end();
}
sendRequest();
這種方式來寫還是有點繁瑣的,在實際的業(yè)務開發(fā)中我們使用一些功能完備的 HTTP 請求模塊,例如 node-fetch、nodejs/undici、axios 等,這些工具都是可以基于 Promise 的形式。
Node.js 做為一個服務端啟動,我們還可以使用 HTTP 模塊,如下方式啟動一個 Server:
const http = require('http');
http.createServer((req, res) => {
req.on('data', chunk => {
// TODO
});
req.on('end', () => res.end('ok!'))
req.on('error', () => ...)
}).listen(3010);
客戶端 DOM 事件與回調
客戶端下的 JavaScript 我們可以獲取指定的 DOM 元素,為特定類型的事件注冊回調函數,當用戶移動鼠標或移動觸摸板、按下鍵盤時,瀏覽器會生成相應的事件并調用我們事先注冊的回調函數,這些都是由事件驅動的。
下例,通過 addEventListener() 函數為事件注冊回調函數。相對來說 DOM 事件在互相依賴、多級依賴嵌套的場景較少些,但是在 Node.js 里面你可能會遇到很多。
<button id="btn"> 點我哦 </button>
<script>
const btn = document.getElementById('btn');
// 單擊時觸發(fā)
btn.addEventListener('click', event => console.log('click!'));
// 鼠標移入觸發(fā)
btn.addEventListener('mouseover', event => console.log('mouseover!'));
// 鼠標移出觸發(fā)
btn.addEventListener('mouseout', event => console.log('mouseout!'));
</script>
Node.js 中的事件與回調
Node.js 作為 JavaScript 的服務端運行時,大部分的 API 都是異步的,大家可能也聽過 Node.js 比較擅長 I/O 密集型任務,這與它的單線程、基于事件驅動模型、異步 I/O是有關系的,它無需像多線程程序那樣為每一個請求創(chuàng)建額外的線程、省掉了線程創(chuàng)建、銷毀、上下文切換等開銷。
它通過主循環(huán)加事件觸發(fā)的方式執(zhí)行程序,事件循環(huán)會不停地處理網絡/文件 IO 事件,每一次的事件循環(huán)就是檢查,檢查是否有待處理的事件,如果有就取出事件及關聯(lián)的回調函數,如果有傳入 JavaScript 回調函數,傳遞到業(yè)務邏輯層執(zhí)行,也許回調函數里還會在發(fā)起一次新的 I/O 請求,整個程序不斷的通過事件循環(huán)調度執(zhí)行。
也許你聽過這樣一句話:“它的優(yōu)秀之處并非原創(chuàng),它的原創(chuàng)之處并不優(yōu)秀。” 異步 I/O 并非 Node.js 原創(chuàng),但 Node.js 卻是第一個成功的平臺,Node.js 2009 年出現(xiàn)之前,JavaScript 在服務端近乎空白。例如,文件 API 在 Node.js 中默認就是異步的,也就是它的標準庫 I/O 本身給你提供的就是非阻塞的,它沒有任何的歷史包袱。
談到異步 I/O 必然少不了異步編程,早期我們的很多程序中都充斥著 Callback 風格的代碼,包括 Node.js 提供的 API 大多數也是,大家都遵循一個默認的規(guī)則 “錯誤優(yōu)先的回調函數”。
例如,下面 API 第一個參數為 err 如果有錯誤就是一個 Error 對象,否則就為 null,這也是一種默認的約定。
fs.readFile(filename, (err, file) => {
// TODO
})
現(xiàn)在 Node.js 的一些系統(tǒng)模塊已經為我們提供了一些工具可以方便的將 callback 轉換為 Promise 的工具,或者文件模塊我們可以通過 fs.promises 直接引入基于 Promise 版本的 API,這些編程方法我們會在后續(xù)章節(jié) Promise 篇幅里講。
一個糟糕的回調地獄例子
當我們在 Node.js 中有時需要處理一些復雜的業(yè)務場景,有些需要多級依賴,如果以 callback 形式很容易造成函數嵌套過深,例如下面示例很容易寫出回調地獄、冗余的代碼,這也是早期 Node.js 被人詬病比較多的地方。包括現(xiàn)在前段在群里仍然還有看到有些提問題的,寫出類似于下面嵌套的代碼,確實要改下了。
fs.readdir('/path/xxxx', (err, files) => {
if (err) {
// TODO...
}
files.forEach((filename, index) => {
fs.lstat(filename, (err, stats) => {
if (err) {
// TODO...
}
if (stats.isFile()) {
fs.readFile(filename, (err, file) => {
// TODO
})
}
})
})
});
異步編程 Callback 的形式一個難點是上面說的容易出現(xiàn)回調地獄的例子,另外一方面是異常的處理很麻煩,在一些同步的代碼中我們可以像下面示例這樣使用 try/catch 捕獲錯誤。
try {
doSomething(...);
} catch(err) {
// TODO
}
這種方式在一些異步方法面前顯得無能為力,上面我們寫的回調嵌套的示例,如果我們對 fs.readFile() 做 try/catch 捕獲,當我們調用 fs.readFile 并為其注冊回調函數這個步驟對應異步 I/O 中是提交請求,而 callback 函數會被存放起來,等到下一個事件循環(huán)到來 callback 才會被取出執(zhí)行,這個時間是將來的某個時間點,而 try/catch 是同步的,捕獲不到這個錯誤的。
下面因為我對一個 null 對象做了非法操作,這時程序會給我們報一個 TypeError: Cannot read property 'a' of null 錯誤,在 Java 中可以稱它為空指針異常。
類似于這樣的一個錯誤如果沒有被捕獲到,在單進程的應用程序中必然會導致進程退出,無關語言。
try {
fs.readFile(filename, (err, file) => {
const obj = null
obj.a;
// TODO
})
} catch () {
// TODO
}
有時候也會聽大家說為什么我的 Node.js 程序老是崩潰?也有人說 Node.js 弱爆了(這個我曾經聽過一個架構師這樣說過...)如果程序這樣寫,就算你用的 Java 照樣崩潰。
在延伸一點,Node.js 的 Process 對象為我們提供了兩個事件可以用來捕獲程序中出現(xiàn)的未捕獲異常,方便程序優(yōu)雅退出,這是筆者之前寫的一篇文章,可以看看如何處理 Node.js 中出現(xiàn)的未捕獲異常?
process.on('uncaughtException', fn);
process.on('unhandledRejection', fn);
總結
異步編程中 Callback 是比較早的模式,也是異步編程的基礎,但是隨著業(yè)務的發(fā)展、復雜度的上升,基于 Callback 的模式已經不能滿足我們的需求了,就像我們的大腦對事物的思考,需要一種同步的、順序的方式表達異步編程思想。
“辦法總比困難多”,解決問題的方案還是很多的,目前的 JavaScript 中已有一些更高級、強大的異步編程模式,在本系列中會逐步的講解。
·END·
匯聚精彩的免費實戰(zhàn)教程
喜歡本文,點個“在看”告訴我


