非同步程式設計及 promise 學習心得
非同步設計到現在執行模組和將來執行模組之間的關係,當然這是核心。我將從什麼是非同步、非同步機制、回撥函式、promise 以及它的設計基礎,這幾個方面介紹非同步程式設計。JavaScript 從前端到 node以及其它的環境,非同步變得越來越重要,也很難學(同步順序執行往往是我們最喜歡的方式)。
什麼是非同步
通常來說程式執行是通過分塊來組織的,常用的有 module ,或者一個函式。有些塊(函式)是現在執行的,有的是將來執行(充滿不確定性,並不是現在的塊執行完後就立即執行例如 Ajax ),現在無法完成的任務交給未來某個時刻去完成。
var data = ajax(/*url*/);
console.log(data);//undefined
複製程式碼
顯然理想的把非同步阻塞執行,然而它並不會這樣做。簡單補充一下 Ajax ,它是一個非同步函式 其中主要的一個物件是 XMLHttpRequest
大多數時候都是非同步執行的,當然它可以同步執行(我勸你最好不要這樣做,同步執行時它會鎖定瀏覽器 UI
所有的按鈕,選單、滾動條、點選事件等都不能產生互動,這是很糟糕的使用者體驗)。
說點題外話,非同步控制檯。console.*
開發中經常使用來做輸出除錯的個函式族,我說它也是非同步的你可能不信,但是它的機制確實是這樣的。我們呢很喜歡用它來列印快照(物件快照)
var count = 0;
console.log(count);
count++;
複製程式碼
通常我們認為console 了 就得列印出:0,然後再自加1,瀏覽器實際上到了console這時 會開一個非同步 i/o 到後臺,等回臺執行時 可能 count++ 已經執行掉了。當然這通常使用都不會出問題,因為這是遊離不定的沒法預測。出現異常時最好結合debugger ,或者JSON
來將資料(物件轉換成字串)快照下來。至少到這個時候你該意識到這是i/o 非同步化造成的。
事件迴圈
先來說說 JavaScript引擎 ,一句話來說就是一個按需執行JavaScript程式碼塊的一個環境。事件迴圈是環境(此時的化境並不是JavaScript引擎提供的環境,而是程式碼執行的工作化境比如:web瀏覽器,node伺服器)提供來處理程式中多個塊的執行,且執行每塊時呼叫JavaScript引擎的一種機制。實際上提供一個事件佇列(這兒有個坑,先挖一下),然後死迴圈這個佇列。每次從頭部取一個任務執行大概這樣簡化模擬一下:
var EventList;
while(true){
if(EventList.length>0){
var event = EventList.shift();
try{
event()
}catch(err){
reportErr(err);
//把錯誤丟擲來
}
}
}
複製程式碼
每次取出的 event 叫做一個 tick ,這樣藉助Ajax 來講一下。
ajax(url,callback);
複製程式碼
環境執行到這個塊時,發現是個非同步 塊。JavaScript引擎就會停下來轉去執行其它的塊,然後環境進行偵聽當資料請求回來以後把 callback 這個回撥函式插入事件佇列中排隊。說到這兒順帶提一句,大家都很喜歡使用來做定時器的 setTimeout
。確實它原本就是一個定時器,但是是給環境定時的。告訴環境多少時間後把回撥函式插入事件佇列。setTimeout(cd,1000)
一秒後把回撥函式 cd
給排隊起來。這時如果事件佇列裡面還有子項(假如有存在耗時比較長的任務,就會很明顯)換句話說不能插進去就立即執行那就對於你來說是超時的(好好想想你想用來幹嘛的)。所以它只能保證多少時間前不能執行!!
認知的誤區(並行與非同步、併發)
非同步是關於現在和將來的存在一個時間空隙,並行是關於同時發生的事。並行通常藉助程式和執行緒來實現計算,可順序執行也可能並行執行。在一個程式中的執行緒可以共享資源。與之相對的是,事件迴圈把任務分為一個一個的執行更細膩的JavaScript引擎也是單執行緒的。因此我們並不去思考並行帶來的很多不確定性(語句順序級)。競態會在 generator 中詳細解釋(挖個坑,肯定會做的)
併發,在同一段時間內兩個任務同時執行。// 不想做這兒的例舉分析,互動(處理競態)、非互動、協作
任務佇列
這很刺激吧!記得要和事件迴圈區分開。前面我們說到了事件迴圈的每一次叫一個 tick,而這個任務佇列就是追加在 tick 後面的一個任務列表(通常是非同步任務)當然promise 就是基於這樣的原理。因此當下一個 tick 執行之前,前一個的任務佇列上的任務都已經執行完畢。因此說promise 來做定時器更精準些。有的參考資料上把事件迴圈和任務佇列稱呼為巨集任務和微任務(有道理)。
//畫圖分析關係,
最基礎的非同步模式——回撥函式
在非同步程式設計中,回撥是最基礎的模式。對於我們初級程式設計師來說很享受用這種方式來非同步程式設計。但是它存在一些問題(heal), ES6
引入的 promise
更高階和複雜的處理非同步。但是其抽象機制,稍微有一點兒麻煩(第一次我是很頭疼的)假如不瞭解其真的核心,就很難把握實現的細節(挖個坑,我會介紹到它)。
巢狀回撥和鏈式回撥
太深層的巢狀會帶來程式碼 bug 追蹤困難可讀性很差(看起來很費勁)不停的切換上下文,有種稱呼叫回撥地獄。其實信任問題才是更地獄!!
listen('click',function handle(){
setTimeout(function request(){
ajax(url,function response(data){
if(data== 'xxx'){
handle();
}else{
request();
}
},function (){})
},100)
})
// 此例 來源 你不知道的JavaScript,
複製程式碼
這種巢狀還是比較簡單了,更瘋狂的回撥。timgsa.baidu.com/timg?image&…
信任問題
繼續使用 ajax
(也可以使用 axios
,fetch-jsonp
等等第三方庫)來說事,通常這些第三方的庫函式執行需要傳入回撥函式。我們將自己封裝的函式執行控制交給第三方(對於你而言這是一個黑盒,當然你也可以檢視原始碼),因此什麼時候執行,執行多少次都是不可見的。這通常被稱為控制反轉。
有這麼一些問題值得思考:
- 呼叫過早/或過晚
- 呼叫次數過多/過少(不執行)
- 吞掉異常和錯誤
- 執行時沒有傳入環境或者引數
你有辦法嗎?朋友你一點辦法都沒有。因此回撥最大的問題就是控制反轉,完全導致信任鏈的斷裂。
補充一點:Error-first 模式。
promise
’你能做的我會,你不會的我還是會‘。任何強大的技術都不止於技術本身,promise一樣當結合 generator (再挖個坑,有空我會繼續寫篇關於這方面的心得)時,異常強大。根據問題現在我們需要一個解決 反轉控制問題,缺乏順序性的正規化。第三方不再去執行我的回撥(因為我們想控制一切),第三方庫只需要給我一個瞭解其任務何時結束的超能力(promise 決議物件)。也就是說,你讓我知道你什麼時候做完我需要你做的工作,我自己決定我接下來該怎麼幹。這不是很好麼!!現在很多平臺新增的API 都是基於Promise 來構建的,趕快跟巨集哥一起學吧!
什麼是promise
先來說一個故事,有一天羅學長心血來潮。打電話給學姐A:“我想去跑步,喝奶茶也行,有空嗎?寶貝”,這個學姐呢很地道不管能不能去都會回訊息。學姐A:“好的,晚點打給你給你答覆”。這時羅學長就拿到一個promise(承諾),高興的開始寫程式碼。晚上十點,學姐A發來簡訊:“今晚有空,你來接我吧!”,當然也可能收到“今晚不行,我要學習”。好吧!看出來了,一個resolve,一個reject 。對吧,至少這就是執行情況了(我們稱之為決議,總會有的)。因此,帶來新的問題,未來的值是什麼(有可能是上面的簡訊),決議後執行的事件(羅學長當場氣死也算)。
///#### 釋出前刪掉彩蛋
promise 值
promise值有三個狀態,pending (未決議),resolve(完成),reject (拒絕)。pending =》 reject /resolve 。一旦決議值就會一直保持,不可修改。通常呼叫函式Promise.prototype.*
函式都會返回一個決議後的promise值。注意:reject 值可能是顯示生成的也可能是隱式生成的(事件函式出現異常,出錯等)。
promise事件
現在我們來實現一下如何註冊事件(成功或者失敗的處理事件)
function MyAjax(url){
//做請求
var data = ajax(url)
return new Promise(function (resolve,reject){
//根據決議來回撥
if (data){
resolve(data);
}else{
reject (new Error)
}
});
}
function foo(promiseObj){
promiseObj && promiseObj.then(function (data){
console.log(data);
},
function (err){
console.log(err);
})
}
var p = MyAjax(/*url*/);
foo(p);
複製程式碼
還有另一種常用事件註冊:.then
.catch
var p = MyAjax(/*url*/);
p.then(function (){},function (){});
複製程式碼
promise 信任問題
生來就是幹這事的!但仍然有那麼一絲遺憾,後ES7 可能會有草案。(出現問題,第三方庫實現,開發者擁護,特麼就變成草案了)
-
呼叫過早問題。即使拿到的是一個已經被決議的 promise 仍然會以非同步的方式呼叫(將其放在tick後的任務佇列),因此不會出現
Zalgo
現象 (javaScript
開發社群將同步非同步呼叫混亂的現象)。所以這個問題是不存在的。 -
呼叫過晚問題。當Promise 生成物件呼叫
resolve()
,reject()
時,這個Promise 的 then()註冊的觀察回撥會被自動排程。也就是說,再下次非同步點(tick)之前這些通過then()註冊的觀察回撥都會執行完畢。來個列子說一說。var p = Promise.resolve(); p.then(function (){ p.then(function(){console.log("C")}); console.log("A") }) p.then(function(){console.log("B")}) //A ,B,C 複製程式碼
-
回撥未呼叫問題。Promise決議是沒有辦法阻止的,語法錯誤也不行(它會隱式或者說靜默失敗,reject),為此就像前面說的(學姐很地道)。但是存在一點就是,你註冊的觀察回撥函式中出現語法錯誤你可能看不到預期的效果(但是依然是執行了的對吧,),補充一點即使這樣它也會給出通知(下一次的promise,挖個坑,會有的)因為最後一次返回得到promise總是沒辦法檢視(因為你通過註冊觀察繼續檢視它就變成倒數第二個了)。因此不存在未呼叫問題。但是存在一種一直未決議(pending),此時高階抽象race 設定超時來遮蔽掉。
function timeout (delay){ return new Promise(function (resolve,reject){ setTimeout(function (){ reject("timeout"); },delay) }) } function foo(delay){ return new Promise(function (resolve,reject){ setTimeout(function (){ resolve("fulfil"); },delay) }) } var p = Promise.race(foo(1000),timeout(500)) p.then(function(){ console.log("沒有超時"); },function(){ console.log("超時了"); }) //注意程式碼中,留了一個坑考考大家 複製程式碼
-
回撥被呼叫多次問題。因為promise一旦決議就不能更改,所以
resolve or reject
僅僅只能被調一次(有有且僅有一次)。 -
吞掉錯誤或異常問題。實際上,Promise 在生成決議甚至在檢視決議結果時出現語法錯誤或者異常時(比如
TypeError
,ReferenceError
) 這些異常都會被捕捉,然後這個Promise就被拒絕了。接下來看看這兩種情況。var p = new Promise(function (resolve,reject){ foo.a(); resolve(12); }) p.then(function(){console.log("fulfil")}, function(){console.log("reject")}) 複製程式碼
我們再看一個
var p = Promise.resolve(12); p.then(function (){foo.a(); console.log("fulfil")}, function(){console.log("reject")}) 複製程式碼
是不是很吶悶,假如出現在觀察回撥函式中的異常呢。此次 promise 決議已經是 resolve(完成狀態),且決議一旦發生就不可更改。怎麼辦?此時會繼續返回一個新的 promise (這就是promise可以鏈式呼叫的根本原因)決議來源就是 上一個 then 中註冊函式中出現的異常。所以異常是不會被吞掉的(除了最後一個)。
Promise建立信任
先說一下 thenable
關於thenable
鴨子型別(duck typeing
),j具有 then 函式的都屬於thenable
型別,雖然這種判斷比較簡陋但是一般情況下已經夠了。記住,別嘗試這樣做Function.prototype.then = function {}
諸如此類的在原型鏈上改變 then。(長得像鴨子,並且叫起來也像鴨子俺麼它就是鴨子)這顯然具有thenable
的並不一定就是promise 物件(或者說成值),因此Promise提供一種高階抽象來避免第三方傳來的並不是一個promise值而只是具有thenable
的值。Promise.resolve()
傳入promise值它直接將其返回(並不存在效能問題)如果只是傳入一個具有thenable
的非promise值,那麼它會進行解析然後返回一個promise值。因此你總能拿到promise可信任的值。(假如你有點暈,一定是沒有說清楚請參考Promise.resolve()
官方定義)。
鏈式流
前面或多或少的提了一些,現在認真說一下鏈式流。呼叫 Promise
的then 會自動建立一個新的promise 從呼叫返回(顯示返回則會覆蓋,return new Promise(...)
)。如果完成或者拒絕函式返回一個值或者一個異常,新的promise 都會相應的決議(此時就成為鏈式流)。順序流程控制還需與generator 結合使用, 一種更適合邏輯的表達模式(這裡不講,放在生成器中)。
//可以寫程式碼 演示
複製程式碼
錯誤處理
同步中錯誤處理通常藉助try{}catch(){} 就能很好的處理掉錯誤,但是非同步中這完全沒有效果。看看程式碼如下:
function foo (count,delay){
setTimeout(function (){
count && count.toString();//未來丟擲12.toString()這樣的錯誤
},delay || 100)
}
try{
foo(12,100);
}catch(e){
console.log(e);//到不了這兒,丟擲全域性錯誤
}
複製程式碼
簡單說一說 error-first
模式,這是回撥函式最常用的方式來處理非同步出錯的情況。
function foo(callback,count,delay){
setTimeout(function (){
try{
let value = count.toString();
callback(null,value);
}catch(err){
callback(err);
}
},delay ||100)
}
foo(function(prop){console.log(prop)},12,100);
複製程式碼
Promise 採用的另外一種風格,分離回撥(split-callback)。Promise 中then(...)第二個引數、catch(..)中傳入的引數都是作為處理錯誤的回撥函式。存在一個陷阱當註冊回撥函式中出現錯誤時then(...) 中的第二個引數註冊的回撥函式並不能拿到這個錯誤(實際上前面提到過)例如:
var p = Promise.resolve(42);
p.then(function (){
12.toString();
},function (err){
console.log(err);//執行不到這兒,
}).catch(function(){
//這兒就能處理掉前面的錯誤
})
複製程式碼
p 已經被42 決議了,不能更改。所以返回新的 promise (使用錯誤決議的值)來呈現錯誤,往往可能導致錯誤被靜默掉。所以有開發者提出總是在最後帶一個catch來捕獲錯誤(但是catch中的錯呢!),這是個坑。這些都是有辦法解決的,預設情況下在這個時間點上(該tick內)還未註冊錯誤處理函式,則會在下一個tick之前向終端報告錯誤。如果想保持這種錯誤狀態,可以使用defer 來推遲報告。
API介紹移步MDN
Promise 存在的一些不足
- 一旦決議不可更改,掛起無法撤銷。但是可以藉助,超時來處理掉它(前面有提及,這兒不再贅述)。
- 順序錯誤處理。可能會造成一些錯誤被靜默的過掉。
寫在最後
此次學習心得,總結的並不是很完美。也存在很多漏洞,如你能發現請告訴我錯誤的地方我會做出改正。如有新的技術更新,一起學習,一起分享。[ 2019-3-24]