30分鐘,讓你徹底明白Promise原理

發表於2017-05-27

原文連結

前言

前一陣子記錄了promise的一些常規用法,這篇文章再深入一個層次,來分析分析promise的這種規則機制是如何實現的。ps:本文適合已經對promise的用法有所瞭解的人閱讀,如果對其用法還不是太瞭解,可以移步我的上一篇博文

本文的promise原始碼是按照Promise/A+規範來編寫的(不想看英文版的移步Promise/A+規範中文翻譯

引子

為了讓大家更容易理解,我們從一個場景開始講解,讓大家一步一步跟著思路思考,相信你一定會更容易看懂。

考慮下面一種獲取使用者id的請求處理

//例1
function getUserId() {
    return new Promise(function(resolve) {
        //非同步請求
        http.get(url, function(results) {
            resolve(results.id)
        })
    })
}

getUserId().then(function(id) {
    //一些處理
})複製程式碼

getUserId方法返回一個promise,可以通過它的then方法註冊(注意註冊這個詞)在promise非同步操作成功時執行的回撥。這種執行方式,使得非同步呼叫變得十分順手。

原理剖析

那麼類似這種功能的Promise怎麼實現呢?其實按照上面一句話,實現一個最基礎的雛形還是很easy的。

極簡promise雛形

function Promise(fn) {
    var value = null,
        callbacks = [];  //callbacks為陣列,因為可能同時有很多個回撥

    this.then = function (onFulfilled) {
        callbacks.push(onFulfilled);
    };

    function resolve(value) {
        callbacks.forEach(function (callback) {
            callback(value);
        });
    }

    fn(resolve);
}複製程式碼

上述程式碼很簡單,大致的邏輯是這樣的:

  1. 呼叫then方法,將想要在Promise非同步操作成功時執行的回撥放入callbacks佇列,其實也就是註冊回撥函式,可以向觀察者模式方向思考;
  2. 建立Promise例項時傳入的函式會被賦予一個函式型別的引數,即resolve,它接收一個引數value,代表非同步操作返回的結果,當一步操作執行成功後,使用者會呼叫resolve方法,這時候其實真正執行的操作是將callbacks佇列中的回撥一一執行;

可以結合例1中的程式碼來看,首先new Promise時,傳給promise的函式傳送非同步請求,接著呼叫promise物件的then屬性,註冊請求成功的回撥函式,然後當非同步請求傳送成功時,呼叫resolve(results.id)方法, 該方法執行then方法註冊的回撥陣列。

相信仔細的人應該可以看出來,then方法應該能夠鏈式呼叫,但是上面的最基礎簡單的版本顯然無法支援鏈式呼叫。想讓then方法支援鏈式呼叫,其實也是很簡單的:

this.then = function (onFulfilled) {
    callbacks.push(onFulfilled);
    return this;
};複製程式碼

see?只要簡單一句話就可以實現類似下面的鏈式呼叫:

// 例2
getUserId().then(function (id) {
    // 一些處理
}).then(function (id) {
    // 一些處理
});複製程式碼

加入延時機制

細心的同學應該發現,上述程式碼可能還存在一個問題:如果在then方法註冊回撥之前,resolve函式就執行了,怎麼辦?比如promise內部的函式是同步函式:

// 例3
function getUserId() {
    return new Promise(function (resolve) {
        resolve(9876);
    });
}
getUserId().then(function (id) {
    // 一些處理
});複製程式碼

這顯然是不允許的,Promises/A+規範明確要求回撥需要通過非同步方式執行,用以保證一致可靠的執行順序。因此我們要加入一些處理,保證在resolve執行之前,then方法已經註冊完所有的回撥。我們可以這樣改造下resolve函式:

function resolve(value) {
    setTimeout(function() {
        callbacks.forEach(function (callback) {
            callback(value);
        });
    }, 0)
}複製程式碼

上述程式碼的思路也很簡單,就是通過setTimeout機制,將resolve中執行回撥的邏輯放置到JS任務佇列末尾,以保證在resolve執行時,then方法的回撥函式已經註冊完成.

但是,這樣好像還存在一個問題,可以細想一下:如果Promise非同步操作已經成功,這時,在非同步操作成功之前註冊的回撥都會執行,但是在Promise非同步操作成功這之後呼叫的then註冊的回撥就再也不會執行了,這顯然不是我們想要的。

加入狀態

恩,為了解決上一節丟擲的問題,我們必須加入狀態機制,也就是大家熟知的pendingfulfilledrejected

Promises/A+規範中的2.1Promise States中明確規定了,pending可以轉化為fulfilledrejected並且只能轉化一次,也就是說如果pending轉化到fulfilled狀態,那麼就不能再轉化到rejected。並且fulfilledrejected狀態只能由pending轉化而來,兩者之間不能互相轉換。一圖勝千言:

alt promise state
alt promise state

改進後的程式碼是這樣的:

function Promise(fn) {
    var state = 'pending',
        value = null,
        callbacks = [];

    this.then = function (onFulfilled) {
        if (state === 'pending') {
            callbacks.push(onFulfilled);
            return this;
        }
        onFulfilled(value);
        return this;
    };

    function resolve(newValue) {
        value = newValue;
        state = 'fulfilled';
        setTimeout(function () {
            callbacks.forEach(function (callback) {
                callback(value);
            });
        }, 0);
    }

    fn(resolve);
}複製程式碼

上述程式碼的思路是這樣的:resolve執行時,會將狀態設定為fulfilled,在此之後呼叫then新增的新回撥,都會立即執行。

這裡沒有任何地方將state設為rejected,為了讓大家聚焦在核心程式碼上,這個問題後面會有一小節專門加入。

鏈式Promise

那麼這裡問題又來了,如果使用者再then函式裡面註冊的仍然是一個Promise,該如何解決?比如下面的例4

// 例4
getUserId()
    .then(getUserJobById)
    .then(function (job) {
        // 對job的處理
    });

function getUserJobById(id) {
    return new Promise(function (resolve) {
        http.get(baseUrl + id, function(job) {
            resolve(job);
        });
    });
}複製程式碼

這種場景相信用過promise的人都知道會有很多,那麼類似這種就是所謂的鏈式Promise

鏈式Promise是指在當前promise達到fulfilled狀態後,即開始進行下一個promise(後鄰promise)。那麼我們如何銜接當前promise和後鄰promise呢?(這是這裡的難點)。

其實也不是辣麼難,只要在then方法裡面return一個promise就好啦。Promises/A+規範中的2.2.7就是這麼說噠(微笑臉)~

下面來看看這段暗藏玄機的then方法和resolve方法改造程式碼:


function Promise(fn) {
    var state = 'pending',
        value = null,
        callbacks = [];

    this.then = function (onFulfilled) {
        return new Promise(function (resolve) {
            handle({
                onFulfilled: onFulfilled || null,
                resolve: resolve
            });
        });
    };

    function handle(callback) {
        if (state === 'pending') {
            callbacks.push(callback);
            return;
        }
        //如果then中沒有傳遞任何東西
        if(!callback.onFulfilled) {
            callback.resolve(value);
            return;
        }

        var ret = callback.onFulfilled(value);
        callback.resolve(ret);
    }


    function resolve(newValue) {
        if (newValue && (typeof newValue === 'object' || typeof newValue === 'function')) {
            var then = newValue.then;
            if (typeof then === 'function') {
                then.call(newValue, resolve);
                return;
            }
        }
        state = 'fulfilled';
        value = newValue;
        setTimeout(function () {
            callbacks.forEach(function (callback) {
                handle(callback);
            });
        }, 0);
    }

    fn(resolve);
}複製程式碼

我們結合例4的程式碼,分析下上面的程式碼邏輯,為了方便閱讀,我把例4的程式碼貼在這裡:

// 例4
getUserId()
    .then(getUserJobById)
    .then(function (job) {
        // 對job的處理
    });

function getUserJobById(id) {
    return new Promise(function (resolve) {
        http.get(baseUrl + id, function(job) {
            resolve(job);
        });
    });
}複製程式碼
  1. then方法中,建立並返回了新的Promise例項,這是序列Promise的基礎,並且支援鏈式呼叫。
  2. handle方法是promise內部的方法。then方法傳入的形參onFulfilled以及建立新Promise例項時傳入的resolve均被push到當前promisecallbacks佇列中,這是銜接當前promise和後鄰promise的關鍵所在(這裡一定要好好的分析下handle的作用)。
  3. getUserId生成的promise(簡稱getUserId promise)非同步操作成功,執行其內部方法resolve,傳入的引數正是非同步操作的結果id
  4. 呼叫handle方法處理callbacks佇列中的回撥:getUserJobById方法,生成新的promisegetUserJobById promise
  5. 執行之前由getUserId promisethen方法生成的新promise(稱為bridge promise)的resolve方法,傳入引數為getUserJobById promise。這種情況下,會將該resolve方法傳入getUserJobById promisethen方法中,並直接返回。
  6. getUserJobById promise非同步操作成功時,執行其callbacks中的回撥:getUserId bridge promise中的resolve方法
  7. 最後執行getUserId bridge promise的後鄰promisecallbacks中的回撥。

更直白的可以看下面的圖,一圖勝千言(都是根據自己的理解畫出來的,如有不對歡迎指正):

alt promise analysis
alt promise analysis

失敗處理

在非同步操作失敗時,標記其狀態為rejected,並執行註冊的失敗回撥:

//例5
function getUserId() {
    return new Promise(function(resolve) {
        //非同步請求
        http.get(url, function(error, results) {
            if (error) {
                reject(error);
            }
            resolve(results.id)
        })
    })
}

getUserId().then(function(id) {
    //一些處理
}, function(error) {
    console.log(error)
})複製程式碼

有了之前處理fulfilled狀態的經驗,支援錯誤處理變得很容易,只需要在註冊回撥、處理狀態變更上都要加入新的邏輯:

function Promise(fn) {
    var state = 'pending',
        value = null,
        callbacks = [];

    this.then = function (onFulfilled, onRejected) {
        return new Promise(function (resolve, reject) {
            handle({
                onFulfilled: onFulfilled || null,
                onRejected: onRejected || null,
                resolve: resolve,
                reject: reject
            });
        });
    };

    function handle(callback) {
        if (state === 'pending') {
            callbacks.push(callback);
            return;
        }

        var cb = state === 'fulfilled' ? callback.onFulfilled : callback.onRejected,
            ret;
        if (cb === null) {
            cb = state === 'fulfilled' ? callback.resolve : callback.reject;
            cb(value);
            return;
        }
        ret = cb(value);
        callback.resolve(ret);
    }

    function resolve(newValue) {
        if (newValue && (typeof newValue === 'object' || typeof newValue === 'function')) {
            var then = newValue.then;
            if (typeof then === 'function') {
                then.call(newValue, resolve, reject);
                return;
            }
        }
        state = 'fulfilled';
        value = newValue;
        execute();
    }

    function reject(reason) {
        state = 'rejected';
        value = reason;
        execute();
    }

    function execute() {
        setTimeout(function () {
            callbacks.forEach(function (callback) {
                handle(callback);
            });
        }, 0);
    }

    fn(resolve, reject);
}複製程式碼

上述程式碼增加了新的reject方法,供非同步操作失敗時呼叫,同時抽出了resolvereject共用的部分,形成execute方法。

錯誤冒泡是上述程式碼已經支援,且非常實用的一個特性。在handle中發現沒有指定非同步操作失敗的回撥時,會直接將bridge promise(then函式返回的promise,後同)設為rejected狀態,如此達成執行後續失敗回撥的效果。這有利於簡化序列Promise的失敗處理成本,因為一組非同步操作往往會對應一個實際功能,失敗處理方法通常是一致的:

//例6
getUserId()
    .then(getUserJobById)
    .then(function (job) {
        // 處理job
    }, function (error) {
        // getUserId或者getUerJobById時出現的錯誤
        console.log(error);
    });複製程式碼

異常處理

細心的同學會想到:如果在執行成功回撥、失敗回撥時程式碼出錯怎麼辦?對於這類異常,可以使用try-catch捕獲錯誤,並將bridge promise設為rejected狀態。handle方法改造如下:

function handle(callback) {
    if (state === 'pending') {
        callbacks.push(callback);
        return;
    }

    var cb = state === 'fulfilled' ? callback.onFulfilled : callback.onRejected,
        ret;
    if (cb === null) {
        cb = state === 'fulfilled' ? callback.resolve : callback.reject;
        cb(value);
        return;
    }
    try {
        ret = cb(value);
        callback.resolve(ret);
    } catch (e) {
        callback.reject(e);
    } 
}複製程式碼

如果在非同步操作中,多次執行resolve或者reject會重複處理後續回撥,可以通過內建一個標誌位解決。

總結

剛開始看promise原始碼的時候總不能很好的理解then和resolve函式的執行機理,但是如果你靜下心來,反過來根據執行promise時的邏輯來推演,就不難理解了。這裡一定要注意的點是:promise裡面的then函式僅僅是註冊了後續需要執行的程式碼,真正的執行是在resolve方法裡面執行的,理清了這層,再來分析原始碼會省力的多。

現在回顧下Promise的實現過程,其主要使用了設計模式中的觀察者模式:

  1. 通過Promise.prototype.then和Promise.prototype.catch方法將觀察者方法註冊到被觀察者Promise物件中,同時返回一個新的Promise物件,以便可以鏈式呼叫。
  2. 被觀察者管理內部pending、fulfilled和rejected的狀態轉變,同時通過建構函式中傳遞的resolve和reject方法以主動觸發狀態轉變和通知觀察者。

參考文獻

深入理解 Promise
JavaScript Promises ... In Wicked Detail

相關文章