JS非同步程式設計之callback

南波發表於2019-02-17

為什麼 JS 是單執行緒?

眾所周知,Javascript 語言的執行環境是"單執行緒"(single thread)。

所謂"單執行緒",就是指一次只能完成一件任務。如果有多個任務,就必須排隊,前面一個任務完成,再執行後面一個任務,以此類推。

而瀏覽器是多執行緒的,JS 執行緒就是其中一個:

  • 瀏覽器 GUI 渲染執行緒
  • JavaScript 引擎執行緒
  • 瀏覽器定時觸發器執行緒
  • 瀏覽器事件觸發執行緒
  • 瀏覽器 http 非同步請求執行緒

瀏覽器執行緒知識中重要的一點是:

GUI渲染程式和 JavaScript 引擎程式是互斥的,因為如果這兩個執行緒可以同時執行的話, JavaScript 的 DOM 操作將會擾亂渲染執行緒執行渲染前後的資料一致性。而且如果 DOM 一變化,介面就立刻重新渲染,效率必然很低

所以 JS 主執行緒執行任務時,瀏覽器渲染執行緒處於掛起狀態。

同理,如果 JS 採用多執行緒同步的模型,那麼如何保證同一時間修改了 DOM, 到底是哪個執行緒先生效呢?從作業系統排程多執行緒的上下文開銷,到實際程式設計裡的鎖、執行緒同步等問題,都讓開發變得比較困難。

所以 JS 最終採用了單執行緒的事件模型。

我之前的文章《JS專題之事件迴圈》也有講過這塊內容,歡迎翻閱。

一、同步與非同步

單執行緒模式這種排隊執行的好處是實現起來比較簡單,執行環境相對單純;壞處是隻要有一個任務耗時很長,後面的任務都必須排隊等著,會拖延整個程式的執行。常見的瀏覽器無響應(假死),往往就是因為某一段Javascript程式碼長時間執行(比如死迴圈),導致整個頁面卡在這個地方,其他任務無法執行。

為了解決這個問題,Javascript語言將任務的執行模式分成兩種:同步(Synchronous)和非同步(Asynchronous)。

那同步和非同步的區別是什麼?

我們想象一個很常見的場景:我們去麵館吃牛肉麵,櫃檯人很多,前面在排隊下單。

這個時候,同步就是,收銀員收了你的錢,告訴你要在櫃檯站著等面煮好,煮好後,就端面開吃,後面的人也只能等前面的人面煮好了才能付款下單然後等著面煮好端走~

而非同步就是,收銀員收了你的錢,然後給了你一張小票,小票上有一個你的編號,收銀員告訴你,可以去座位上,你的面一煮好,會大聲叫你,你就來端面開吃。

我們可以看出,我們是過程的呼叫者,麵館是被呼叫者,牛肉麵煮好,是我們想要的結果,同步是呼叫者需要主動地等待這個結果。非同步是被動的等待結果,當被呼叫者有結果了,就會通過訊息機制或者回撥機制告訴呼叫者結果。

同步和非同步關注的是訊息通訊機制,同步就是在發出一個呼叫時,在沒有得到結果之前,該呼叫就不返回。但是一旦呼叫返回,就得到返回值了。

而非同步則是相反,呼叫在發出之後,這個呼叫就直接返回了,所以沒有返回結果, 而是在呼叫發出後,被呼叫者通過狀態、通知來通知呼叫者,或通過回撥函式處理這個呼叫。

以上:

  • 下單吃麵是發起呼叫函式
  • 端面開吃的回撥函式
  • 煮好的面是呼叫的結果,也是回撥函式的引數

將例子抽象成虛擬碼:

orderNoodle("牛肉麵", function(noodle) {
        // 端面
        getNoodle();
        // 吃麵
        eatNoodle();
});
複製程式碼

三、事件迴圈

關於事件迴圈如何執行非同步程式碼可以翻閱前面的文章《JS專題之事件迴圈》,這裡大概提一下。

如果遇到非同步事件,JS 引擎會把事件函式壓入執行呼叫棧,但瀏覽器識別到它是非同步事件後,會將其彈出執行棧,當非同步函式有返回結果後,JS 引擎將非同步事件的回撥函式放入事件佇列中,如果執行呼叫棧為空,就將回撥函式壓入執行呼叫棧執行。

四、回撥函式

在 JavaScript 中,函式 function 作為一等公民,使用上非常自由,無論呼叫它,或者作為引數,或者作為返回值都可以。

因為單執行緒非同步的特點,後來在 JS 中,慢慢將函式的業務重點轉移到了回撥函式中。

function step1(cb) {
    console.log("step1");
    cb()
}

function step2(){
    console.log("step2");
}

step1(step2);  // step1  step2
複製程式碼

程式碼會按先後順序執行 step1, step2。

現在假設我們有這樣的需求:請求檔案1後,獲取檔案1 中的資料後請求檔案2,獲取檔案 2 中的資料後,又請求檔案三。

var fs = require("fs");

fs.readFile("./file1.json", function(err, data1) {
    fs.readFile("./file2.json", function (err, data2) {
        fs.readFile("./file3.json", function(err, data3) {
            
        })
    })
})
複製程式碼

五、回撥函式的問題

由第四節可以看出,回撥函式的寫法存在很多問題。

  1. 回撥地獄(洋蔥模型)

當多個非同步事務多級依賴時,回撥函式會形成多級的巢狀,被花括號一層層包括,程式碼變成 金字塔型結構,也被稱為回撥地獄和洋蔥模型。

在回撥地獄的情況下,程式碼邏輯的梳理,流程的控制,程式碼封裝維護,錯誤處理都變得越來越困難。

  1. 異常處理

try...catch 是被設計成捕獲當前執行環境的異常,意思是隻能捕獲同步程式碼裡面的異常,非同步呼叫裡面的異常無法捕獲。

function readFile(fileName) {
    setTimeout(function () {
      throw new Error("型別錯誤");
    }, 1000);
}
try {
    readFile('./file1.json');
} catch (e) {
    // 如果非同步事件出錯,列印不出來錯誤資訊
    console.log('err', e);
}
複製程式碼

在 nodejs 對回撥函式採用 error first 的思想,回撥函式的第一個引數保留給一個錯誤error物件,如果有錯誤發生,錯誤將通過第一個引數err返回。

原因是一個有回撥函式的函式,執行分兩段,第一段執行完之後,任務所在的上下文環境就已經結束了。在這以後丟擲的錯誤,原來的上下文已經無法捕捉,只能當做引數,傳入第二階段。

fs.readFile('/etc/passwd', 'utf8', function (err, data) {
    if(err) {
        console.log(err)
        return;
    }
});
複製程式碼

總結

回撥函式是 JS 非同步程式設計中的基石,但同時也存在很多問題,不太適合人類自然語言的線性思維習慣。

接下來幾篇文章,我將梳理 JS 中非同步程式設計中的歷史演進中 Promise, generator, async&await 相關的內容,歡迎關注。

歡迎關注我的個人公眾號“謝南波”,專注分享原創文章。

JS非同步程式設計之callback

掘金專欄 JavaScript 系列文章

  1. JavaScript之變數及作用域
  2. JavaScript之宣告提升
  3. JavaScript之執行上下文
  4. JavaScript之變數物件
  5. JavaScript之原型與原型鏈
  6. JavaScript之作用域鏈
  7. JavaScript之閉包
  8. JavaScript之this
  9. JavaScript之arguments
  10. JavaScript之按值傳遞
  11. JavaScript之例題中徹底理解this
  12. JavaScript專題之模擬實現call和apply
  13. JavaScript專題之模擬實現bind
  14. JavaScript專題之模擬實現new
  15. JS專題之事件模型
  16. JS專題之事件迴圈
  17. JS專題之去抖函式
  18. JS專題之節流函式
  19. JS專題之函式柯里化
  20. JS專題之陣列去重
  21. JS專題之深淺拷貝
  22. JS專題之陣列展開
  23. JS專題之嚴格模式
  24. JS專題之memoization
  25. JS專題之垃圾回收
  26. JS專題之繼承

相關文章