Javascript 非同步程式設計

Jack おう發表於2020-11-14

前言

自己著手準備寫這篇文章的初衷是覺得如果想要更深入的理解 JS,非同步程式設計則是必須要跨過的一道坎。由於這裡面涉及到的東西很多也很廣,在初學 JS 的時候可能無法完整的理解這一概念,即使在現在來看還是有很多自己沒有接觸和理解到的知識點,但是為了跨過這道坎,我仍然願意鼓起勇氣用我已經掌握的部分知識盡全力講述一下 JS 中的非同步程式設計。如果我所講的一些概念或術語有錯誤,請讀者向我指出問題所在,我會立即糾正更改。

同步與非同步

我們知道無論是在瀏覽器端還是在伺服器 ( Node ) 端,JS 的執行都是在單執行緒下進行的。我們以瀏覽器中的 JS 執行執行緒為例,在這個執行緒中 JS 引擎會建立執行上下文棧,之後我們的程式碼就會作為執行上下文 ( 全域性、函式、eval ) 像一系列任務一樣在執行上下文棧中按照後進先出 ( LIFO ) 的方式依次執行。而同步最大的特性就是會阻塞後面任務的執行,比如此時 JS 正在執行大量的計算,這個時候就會使執行緒阻塞從而導致頁面渲染載入不連貫 ( 在瀏覽器端的 Event Loop 中每次執行棧中的任務執行完畢後都會去檢查並執行事件佇列裡面的任務直到佇列中的任務為空,而事件佇列中的任務又分為微佇列與巨集佇列,當微佇列中的任務執行完後才會去執行巨集佇列中的任務,而在微佇列任務執行完到巨集佇列任務開始之前瀏覽器的 GUI 執行緒會執行一次頁面渲染 ( UI rendering ),這也就解釋了為什麼在執行棧中進行大量的計算時會阻塞頁面的渲染 ) 。

與同步相對的非同步則可以理解為在非同步操作完成後所要做的任務,它們通常以回撥函式或者 Promise 的形式被放入事件佇列,再由事件迴圈 ( Event Loop ) 機制在每次輪詢時檢查非同步操作是否完成,若完成則按事件佇列裡面的執行規則來依次執行相應的任務。也正是得益於事件迴圈機制的存在,才使得非同步任務不會像同步任務那樣完全阻塞 JS 執行執行緒。

非同步操作一般包括 網路請求檔案讀取資料庫處理

非同步任務一般包括 setTimout / setIntervalPromiserequestAnimationFrame ( 瀏覽器獨有 )setImmediate ( Node 獨有 )process.nextTick ( Node 獨有 )etc ...

注意: 在瀏覽器端與在 Node 端的 Event Loop 機制是有所不同的,下面給出的兩張圖簡要闡述了在不同環境下事件迴圈的執行機制,由於 Event Loop 不是本文內容的重點,但是 JS 非同步程式設計又是建立在它的基礎之上的,故在下面給出相應的閱讀連結,希望能夠幫助到有需要的讀者。

瀏覽器端

Javascript 非同步程式設計

Node 端

Javascript 非同步程式設計

閱讀連結

為非同步而生的 JS 語法

回望歷史,在最近幾年裡 ECMAScript 標準幾乎每年都有版本的更新,也正是因為有像 ES6 這種在語言特性上大版本的更新,到了現今的 8102 年, JS 中的非同步程式設計相對於那個只有回撥函式的遠古時代有了很大的進步。下面我將介紹 callback 、Promise 、generator 、async / await 的基本用法以及如何在非同步程式設計中使用它們。

callback

回撥函式並不算是 JS 中的語法但它卻是解決非同步程式設計問題中最常用的一種方法,所以在這裡有必要提出來,下面舉一個例子,大家看一眼就懂。

const foo = function (x, y, cb) {
    setTimeout(() => {
        cb(x + y)
    }, 2000)
}

// 使用 thunk 函式,有點函式柯里化的味道,在最後處理 callback。
const thunkify = function (fn) {
    return function () {
        let args = Array.from(arguments)
        return function (cb) {
            fn.apply(null, [...args, cb])
        }
    }
}

let fooThunkory = thunkify(foo)

let fooThunk1 = fooThunkory(2, 8)
let fooThunk2 = fooThunkory(4, 16)

fooThunk1((sum) => {
    console.log(sum) // 10
})

fooThunk2((sum) => {
    console.log(sum) // 20
})
複製程式碼

Promise

在 ES6 沒有釋出之前,作為非同步程式設計主力軍的回撥函式一直被人詬病,其原因有太多比如回撥地獄、程式碼執行順序難以追蹤、後期因程式碼變得十分複雜導致無法維護和更新等,而 Promise 的出現在很大程度上改變了之前的窘境。話不多說先直接上程式碼提前感受下它的魅力,然後我再總結下自己認為在 Promise 中很重要的幾個點。

const foo = function () {
    let args = [...arguments]
    let cb = args.pop()
    setTimeout(() => {
        cb(...args)
    }, 2000)
}

const promisify = function (fn) {
    return function () {
        let args = [...arguments]
        return function (cb) {
            return new Promise((resolve, reject) => {
                fn.apply(null, [...args, resolve, reject, cb])
            })
        }
    }
}

const callback = function (x, y, isAdd, resolve, reject) {
    if (isAdd) {
        resolve(x + y)
    } else {
        reject('Add is not allowed.')
    }
}

let promisory = promisify(foo)

let p1 = promisory(4, 16, false)
let p2 = promisory(2, 8, true)

p1(callback)
.then((sum) => {
    console.log(sum)
}, (err) => {
    console.error(err) // Add is not allowed.
})
.finally(() => {
    console.log('Triggered once the promise is settled.')
})

p2(callback)
.then((sum) => {
    console.log(sum) // 10
    return 'evil ?'
})
.then((unknown) => {
    throw new Error(unknown)
})
.catch((err) => {
    console.error(err) // Error: evil ?
})
複製程式碼

要點一:反控制反轉 ( 關注點分離 )

什麼是反控制反轉呢?要理解它我們應該先弄清楚控制反轉的含義,來看一段虛擬碼。

const request = require('request')

// 某購物系統獲取使用者必要資訊後執行收費操作
const purchase = function (url) {
    request(url, (err, response, data) => {
        if (err) return console.error(err)
        if (response.statusCode === 200) {
            chargeUser(data)
        }
    })
}

purchase('https://cosmos-alien.com/api/getUserInfo')
複製程式碼

顯然在這裡 request 模組屬於第三方庫是不能夠完全信任的,假如某一天該模組出了 bug , 原本只會向目標 url 傳送一次請求卻變成了多次,相應的我們的 chargeUser 函式也就是收費操作就會被執行多次,最終導致使用者被多次收費,這樣的結果完全就是噩夢!然而這就是控制反轉,即把自己的程式碼交給第三方掌控,因此是不可完全信任的。

那麼反控制反轉現在我們可以猜測它的含義應該就是將控制權交還到我們自己寫的程式碼中,而要實現這點通常我們會引入一個第三方協商機制,在 Promise 之前我們會通過事件監聽的形式來解決這類問題。現在我們將程式碼更改如下:

const request = require('request')
const events = require('events')

const listener = new events.EventEmitter()

listener.on('charge', (data) => {
    chargeUser(data)
})

const purchase = function (url) {
    request(url, (err, response, data) => {
        if (err) return console.error(err)
        if (response.statusCode === 200) {
            listener.emit('charge', data)
        }
    })
}

purchase('https://cosmos-alien.com/api/getUserInfo')
複製程式碼

更改程式碼之後我們會發現控制反轉的恢復其實是更好的實現了關注點分離,我們不用去關心 purchase 函式內部具體發生了什麼,只需要知道它在什麼時候完成,之後我們的關注點就從 purchase 函式轉移到了 listener 物件上。我們可以把 listener 物件提供給程式碼中多個獨立的部分,在 purchase 函式完成後,它們同樣也能收到通知並進行下一步的操作。以下是維基百科上關於關注點分離的一部分介紹。

關注點分離的價值在於簡化計算機程式的開發和維護。當關注點分開時,各部分可以重複使用,以及獨立開發和更新。具有特殊價值的是能夠稍後改進或修改一段程式碼,而無需知道其他部分的細節必須對這些部分進行相應的更改。

一一 維基百科

顯然在 Promisenew Promise() 返回的物件就是關注點分離中分離出來的那個關注物件。

要點二:不可變性 ( 值得信任 )

細心的讀者可能會發現,要點一中基於事件監聽的反控制反轉仍然沒有解決最重要的信任問題,收費操作仍舊可以因為第三方 API 的多次呼叫而被觸發且執行多次。幸運的是現在我們擁有 Promise 這樣強大的機制,才得以讓我們從信任危機中解脫出來。所謂不可變性就是:

Promise 只能被決議一次,如果程式碼中試圖多次呼叫 resolve(..) 或者 reject(..) ,Promise 只會接受第一次決議,決議後就是外部不可變的值,因此任何通過 then(..) 註冊的回撥只會被呼叫一次。

現在要點一中的示例程式碼就可以最終更改為:

const request = require('request')

const purchase = function (url) {
    return new Promise((resolve, reject) => {
        request(url, (err, response, data) => {
            if (err) reject(err)
            if (response.statusCode === 200) {
                resolve(data)
            }
        })
    })
}

purchase('https://cosmos-alien.com/api/getUserInfo')
.then((data) => {
    chargeUser(data)
})
.catch((err) => {
    console.error(err)
})
複製程式碼

要點三:錯誤處理及一些細節

還記得最開始講 Promise 時的那一段程式碼嗎?我們把列印結果的那部分程式碼再次拿出來看看。

p1(callback)
.then((sum) => {
    console.log(sum)
}, (err) => {
    console.error(err) // Add is not allowed.
})
.finally(() => {
    console.log('Triggered once the promise is settled.')
})

p2(callback)
.then((sum) => {
    console.log(sum) // 10
    return 'evil ?'
})
.then((unknown) => {
    throw new Error(unknown)
})
.catch((err) => {
    console.error(err) // Error: evil ?
})
複製程式碼

首先我們說下 then(..) ,它的第一個引數作為函式接收 promise 物件中 resolve(..) 的值,第二個引數則作為錯誤處理函式處理在 Promise 中可能發生的錯誤。

而在 Promise 中有兩種錯誤可能會出現,一種是顯式 reject(..) 丟擲的錯誤,另一種則是程式碼自身有錯誤會被 Promise 捕捉,通過 then(..) 中的錯誤處理函式我們可以接收到它前面 promise 物件中出現的錯誤,而如果在 then(..) 接收 resolve(..) 值的函式中也出現錯誤,該錯誤則會被下一個 then(..) 的錯誤處理函式所接收 ( 有兩個前提,第一是要寫出這個 then(..) 否則該錯誤最終會在全域性丟擲,第二個則是要確保前一個 then(..) 在它的 Promise 決議後呼叫的是第一個引數即接收 resolve(..) 值的函式而不是錯誤處理函式 )。

一些值得注意的細節:

catch(..) 相當於 then(..) 中的錯誤處理函式 ,只是省略了第一個引數。

finally(..) 在 Promise 一旦決議後 ( 無論是 resolve 還是 reject ) 都會被執行。

then(..)catch(..)finally(..) 都是非同步呼叫,作為 Event Loop 裡事件佇列中的微佇列任務執行。

補充:手寫一個 Promise

function iPromise(fn) {
    let state = 'pending',
        value = null,
        error = null,
        callbacks = []

    this.then = function (onFulfilled, onRejected) {
        return new iPromise((resolve, reject) => {
            transition({
                onFulfilled: onFulfilled,
                onRejected: onRejected,
                resolve: resolve,
                reject: reject
            })
        })
    }

    function transition(callback) {
        let result
        switch (state) {
            case 'pending':
                callbacks.push(callback)
                return
            case 'resolved':
                try {
                    if (callback.onFulfilled) result = callback.onFulfilled(value)
                } catch (e) {
                    if (callback.onRejected) result = callback.onRejected(e)
                }
                break
            case 'rejected':
                if (callback.onRejected) result = callback.onRejected(error)
                break
        }
        if (result instanceof iPromise) {
            result.then(callback.resolve, callback.reject)
            return
        }
        state === 'resolved' ? callback.resolve(result) : callback.reject(result)
    }

    function resolve(newValue) {
        state = 'resolved'
        value = newValue
        execute()
    }

    function reject(err) {
        state = 'rejected'
        error = err
        execute()
    }

    function execute() {
        callbacks.length ? callbacks.map(callback => transition(callback)) : null
    }

    fn(resolve, reject)
}

var p = new iPromise((resolve) => {
    setTimeout(() => resolve(2333), 1000)
})

p.then(res =>
    new iPromise((resolve) => {
        setTimeout(() => {
            resolve(res)
        }, 2000)
    })
).then(res =>
    new iPromise((resolve, reject) => {
        reject(res)
    })
).then(null, err => console.error(err)) // 2333
複製程式碼

可以看到實現 Promise 的關鍵就是為其設定 pendingresolvedrejected 三種狀態,而且只能由 pending 轉換到 resolved 或者 rejected 。需要注意的是我們用 then(..) 註冊的那些回撥函式早在執行同步程式碼的時候就已經被快取在對應 Promise 中的 callbacks 陣列裡 ( 如果此時的狀態為 pending ),當非同步操作完成後我們執行從 Promise 傳遞出來的 resolve 或者 rejected 函式去觸發 callbacks 陣列中相應函式的執行。我們還會發現 then(..) 方法是鏈式呼叫的,即在 Promise 內部當前一個 Promise 的 then(..) 註冊的回撥函式執行完後就會自動呼叫下一個 Promise 中的 resolve 函式,然後再去執行該 Promise 中 callbacks 陣列裡快取的回撥函式。

generator

generator 也叫做生成器,它是 ES6 中引入的一種新的函式型別,在函式內部它可以多次啟動和暫停,從而形成阻塞同步的程式碼。下面我將先講述它的基本用法然後是它在非同步程式設計中的使用最後會簡單探究一下它的工作原理。

生成器基本用法

let a = 2

const foo = function *(x, y) {
    let b = (yield x) + a
    let c = (yield y) + b
    console.log(a + b + c)
}

let it = foo(6, 8)

let x = it.next().value
a++
let y = it.next(x * 5).value
a++

it.next(x + y) // 84
複製程式碼

從上面的程式碼我們可以看到與普通的函式不同,生成器函式執行後返回的是一個迭代器物件,用來控制生成器的暫停和啟動。在常見的設計模式中就有一種模式叫做迭代器模式,它指的是提供一種方法順序訪問一個聚合物件中的各個元素,而又不需要暴露該物件的內部表示。迭代器物件 it 包含一個 next(..) 方法且在呼叫之後返回一個 { done: .. , value: .. } 物件,現在我們先來自己實現一個簡單的迭代器。

const iterator = function (obj) {
    let current = -1
    return {
        [Symbol.iterator]() {
            return this
        },
        next() {
            current++
            return { done: current < obj.length ? false : true, value: obj[current] }
        }
    }
}

let it1 = iterator([1,2,3,4])

it1.next().value // 1
it1.next().value // 2
it1.next().value // 3
it1.next().value // 4

let it2 = iterator([5,6,7,8])

for (let v of it2) { console.log(v) } // 5 6 7 8
複製程式碼

可以看到我們自己實現的迭代器不僅能夠手動進行迭代,還能被 for..of 自動迭代展開,這是因為在 ES6 中只要物件具有 Symbol.iterator 屬性且該屬性返回的是一個迭代器物件,就能夠被 for..of 所消費。

回頭來看最開始的那個 generator 示例程式碼中生成器產生的迭代器物件 it ,似乎它比普通的迭代器有著更強大的功能,其實就是與 yield 表示式緊密相連的訊息雙向傳遞。現在我先來總結一下自己認為在生成器中十分重要的點,然後再來分析下那段示例程式碼的完整執行過程。

每次呼叫 it.next() 後生成器函式內的程式碼就會啟動執行且返回一個 { done: .. , value: .. } 物件,一旦遇到 yield 表示式就會暫停執行,如果此時 yield 表示式後面跟有值例如 yield val,那麼這個 val 就會被傳入返回物件中鍵名 value 對應的鍵值,當再次呼叫 it.next()yield 的暫停效果就會被取消,如果此時的 next 為形如 it.next(val) 的呼叫,yield 表示式就會被 val 所替換。這就是生成器內部與迭代器物件外部之間的訊息雙向傳遞。

弄清了生成器中重要的特性後要理解開頭的那段程式碼就不難了,首先執行第一個 it.next().value ,遇到第一個 yield 後生成器暫停執行,此時變數 x 接受到的值為 6。在全域性環境下執行 a++ 後再次執行 it.next(x * 5).value 生成器繼續執行且傳入值 30,因此變數 b 的值就為 33,當遇到第二個 yield 後生成器又暫停執行,並且將值 8 傳出給變數 y 。再次執行 a++ ,然後執行 it.next(x + y) 恢復生成器執行並傳入值 14,此時變數 c 的值就為 47,最終計算 a + b + c 便可得到值 84。

在非同步程式設計中使用生成器

既然現在我們已經知道了生成器內部擁有能夠多次啟動和暫停程式碼執行的強大能力,那麼將它用於非同步程式設計中也便是理所當然的事情了。先來看一個非同步迭代生成器的例子。

const request = require('request')

const foo = function () {
    request('https://cosmos-alien.com/some.url', (err, response, data) => {
        if (err) it.throw(err)
        if (response.statusCode === 200) {
            it.next(data)
        }
    })
}

const main = function *() {
    try {
        let result = yield foo()
        console.log(result)
    }
    catch (err) {
        console.error(err)
    }
}

let it = main()

it.next()
複製程式碼

這個例子的邏輯很簡單,呼叫 it.next() 後生成器啟動,遇到 yield 時生成器暫停執行,但此時 foo 函式已經執行即網路請求已經發出,等到有響應結果時如果出錯則呼叫 it.throw(err) 將錯誤拋回生成器內部由 try..catch 同步捕獲,否則將返回的 data 作為傳回生成器的值在恢復執行的同時將 data 賦值給變數 result ,最後列印 result 得到我們想要的結果。

在 ES6 中最完美的世界就是生成器 ( 看似同步的非同步程式碼 ) 和 Promise ( 可信任可組合 ) 的結合,因此我們現在再來看一個由生成器 + Promise 實現非同步操作的例子。

const axios = require('axios')

const foo = function () {
    return axios({
        method: 'GET',
        url: 'https://cosmos-alien.com/some.url'
    })
}

const main = function *() {
    try {
        let result = yield foo()
        console.log(result)
    }
    catch (err) {
        console.error(err)
    }
}

let it = main()

let p = it.next().value

p.then((data) => {
    it.next(data)
}, (err) => {
    it.throw(err)
})
複製程式碼

這個例子跟前面非同步迭代生成器的例子幾乎是差不多的,唯一不同的就是 yield 傳遞出去的是一個 promise 物件,之後我們在 then(..) 中來恢復執行生成器裡下一步的操作或是丟擲一個錯誤。

生成器工作原理

在講了那麼多關於 generator 生成器的使用後,相信讀者也跟我一樣想知道生成器究竟是如何實現能夠控制函式內部程式碼的暫停和啟動,從而形成阻塞同步的效果。

我們先來簡單瞭解下有限狀態機 ( FSM ) 這個概念,維基百科上給出的解釋是表示有限個狀態以及在這些狀態之間的轉移和動作等行為的數學模型。簡單的來說,它有三個主要特徵:

  1. 狀態總數 ( state ) 是有限的
  2. 任一時刻,只處在一種狀態之中
  3. 某種條件下,會從一種狀態轉變 ( transition ) 到另一種狀態

其實生成器就是通過暫停自己的作用域 / 狀態來實現它的魔法的,下面我們就以上文的生成器 + Promise 的例子為基礎,用有限狀態機的方式來闡述生成器的基本工作原理。

let stateRequest = {
    done: false,
    transition(message) {
        this.state = this.stateResult
        console.log(message)
        // state 1
        return foo()
    }
}

let stateResult = {
    done: true,
    transition(data) {
        // state 2
        let result = data
        console.log(result)
    }
}

let stateError = {
    transition(err) {
        // state 3
        console.error(err)
    }
}

let it = {
    init() {
        this.stateRequest = Object.create(stateRequest)
        this.stateResult = Object.create(stateResult)
        this.stateError = Object.create(stateError)
        this.state = this.stateRequest
    },
    next(data) {
        if (this.state.done) {
            return {
                done: true,
                value: undefined
            }
        } else {
            return {
                done: this.state.done,
                value: this.state.transition.call(this, data)
            }
        }
    },
    throw(err) {
        return {
            done: true,
            value: this.stateError.transition(err)
        }
    }
}

it.init()
it.next('The request begins !')
複製程式碼

在這裡我使用了行為委託模式和狀態模式實現了一個簡單的有限狀態機,而它卻展現了生成器中核心部分的工作原理,下面我們來逐步分析它是如何執行的。

首先這裡我們自己建立的 it 物件就相當於生成器函式執行後返回的迭代器物件,我們把上文生成器 + Promise 示例中的 main 函式程式碼分為了三個狀態並將跟該狀態有關的行為封裝到了 stateRequeststateResultstateError 三個物件中。然後我們再呼叫 init(..)it 物件上的行為委託到這三個物件上並初始化當前的狀態物件。在準備工作完成後呼叫 next(..) 啟動生成器,這個時候我們就進入了狀態一,即執行 foo 函式發出網路請求。在 foo 函式內部當得到請求響應資料後就執行 it.next(data) 觸發狀態機內部的狀態改變,此時執行狀態二內部的程式碼即列印網路請求返回的結果。如果網路請求中出現錯誤就會執行 it.throw(err) ,這個時候的狀態就會轉換到狀態三即錯誤處理狀態。

在這裡我們似乎忽略了一個很重要的地方,就是生成器是如何做到將其內部的程式碼分為多個狀態的,當然我們知道這肯定是 yield 表示式的功勞,但是其內部又是怎麼實現的呢?由於本人能力還不夠,而且還有很多東西來不及去學習和了解,因此暫時無法解決這個問題,但我還是願意把這個問題提出來,如果讀者確實有興趣能夠通過查閱資料找到答案或者已經知道它的原理還是可以分享出來,畢竟經歷這樣刨根問底的過程還是滿有趣的。

async / await

終於講到最後一個非同步語法了,作為壓軸的身份出場,據說 async / await 是 JS 非同步程式設計中的終極解決方案。話不多說,先直接上程式碼看看它的基本用法,然後我們再來探討一下它的實現原理。

const foo = function (time) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(time + 200)
        }, time)
    })
}

const step1 = time => foo(time)
const step2 = time => foo(time) 
const step3 = time => foo(time)

const main = async function () {
    try {
        console.time('run')
        let time1 = 200
        let time2 = await step1(time1)
        let time3 = await step2(time2)
        await step3(time3)
        console.log(`All steps took ${time1 + time2 + time3} ms.`)
        console.timeEnd('run')
    } catch(err) {
        console.error(err)
    }
}

main()
// All steps took 1200 ms.
// run: 1222.87939453125ms
複製程式碼

我們可以看到 async 函式跟生成器函式極為相似,只是將之前的 * 變成了 asyncyield 變成了 await 。其實它就是一個能夠自動執行的 generator 函式,我們不用再通過手動執行 it.next(..) 來控制生成器函式的暫停與啟動。

await 幫我們做到了在同步阻塞程式碼的同時還能夠監聽 Promise 物件的決議,一旦 promise 決議,原本暫停執行的 async 函式就會恢復執行。這個時候如果決議是 resolve ,那麼返回的結果就是 resolve 出來的值。如果決議是 reject ,我們就必須用 try..catch 來捕獲這個錯誤,因為它相當於執行了 it.throw(err)

下面直接給出一種主流的 async / await 語法版本的實現程式碼:

const runner = function (gen) {
    return new Promise((resolve, reject) => {
        var it = gen()
        const step = function (execute) {
            try {
                var next = execute()
            } catch (err) {
                reject(err)
            }
            
            if (next.done) return resolve(next.value)
            
            Promise.resolve(next.value)
            .then(val => step(() => it.next(val)))
            .catch(err => step(() => it.throw(err)))
        }
        step(() => it.next())
    })
}

async function fn() {
    // ...
}

// 等同於

function fn() {
    const gen = function *() {
        // ...
    }
    runner(gen)
}
複製程式碼

從上面的程式碼我們可以看出 async 函式執行後返回的是一個 Promise 物件,然後使用遞迴的方法去自動執行生成器函式的暫停與啟動。如果呼叫 it.next().value 傳出來的是一個 promise ,則用 Promise.resolve() 方法將其非同步展開,當這個 promise 決議時就可以重新啟動執行生成器函式或者丟擲一個錯誤被 try..catch 所捕獲並最終在 async 函式返回的 Promise 物件的錯誤處理函式中處理。

關於 async / await 的執行順序

下面給出一道關於 async / await 執行順序的經典面試題,網上給出的解釋給我感覺似乎很含糊。在這裡我們結合上文所講的 generator 函式執行機制和 async / await 實現原理來具體闡述下為什麼執行順序是這樣的。

async function async1(){
    console.log('async1 start')
    await async2()
    console.log('async1 end')
}

async function async2(){
    console.log('async2')
}

console.log('script start')

setTimeout(() => {
    console.log('setTimeout')
})

async1()

new Promise((resolve) => {
    console.log('promise1')
    resolve()
})
.then(() => {
    console.log('promise2')
})

console.log('script end')
複製程式碼

將這段程式碼放在瀏覽器中執行,最終的結果這樣的:

script start
async1 start
async2
promise1
script end
promise2
async1 end
setTimeout
複製程式碼

其實最主要的地方還是要分清在執行棧中同步執行的任務與事件佇列中非同步執行的任務。首先我們執行同步任務,列印 script start ,呼叫函式 async1 ,在我們遇到 await 表示式後就會暫停函式 async1 的執行。因為在這裡它相當於 yield async2() ,根據上文的 async / await 原理實現程式碼可以看出,當自動呼叫 it.next() 時遇到第一個 yield 後會暫停執行,但此時函式 async2 已經執行。上文還提到過 async 函式在執行完後會返回一個 Promise 物件,故此時 it.next().value 的值就是一個 promise 。接下來要講的就是重點啦 !!!

我們用 Promise.resolve() 去非同步地展開一個 promise ,因此第一個放入事件佇列中的微佇列任務其實就是這個 promise 。之後我們再繼續執行執行棧中剩下的同步任務,此時列印出 promise1script end ,同時第二個非同步任務被加入到事件佇列中的微佇列。同步的任務執行完了,現在來執行非同步任務,首先將微佇列中第一個放入的那個 promise 拿到執行棧中去執行,這個時候之前 Promise.resolve() 後面註冊的回撥任務才會作為第三個任務加入到事件佇列中的微佇列裡去。然後我們執行微佇列中的第二個任務,列印 promise2,再執行第三個任務即呼叫 step(() => it.next(val)) 恢復 async 函式的執行,列印 async1 end 。最後,因為微佇列總是搶佔式的在巨集佇列之前插入執行,故只有當微佇列中沒有了任務以後,巨集佇列中的任務才會開始執行,故最終列印出 setTimeout

常見非同步模式

在軟體開發中有著設計模式這一專業術語,通俗一點來講設計模式其實就是在某種場合下針對某個問題的一種解決方案。

在 JS 非同步程式設計的世界裡,很多時候我們也會遇到因為是非同步操作而出現的特定問題,而針對這些問題所提出的解決方案 ( 邏輯程式碼 ) 就是非同步程式設計的核心,似乎在這裡它跟設計模式的概念很相像,所以我把它叫做非同步模式。下面我將介紹幾種常見的非同步模式在實際場景下的應用。

併發互動模式

當我們在同時執行多個非同步任務時,這些任務返回響應結果的時間往往是不確定的,因而會產生以下兩種常見的需求:

  1. 多個非同步任務同時執行,等待所有任務都返回結果後才開始進行下一步的操作。
  2. 多個非同步任務同時執行,只返回最先完成非同步操作的那個任務的結果然後再進行下一步的操作。

場景一:

同時讀取多個含有英文文章的 txt 檔案內容,計算其中單詞 of 的個數。

  1. 等待所有檔案中的 of 個數計算完畢,再計算輸出總的 of 數。
  2. 直接輸出第一個計算完 of 的個數。
const fs = require('fs')
const path = require('path')

const addAll = (result) => console.log(result.reduce((prev, cur) => prev + cur))

let dir = path.join(__dirname, 'files')

fs.readdir(dir, (err, files) => {
    if (err) return console.error(err)
    let promises = files.map((file) => {
        return new Promise((resolve, reject) => {
            let fileDir = path.join(dir, file)
            fs.readFile(fileDir, { encoding: 'utf-8' }, (err, data) => {
                if (err) reject(err)
                let count = 0
                data.split(' ').map(word => word === 'of' ? count++ : null)
                resolve(count)
            })
        })
    })
    Promise.all(promises).then(result => addAll(result)).catch(err => console.error(err))
    Promise.race(promises).then(result => console.log(result)).catch(err => console.error(err))
})
複製程式碼

併發控制模式

有時候我們會遇到大量非同步任務併發執行而且還要處理返回資料的情況,即使擁有事件迴圈 ( Event Loop ) 機制,在併發量過高的情況下程式仍然會崩潰,所以這個時候就應該考慮併發控制。

場景二:

利用 Node.js 實現圖片爬蟲,控制爬取時的併發量。一是防止 IP 被封掉 ,二是防止併發請求量過高使程式崩潰。

const fs = require('fs')
const path = require('path')
const request = require('request')
const cheerio = require('cheerio')

const target = `http://www.zimuxia.cn/${encodeURIComponent('我們的作品')}`

const isError = (err, res) => (err || res.statusCode !== 200) ? true : false

const getImgUrls = function (pages) {
    return new Promise((resolve) => {
        let limit = 8, number = 0, imgUrls = []
        const recursive = async function () {
            pages = pages - limit
            limit = pages >= 0 ? limit : (pages + limit)
            let arr = []
            for (let i = 1; i <=limit; i++) {
                arr.push(
                    new Promise((resolve) => {
                        request(target + `?set=${number++}`, (err, res, data) => {
                            if (isError(err, res)) return console.log('Request failed.')
                            let $ = cheerio.load(data)
                            $('.pg-page-wrapper img').each((i, el) => {
                                let imgUrl = $(el).attr('data-cfsrc')
                                imgUrls.push(imgUrl)
                                resolve()
                            })
                        })
                    })
                )
            }
            await Promise.all(arr)
            if (limit === 8) return recursive()
            resolve(imgUrls)
        }
        recursive()
    })
}

const downloadImages = function (imgUrls) {
    console.log('\n Start to download images. \n')
    let limit = 5
    const recursive = async function () {
        limit = imgUrls.length - limit >= 0 ? limit : imgUrls.length
        let arr = imgUrls.splice(0, limit)
        let promises = arr.map((url) => {
            return new Promise((resolve) => {
                let imgName = url.split('/').pop()
                let imgPath = path.join(__dirname, `images/${imgName}`)
                request(url)
                .pipe(fs.createWriteStream(imgPath))
                .on('close', () => {
                    console.log(`${imgName} has been saved.`)
                    resolve()
                })
            })
        })
        await Promise.all(promises)
        if (imgUrls.length) return recursive()
        console.log('\n All images have been downloaded.')
    }
    recursive()
}

request({
    url: target,
    method: 'GET'
}, (err, res, data) => {
    if (isError(err, res)) return console.log('Request failed.')
    let $ = cheerio.load(data)
    let pageNum = $('.pg-pagination li').length
    console.log('Start to get image urls...')
    getImgUrls(pageNum)
    .then((result) => {
        console.log(`Finish getting image urls and the number of them is ${result.length}.`)
        downloadImages(result)
    })
})
複製程式碼

釋出 / 訂閱模式

我們假定,存在一個"訊號中心",當某個任務執行完成,就向訊號中心"釋出" ( publish ) 一個訊號,其他任務可以向訊號中心"訂閱" ( subscribe ) 這個訊號,從而知道什麼時候自己可以開始執行,當然我們還可以取消訂閱這個訊號。

我們先來實現一個簡單的釋出訂閱物件:

class Listener {
    constructor() {
        this.eventList = {}
    }
    on(event, fn) {
        if (!this.eventList[event]) this.eventList[event] = []
        if (fn.name) {
            let obj = {}
            obj[fn.name] = fn
            fn = obj
        }
        this.eventList[event].push(fn)
    }
    remove(event, fn) {
        if (!fn) return console.error('Choose a named function to remove!')
        this.eventList[event].map((item, index) => {
            if (typeof item === 'object' && item[fn.name]) {
                this.eventList[event].splice(index, 1)
            }
        })
    }
    emit(event, data) {
        this.eventList[event].map((fn) => {
            if (typeof fn === 'object') {
                Object.values(fn).map((f) => f.call(null, data))
            } else {
                fn.call(null, data)
            }
        })
    }
}

let listener = new Listener()

function foo(data) { console.log('Hello ' + data) }

listener.on('click', (data) => console.log(data))

listener.on('click', foo)

listener.emit('click', 'RetroAstro')

// Hello
// Hello RetroAstro

listener.remove('click', foo)

listener.emit('click', 'Barry Allen')

// Barry Allen
複製程式碼

場景三:

監聽 watch 資料夾,當裡面的檔案有改動時自動壓縮該檔案並儲存到 done 資料夾中。

// gzip.js
const fs = require('fs')
const path = require('path')
const zlib = require('zlib')

const gzipFile = function (file) {
    let dir = path.join(__dirname, 'watch')
    fs.readdir(dir, (err, files) => {
        if (err) console.error(err)
        files.map((filename) => {
            let watchFile = path.join(dir, filename)
            fs.stat(watchFile, (err, stats) => {
                if (err) console.error(err)
                if (stats.isFile() && file === filename) {
                    let doneFile = path.join(__dirname, `done/${file}.gz`)
                    fs.createReadStream(watchFile)
                    .pipe(zlib.createGzip())
                    .pipe(fs.createWriteStream(doneFile))
                }
            })
        })
    })
}

module.exports = {
    gzipFile: gzipFile
}
複製程式碼

開始監聽 watch 資料夾中的檔案

// watch.js
const fs = require('fs')
const path = require('path')

const { gzipFile } = require('./gzip')
const { Listener } = require('./listener')

let listener = new Listener()

listener.on('gzip', (data) => gzipFile(data))

let dir = path.join(__dirname, 'watch')

let wait = true

fs.watch(dir, (event, filename) => {
    if (filename && event === 'change' && wait) {
        wait = false
        setTimeout(() => wait = true, 100)
        listener.emit('gzip', filename)
    }
})
複製程式碼

結語

對於 JavaScript 非同步程式設計在這裡我就講這麼多了,當然還有很多東西自己沒有了解和學習到,因此在本篇文章中沒有涉及。最後還是給出上面三個場景程式碼的 GitHub 地址 ,總之在前端學習的路上還得繼續加油嘞 ?。

參考書籍及文章

  • 《 你不知道的 JavaScript 》(上) (中)
  • 《 JavaScript 設計模式與開發實踐 》
  • 《 Node.js 實戰 》( 第二版 )

相關文章