天天看點

掘金 php,掘金 - SegmentFault 思否

前言

從我們一開始學習JavaScript的時候就聽到過一段話:JS是單線程的,天生異步,适合IO密集型,不适合CPU密集型。但是,多數JavaScript開發者從來沒有認真思考過自己程式中的異步到底是怎麼出現的,以及為什麼會出現,也沒有探索過處理異步的其他方法。到目前為止,還有很多人堅持認為回調函數就完全夠用了。

但是,随着JavaScript面臨的需求越來越多,它可以運作在浏覽器、伺服器、甚至是嵌入式裝置上,為了滿足這些需求,JavaScript的規模和複雜性也在持續增長,使用回調函數來管理異步也越來越讓人痛苦,這一切,都需要更強大、更合理的異步方法,通過這篇文章,我想對目前已有JavaScript異步的處理方式做一個總結,同時試着去解釋為什麼會出現這些技術,讓大家對JavaScript異步程式設計有一個更宏觀的了解,讓知識變得更體系化一些。

本文也會同步到我的個人網站。

正文

Step1 - 回調函數

回調函數大家肯定都不陌生,從我們寫一段最簡單的定時器開始:setTimeout(function () {

console.log('Time out');

}, 1000);

定時器裡面的匿名函數就是一個回調函數,因為在JS中函數是一等公民,是以它可以像其他變量一樣作為參數進行傳遞。這樣看來,通過回調函數來處理異步挺好的,寫着也順手,為什麼要用别的方法呢?

我們來看這樣一個需求:

掘金 php,掘金 - SegmentFault 思否

上面是微信小程式的登入時序圖,我們的需求和它類似但又有些差别,想要擷取一段業務資料,整個過程分為3步:調用秘鑰接口,擷取key

攜帶key調用登入接口,擷取token和userId

攜帶token和userId調用業務接口,擷取資料

可能上述步驟和實際業務中的有些出入,但是卻可以用來說明問題,請大家諒解。

我們寫一段代碼來實作上述需求: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)。

為什麼會出現這種現象?

如果某個業務,依賴于上層業務的資料,上層業務又依賴于更上一層的資料,我們還采用回調的方式來處理異步的話,就會出現回調地獄。

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

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

回調函數還會存在别的問題嗎?

讓我們再深入思考一下回調的概念:// A

$.ajax({

...

success: function (...) {

// C

}

});

// B

A和B發生于現在,在JavaScript主程式的直接控制之下,而C會延遲到将來發生,并且是在第三方的控制下,在本例中就是函數$.ajax(...)。從根本上來說,這種控制的轉移通常不會給程式帶來很多問題。

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

這種情況稱為控制反轉,也就是把自己程式一部分的執行控制交給某個第三方,在你的代碼和第三方工具直接有一份并沒有明确表達的契約。

既然是無法控制的第三方在執行你的回調函數,那麼就有可能存在以下問題,當然通常情況下是不會發生的:調用回調過早

調用回調過晚

調用回調次數太多或者太少

未能把所需的參數成功傳給你的回調函數

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

......

這種控制反轉會導緻信任鍊的完全斷裂,如果你沒有采取行動來解決這些控制反轉導緻的信任問題,那麼你的代碼已經有了隐藏的Bug,盡管我們大多數人都沒有這樣做。

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

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

1. 缺乏順序性: 回調地獄導緻的調試困難,和大腦的思維方式不符

2. 缺乏可信任性: 控制反轉導緻的一系列信任問題

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

Step2 - Promise

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

至于Promise是什麼,大家肯定都有所了解,這裡是PromiseA+規範,ES6的Promise也好,jQuery的Promise也好,不同的庫有不同的實作,但是大家遵循的都是同一套規範,是以,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:在JavaScript事件隊列的目前運作完成之前,回調函數永遠不會被調用

通過 .then 形式添加的回調函數,甚至都在異步操作完成之後才被添加的函數,都會被調用

通過多次調用 .then,可以添加多個回調函數,它們會按照插入順序并且獨立運作

下面我們針對前面提過的回調函數處理異步導緻的一系列信任問題來讨論,如果是用Promise來處理,是否還會存在這些問題,當然前提是實作的Promise完全遵循

調用過早

當使用回調函數的時候,我們無法保證或者不知道第三方對于回調函數的調用是何種形式的,如果它在某種情況下是立即完成以同步的方式來調用,那可能就會導緻我們代碼中的邏輯錯誤。

但是,根據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 - 生成器Generator

在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屬性也不指定。

2. 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

上面我們介紹了Promise和Generator,把這兩者結合起來,就是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異步程式設計的發展曆程:第一個階段 - 回調函數,但會導緻兩個問題:缺乏順序性: 回調地獄導緻的調試困難,和大腦的思維方式不符

缺乏可信任性: 控制反轉導緻的一系列信任問題

第二個階段 - Promise,Promise是基于PromiseA+規範的實作,它很好的解決了控制反轉導緻的信任問題,将代碼執行的主動權重新拿了回來。

第三個階段 - 生成器函數Generator,使用Generator,可以讓我們用同步的方式來書寫代碼,解決了順序性的問題,但是需要手動去控制next(...),将回調成功傳回的資料送回JavaScript主流程中。

第四個階段 - Async/Await,Async/Await結合了Promise和Generator,在await後面跟一個Promise,它會自動等待Promise的決議值,解決了Generator需要手動控制next(...)執行的問題,真正實作了用同步的方式書寫異步代碼。

我們可以看到,每項技術的突破都是為了解決現有技術存在的一些問題,它是循序漸進的,我們在學習的過程中,要真正去了解這項技術解決了哪些痛點,它為什麼會存在,這樣會有益于我們建構體系化的知識,同時也會更好的去了解這門技術。

最後,希望大家可以通過這篇文章對JavaScript異步程式設計有一個更宏觀的體系化的了解,我們一起進步。

歡迎關注我的公衆号

掘金 php,掘金 - SegmentFault 思否

參考:檢視原文