一、難以掌控的回調 我在第一話中介紹了非同步的概念、事件迴圈、以及JS編程中可能的3種非同步情況(用戶交互、I/O、定時器)。在編寫非同步操作代碼時,最直接、也是每個JSer最先接觸的寫法一定是回調函數(callback),比如下麵這位段代碼: Ajax請求是一種I/O操作,往往需要較長時間來完成,為了不 ...
一、難以掌控的回調
我在第一話中介紹了非同步的概念、事件迴圈、以及JS編程中可能的3種非同步情況(用戶交互、I/O、定時器)。在編寫非同步操作代碼時,最直接、也是每個JSer最先接觸的寫法一定是回調函數(callback),比如下麵這位段代碼:
ajax('www.someurl.com', function(res) { doSomething(); ... });
Ajax請求是一種I/O操作,往往需要較長時間來完成,為了不阻塞單線程的JS程式,故設計為非同步操作。此處,將一個匿名函數作為參數傳給ajax,意思是“這個匿名函數先放你那兒,但暫不執行,須在收到response之後,再回過頭來調用這個函數”,因此這個匿名函數也被稱為“回調”。這樣的寫法相信每個JSer都再熟悉不過了,但仔細想想,這種寫法可能有什麼問題?
問題就出在“控制反轉”。
匿名函數的代碼,完完全全是我寫的。但是,這段代碼何時被調用、調用幾次、調用時傳入什麼參數……等等,我卻無法掌握;而本來是被我所調用的ajax函數,竟堂而皇之地接管了我的代碼,回調的控制權旁落到了寫ajax函數的那家伙手裡——控制被反轉了。
很多情況下,“那家伙”是個非常可信的機構或公司(比如Google的Chrome團隊)、或是比你我牛得多的天才程式員,因此可以放心地把回調交給他。但也有很多情況下,事情並非如此:假如你在開發一個電商網站的代碼,把“刷一次信用卡”的回調傳給一個第三方庫,而那個庫很不巧地在某種特殊情況下把這個回調調用了5次,那麼,你的老闆可能不得不做好準備,在電話中親自安撫怒氣衝衝的顧客。而且,即使換一個第三方合作伙伴,就能保證不再出類似的問題嗎?
換句話說,我們無法100%信任接管回調的第三方(當然,那個“第三方”也可能是自己)。
另一個問題是,非同步操作本質上是無法保證完成時間的,因此,當多個非同步操作需要按先後順序依次執行、並且後面的步驟依賴於前面步驟的返回結果時,如果用回調的寫法,就只能把後一個的步驟硬編碼在前一個步驟的回調中,整個操作流程形成一個嵌一個的回調金字塔,再加上異常處理和多分支等情況,口味更加酸爽:
ajax(url, function (res){ ajax(res.url, function(res) { ajax(res.url, function(res) { if (res.status == '1') { ajax(res.url, function(res) { ... } } else if (res.status == '2') { ajax(url2, function(res) { ... } ... } } } );
這樣的流程是極其脆弱的,而且包含大量重覆卻無法復用的代碼,體驗非常糟心。
面對越來越複雜的業務場景,簡單的回調已經越來越力不從心,更好的解決方案在哪兒呢?
二、事件訂閱模式的啟示
也許我們可以嘗試換一種模式:不是把回調的控制權交出去,而是讓非同步操作在返回時觸發一個事件,通知主線程非同步操作的結果,隨後主線程根據預先的設定執行事件相應的回調,這就是“事件訂閱模式”。在這種模式下,本來要被反轉的回調控制權又被反轉回來了,因此稱為“反控制反轉”。偽代碼如下:
on('ajax_return', function(val) {
doSomething();
});
ajax(url, function(res) { emitEvent('ajax_return', res); });
on()是假想的用於註冊事件回調的函數,emitEvent()是假想的用於觸發事件的函數。
這種模式解決了控制反轉的問題,而且用ES5也能輕鬆實現。但是,它還沒有很好地解決非同步流程的問題——總不能為每一個非同步操作都單獨註冊一個事件吧?無論如何,事件訂閱模式給我們提供了十分有益的啟示,接下來上場的主角正是以這種模式為基礎設計的。
三、理解Promise的姿勢
Promise是一種範式,專治非同步操作的各種疑難雜症。本節不打算逐一介紹Promise的API,而是著重探求其設計思想,由此學習其正確的使用方法。
第一,Promise基於事件訂閱模式。我們知道,Promise有三種狀態:未決議、決議、拒絕。從未決議變化到決議或拒絕,就相當於觸發了一個匿名事件,使得通過then方法註冊的fulfilled或rejected回調被調用,實現了反控制反轉。
第二,Promise“只能決議一次”的特性,使得“裸回調”和不可信的thenable對象都可以包裝為可信的Promise對象。示例代碼如下:
// 例1.將ajax函數的返回結果Promise化 let p1 = new Promise((resolve, reject) => { ajax(url, function(res) { if (res.error) reject(res.error); resolve(res); }); }); // 例2.將不規範的thenable對象Promise化 let obj = { then: function(cb, errcb) { cb(1); cb(2); // 不合規範的用法! errcb('evil laugh'); } }; let p2 = new Promise((resolve, reject) => { obj.then(resolve, reject); });
// 或寫成如下語法糖
let p2 = Promise.resolve(obj);
例1中,傳給ajax的匿名函數不知道會被調用幾次,然而由於Promise的特性,保證了只有第一次調用會使Promise的狀態發生決議,之後的調用都被直接忽略。
例2中,obj對象有一個then方法,接受兩個函數作為參數,所以它是一個thenable對象;但是其內部的代碼卻完全不符合Promise規範——"fulfilled"被調用了兩次,"rejected"也在resolve時被調用,完全是亂來嘛!但是,只要把它包裝成p2,那就沒有問題了——resolve(1)順利執行,resolve(2)和reject('evil laugh')被直接忽略。
第三,then方法註冊的回調一定會被非同步調用,比如:
console.log('A'); Promise.resolve('B').then(console.log); console.log('C');
執行結果是 A C B。
這是為了將現在值(同步)和未來值(非同步)歸一化,避免出現Zalgo現象(指同一個操作既可能同步返回也可能非同步返回,比如緩存命中則同步返回、未命中則非同步返回)。
再看一段代碼:
setTimeout(function(){console.log('A');}, 0); setTimeout(function(){console.log('B');}, 0); Promise.resolve('C').then(console.log);
Promise.resolve('D').then(console.log); console.log('E');
執行結果為 E C D A B。
原因在於,Promise的then回調實現非同步不是用setTimeout(.., 0),而是用一種叫做Job Queue(任務隊列)的專門機制。傳統的setTimeout(.., 0)把回調放在Event Loop的末尾,作為一個新的event老老實實排隊;而Job Queue是Event Loop中每個event後面掛著的一個隊列,往這個隊列里插入回調,可以搶在下個event之前執行,相當於“插隊”,因此Promise一旦決議,可以以最快的速度(在當前同步代碼執行完之後,立刻)調用回調,沒有別的非同步能夠搶在前面(除了另一個Promise)!
第四,then方法會返回一個新的Promise,以fulfilled回調為其resolve,以rejected回調為其reject,因此連續調用then方法可以構成一條Promise鏈。由於鏈上的Promise決議有先後順序(別忘了,每一步都是非同步的),因此可以用來控制非同步操作的順序。當然,一般情況下同步操作就不要強行非同步化了,我見過p.then(res=>res.text).then(...)這樣的代碼,除了增加程式複雜度以外好像沒什麼用處。。。
從以上幾點可以看出,Promise是一種非常強大的模式,對於非同步操作中可能遇到的信任問題、硬編碼流程問題等,都設計了相應的機制來加以剋服,試著正確地瞭解它、使用它,你一定能體會到它的好處,從而愛不釋手。但是,探尋更優雅的非同步操作方法的任務,還沒有結束……
推薦閱讀:《你不知道的JavaScript·中捲》第二部分:非同步和性能