JavaScript異步編程

前言

從咱們一開始學習JavaScript的時候就聽到過一段話:JS是單線程的,天生異步,適合IO密集型,不適合CPU密集型。可是,多數JavaScript開發者歷來沒有認真思考過本身程序中的異步究竟是怎麼出現的,以及爲何會出現,也沒有探索過處理異步的其餘方法。到目前爲止,還有不少人堅持認爲回調函數就徹底夠用了。javascript

可是,隨着JavaScript面臨的需求愈來愈多,它能夠運行在瀏覽器、服務器、甚至是嵌入式設備上,爲了知足這些需求,JavaScript的規模和複雜性也在持續增加,使用回調函數來管理異步也愈來愈讓人痛苦,這一切,都須要更強大、更合理的異步方法,經過這篇文章,我想對目前已有JavaScript異步的處理方式作一個總結,同時試着去解釋爲何會出現這些技術,讓你們對JavaScript異步編程有一個更宏觀的理解,讓知識變得更體系化一些。java

正文

Step1 - 回調函數

回調函數你們確定都不陌生,從咱們寫一段最簡單的定時器開始:ajax

setTimeout(function () {
    console.log('Time out');
}, 1000);

定時器裏面的匿名函數就是一個回調函數,由於在JS中函數是一等公民,因此它能夠像其餘變量同樣做爲參數進行傳遞。這樣看來,經過回調函數來處理異步挺好的,寫着也順手,爲何要用別的方法呢?編程

咱們來看這樣一個需求:小程序

上面是微信小程序的登陸時序圖,咱們的需求和它相似但又有些差異,想要獲取一段業務數據,整個過程分爲3步:微信小程序

  • 調用祕鑰接口,獲取key
  • 攜帶key調用登陸接口,獲取token和userId
  • 攜帶token和userId調用業務接口,獲取數據

可能上述步驟和實際業務中的有些出入,可是卻能夠用來講明問題,請你們諒解。api

咱們寫一段代碼來實現上述需求:數組

let key, token, userId;

$.ajax({
    type: 'get',
    url: 'http://localhost:3000/apiKey',
    success: function (data) {
        key = data;
        
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/getToken',
            data: {
                key: key
            },
            success: function (data) {
                token = data.token;
                userId = data.userId;
                
                $.ajax({
                    type: 'get',
                    url: 'http://localhost:3000/getData',
                    data: {
                        token: token,
                        userId: userId
                    },
                    success: function (data) {
                        console.log('業務數據:', data);
                    },
                    error: function (err) {
                        console.log(err);
                    }
                });
            },
            error: function (err) {
                console.log(err);
            }
        });
    },
    error: function (err) {
        console.log(err);
    }
});

能夠看到,整段代碼充滿了回調嵌套,代碼不只在縱向擴展,橫向也在擴展。我相信,對於任何人來講,調試起來都會很困難,咱們不得不從一個函數跳到下一個,再跳到下一個,在整個代碼中跳來跳去以查看流程,而最終的結果藏在整段代碼的中間位置。真實的JavaScript程序代碼可能要混亂的多,使得這種追蹤難度會成倍增長。這就是咱們常說的回調地獄(Callback Hell)promise

爲何會出現這種現象?瀏覽器

若是某個業務,依賴於上層業務的數據,上層業務又依賴於更上一層的數據,咱們還採用回調的方式來處理異步的話,就會出現回調地獄。

大腦對於事情的計劃方式是線性的、阻塞的、單線程的語義,可是回調錶達異步流程的方式是非線性的、非順序的,這使得正確推導這樣的代碼的難度很大,很容易產生Bug。

這裏咱們引出了回調函數解決異步的第1個問題:回調地獄

回調函數還會存在別的問題嗎?
讓咱們再深刻思考一下回調的概念:

// A
$.ajax({
    ...
    success: function (...) {
        // C
    }
});

// B

A和B發生於如今,在JavaScript主程序的直接控制之下,而C會延遲到未來發生,而且是在第三方的控制下,在本例中就是函數$.ajax(…)。從根本上來講,這種控制的轉移一般不會給程序帶來不少問題。

可是,請不要被這個小几率迷惑而認爲這種控制切換不是什麼大問題。實際上,這是回調驅動設計最嚴重(也是最微妙)的問題。它以這樣一個思路爲中心:有時候ajax(…),也就是你交付回調函數的第三方不是你編寫的代碼,也不在你的直接控制之下,它是某個第三方提供的工具。

這種狀況稱爲控制反轉,也就是把本身程序一部分的執行控制交給某個第三方,在你的代碼和第三方工具直接有一份並無明確表達的契約。

既然是沒法控制的第三方在執行你的回調函數,那麼就有可能存在如下問題,固然一般狀況下是不會發生的:

  • 調用回調過早
  • 調用回調過晚
  • 調用回調次數太多或者太少
  • 未能把所需的參數成功傳給你的回調函數
  • 吞掉可能出現的錯誤或異常
  • ……

這種控制反轉會致使信任鏈的徹底斷裂,若是你沒有采起行動來解決這些控制反轉致使的信任問題,那麼你的代碼已經有了隱藏的Bug,儘管咱們大多數人都沒有這樣作。

這裏,咱們引出了回調函數處理異步的第二個問題:控制反轉

綜上,回調函數處理異步流程存在2個問題:

  1. 缺少順序性: 回調地獄致使的調試困難,和大腦的思惟方式不符
  2. 缺少可信任性: 控制反轉致使的一系列信任問題

那麼如何來解決這兩個問題,先驅者們開始了探索之路……

Step2 - Promise

開門見山,Promise解決的是回調函數處理異步的第2個問題:控制反轉。

至於Promise是什麼,你們確定都有所瞭解,這裏是PromiseA+規範,ES6Promise也好,jQueryPromise也好,不一樣的庫有不一樣的實現,可是你們遵循的都是同一套規範,因此,Promise並不指特定的某個實現,它是一種規範,是一套處理JavaScript異步的機制。

咱們把上面那個多層回調嵌套的例子用Promise的方式重構:

let getKeyPromise = function () {
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/apiKey',
            success: function (data) {
               let key = data;
               resolve(key);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

let getTokenPromise = function (key) {
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/getToken',
            data: {
                key: key
            },
            success: function (data) {
                resolve(data);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

let getDataPromise = function (data) {
    let token = data.token;
    let userId = data.userId;
    
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/getData',
            data: {
                token: token,
                userId: userId
            },
            success: function (data) {
                resolve(data);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

getKeyPromise()
    .then(function (key) {
        return getTokenPromise(key);
    })
    .then(function (data) {
        return getDataPromise(data);
    })
    .then(function (data) {
        console.log('業務數據:', data);
    })
    .catch(function (err) {
        console.log(err);
    });

能夠看到,Promise在必定程度上其實改善了回調函數的書寫方式,最明顯的一點就是去除了橫向擴展,不管有再多的業務依賴,經過多個then(…)來獲取數據,讓代碼只在縱向進行擴展;另一點就是邏輯性更明顯了,將異步業務提取成單個函數,整個流程能夠看到是一步步向下執行的,依賴層級也很清晰,最後須要的數據是在整個代碼的最後一步得到。

因此,Promise在必定程度上解決了回調函數的書寫結構問題,但回調函數依然在主流程上存在,只不過都放到了then(…)裏面,和咱們大腦順序線性的思惟邏輯仍是有出入的。

這裏我想主要討論的是,Promise是如何解決控制反轉帶來的信任缺失問題

首先明確一點,Promise能夠保證如下狀況,引用自JavaScript | MDN

  1. 在JavaScript事件隊列的當前運行完成以前,回調函數永遠不會被調用
  2. 經過 .then 形式添加的回調函數,甚至都在異步操做完成以後才被添加的函數,都會被調用
  3. 經過屢次調用 .then,能夠添加多個回調函數,它們會按照插入順序而且獨立運行

下面咱們針對前面提過的回調函數處理異步致使的一系列信任問題來討論,若是是用Promise來處理,是否還會存在這些問題,固然前提是實現的Promise徹底遵循PromiseA+規範。

調用過早

當使用回調函數的時候,咱們沒法保證或者不知道第三方對於回調函數的調用是何種形式的,若是它在某種狀況下是當即完成以同步的方式來調用,那可能就會致使咱們代碼中的邏輯錯誤。

可是,根據PromiseA+規範,Promise就沒必要擔憂這種問題,由於即便是當即完成的Promise(相似於new Promise(function (resolve, reject) {resolve(2);})),也沒法被同步觀察到。

也就是說,對一個Promise調用then(…)的時候,即便這個Promise已經決議,提供給then(…)的回調也總會在JavaScript事件隊列的當前運行完成後,再被調用,即異步調用。

調用過晚

當Promise建立對象調用resolve(…)reject(…)時,這個Promise經過then(…)註冊的回調函數就會在下一個異步時間點上被觸發。

而且,這個Promise上的多個經過then(…)註冊的回調都會在下一個異步時間點上被依次調用,這些回調中的任意一個都沒法影響或延誤對其餘回調的調用。

舉例以下:

p.then(function () {
    p.then(function () {
        console.log('C');
    });
    console.log('A');
})
.then(funtion () {
    console.log('B');
});

// 打印 A B C

經過這個例子能夠看到,C沒法打斷或搶佔B,因此Promise沒有調用過晚的現象,只要你註冊了then(…),就確定會按順序依次調用,由於這就是Promise的運做方式。

回調未調用

沒有任何東西(甚至JavaScript錯誤)能阻止Promise向你通知它的決議(若是它決議了的話)。若是你對一個Promise註冊了一個成功回調和拒絕回調,那麼Promise在決議的時候總會調用其中一個。

固然,若是你的回調函數自己包含JavaScript錯誤,那可能就會看不到你指望的結果,但實際上回調仍是被調用了。

p.then(function (data) {
    console.log(data);
    foo.bar();       // 這裏沒有定義foo,因此這裏會報Type Error, foo is not defined
}, function (err) {

});
調用次數太多或者太少

根據PromiseA+規範,回調被調用的正確次數應該是1次。「太少」就是不調用,前面已經解釋過了。

「太多」的狀況很容易解釋,Promise的定義方式使得它只能被決議一次。若是處於多種緣由,Promise建立代碼試圖調用屢次resolve(…)或reject(…),或者試圖二者都調用,那麼這個Promise將只會接受第一次決議,並默默忽略任何後續調用。

因爲Promise只能被決議一次,因此任何經過then(…)註冊的回調就只會被調用一次。

未能傳遞參數值

若是你沒有把任何值傳遞給resolve(…)或reject(…),那麼這個值就是undefined。但無論這個值是什麼,它都會被傳給全部註冊在then(…)中的回調函數。

若是使用多個參數調用resolve(…)或reject(…),那麼第一個參數以後的全部參數都會被忽略。若是要傳遞多個值,你就必須把它們封裝在單個值中進行傳遞,好比一個數組或對象。

吞掉可能出現的錯誤或異常

若是在Promise的建立過程當中或在查看其決議結果的過程當中的任什麼時候間點上,出現了一個JavaScript異常錯誤,好比一個TypeError或ReferenceError,這個異常都會被捕捉,而且會使這個Promise被拒絕。

舉例以下:

var p = new Promise(function (resolve, reject) {
    foo.bar();    // foo未定義
    resolve(2);
});

p.then(function (data) {
    console.log(data);    // 永遠也不會到達這裏
}, function (err) {
    console.log(err);    // err將會是一個TypeError異常對象來自foo.bar()這一行
});
foo.bar()中發生的JavaScript異常致使了Promise的拒絕,你能夠捕捉並對其做出響應。
不是全部的thenable均可以信任

到目前爲止,咱們討論了使用Promise能夠避免上述多種由控制反轉致使的信任問題。可是,你確定也注意到了,Promise並無徹底擺脫回調,它只是改變了傳遞迴調的位置。咱們並非把回調傳遞給foo(…)讓第三方去執行,而是從foo(…)獲得某個東西(Promise對象),而後把回調傳遞給這個東西。

可是,爲何這就比單純使用回調更值得信任呢?如何可以肯定返回的這個東西實際上就是一個可信任的Promise呢?

Promise對於這個問題已經有了解決方案,ES6實現的Promise的解決方案就是Promise.resolve(…)。

若是向Promise.resolve(…)傳遞一個非Promise,非thenable得當即值,就會獲得一個用這個值填充的Promise。

舉例以下:

var p1 = new Promise(function (resolve, reject) {
    resolve(2);
});

var p2 = Promise.resolve(2);

// 這裏p1和p2的效果是同樣的

而若是向Promise.resolve(…)傳遞一個真正的Promise,就只會返回同一個Promise。
舉例以下:

var p1 = Promise.resolve(2);
var p2 = Promise.resolve(p1);

p1 === p2;    // true

更重要的是,若是向Promise.resolve(…)傳遞了一個非Promise的thenable值,前者就會試圖展開這個值,並且展開過程當中會持續到提取出一個具體的非類Promise的最終值。

舉例以下:

var p = {
    then: function (cb, errCb) {
        cb(2);
        errCb('haha');
    }
};

// 這能夠工做,由於函數是一等公民,能夠當作參數進行傳遞
p.then(function (data) {
    console.log(data);    // 2
}, function (err) {
    console.log(err);    // haha
});

這個p是一個thenable,但不是一個真正的Promise,其行爲和Promise並不徹底一致,它同時觸發了成功回調和拒絕回調,它是不可信任的。

儘管如此,咱們仍是均可以把這樣的p傳給Promise.resolve(…),而後就會獲得指望中的規範化後的安全結果:

Promise.resolve(p)
    .then(function (data) {
        console.log(data);    // 2
    }, function (err) {
        console.log(err);    // 永遠不會到達這裏
    });

由於前面討論過,一個Promise只接受一次決議,若是屢次調用resolve(…)reject(…),後面的會被自動忽略。

Promise.resolve(…)能夠接受任何thenable,將其解封爲它的非thenable值。從Promise.resolve(…)獲得的是一個真正的Promise,是一個能夠信任的值。若是你傳入的已是真正的Promise,那麼你獲得的就是它自己,因此經過Promise.resolve(…)過濾來得到可信任性徹底沒有壞處。

綜上,咱們明確了,使用Promise處理異步能夠解決回調函數控制反轉帶來的一系列信任問題。
很好,咱們又向前邁了一步。

Step3 - 生成器Gererator

在Step1中,咱們肯定了用回調錶達異步流程的兩個關鍵問題:

  • 基於回調的異步不符合大腦對任務步驟的規範方式
  • 因爲控制反轉,回調並非可信任的

在Step2中,咱們詳細介紹了Promise是如何把回調的控制反轉又反轉過來,恢復了可信任性。

如今,咱們把注意力轉移到一種順序、看似同步的異步流程控制表達風格,這就是ES6中的生成器(Gererator)

可迭代協議和迭代器協議

瞭解Generator以前,必須先了解ES6新增的兩個協議:可迭代協議和迭代器協議。

可迭代協議

可迭代協議運行JavaScript對象去定義或定製它們的迭代行爲,例如(定義)在一個for…of結構中什麼值能夠被循環(獲得)。如下內置類型都是內置的可迭代對象而且有默認的迭代行爲:

  • Array
  • Map
  • Set
  • String
  • TypedArray
  • 函數的Arguments對象
  • NodeList對象

注意,Object不符合可迭代協議。

爲了變成可迭代對象,一個對象必須實現@@iterator方法,意思是這個對象(或者它原型鏈prototype chain上的某個對象)必須有一個名字是Symbol.iterator的屬性:

屬性
[Symbol.iterator] 返回一個對象的無參函數,被返回對象符合迭代器協議

當一個對象須要被迭代的時候(好比開始用於一個for…of循環中),它的@@iterator方法被調用而且無參數,而後返回一個用於在迭代中得到值的迭代器。

迭代器協議

迭代器協議定義了一種標準的方式來產生一個有限或無限序列的值。
當一個對象被認爲是一個迭代器時,它實現了一個next()的方法而且擁有如下含義:

屬性
next 返回一個對象的無參函數,被返回對象擁有兩個屬性
  1. done(boolean)
  • 若是迭代器已經通過了被迭代序列時爲true。這時value可能描述了該迭代器的返回值
  • 若是迭代器能夠產生序列中的下一個值,則爲false。這等效於連同done屬性也不指定。
  1. value
  • 迭代器返回的任何JavaScript值。done爲true時能夠忽略。 |

使用可迭代協議和迭代器協議的例子:

var str = 'hello';

// 可迭代協議使用for...of訪問
typeof str[Symbol.iterator];    // 'function'

for (var s of str) {
    console.log(s);    // 分別打印 'h'、'e'、'l'、'l'、'o'
}

// 迭代器協議next方法
var iterator = str[Symbol.iterator]();

iterator.next();    // {value: "h", done: false}
iterator.next();    // {value: "e", done: false}
iterator.next();    // {value: "l", done: false}
iterator.next();    // {value: "l", done: false}
iterator.next();    // {value: "o", done: false}
iterator.next();    // {value: undefined, done: true}

咱們本身實現一個對象,讓其符合可迭代協議和迭代器協議:

var something = (function () {
    var nextVal;
    
    return {
        // 可迭代協議,供for...of消費
        [Symbol.iterator]: function () {
            return this;
        },
        
        // 迭代器協議,實現next()方法
        next: function () {
            if (nextVal === undefined) {
                nextVal = 1;
            } else {
                nextVal = (3 * nextVal) + 6;
            }
            
            return {value: nextVal, done: false};
        }
    };
})();

something.next().value;    // 1
something.next().value;    // 9
something.next().value;    // 33
something.next().value;    // 105
用Generator實現異步

若是咱們用Generator改寫上面回調嵌套的例子會是什麼樣的呢?見代碼:

function getKey () {
    $.ajax({
        type: 'get',
        url: 'http://localhost:3000/apiKey',
        success: function (data) {
            key = data;
            it.next(key);
        }
        error: function (err) {
            console.log(err);
        }
    });
}

function getToken (key) {
    $.ajax({
        type: 'get',
        url: 'http://localhost:3000/getToken',
        data: {
            key: key
        },
        success: function (data) {
            loginData = data;
            it.next(loginData);
        }
        error: function (err) {
            console.log(err);
        }
    });
}

function getData (loginData) {
    $.ajax({
        type: 'get',
        url: 'http://localhost:3000/getData',
        data: {
            token: loginData.token,
            userId: loginData.userId
        },
        success: function (busiData) {
            it.next(busiData);
        }
        error: function (err) {
            console.log(err);
        }
    });
}



function *main () {
    let key = yield getKey();
    let LoginData = yield getToken(key);
    let busiData = yield getData(loginData);
    console.log('業務數據:', busiData);
}

// 生成迭代器實例
var it = main();

// 運行第一步
it.next();
console.log('不影響主線程執行');

咱們注意*main()生成器內部的代碼,不看yield關鍵字的話,是徹底符合大腦思惟習慣的同步書寫形式,把異步的流程封裝到外面,在成功的回調函數裏面調用it.next(),將傳回的數據放到任務隊列裏進行排隊,當JavaScript主線程空閒的時候會從任務隊列裏依次取出回調任務執行。

若是咱們一直佔用JavaScript主線程的話,是沒有時間去執行任務隊列中的任務:

// 運行第一步
it.next();

// 持續佔用JavaScript主線程
while(1) {};    // 這裏是拿不到異步數據的,由於沒有機會去任務隊列裏取任務執行

綜上,生成器Generator解決了回調函數處理異步流程的第一個問題:不符合大腦順序、線性的思惟方式。。

Step4 - Async/Await

上面咱們介紹了PromiseGenerator,把這二者結合起來,就是Async/Await

Generator的缺點是還須要咱們手動控制next()執行,使用Async/Await的時候,只要await後面跟着一個Promise,它會自動等到Promise決議之後的返回值,resolve(…)或者reject(…)均可以。

咱們把最開始的例子用Async/Await的方式改寫:

let getKeyPromise = function () {
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/apiKey',
            success: function (data) {
               let key = data;
               resolve(key);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

let getTokenPromise = function (key) {
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/getToken',
            data: {
                key: key
            },
            success: function (data) {
                resolve(data);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

let getDataPromise = function (data) {
    let token = data.token;
    let userId = data.userId;
    
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/getData',
            data: {
                token: token,
                userId: userId
            },
            success: function (data) {
                resolve(data);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

async function main () {
    let key = await getKeyPromise();
    let loginData = await getTokenPromise(key);
    let busiData = await getDataPromise(loginData);
    
    console.log('業務數據:', busiData);
}

main();

console.log('不影響主線程執行');

能夠看到,使用Async/Await,徹底就是同步的書寫方式,邏輯和數據依賴都很是清楚,只須要把異步的東西用Promise封裝出去,而後使用await調用就能夠了,也不須要像Generator同樣須要手動控制next()執行。

Async/Await是Generator和Promise的組合,徹底解決了基於回調的異步流程存在的兩個問題,多是如今最好的JavaScript處理異步的方式了。

總結

本文經過四個階段來說述JavaScript異步編程的發展歷程:

  1. 第一個階段 - 回調函數,但會致使兩個問題:
  • 缺少順序性: 回調地獄致使的調試困難,和大腦的思惟方式不符
  • 缺少可信任性: 控制反轉致使的一系列信任問題
  1. 第二個階段 - PromisePromise是基於PromiseA+規範的實現,它很好的解決了控制反轉致使的信任問題,將代碼執行的主動權從新拿了回來。
  2. 第三個階段 - 生成器函數Generator,使用Generator,可讓咱們用同步的方式來書寫代碼,解決了順序性的問題,可是須要手動去控制next(…),將回調成功返回的數據送回JavaScript主流程中。
  3. 第四個階段 - Async/AwaitAsync/Await結合了PromiseGenerator,在await後面跟一個Promise,它會自動等待Promise的決議值,解決了Generator須要手動控制next(…)執行的問題,真正實現了用同步的方式書寫異步代碼。

咱們能夠看到,每項技術的突破都是爲了解決現有技術存在的一些問題,它是按部就班的,咱們在學習的過程當中,要真正去理解這項技術解決了哪些痛點,它爲何會存在,這樣會有益於咱們構建體系化的知識,同時也會更好的去理解這門技術。

最後,但願你們能夠經過這篇文章對JavaScript異步編程有一個更宏觀的體系化的瞭解,咱們一塊兒進步。

參考:

https://developer.mozilla.org...

關於

相關文章
相關標籤/搜索