1. 程式人生 > >前端非同步程式設計系列之Promise/Deferred模式(3/4)

前端非同步程式設計系列之Promise/Deferred模式(3/4)

在這篇文章中,我會介紹另外一種非同步程式設計的解決方案:Promise/Deferred模式。這種模式最早出現於Dojo的程式碼中,09年被Kris Zyp抽象為一個提議草案,釋出於CommonJS規範中,並抽象出Promise/A、Promise/B、Promise/D這樣典型的非同步Promise/Deferred模型,這使得非同步操作可以以一種優雅的方式出現。他最大的特點就是可以先執行非同步呼叫,然後延遲傳遞處理操作。有了Promise物件,就可以將非同步操作以同步操作的流程表達出來,避免了層層巢狀的回撥函式。此外,Promise物件提供統一的介面,使得控制非同步操作更加容易。本篇文章通過Promise/A模式來作為敲門磚來簡略介紹Promise/Deferred模式,以ES6的Promise實現來重點介紹Promise的特性以及對於非同步程式設計問題的解決方案。

1.Promise/A的定義。

Promise/A提議對單個非同步操作有如下規定:

1.Promise操作只會有三個狀態:未完成,完成,失敗

2.他的狀態轉換隻能是:未完成 => 完成 和 未完成 => 失敗兩種,並且轉換是不可逆,並且完成和失敗之間不能相互轉換。

3.狀態一旦變化就不能被更改。

如下圖所示:

而在api的定義上,最低要具備一個then方法即可,並且then方法的行為包括:

1.接受完成態,錯誤態時的回撥方法,以便在Promise狀態改變時,呼叫相應回撥方法。

2.只接受函式

3.then方法會繼續返回一個Promise,以實現鏈式呼叫。

其實then方法的行為只是將他接受的回撥函式儲存起來,在某一刻Promise狀態改變時,再進行呼叫罷了。

示例程式碼如下:

const myPromise = function () {
    this.handle = {};  // 儲存處理函式
};
// then的作用在於把,成功和失敗的回撥給儲存起來。以方便在以後的某個時刻呼叫
myPromise.prototype.then = function(resolveHandler,rejectHandler) {
    let handle = {};
    if(typeof resolve == "function") {
        handle.resolve = resolveHandler
    }
    if(typeof reject == "function") {
        handle.reject = rejectHandler
    }
    this.handle = handle;
}

不過如果要完整走完流程,還需要一個可以改變Promise狀態的物件,而這個物件就是Deferred,即延遲物件。他用來改變Promise的狀態,示例程式碼如下:

const myDeferred = function () {
    this.status = "pending";  // 未完成的等待狀態。
    this.promise = new myPromise();  // 讓deferred和一個promise進行關聯,以控制promise的狀態。
};
// 更改為完成狀態,並執行相應的處理函式。
myDeferred.prototype.resolve = function (obj) {
    this.status = "resolve";
    let handle = this.promise.handle;
    if(handle && handle.resolve) {
        handle.resolve(obj);
    }
}
// 更改為失敗狀態,並執行相應的處理函式。
myDeferred.prototype.reject = function (obj) {
    this.status = "reject";
    let handle = this.promise.handle;
    if(handle && handle.reject) {
        handle.reject(obj);
    }
}

在這裡,Deferred的作用主要就是更改狀態,然後從關聯的Promise中取出then方法所儲存的相應的處理函式。

Promise和Deferred的整體關係圖如下所示:

可以看出,Promise主要作用於外部,通過then方法,儲存邏輯處理函式,而Deferred用於內部,改變Promise的狀態,並呼叫相應的處理函式。

拿node中讀取檔案為例,讀取一個檔案,經過Promsie/Deferred封裝後,會變成如下形式:

const fs = require("fs");
function read(path) {
    let deferred = new myDeferred();
    fs.readFile(path,(err,data) => {
        if(err) {
            deferred.reject(err);
            return;
        }
        deferred.resolve(data);
    })
    return deferred.promise;
}
// 非同步呼叫一個檔案
read("./promise/test1.txt").then((data) => {
    console.log(data);
},(err) => {
    console.log(err);
})

以上算是對Promise/Deferred模式的一些基本介紹。它不是新的語法功能,而是一種新的寫法,允許將回調函式的巢狀,改成鏈式呼叫。當然了,上面的程式碼只是基本簡略的按照規範實現他的功能而已,看不出什麼,至於剩餘的更加完善的Promise/Deferred模式,以及他對於解決非同步程式設計問題方式,以下我會以ES6的Promise實現來進行介紹和講訴。

2.ES6的Promise實現

在ES6也實現了Promise這種非同步模式。他也擁有then方法來儲存處理函式,並且擁有catch函式來專門儲存異常處理函式,並支援鏈式呼叫,處理非同步協同等。接下來會一個個介紹。

使用:

ES6內建Promise物件,可以通過一個他來獲取一個Promise例項:

const promise = new Promise(function(resolve, reject) {
  if (/* 非同步操作成功 */){
    resolve(value);
  } else {
    reject(error);//非同步操作失敗
  }
});

Promise建構函式接受一個函式,這個函式有resolve和reject兩個由JavaScript引擎提供的函式。

1.resolve函式的作用是把這個Promise例項從未完成變為完成的狀態。

2.reject函式的作用是把這個Promise例項從未完成變為失敗的狀態。

而返回的Promise例項,你可以呼叫他的then方法,為其設定完成時和失敗時的處理函式:

promise.then(function(data) {
  // 成功,data的值為呼叫resolve函式傳入時的那個引數
}, function(error) {
  // 失敗,error的值為呼叫reject函式傳入時的那個引數
});

而resolve和reject都只接受一個引數,會傳遞給對應的處理函式。所以,then的成功和失敗處理函式也只接受一個數據引數。

比如下面讀取一個檔案的使用例子:

const fs = require("fs");
const filePromiseTest = new Promise((resolve,reject) => {
    fs.readFile("./test.txt",(err,data) => {
        if(err) {
            reject(err);
            return;
        }
        resolve(data);
        return;
    })
})
// then方法:then方法返回的是一個新的Promise例項(注意,不是原來那個Promise例項)
filePromiseTest.then((data) => {
    console.log(data.toString());
},(err) => {
    console.log(err);
})

其實和之前那個封裝後的Promise/A實現的使用方式差不多,不過,和上面那個草率的Promise/A實現相比,其之間的差別就如下面的介紹所示了:

1.鏈式呼叫

ES6的Promise物件支援鏈式呼叫,因為Promise例項的then方法返回的也是一個新的Promise物件,所以上面的那個讀取檔案例子中的then可以變成這樣:

filePromiseTest
.then((data) => {
    console.log(data.toString());
},(err) => {
    console.log(err);
})
.then()
.then()
.then().....

你可以寫很多個then,一直這樣鏈式呼叫下去。所以,當有多個非同步操作使用回撥函式需要巢狀時,那麼就可以改為使用Promise的鏈式呼叫then來編寫。這樣,是不是有一點同步寫程式碼的感覺了?

then方法可以只接受一個回撥引數,那麼這個回撥引數會用於resolve時的處理函式,那麼reject時的處理函式怎麼辦?ES6提供了一個catch函式專門用於儲存reject狀態時的處理函式,即出現異常時的處理函式,他是這樣用的:

filePromiseTest.then((data) => {
    console.log(data.toString());
}).catch((err) => {
    console.log(err);
});

這樣,如果filePromiseTest的狀態變為失敗狀態了(即呼叫reject函式),那麼程式碼中的then方法傳入的那個函式不會執行,會執行catch方法傳入的那個函式,即會執行:console.log(err); 這一句。而且,即使你這麼寫,只要下面的then中有一個報錯了,那麼都會跳過之後的所有then,而去執行catch。比如:

filePromiseTest
.then((data) => {
    console.log(data.toString());
},(err) => {
    console.log(err);
})
.then()
.then()
.then()
.catch((err) => {
    // 只要以上有任意一個then出錯或者filePromiseTest本身就為reject,那麼就會跳過其間所有的then,而直接執行我這個catch
    //TODO
}).....

而且,catch和then一樣,都是返回一個新的Promsie例項。ES6中通常使用catch方法來處理異常,並且所以then或者catch方法都可以支援鏈式呼叫。

上面的then和catch的介紹只是ES6中最基本用法,下面說的才是ES6中的then和catch函式的重點:

鏈式呼叫中,then或catch的行為。

我們來看一個程式碼A的例子:

filePromiseTest.then((data) => {
    console.log(data.toString());
}).catch((err) => {
    console.log(err);
});

好,上面的那段程式碼A那個的行為是:如果filePromiseTest這個Promise的狀態為成功 => 呼叫then方法的那個處理函式,如果filePromiseTest這個Promise的狀態為失敗 => 就去呼叫catch方法的那個處理函式。從邏輯上,這段程式碼A的行為沒有問題,而且按理來說就是應該這麼走的一個邏輯。

那麼這段程式碼看上去是不是應該這個道理:程式碼A中呼叫的then和catch方法是不是就是給filePromiseTest這個Promise例項 本身 新增一個成功時的處理函式和失敗時的處理函式呢?當這個filePromiseTest例項狀態改變了,是不是就是直接呼叫儲存在這個Promise例項上的相應處理函式呢?

不 ,其實不是,這段程式碼看上去是存在著誤導的,其行為看上去也是存在著誤導。這時候就要認真聽了啊。

注意,then和catch方法他們返回的是一個新的Promise例項,而不是返回呼叫這兩個方法的例項。所以,程式碼A其實等價於下面這個程式碼B:

程式碼B:

const promise = filePromiseTest.then((data) => {
    console.log(data.toString());
})
上面的那個promise是一個then方法所返回的一個新Promsie例項。然後我又在這個promise上面呼叫catch方法,來監聽這個Promsie失敗時的處理函式。
promise.catch((err) => {
    console.log(err);
});

所以,filePromiseTest這個Promise例項上面只存在成功時的處理函式,而promise例項上面只存在失敗時的回撥函式。這就是程式碼A所產生的結果。所以,你看那種很多鏈式呼叫then和catch的程式碼,其實他們的then和catch所新增的處理函式都是新增到不同的Promise例項上了,但是為什麼程式碼的邏輯行為確可以是我們想要的呢?

那就要了解另外一個關於then和catch的知識了:then和catch返回的新Promise例項的狀態是怎麼確定的。

我們來看程式碼B吧,雖然在真正寫程式碼的時候會一般寫成程式碼A,不過程式碼B更好理解一些:

程式碼B中的promise是一個then方法所返回的Promise例項,那麼他的狀態其實和返回他的那個then方法的下列情況有關:

1.如果那個then方法返回一個非Promise的js的普通值(字串,數字,物件,陣列等)那麼promise的狀態為resolve。並且,這個then所返回的值,會傳遞給這個promise

2.如果那個then方法返回一個新的Promise,代號P,那麼這個promise會等待這個P的狀態變為成功或者失敗時,這個promise才會改變狀態,並且:如果P的狀態為成功,那麼這個promise的狀態也為成功,否則,那就是失敗了。並且,P的狀態變化時的接收的資料,也會同樣傳遞給promise。

3.如果那個then方法中處理函式丟擲了錯誤,那麼promise狀態會變為reject,並且捕獲的錯誤就是reject的引數。

瞭解以上三種情況,那麼就可以瞭解下面這種程式碼的行為了:

filePromiseTest
.then()  //1
.then()  //2
.then()  //3
.then().....

第一個then的執行看filePromiseTest,如果filePromiseTest為resolve成功,那麼第一個then會執行,然後,第二個then的執行看第一個then的返回值,如果是返回普通的js值,那麼第二個then也會執行,然後第三個then也要看第二個then的執行情況,依次類推。

但是,上面三種情況只是瞭解其一,還有其二:

為什麼程式碼A中,filePromiseTest為失敗時,會直接跳過那個then,而可以直接執行catch?filePromiseTest這個Promise例項只有一個成功時的處理函式,那麼他如何控制then方法返回的那個promise的狀態的?這就是隱藏的其二了:

在我實驗了許多不同的then和catch呼叫情況後,我猜測瞭如下規律來解釋ES6的這種Promise行為:(重點)

首先:

一個promise1的then和catch都會返回一個和 本promise(代號為p1) 例項 相關連 的新promise例項,並且,在p1例項狀態改變時由p1的then和catch方法所返回的所有新例項promise的狀態也會改變狀態(由p1的then和catch方法所返回的新Promise例項都會和p1進行一種關聯)。具體新Promise的狀態改變的表現為:

1.當p1為resolve時,執行p1的then,並且,由p1的then方法所返回的新promise例項會根據其相應的每個then的返回值來確定其狀態(也就是符合那三種情況的)。而這時候,p1的catch函式不會執行,並且由p1的所有catch方法所返回的新promise例項的狀態都會變成resolve,即成功狀態,並執行其then。而此時then的引數,為p1的resolve執行時傳遞的那個引數。

2.當p1為reject時,執行p1的catch,並且,由p1的catch方法所返回的新Promise例項會根據其每個catch方法的返回值來確定其狀態(也是符合那三種情況的)。而這時候,p1的then方法的處理函式不會執行,並且由p1的所有then方法所返回的新promise例項的狀態都會變成reject,並執行其catch。而此時catch的引數,為p1的reject執行時傳遞的那個引數

很拗口,那麼使用程式碼B來試一下看程式碼B的行為是否符合上面的邏輯:

程式碼B:

const promise = filePromiseTest.then((data) => {
    console.log(data.toString());
})
上面的那個promise是一個then方法所返回的一個新Promsie例項。然後我又在這個promise上面呼叫catch方法,來監聽這個Promsie失敗時的處理函式。
promise.catch((err) => {
    console.log(err);
});

如果filePromise的狀態為resolve,那麼會執行filePromise的then,並且,promise這個Promise例項的狀態會由這個then的返回值決定,如果這個then中的處理函式沒有報錯,那麼返回的應該是undefined,是一個js普通值,那麼promise的狀態會變為resolve,所以promise的catch不會執行。

如果filePromise的狀態為reject,那麼會執行filePromise的catch(這裡沒有設定),而這時,filePromise的then方法的處理函式不會執行,由filePromise的then方法返回的promise變數的狀態會變為reject(失敗態),promise會執行catch。所以會執行:console.log(err); 這一句。

再看一個分解過後的複雜的例子:

程式碼C:nodejs中執行

const fs = require("fs");
// 讀取檔案的promise封裝方法
function readFile(path) {
    const promise = new Promise((resolve, reject) => {
        fs.readFile(path,(err,data) => {
            if(err) {
                reject(err);
                return;
            }
            resolve(data);
            return;
        })
    })
    return promise;
}
const readFile1 = readFile("./test3.txt");
const read1 = readFile1.then((data) => {
    // 返回一個新的Promise例項
    return readFile(data.toString())
})
const read2 = readFile1.catch((err) => {
    console.log("readFile1輸出錯誤")
    return '我是readFile1的catch處理函式所返回的';
})
read1.then((data) => {
    console.log("read1的then",data.toString())
})
read1.catch((err) => {
    console.log("read1的err",err)
})
read2.then((data) => {
    console.log("read2的then",data)
})
read2.catch((err) => {
    console.log("read2的err")
})
// 執行結果如下:
readFile1輸出錯誤
read1的err { [Error: ENOENT: no such file or directory, open 'C:\Users\Administrator\Desktop\test\test3.txt']
  errno: -4058,
  code: 'ENOENT',
  syscall: 'open',
  path: 'C:\\Users\\Administrator\\Desktop\\test\\test3.txt' }
read2的then 我是readFile1的catch處理函式所返回的

text.txt檔案內容:./test2.txt

text2.txt檔案內容:我是text2檔案

text3.txt檔案不存在

read1和read2分別為readFile1這個Promise例項的then方法和catch返回的新Promise例項,並且,都給新的例項添加了then和catch。

readFile1返回的是一個請求text3.txt檔案的非同步Promsie例項,由於text3.txt不存在,那麼readFile1的狀態會變為reject,所以輸出:readFile1輸出錯誤。然後read1的狀態會變為reject,並且引數為readFile1的reject執行所傳遞的那個引數,所以輸出:read1的err + err物件。而read2的狀態則是由readFile1的catch處理函式的返回值所決定的,這裡返回了一段字串,所以,read2的狀態為resolve,所以執行then方法的處理函式,並且,其處理函式所接收的引數正好是readFile1的catch處理函式所那個返回的那個值。

所以,通過這兩個例子看出,我猜測的規律應該是正確的,那麼當你如果在編寫ES6的Promise非同步程式設計時遇到了比較費解或者不明白的非同步行為,不妨可以根據上面的兩點進行推導一下,也許就能夠理解他的行為了。

 

當然了,ES6的then和catch還有其他的一些比較重要的知識點,比如:

1.一個Promise的狀態已經變了,也就是變為為resolve或者reject了。那麼再次執行catch和then方法時,那麼catch或者then會立即執行,不過是會在這一輪程式的任務的最後才執行。所以,說明promise例項會把執行resolve或者reject時的那個資料存起來。

2.如果一個Promise(p2)只由他的一個then新增的成功和失敗的處理函式,那麼這個then所返回的Promise新例項會看p2到底是執行成功處理函式還是失敗處理函式,並根據執行處理函式的返回值來確定狀態。

3.在promise裡面丟擲的錯誤,根本不會被外界程式碼捕獲。一般總是要建議在最後都要新增一個catch方法來處理錯誤的。不過一般來說,最好不要使用then來定義失敗時的回撥函式(即不要定義第二個引數函式),而是總是使用catch方法。

 

ES6的Promise還有一個finally方法,他的作用就是不管Promsie的狀態如何,都會執行finally。不過這裡就不做過多的介紹。

 

2.非同步協同

 

非同步協同在ES6的Promise實現中也很簡單,他提供了專門的all方法,all方法用於將多個Promise例項包裝成一個新的Promise例項:

const p = Promise.all([p1, p2, p3]);

p1、p2、p3都是 Promise 例項,如果不是,就會先呼叫Promise.resolve方法,將引數轉為 Promise 例項。(Promise.resolve和Promise.reject方法的作用都是接收一個引數,並將引數轉換為一個Proomise例項並返回)

而p的狀態分為兩種:

1.只有p1、p2、p3的狀態都變成成功狀態,p的狀態才會變成成功狀態,此時p1、p2、p3的返回值組成一個數組,傳遞給p的回撥函式。

2.只要p1、p2、p3之中有一個為失敗狀態,p的狀態就變成失敗狀態,此時第一個被reject的例項的返回值,會傳遞給p的回撥函式。

除此之外,ES6還定義了一個Promise.race方法,他和all方法的區別在於:只要傳入的Promise例項列表中,只要有一個的狀態改變了,那麼race返回的那個Promise狀態也會變化。他可以用來設定請求超時,比如:

const racePromise = Promise.race([
    readFile('/test.txt'),
    new Promise((resolve, reject) => {
        setTimeout(() => {
            reject(new Error('請求超時'))
        },5000)
    })
]);

上面的程式碼中,如果readFile讀取text.txt檔案在5秒後還沒有成功,那麼第二個Promise例項就會變成reject失敗狀態了,從而racePromise就會變成失敗狀態。

有了all和race方法,我們就很容易的處理多非同步協同問題,而我自己在模仿ES6的Promise實現中,all和race方法使用了哨兵變數來實現這一功能。

 

總結

以上就是關於Promise/Deferred規範的一點介紹和心得,實際上Promise的出現已經可以比較優雅的解決了非同步程式設計的回撥以及異常處理,不過,所有非同步程式碼經過Promise的包裝,一眼看上去全是then和catch,程式碼流程看上去不是很清晰,雖然經過鏈式呼叫,寫起來勉強看上去算是同步程式碼,不過離真正的同步程式碼還是有一些不小的差距的,而這時,就要期待我下一篇介紹的Generator函式和ES7的async函數了,他們和Promsie結合,所編寫出來的程式碼才算是真正的寫起來像同步程式碼的。

 

順便貼上一個es6的Promise實現的github地址:https://github.com/stefanpenner/es6-promise。如果你的編碼環境不支援es6的promise,又想要使用ES6的Promsie特性的話,那麼可以不妨試試這一個。

順便,貼上僅供交流學習之用的我本人模仿ES6的Promise行為所實現的Promise程式碼(附件中),不過如果你想要真正在自己的程式碼中使用ES6的Promise,請使用上面的那個。

附件:https://download.csdn.net/download/qq_33024515/10864408