漫話JavaScript與異步·第二話——Promise:一諾千金


一、難以掌控的回調

我在第一話中介紹了異步的概念、事件循環、以及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·中卷》第二部分:異步和性能


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM