JavaScript 非同步及Promise 菜鳥學習心得

行走的烏龜發表於2019-03-25

非同步程式設計及 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]

相關文章