首頁 > 軟體

JavaScript中非同步與回撥的基本概念及回撥地獄現象

2022-07-11 14:01:11

JavaScript非同步與回撥

一、前言

在學習本文內容之前,我們必須要先了解非同步的概念,首先要強調的是非同步和並行有著本質的區別

  • 並行,一般指平行計算,是說同一時刻有多條指令同時被執行,這些指令可能執行於同一CPU的多核上,或者多個CPU上,或者多個物理主機甚至多個網路中。
  • 同步,一般指按照預定的順序依次執行任務,只有當上一個任務完成後,才開始執行下一個任務。
  • 非同步,與同步相對應,非同步指的是讓CPU暫時擱置當前任務,先處理下一個任務,當收到上個任務的回撥通知後,再返回上個任務繼續執行,整個過程無需第二個執行緒參與

也許用圖片的方式解釋並行、同步和非同步更為直觀,假設現在有A、B兩個任務需要處理,使用並行、同步和非同步的處理方式會分別採用如下圖所示的執行方式:

二、非同步函數

JavaScript為我們提供了許多非同步的函數,這些函數允許我們方便的執行非同步任務,也就是說,我們現在開始執行一個任務(函數),但任務會在稍後完成,具體完成時間並不清楚。

例如,setTimeout函數就是一個非常典型的非同步函數,此外,fs.readFilefs.writeFile同樣也是非同步函數。

我們可以自己定義一個非同步任務的案例,例如自定義一個檔案複製函數copyFile(from,to)

const fs = require('fs')

function copyFile(from, to) {
    fs.readFile(from, (err, data) => {
        if (err) {
            console.log(err.message)
            return
        }
        fs.writeFile(to, data, (err) => {
            if (err) {
                console.log(err.message)
                return
            }
            console.log('Copy finished')
        })
    })
}

函數copyFile首先從引數from讀取檔案資料,隨後將資料寫入引數to指向的檔案。

我們可以像這樣呼叫copyFile

copyFile('./from.txt','./to.txt')//複製檔案

如果這個時候,copyFile(...)後面還有其他程式碼,那麼程式不會等待copyFile執行結束,而是直接向下執行,檔案複製任務何時結束,程式並不關心。

copyFile('./from.txt','./to.txt')
//下面的程式碼不會等待上面的程式碼執行結束
...

執行到這裡,好像一切還都是正常的,但是,如果我們在copyFile(...)函數後,直接存取檔案./to.txt中的內容會發生什麼呢?

這將不會讀到複製過來的內容,就行這樣:

copyFile('./from.txt','./to.txt')
fs.readFile('./to.txt',(err,data)=>{
    ...
})

如果在執行程式之前,./to.txt檔案還沒有建立,將得到如下錯誤:

PS E:CodeNodedemos3-callback> node .index.js
finished
Copy finished
PS E:CodeNodedemos3-callback> node .index.js
錯誤:ENOENT: no such file or directory, open 'E:CodeNodedemos3-callbackto.txt'
Copy finished

即使./to.txt存在,也無法讀取其中複製的內容。

造成這種現象的原因是:copyFile(...)是非同步執行的,程式執行到copyFile(...)函數後,並不會等待其複製完畢,而是直接向下執行,從而導致出現檔案./to.txt不存在的錯誤,或者檔案內容為空錯誤(如果提前建立檔案)。

三、回撥函數

非同步函數的具體執行結束的時間是不能確定的,例如readFile(from,to)函數的執行結束時間大概率取決於檔案from的大小。

那麼,問題在於我們如何才能準確的定位copyFile執行結束,從而讀取to檔案中的內容呢?

這就需要使用回撥函數,我們可以修改copyFile函數如下:

function copyFile(from, to, callback) {
    fs.readFile(from, (err, data) => {
        if (err) {
            console.log(err.message)
            return
        }
        fs.writeFile(to, data, (err) => {
            if (err) {
                console.log(err.message)
                return
            }
            console.log('Copy finished')
            callback()//當複製操作完成後呼叫回撥函數
        })
    })
}

這樣,我們如果需要在檔案複製完成後,立即執行一些操作,就可以把這些操作寫入回撥函數中:

function copyFile(from, to, callback) {
    fs.readFile(from, (err, data) => {
        if (err) {
            console.log(err.message)
            return
        }
        fs.writeFile(to, data, (err) => {
            if (err) {
                console.log(err.message)
                return
            }
            console.log('Copy finished')
            callback()//當複製操作完成後呼叫回撥函數
        })
    })
}
copyFile('./from.txt', './to.txt', function () {
    //傳入一個回撥函數,讀取「to.txt」檔案中的內容並輸出
    fs.readFile('./to.txt', (err, data) => {
        if (err) {
            console.log(err.message)
            return
        }
        console.log(data.toString())
    })
})

如果,你已經準備好了./from.txt檔案,那麼以上程式碼就可以直接執行:

PS E:CodeNodedemos3-callback> node .index.js
Copy finished
加入社群“仙宗”,和我一起修仙吧
社群地址:http://t.csdn.cn/EKf1h

這種程式設計方式被稱為“基於回撥”的非同步程式設計風格,非同步執行的函數應當提供一個回撥引數用於在任務結束後呼叫。

這種風格在JavaScript程式設計中普遍存在,例如檔案讀取函數fs.readFilefs.writeFile都是非同步函數。

四、回撥的回撥

回撥函數可以準確的在非同步工作完成後處理後繼事宜,如果我們需要依次執行多個非同步操作,就需要巢狀回撥函數。

案例場景:依次讀取檔案A和檔案B

程式碼實現:

fs.readFile('./A.txt', (err, data) => {
    if (err) {
        console.log(err.message)
        return
    }
    console.log('讀取檔案A:' + data.toString())
    fs.readFile('./B.txt', (err, data) => {
        if (err) {
            console.log(err.message)
            return
        }
        console.log("讀取檔案B:" + data.toString())
    })
})

執行效果:

PS E:CodeNodedemos3-callback> node .index.js
讀取檔案A:仙宗無限好,只是缺了佬

讀取檔案B:要想入仙宗,連結不能少  
http://t.csdn.cn/H1faI

通過回撥的方式,就可以在讀取檔案A之後,緊接著讀取檔案B。

如果我們還想在檔案B之後,繼續讀取檔案C呢?這就需要繼續巢狀回撥:

fs.readFile('./A.txt', (err, data) => {//第一次回撥
    if (err) {
        console.log(err.message)
        return
    }
    console.log('讀取檔案A:' + data.toString())
    fs.readFile('./B.txt', (err, data) => {//第二次回撥
        if (err) {
            console.log(err.message)
            return
        }
        console.log("讀取檔案B:" + data.toString())
        fs.readFile('./C.txt',(err,data)=>{//第三次回撥
            ...
        })
    })
})

也就是說,如果我們想要依次執行多個非同步操作,需要多層巢狀回撥,這在層數較少時是行之有效的,但是當巢狀次數過多時,會出現一些問題。

回撥的約定

實際上,fs.readFile中的回撥函數的樣式並非個例,而是JavaScript中的普遍約定。我們日後會自定義大量的回撥函數,也需要遵守這種約定,形成良好的編碼習慣。

約定是:

  • callback 的第一個引數是為 error 而保留的。一旦出現 error,callback(err) 就會被呼叫。
  • 第二個以及後面的引數用於接收非同步操作的成功結果。此時 callback(null, result1, result2,...) 就會被呼叫。

基於以上約定,一個回撥函數擁有錯誤處理和結果接收兩個功能,例如fs.readFile('...',(err,data)=>{})的回撥函數就遵循了這種約定。

五、回撥地獄

如果我們不深究的話,基於回撥的非同步方法處理似乎是相當完美的處理方式。問題在於,如果我們有一個接一個 的非同步行為,那麼程式碼就會變成這樣:

fs.readFile('./a.txt',(err,data)=>{
    if(err){
        console.log(err.message)
        return
    }
    //讀取結果操作
    fs.readFile('./b.txt',(err,data)=>{
        if(err){
            console.log(err.message)
            return
        }
        //讀取結果操作
        fs.readFile('./c.txt',(err,data)=>{
            if(err){
                console.log(err.message)
                return
            }
            //讀取結果操作
            fs.readFile('./d.txt',(err,data)=>{
                if(err){
                    console.log(err.message)
                    return
                }
                ...
            })
        })
    })
})

以上程式碼的執行內容是:

  • 讀取檔案a.txt,如果沒有發生錯誤的話;
  • 讀取檔案b.txt,如果沒有發生錯誤的話;
  • 讀取檔案c.txt,如果沒有發生錯誤的話;
  • 讀取檔案d.txt,…

隨著呼叫的增加,程式碼巢狀層級越來越深,包含越來越多的條件語句,從而形成不斷向右縮排的混亂程式碼,難以閱讀和維護。

我們稱這種不斷向右增長(向右縮排)的現象為“回撥地獄”或者“末日金字塔”!

fs.readFile('a.txt',(err,data)=>{
    fs.readFile('b.txt',(err,data)=>{
        fs.readFile('c.txt',(err,data)=>{
            fs.readFile('d.txt',(err,data)=>{
                fs.readFile('e.txt',(err,data)=>{
                    fs.readFile('f.txt',(err,data)=>{
                        fs.readFile('g.txt',(err,data)=>{
                            fs.readFile('h.txt',(err,data)=>{
                                ...
                                /*
								  通往地獄的大門
								  ===>
                                */
                            })
                        })
                    })
                })
            })
        })
    })
})

雖然以上程式碼看起來相當規整,但是這只是用於舉例的理想場面,通常業務邏輯中會有大量的條件語句、資料處理操作等程式碼,從而打亂當前美好的秩序,讓程式碼變的難以維護。

幸運的是,JavaScript為我們提供了多種解決途徑,Promise就是其中的最優解。

(原諒我賣了一個關子,這篇文章太長了,下篇繼續講)

六、總結

本文主要介紹了非同步和回撥的基本概念,二者是JavaScript的核心內容,需要所有熱愛JS的小夥伴深入瞭解。

  • 非同步、並行、同步的基本概念;
  • 使用回撥函數處理非同步任務;
  • 回撥函數的巢狀和約定;
  • 回撥地獄的基本概念;

到此這篇關於JavaScript中非同步與回撥的基本概念,以及回撥地獄現象的文章就介紹到這了,更多相關js非同步回撥地獄內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com