JavaScript 異步編程

前言

本身着手準備寫這篇文章的初衷是以爲若是想要更深刻的理解 JS,異步編程則是必需要跨過的一道坎。因爲這裏面涉及到的東西不少也很廣,在初學 JS 的時候可能沒法完整的理解這一律念,即便在如今來看仍是有不少本身沒有接觸和理解到的知識點,可是爲了跨過這道坎,我仍然願意鼓起勇氣用我已經掌握的部分知識盡全力講述一下 JS 中的異步編程。若是我所講的一些概念或術語有錯誤,請讀者向我指出問題所在,我會當即糾正更改。javascript

同步與異步

咱們知道不管是在瀏覽器端仍是在服務器 ( Node ) 端,JS 的執行都是在單線程下進行的。咱們以瀏覽器中的 JS 執行線程爲例,在這個線程中 JS 引擎會建立執行上下文棧,以後咱們的代碼就會做爲執行上下文 ( 全局、函數、eval ) 像一系列任務同樣在執行上下文棧中按照後進先出 ( LIFO ) 的方式依次執行。而同步最大的特性就是會阻塞後面任務的執行,好比此時 JS 正在執行大量的計算,這個時候就會使線程阻塞從而致使頁面渲染加載不連貫 ( 在瀏覽器端的 Event Loop 中每次執行棧中的任務執行完畢後都會去檢查並執行事件隊列裏面的任務直到隊列中的任務爲空,而事件隊列中的任務又分爲微隊列與宏隊列,當微隊列中的任務執行完後纔會去執行宏隊列中的任務,而在微隊列任務執行完到宏隊列任務開始以前瀏覽器的 GUI 線程會執行一次頁面渲染 ( UI rendering ),這也就解釋了爲何在執行棧中進行大量的計算時會阻塞頁面的渲染 ) 。html

與同步相對的異步則能夠理解爲在異步操做完成後所要作的任務,它們一般以回調函數或者 Promise 的形式被放入事件隊列,再由事件循環 ( Event Loop ) 機制在每次輪詢時檢查異步操做是否完成,若完成則按事件隊列裏面的執行規則來依次執行相應的任務。也正是得益於事件循環機制的存在,才使得異步任務不會像同步任務那樣徹底阻塞 JS 執行線程。前端

異步操做通常包括 網絡請求文件讀取數據庫處理java

異步任務通常包括 setTimout / setIntervalPromiserequestAnimationFrame ( 瀏覽器獨有 )setImmediate ( Node 獨有 )process.nextTick ( Node 獨有 )etc ...node

注意: 在瀏覽器端與在 Node 端的 Event Loop 機制是有所不一樣的,下面給出的兩張圖簡要闡述了在不一樣環境下事件循環的運行機制,因爲 Event Loop 不是本文內容的重點,可是 JS 異步編程又是創建在它的基礎之上的,故在下面給出相應的閱讀連接,但願可以幫助到有須要的讀者。

瀏覽器端ios

Node 端git

閱讀連接github

爲異步而生的 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 地址 ,總之在前端學習的路上還得繼續加油嘞 😄。

參考書籍及文章

相關文章
相關標籤/搜索