一文帶你走進 JavaScript 異步編程

陳晨,微醫雲服務團隊前端工程師,一位 “生命在於靜止” 的程序員。

異步的由來

JavaScript 是單線程語言,瀏覽器只分配了一個主線程執行任務,意味着如果有多個任務,則必須按照順序執行,前一個任務執行完成之後才能繼續下一個任務。

這個模式比較清晰,但是當任務耗時較長的時候,比如網絡請求,定時器和事件監聽等,這個時候後續任務繼續等待,效率比較低。我們常見的頁面無響應,有時候就是因爲任務耗時長或者無限循環等造成的。那現在是怎麼解決這個問題呢。。。。

首先維護了一個 “任務隊列”。JavaScript 雖然是單線程的,但運行的宿主環境(瀏覽器)是多線程的,瀏覽器爲這些耗時任務開闢了另外的線程,主要包括 http 請求線程,瀏覽器定時觸發器,瀏覽器事件觸發線程。這些線程主要把任務回調,放在任務隊列裏,等待主線程執行。
簡單介紹如下圖:

這樣就實現了 JavaScript 的單線程異步,任務被分爲同步任務和異步任務兩種:
同步任務:排隊執行的任務,後一個任務等待前一個任務結束。
異步任務:放入任務隊列的任務,未來纔會觸發執行的事件。

異步執行機制

異步任務分爲宏任務和微任務。

宏任務(macroTask)

宏任務,其實就是標準機制下的常規任務,即” 任務隊列中 “等待被主線程執行的事件,是由瀏覽器宿主發起的任務,例如:

宏任務會被放在宏任務隊列裏,先進先出的原則,兩個宏任務中間可能會被插入其他系統任務,間隔時間不定,效率較低 。

微任務(microTask)

由於宏任務間隔不定,時間顆粒大,對於實時性要求比較高的場景就需要更精確地控制,需要把任務插入到當前宏任務執行,從而產生了微任務的概念。
微任務是 JavaScript 引擎發起的,是需要異步執行的函數。例如:

在執行 JavaScript 腳本,創建全局執行上下文的時候,JavaScript 引擎就會創建一個微任務隊列,在執行當前宏任務時,產生的微任務都會保存到微任務隊列裏。在宏任務主函數執行結束之後,宏任務結束之前,清空微任務隊列。
微任務和宏任務是綁定的,每個宏任務都會創建自己的微任務:

事件循環(Event loop)

主線程運行 JavaScript 代碼時,會生成個執行棧 (先進後出),管理主線程上函數調用關係的數據結構。
當執行棧中的所有同步任務執行完畢,系統就會不斷的從 "任務隊列" 中讀取事件,這個過程是循環不斷的,稱爲 Event Loop(事件循環)。
事件循環機制調度宏任務和微任務,機制如下:

  1. 執行一個宏任務(第一次是最外層同步代碼),執行過程中如果遇到微任務會加入微任務隊列;

  2. 代碼執行完成後,查看是否有微任務,如果有的執行第 3 步,沒有則執行第 4 步;

  3. 依次執行所有微任務,在執行微任務的過程中產生的新的微任務也會被事件循環處理,直到隊列清空,宏任務完成,執行第 4 步;

  4. 查看是否有下一個宏任務,有的話則執行第 1 步,沒有則結束。

因爲微任務自身可以入列更多的微任務,且事件循環會持續處理微任務直至隊列爲空,那麼就存在一種使得事件循環無盡處理微任務的真實風險。如何處理遞歸增加微任務是要謹慎而行的。

異步的實現歷程

回調函數

回調函數是一個函數被當做參數傳遞給另一個函數,另一個函數完成之後執行回調。比如 Ajax 請求、IO 操作、定時器的回調等。
下面是 setTimeout 例子:

console.log('setTimeout 調用之前')
setTimeout(() ={console.log('setTimeout 輸出')}, 0);
console.log('setTimeout 調用之後')
// 結果
setTimeout 調用之前
setTimeout 調用之後
setTimeout 輸出

setTimeout 回調放入任務隊列中,當主線程的同步代碼執行完之後,纔會執行任務隊列的回調,所以是如上的輸出結果。

優缺點

優點:回調函數相對比較簡單、容易理解。
缺點:不利於代碼的閱讀和維護,各個部分之間高度耦合,流程會很混亂,而且每個任務只能指定一個回調函數,易形成回調函數地獄。如下:

setTimeout(function(){
    let value1 = step1()
    setTimeout(function(){
        let value2 = step2(value1)
        setTimeout(function(){
            step3(value2)
        },0);
    },0);
},0);

Promise

Promise 是 ES6 新增的異步編程的方式,在一定程度上解決了回調地域的問題。簡單說就是一個容器,裏面保存着某個未來纔會結束的事件(通常是一個異步操作)的結果。從語法上說,Promise 是一個對象,從它可以獲取異步操作的消息。
使用 Promise 首先要明白以下特點:

  1. Promise 有三種狀態 pending、rejected、resolved,狀態一旦確定就不能改變,且只能夠由 pending 狀態變成 rejected 或者 resolved 狀態;

  2. Promise 實例最主要的方法就是 then 的實現,有兩個參數。Promise 執行成功時,調用 then 方法的第一個回調函數,失敗則調用第二個回調函數,而且 then 方法會返回一個新的 Promise 實例。

  3. 其次常用的就是 catch 方法,catch 方法實際是 then 方法第一個參數是 null 的情況,用於指定發生錯誤時的回調函數。

  4. 還有很多其他的 finally、all、race、allSettled、any、resolve、reject 等一系列 Api。

下面的例子就是常見的異步操作,主要是使用的 then 和 catch:

new Promise((resolve) ={
    resolve(step1())
}).then(res ={
    return step2(res)
}).catch(err ={
    console.log(err)
})

step1 和 step2 是異步操作,step1 執行完之後的返回值會透傳給 then 回調,當做 step2 的入參,通過 then 一層層的代替回調地域。其中 then 的回調會加入微任務隊列。

Promise 爲什麼是微任務呢?

當 Promise 入參是同步代碼時:

console.log('start')
new Promise((resolve) ={
    console.log('開始 resolve')
    resolve('resolve 返回值')
}).then(data ={
    console.log(data)
})
console.log('end')

// 原生 promise 輸出結果
start
開始 resolve
end
resolve 返回值

首先看下 Promise 的極簡實現:

class Promise {
    constructor (executor) {
        // 回調值
        this.value = ''
        // 成功的回調
        this.onResolvedCallbacks = []
        executor(this.resolve.bind(this))
    }
    resolve (value) {
        this.value = value
        this.onResolvedCallbacks.forEach(callback => callback())
    }
    then (onResolved, onRejected) {
        this.onResolvedCallbacks.push(() ={
            onResolved(this.value)
        })
    }
}

// 此時上面例子執行結果如下
start
開始 resolve
end

由於 Promise 是延遲綁定機制(回調在業務代碼的後面),executor 是同步代碼時,在執行到 resolve 的時候,還沒有執行 then,所以 onResolvedCallbacks 是空數組。這個時候需要讓 resolve 延後執行,可以先加一個定時器。如下:

resolve (value) {
    setTimeout(() ={
        this.value = value
        this.onResolvedCallbacks.forEach(callback => callback())
    })
}

輸出結果和預期是一致的,這裏使用 setTimeout 來延遲執行 resolve。但是 setTimeout 是宏任務,效率不高,這裏只是用 setTimeout 代替,在瀏覽器中,JavaScript 引擎會把 Promise 回調映射到微任務,既可以延遲被調用,又提升了代碼的效率。

優缺點

優點:

缺點:

Generator/yield

Generator 是 ES6 提供的異步解決方案,其最大的特點就是可以控制函數的執行。整個 Generator 函數就是一個封裝的異步任務,或者說是異步任務的容器,異步操作需要暫停的地方,都用 yield 語句註明。
Generator 函數的特徵:

  1. function 關鍵字與函數名之間有一個星號;

  2. 函數體內部使用 yield 表達式,定義不同的內部狀態;

  3. 通過 yield 暫停執行;

  4. next 恢復執行,並且返回一個包含 value 和 done 屬性的對象,其中 value 表示 yield  表達式的值,done 表示遍歷器是否完成;

  5. next 方法也可以接受參數, 作爲上一次 yield 語句的返回值。

function* getData () {
  let value1 = yield 111
  let value2 = yield value1 + 111 // 這裏的 value1 就是下面傳入的 val1.value
  return value2
}
let meth = getData()
let val1 = meth.next() 
console.log(val1) // { value: 111, donefalse }
let val2 = meth.next(val1.value)
console.log(val2) // { value: 222, donefalse }
let val3 = meth.next(val2.value)
console.log(val3) // { value: 222, donetrue }
  1. 調用 getData 函數,會返回一個內部指針 meth(即遍歷器);

  2. 調用指針 meth 的 next 方法,移動內部指針,指向第一個遇到的 yield 語句,輸出返回值爲 {value: 111, done: false}

  3. 再次調用指針 meth 的 next 方法,入參爲 111,賦值給 value1,移動內部指針,指向下一個 yield 語句,輸出表達式的返回值爲 {value: 222, done: false}

  4. 持續調用指針 meth 的 next 方法,入參爲 222,賦值給 value2,遇到 return 結束遍歷器,輸出返回值{ value: 222, done: true }

Generator 是怎麼實現暫停和恢復執行的呢?

Generator 是協程的一種實現方式。
協程:協程是一種比線程更加輕量級的存在,協程處在線程的環境中,一個線程可以存在多個協程,可以將協程理解爲線程中的一個個任務。通過應用程序代碼進行控制。
上面的例子中協程具體流程如下:

  1. 通過生成器函數 getData 創建一個協程 meth,創建之後沒有立即執行;

  2. 調用 meth.next() 讓協程執行;

  3. 協程執行時,通過關鍵字 yield 暫停協程;

  4. 協程執行時,遇到 return,JavaScript 引擎結束當前協程,並把結果返回給父協程。

meth 協程和父協程在主線程上交替執行,通過 next() 和 yield 進行控制,只有用戶態,切換效率高。

優缺點

優點:Generator 是以一種看似順序、同步的方式實現了異步控制流程,增強了代碼可讀性。
缺點:需要手動 next 執行下一步。

async/await

async/await 將 Generator 函數和自動執行器,封裝在一個函數中,是 Generator 的一種語法糖,簡化了外部執行器的代碼,同時利用 await 替代 yield,async 替代生成器的 (*) 號。

async 和 Generator 相比改進的地方:

下面來看個 sleep 的例子:

function sleep(time) {
    return new Promise((resolve, reject) ={
        time+=1000
        setTimeout(() ={
            resolve(time);
        }, 1000);
    });
}
    
async function test () {
    let time =  0 
    for(let i = 0; i < 4; i++) {
        time = await sleep(time);
        console.log(time);
    }
}

test()

// 輸出結果
1000
2000
3000

執行結果每隔一秒會輸出 time,await 是等待的意思,等待 sleep 執行完畢後通過 resolve 返回,纔會繼續執行,間隔至少一秒。

把 async/await 轉成 Generator 和 Promise 來實現。

function test () {
    let time =  0 
    // stepGenerator 生成器
    function* stepGenerator() {
        for (let i = 0; i < 4; i++) {
            let result = yield sleep(time);
            console.log(result);
        }
    }
    let step = stepGenerator()
    let info
    return new Promise((resolve) ={
        // 自執行 next()
        function stepNext ()  {
            info = step.next(time)
            //  執行結束則返回 value
            if (info.done) {
                resolve(info.value)
            } else {
            // 遍歷沒有結束 ,繼續執行
                return Promise.resolve(info.value).then((res) ={
                    time = res
                    return stepNext()
                })
            }
        }
        stepNext()
    })
}
test()
  1. 首先把 async 包裝成 Promise,async/await 轉換成 stepGenerator 生成器,yield 替換 await;

  2. 執行 stepNext();

  3. stepNext 裏,step 遍歷器會執行 next()。done 爲 false 時,說明遍歷沒有完成,通過 Promise.resolve 等待執行結果,獲取結果之後繼續執行 next(),直到 done 爲 true,async 的 resolve 把最終返回。

優缺點

優點:是 Generator 更簡化的方式,相當於自動執行 Generator,代碼更清晰,更簡單。
缺點:濫用 await 可能會導致性能問題,因爲 await 會阻塞代碼,非依賴代碼失去併發性。

多個異步的執行順序問題

多個異步的執行順序問題是很考驗對異步的理解的。下面我們把 setTimeout、Promise、async/await 放在一起,看下返回結果和預想的是否一致:

console.log('start')
setTimeout(function() {
    console.log('setTimeout')
}, 0);
async function test () {
    let a = await 'await-result'
    console.log(a)
}
test()
new Promise(function(resolve) {
    console.log('promise-resolve')
    resolve()
}).then(function() {
    console.log('promise-then')
})
console.log('end')

//執行結果
start
promise-resolve
end
await-result
promise-then
setTimeout

上述例子中,外層主程序 和 setTimeout 都是宏任務,Promise 和 async/await 是微任務,所以整個流程如下:

  1. 第一個宏任務(主程序)開始執行 ------ 輸出 start

  2. setTimeout 加入宏任務隊列

  3. 執行 test(),async/await 加入微任務隊列

  4. Promise 初始入參是同步代碼,主程序一起執行 ------ 輸出 promise-resolve

  5. Promise 的 then 回調加入微任務隊列

  6. 繼續執行主程序 ------ 輸出 end

  7. 執行第一個微任務 ------ 輸出 await-result

  8. 執行第二個微任務 ------ 輸出 promise-then

  9. 再執行下一個宏任務(setTimeout) ------ 輸出 setTimeout

總結

前端程序員日常代碼經常會用到異步編程,瞭解異步運行的機制和順序有助於更流暢清晰的實現異步代碼,這裏主要分析了異步的由來和異步代碼實現,可結合不同的場景和要求進行選擇。

參考資料

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/IW95ZHuxyWKhKOnQjnVQTQ