JavaScript非同步程式設計-基礎篇

夜曉宸發表於2019-02-18

唯一比不知道程式碼為什麼崩潰更可怕的事情是,不知道為什麼一開始它是工作的!

在 ECMA 規範的最近幾次版本里不斷有新成員加入,尤其在處理非同步的問題上,更是不斷推陳出新。然而,我們在享受便利的同時,也應該瞭解非同步到底是怎麼一回事。

現在與將來

JavaScript 是單執行緒的,一次只能專注於一件事。如果瀏覽器只靠 JavaScript 引擎執行緒來完成所有工作,先不說能不能搞定,即使可以,那也會花費很長時間。幸好在瀏覽器裡 JavaScript 引擎並不孤單,還有 GUI 渲染執行緒、事件觸發執行緒、定時觸發器執行緒、非同步http請求執行緒等其它執行緒。這些執行緒之間的協作才有了我們看到的瀏覽器介面效果(遠不止這些)。

(盜了一張圖)

thread

一個程式在執行過程中可能會有等待使用者輸入、從資料庫或檔案系統中請求資料、通過網路傳送並等待響應,或是以固定時間間隔執行重複任務(比如動畫)等情況。(這些情況,當下是無法得出結果的,但是一旦有了結果,我們知道需要去做些什麼。)

JavaScript 引擎不是一個人在戰鬥,它把以上的任務交給其它執行緒,並計劃好任務完成後要做的事,JavaScript 引擎又可以繼續做自己的事了。從這裡可以看出,一個程式的執行包括兩部分,現在執行和將來執行。而現在執行和將來執行的關係正是非同步程式設計的核心。

let params = {type:'asynchronous'}
let response = ajax(params,'http://someURL.com'); // 非同步請求
if (!response) throw '無資料!';
複製程式碼

以上程式碼肯定會拋錯的,非同步請求任務交出去之後,程式會繼續執行下去。由於ajax(...) 是非同步操作,即使立刻返回結果,當下的 response 也不會被賦值。一個是現在,一個是將來,兩者本就不屬於一個時空的。

事件迴圈

現在和將來是相對的,等將來的時刻到了,將來也就成為了現在。

JavaScript 引擎執行在宿主環境中,宿主環境提供了一種機制來處理程式中多個塊的執行,且執行每個塊時呼叫 JavaScript 引擎,這種機制被稱為事件迴圈。即,JavaScript 引擎本身並沒有時間的概念,只是一個按需執行 JavaScript 任意程式碼片段的環境。

“事件”(JavaScript 程式碼執行)排程總是由包含它的環境進行。

點選圖片進入或點此進入

EventLoop

一個 JavaScript 執行時包含了一個待處理的訊息佇列。每一個訊息都關聯著一個用以處理這個訊息的函式。 在事件迴圈期間的某個時刻,執行時從最先進入佇列的訊息開始處理佇列中的訊息。為此,這個訊息會被移出佇列,並作為輸入引數呼叫與之關聯的函式。

while (queue.waitForMessage()) {
    queue.processNextMessage();
}
複製程式碼

一旦有事件需要進行,事件迴圈就會執行,直到佇列清空。事件迴圈的每一輪稱為一個 tick。使用者互動,IO 和定時器會向事件佇列中加入事件。

(又盜了一張圖)

thread

任務佇列

任務佇列(job queue)建立在事件迴圈佇列之上。(Promise 的非同步特性就是基於任務。)

最好的理解方式,它是掛在事件迴圈佇列的每個tick之後的一個佇列。在事件迴圈的每個tick中,可能出現的非同步動作不會導致一個完整的新事件新增到事件迴圈佇列中,而會在當前 tick 的任務佇列末尾新增一個專案(一個任務)。 即,由 Call Stack 生成的任務佇列會緊隨其後執行。

Promise.resolve().then(function promise1 () {
   console.log('promise1');
})
setTimeout(function setTimeout1 (){
    console.log('setTimeout1');
    Promise.resolve().then(function  promise2 () {
    console.log('promise2');
    })
}, 0)

setTimeout(function setTimeout2 (){
console.log('setTimeout2');
    Promise.resolve().then(function  promise3 () {
        console.log('promise3');
        setTimeout(function setTimeout3 () {
            console.log('setTimeout3');
        })
        Promise.resolve().then(function promise4 () {
            console.log('promise4');
        })
    })
}, 0)
// promise1
// setTimeout1
// promise2
// setTimeout2
// promise3
// promise4
// setTimeout3
複製程式碼

非同步回撥

被作為實參傳入另一函式,並在該外部函式內被呼叫,用以來完成某些任務的函式,稱為回撥函式。回撥函式經常被用於繼續執行一個非同步完成後的操作,它們被稱為非同步回撥。立即執行的稱之為同步回撥。

回撥函式是事件迴圈“回頭呼叫”到程式中的目標,佇列處理到這個專案的時候會執行它。

回撥是 JavaScript 語言中最基礎的非同步模式。

生活中,我們喜歡和有條理的人打交道,因為我們的大腦習慣了這種思維模式。然而回撥的使用打破了這種模式,因為程式碼的巢狀使得我們要在不同塊間切換。巢狀越多,邏輯越複雜,我們也就越難理解和處理程式碼,尤其在表達非同步的方式上。

(又盜了一張圖)

回撥地獄

除了巢狀的問題,非同步回撥還存在一些信任問題。

  • 回撥性質的不確定

  • 呼叫回撥方式不確定(沒呼叫,重複呼叫等)

  • ......

針對第一點的建議是:永遠非同步呼叫回撥,即使就在事件迴圈的下一輪,這樣,所有回撥都是可預測的非同步呼叫了。 在理解這個建議之前,我們首先了解下控制反轉,控制反轉就是把自己程式一部分的執行控制交個某個第三方。

let a = 0; // A
thirdparty(() => {
    console.log('a', a);    // B
})
a++;    // C
複製程式碼

A 和 C 是現在執行的,B 雖然程式碼是我們的,但是卻受制於第三方,因為我們無法確定它是現在執行還是將來執行的。這裡的回撥函式可能是同步回撥也可能是非同步回撥。a 是 0 還是 1,都有可能。

// 同步回撥
const thirdparty = cb => {
    cb();
}
// 非同步回撥
const thirdparty = cb => {
    setTimeout(() => cb(), 0);
}
複製程式碼

所以,永遠非同步呼叫回撥,可預測。

function asyncify(fn) {
    let func = fn;
    let t = setTimeout(() => {
        t = null;
        if (fn) fn();
    }, 0);
    fn = null;
    return () => {
        if (t) {
            fn = func.bind(this, ...arguments);
        } else {
            func.apply(this, arguments);
        }
    }
}

let a = 0;
thirdparty(asyncify(() => {
    console.log('a', a);
}))
a++;
// 1
複製程式碼

相關文章