只會用就out了,手寫一個符合規範的Promise

恍然小悟發表於2018-08-31

Promise是什麼

所謂Promise,簡單說就是一個容器,裡面儲存著某個未來才會結束的事件(通常是一個非同步操作)的結果。從語法上說,Promise 是一個物件,從它可以獲取非同步操作的訊息。Promise 提供統一的 API,各種非同步操作都可以用同樣的方法進行處理。

只會用就out了,手寫一個符合規範的Promise

Promise是處理非同步編碼的一個解決方案,在Promise出現以前,非同步程式碼的編寫都是通過回撥函式來處理的,回撥函式本身沒有任何問題,只是當多次非同步回撥有邏輯關係時就會變得複雜:

const fs = require('fs');
fs.readFile('1.txt', (err,data) => {
    fs.readFile('2.txt', (err,data) => {
        fs.readFile('3.txt', (err,data) => {
            //可能還有後續程式碼
        });
    });
});
複製程式碼

上面讀取了3個檔案,它們是層層遞進的關係,可以看到多個非同步程式碼套在一起不是縱向發展的,而是橫向,不論是從語法上還是從排錯上都不好,於是Promise的出現可以解決這一痛點。

上述程式碼如果改寫成Promise版是這樣:

const util = require('util');
const fs = require('fs');
const readFile = util.promisify(fs.readFile);

readFile('1.txt')
    .then(data => {
        return readFile('2.txt');
    }).then(data => {
        return readFile('3.txt');
    }).then(data => {
        //...
    });
複製程式碼

可以看到,程式碼是從上至下縱向發展了,更加符合人們的邏輯。

下面手寫一個Promise,按照Promises/A+規範,可以參照規範原文: Promises/A+規範

手寫實現Promise是一道前端經典的面試題,比如美團的面試就是必考題,Promise的邏輯還是比較複雜的,考慮的邏輯也比較多,下面總結手寫Promise的關鍵點,和怎樣使用程式碼來實現它。

Promise程式碼基本結構

例項化Promise物件時傳入一個函式作為執行器,有兩個引數(resolve和reject)分別將結果變為成功態和失敗態。我們可以寫出基本結構

function Promise(executor) {
    this.state = 'pending'; //狀態
    this.value = undefined; //成功結果
    this.reason = undefined; //失敗原因

    function resolve(value) {
        
    }

    function reject(reason) {

    }
}

module.exports = Promise;
複製程式碼

其中state屬性儲存了Promise物件的狀態,規範中指明,一個Promise物件只有三種狀態:等待態(pending)成功態(resolved)和失敗態(rejected)。 當一個Promise物件執行成功了要有一個結果,它使用value屬性儲存;也有可能由於某種原因失敗了,這個失敗原因放在reason屬性中儲存。

then方法定義在原型上

每一個Promise例項都有一個then方法,它用來處理非同步返回的結果,它是定義在原型上的方法,我們先寫一個空方法做好準備:

Promise.prototype.then = function (onFulfilled, onRejected) {
};
複製程式碼

當例項化Promise時會立即執行

當我們自己例項化一個Promise時,其執行器函式(executor)會立即執行,這是一定的:

let p = new Promise((resolve, reject) => {
    console.log('執行了');
});
複製程式碼

執行結果:

執行了
複製程式碼

因此,當例項化Promise時,建構函式中就要馬上呼叫傳入的executor函式執行

function Promise(executor) {
    var _this = this;
    this.state = 'pending';
    this.value = undefined;
    this.reason = undefined;

    executor(resolve, reject); //馬上執行
    
    function resolve(value) {}
    function reject(reason) {}
}
複製程式碼

已經是成功態或是失敗態不可再更新狀態

規範中規定,當Promise物件已經由pending狀態改變為了成功態(resolved)或是失敗態(rejected)就不能再次更改狀態了。因此我們在更新狀態時要判斷,如果當前狀態是pending(等待態)才可更新:

    function resolve(value) {
        //當狀態為pending時再做更新
        if (_this.state === 'pending') {
            _this.value = value;//儲存成功結果
            _this.state = 'resolved';
        }

    }

    function reject(reason) {
    //當狀態為pending時再做更新
        if (_this.state === 'pending') {
            _this.reason = reason;//儲存失敗原因
            _this.state = 'rejected';
        }
    }
複製程式碼

以上可以看到,在resolve和reject函式中分別加入了判斷,只有當前狀態是pending才可進行操作,同時將成功的結果和失敗的原因都儲存到對應的屬性上。之後將state屬性置為更新後的狀態。

then方法的基本實現

當Promise的狀態發生了改變,不論是成功或是失敗都會呼叫then方法,所以,then方法的實現也很簡單,根據state狀態來呼叫不同的回撥函式即可:

Promise.prototype.then = function (onFulfilled, onRejected) {
    if (this.state === 'resolved') {
        //判斷引數型別,是函式執行之
        if (typeof onFulfilled === 'function') {
            onFulfilled(this.value);
        }

    }
    if (this.state === 'rejected') {
        if (typeof onRejected === 'function') {
            onRejected(this.reason);
        }
    }
};
複製程式碼

需要一點注意,規範中說明了,onFulfilled 和 onRejected 都是可選引數,也就是說可以傳也可以不傳。傳入的回撥函式也不是一個函式型別,那怎麼辦?規範中說忽略它就好了。因此需要判斷一下回撥函式的型別,如果明確是個函式再執行它。

讓Promise支援非同步

程式碼寫到這裡似乎基本功能都實現了,可是還有一個很大的問題,目前此Promise還不支援非同步程式碼,如果Promise中封裝的是非同步操作,then方法無能為力:

let p = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(1);
    },500);
});

p.then(data => console.log(data)); //沒有任何結果
複製程式碼

執行以上程式碼發現沒有任何結果,本意是等500毫秒後執行then方法,哪裡有問題呢?原因是setTimeout函式使得resolve是非同步執行的,有延遲,當呼叫then方法的時候,此時此刻的狀態還是等待態(pending),因此then方法即沒有呼叫onFulfilled也沒有呼叫onRejected。

這個問題如何解決?我們可以參照釋出訂閱模式,在執行then方法時如果還在等待態(pending),就把回撥函式臨時寄存到一個陣列裡,當狀態發生改變時依次從陣列中取出執行就好了,清楚這個思路我們實現它,首先在類上新增兩個Array型別的陣列,用於存放回撥函式:

function Promise(executor) {
    var _this = this;
    this.state = 'pending';
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledFunc = [];//儲存成功回撥
    this.onRejectedFunc = [];//儲存失敗回撥
    //其它程式碼略...
}
複製程式碼

這樣當then方法執行時,若狀態還在等待態(pending),將回撥函式依次放入陣列中:

Promise.prototype.then = function (onFulfilled, onRejected) {
    //等待態,此時非同步程式碼還沒有走完
    if (this.state === 'pending') {
        if (typeof onFulfilled === 'function') {
            this.onFulfilledFunc.push(onFulfilled);//儲存回撥
        }
        if (typeof onRejected === 'function') {
            this.onRejectedFunc.push(onRejected);//儲存回撥
        }
    }
    //其它程式碼略...
};
複製程式碼

寄存好了回撥,接下來就是當狀態改變時執行就好了:

    function resolve(value) {
        if (_this.state === 'pending') {
            _this.value = value;
            //依次執行成功回撥
            _this.onFulfilledFunc.forEach(fn => fn(value));
            _this.state = 'resolved';
        }

    }

    function reject(reason) {
        if (_this.state === 'pending') {
            _this.reason = reason;
            //依次執行失敗回撥
            _this.onRejectedFunc.forEach(fn => fn(reason));
            _this.state = 'rejected';
        }
    }
複製程式碼

至此,Promise已經支援了非同步操作,setTimeout延遲後也可正確執行then方法返回結果。

鏈式呼叫

Promise處理非同步程式碼最強大的地方就是支援鏈式呼叫,這塊也是最複雜的,我們先梳理一下規範中是怎麼定義的:

  1. 每個then方法都返回一個新的Promise物件(原理的核心
  2. 如果then方法中顯示地返回了一個Promise物件就以此物件為準,返回它的結果
  3. 如果then方法中返回的是一個普通值(如Number、String等)就使用此值包裝成一個新的Promise物件返回。
  4. 如果then方法中沒有return語句,就視為返回一個用Undefined包裝的Promise物件
  5. 若then方法中出現異常,則呼叫失敗態方法(reject)跳轉到下一個then的onRejected
  6. 如果then方法沒有傳入任何回撥,則繼續向下傳遞(值的傳遞特性)。

規範中說的很抽像,我們可以把不好理解的點使用程式碼演示一下。

其中第3項,如果返回是個普通值就使用它包裝成Promise,我們用程式碼來演示:

let p =new Promise((resolve,reject)=>{
    resolve(1);
});

p.then(data=>{
    return 2; //返回一個普通值
}).then(data=>{
    console.log(data); //輸出2
});
複製程式碼

可見,當then返回了一個普通的值時,下一個then的成功態回撥中即可取到上一個then的返回結果,說明了上一個then正是使用2來包裝成的Promise,這符合規範中說的。

第4項,如果then方法中沒有return語句,就視為返回一個用Undefined包裝的Promise物件

let p = new Promise((resolve, reject) => {
    resolve(1);
});

p.then(data => {
    //沒有return語句
}).then(data => {
    console.log(data); //undefined
});
複製程式碼

可以看到,當沒有返回任何值時不會報錯,沒有任何語句時實際上就是return undefined;即將undefined包裝成Promise物件傳給下一個then的成功態。

第6項,如果then方法沒有傳入任何回撥,則繼續向下傳遞,這是什麼意思呢?這就是Promise中值的穿透,還是用程式碼演示一下:

let p = new Promise((resolve, reject) => {
    resolve(1);
});

p.then(data => 2)
.then()
.then()
.then(data => {
    console.log(data); //2
});
複製程式碼

以上程式碼,在第一個then方法之後連續呼叫了兩個空的then方法 ,沒有傳入任何回撥函式,也沒有返回值,此時Promise會將值一直向下傳遞,直到你接收處理它,這就是所謂的值的穿透。

現在可以明白鏈式呼叫的原理,不論是何種情況then方法都會返回一個Promise物件,這樣才會有下個then方法。

搞清楚了這些點,我們就可以動手實現then方法的鏈式呼叫,一起來完善它:

Promise.prototype.then = function (onFulfilled, onRejected) {
    var promise2 = new Promise((resolve, reject) => {
    //程式碼略...
    }
    return promise2;
};
複製程式碼

首先,不論何種情況then都返回Promise物件,我們就例項化一個新promise2並返回。

接下來就處理根據上一個then方法的返回值來生成新Promise物件,由於這塊邏輯較複雜且有很多處呼叫,我們抽離出一個方法來操作,這也是規範中說明的:

/**
 * 解析then返回值與新Promise物件
 * @param {Object} promise2 新的Promise物件 
 * @param {*} x 上一個then的返回值
 * @param {Function} resolve promise2的resolve
 * @param {Function} reject promise2的reject
 */
function resolvePromise(promise2, x, resolve, reject) {
    //...
}
複製程式碼

resolvePromise方法用來封裝鏈式呼叫產生的結果,下面我們分別一個個情況的寫出它的邏輯,首先規範中說明,如果promise2x 指向同一物件,就使用TypeError作為原因轉為失敗。原文如下:

If promise and x refer to the same object, reject promise with a TypeError as the reason.

這是什麼意思?其實就是迴圈引用,當then的返回值與新生成的Promise物件為同一個(引用地址相同),則會丟擲TypeError錯誤:

let promise2 = p.then(data => {
    return promise2;
});
複製程式碼

執行結果:

TypeError: Chaining cycle detected for promise #<Promise>
複製程式碼

很顯然,如果返回了自己的Promise物件,狀態永遠為等待態(pending),再也無法成為resolved或是rejected,程式會死掉,因此首先要處理它:

function resolvePromise(promise2, x, resolve, reject) {
    if (promise2 === x) {
        reject(new TypeError('Promise發生了迴圈引用'));
    }
}
複製程式碼

接下來就是分各種情況處理。當x就是一個Promise,那麼就執行它,成功即成功,失敗即失敗。若x是一個物件或是函式,再進一步處理它,否則就是一個普通值:

function resolvePromise(promise2, x, resolve, reject) {
    if (promise2 === x) {
        reject(new TypeError('Promise發生了迴圈引用'));
    }

    if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
        //可能是個物件或是函式
    } else {
        //否則是個普通值
        resolve(x);
    }
}
複製程式碼

此時規範中說明,若是個物件,則嘗試將物件上的then方法取出來,此時如果報錯,那就將promise2轉為失敗態。原文:

If retrieving the property x.then results in a thrown exception e, reject promise with e as the reason.

function resolvePromise(promise2, x, resolve, reject) {
    //程式碼略...
    if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
        //可能是個物件或是函式
        try {
            let then = x.then;//取出then方法引用
        } catch (e) {
            reject(e);
        }
        
    } else {
        //否則是個普通值
        resolve(x);
    }
}
複製程式碼

多說幾句,為什麼取物件上的屬性有報錯的可能?Promise有很多實現(bluebird,Q等),Promises/A+只是一個規範,大家都按此規範來實現Promise才有可能通用,因此所有出錯的可能都要考慮到,假設另一個人實現的Promise物件使用Object.defineProperty()惡意的在取值時拋錯,我們可以防止程式碼出現Bug。

此時,如果物件中有then,且then是函式型別,就可以認為是一個Promise物件,之後,使用x作為this來呼叫then方法。

If then is a function, call it with x as this

//其他程式碼略...
if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
    //可能是個物件或是函式
    try {
        let then = x.then; 
        if (typeof then === 'function') {
            //then是function,那麼執行Promise
            then.call(x, (y) => {
                resolve(y);
            }, (r) => {
                reject(r);
            });
        } else {
            resolve(x);
        }
    } catch (e) {
        reject(e);
    }

} else {
    //否則是個普通值
    resolve(x);
}
複製程式碼

這樣鏈式寫法就基本完成了。但是還有一種極端的情況,如果Promise物件轉為成功態或是失敗時傳入的還是一個Promise物件,此時應該繼續執行,直到最後的Promise執行完。

p.then(data => {
    return new Promise((resolve,reject)=>{
        //resolve傳入的還是Promise
        resolve(new Promise((resolve,reject)=>{
            resolve(2);
        }));
    });
})
複製程式碼

此時就要使用遞迴操作了。

規範中原文如下:

If a promise is resolved with a thenable that participates in a circular thenable chain, such that the recursive nature of [[Resolve]](promise, thenable) eventually causes [[Resolve]](promise, thenable) to be called again, following the above algorithm will lead to infinite recursion. Implementations are encouraged, but not required, to detect such recursion and reject promise with an informative TypeError as the reason.

很簡單,把呼叫resolve改寫成遞迴執行resolvePromise方法即可,這樣直到解析Promise成一個普通值才會終止,即完成此規範:

//其他程式碼略...
if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
    //可能是個物件或是函式
    try {
        let then = x.then; 
        if (typeof then === 'function') {
            let y = then.call(x, (y) => {
                //遞迴呼叫,傳入y若是Promise物件,繼續迴圈
                resolvePromise(promise2, y, resolve, reject);
            }, (r) => {
                reject(r);
            });
        } else {
            resolve(x);
        }
    } catch (e) {
        reject(e);
    }

} else {
    //是個普通值,最終結束遞迴
    resolve(x);
}

複製程式碼

到此,鏈式呼叫的程式碼已全部完畢。在相應的地方呼叫resolvePromise方法即可。

最後的最後

其實,寫到此處Promise的真正原始碼已經寫完了,但是距離100分還差一分,是什麼呢?

規範中說明,Promise的then方法是非同步執行的。

onFulfilled or onRejected must not be called until the execution context stack contains only platform code.

ES6的原生Promise物件已經實現了這一點,但是我們自己的程式碼是同步執行,不相信可以試一下,那麼如何將同步程式碼變成非同步執行呢?可以使用setTimeout函式來模擬一下:

setTimeout(()=>{
    //此處的程式碼會非同步執行
},0);
複製程式碼

利用此技巧,將程式碼then執行處的所有地方使用setTimeout變為非同步即可,舉個栗子:

setTimeout(() => {
    try {
        let x = onFulfilled(value);
        resolvePromise(promise2, x, resolve, reject);
    } catch (e) {
        reject(e);
    }
},0);
複製程式碼

好了,現在已經是滿分的Promise原始碼了。

滿分的測試

好不容易寫好的Promise原始碼,最終是否真的符合Promises/A+規範,開源社群提供了一個包用於測試我們的程式碼:promises-aplus-tests

這個包的使用方法不在詳述,此包可以一項項的檢查我們寫的程式碼是否合規,如果有任一項不符就會給我們報出來,如果檢查你的程式碼一路都是綠色,那恭喜,你的Proimse已經合法了,可以上線提供給別人使用了:

只會用就out了,手寫一個符合規範的Promise

872項測試通過!

現在原始碼都會寫,終於可以自信的回答面試官的問題了。

相關文章