JavaScript 異步編程之路

本文來自OPPO互聯網技術團隊,轉載請註名做者。同時歡迎關注咱們的公衆號:OPPO_tech,與你分享OPPO前沿互聯網技術及活動。javascript

1、基本介紹

咱們知道,JavaScript 語言的一大特色是單線程,這是由它最初的應用場景決定的。它最初做爲瀏覽器的腳本語言,用來與用戶進行交互,而且能夠用來操做 DOM。若是它是多線程的,可能會帶來複雜的衝突,所以 JavaScript 最初被設計時即爲單線程的。前端

雖然在 HTML5 標準中新增了 Web Worker 的概念,它容許 JavaScript 建立多個線程,但這些子線程徹底受主線程的控制,且不能操做 DOM,所以本質上 JavaScript 仍是單線程的。在 JavaScript 中,除主線程外,還存在一個任務隊列,主線程循環不斷地從任務隊列中讀取事件,這整個運行機制被稱爲事件循環,事件循環的過程在這裏就不展開討論了。java

在主線程上的任務是排隊執行的,只有前一個任務完成了纔會執行後一個任務,這些任務是「同步」的;而任務隊列中的任務(如定時器、網絡請求、Promise 等)只有在知足條件時纔會被加入到主線程中執行,在知足條件以前不會阻塞主線程中的任務,這些任務是「異步」的。從執行順序來講,同步和異步的特色是:node

  • 同步:從上到下執行,便於理解,寫起來方便,但下一條語句須要等待上一條完成後才能執行;
  • 異步:遇到異步任務能夠繼續往下執行,等到異步任務完成了再執行特定的語句,但代碼寫起來稍微複雜一些。

所以咱們有個小小的願望——若是能用同步的寫法來實現異步就行了。下面開始介紹 JavaScript 異步編程方法的發展之路。git

2、回調函數

2.1 回調函數的簡單用法

const fn = _ => {
    console.log('JavaScript yes!')
}

console.log('start')
setTimeout(fn, 500)
console.log('end')
// start
// end
// JavaScript yes! (about 500ms later)
複製代碼

其中 fn 即爲 回調函數。從該例子中能夠看到,執行了 setTimeout 後,線程並未阻塞在其中,而是繼續往下執行,打印出了「end」後通過約 500ms,回調函數執行,打印出 "JavaScript yes!"。github

2.2 異步網絡請求

舉一個異步網絡請求的例子,假設有一個 score.json 數據,咱們經過 XMLHttpRequest 發起異步請求,並在成功返回數據時,以返回數據爲參數調用傳入的回調函數。編程

// score.json
{
  "name": "Daniel",
  "score": 95
}

// loadData.js
// 參數 callback 即爲回調函數
const loadData = (item, callback) => {	// line: 9
  if (item === 'score') {
    let xhr = new XMLHttpRequest()
    xhr.open('GET', './score.json')
    xhr.onreadystatechange = function () {
      // 待到結果返回時,調回調函數
      if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
        callback(xhr.responseText)	// line: 16
      }
    }
    xhr.open()
  }
}

const displayData = data => {	// line: 23
  console.log(`data: ${data}`)
}

console.log('start')
loadData('score', displayData)	// line: 28
console.log('end')

/* start end data: { "name": "Daniel", "score": 95 } */
複製代碼

第 9 行處, loadData 函數的第二個參數爲 callback,即回調函數。第 28 行處,調用 loadData 函數時,傳入的第二個參數爲 displayData,此函數(第 23 行)接收一個參數並打印輸出。在 loadData 函數體內,第 16 行處,待到結果返回時,以 xhr.responseText 爲參數調用了 callback 函數,即 displayData 函數。因而打印出了:json

data: {
  "name": "Daniel",
  "score": 95
}
複製代碼

2.3 比較麻煩的狀況

當連續出現「後一個異步操做依賴上一個異步操做的返回結果」時,回調函數會變得難以使用。promise

load('score', data => {
    console.log(`score: ${data.score}`)
    if (data.score < 60) {
		sendToMon(data.score, res => {
            console.log(`message: ${res}`)
            sendToTeacher(res, comment => {
                console.log(`comment: ${comment}`)
                showComment(comment, state => {
                    if (state === 'success') {
                        console.log('complete')
                    }
                })
            })
        })
    }
})
複製代碼

2.4 小結

回調函數可以實現異步處理,但存在一些問題(「回調地獄」):瀏覽器

  1. 一層層回調函數堆疊起來,不利於代碼的維護;
  2. 結構混亂,邏輯耦合強,不利於錯誤處理;
  3. 代碼橫向發展,不利於閱讀。

3、Promise

3.1 Promise 的簡單用法

let p = new Promise((resolve, reject) => {
    console.log('start')
    setTimeout(_ => {
        reject(2333)
    }, 500)
    console.log('end')
})

p.then(data => {
    console.log(`data: ${data}`)
}, err => {
    console.log(`error: ${err}`)
})
// start
// end
// error: 2333
複製代碼

p 是咱們定義的 Promise 實例,Promise 接收一個函數做爲參數,該函數有兩個參數,分別爲 resolvereject,他們也都是函數,由 JS 內部實現,在不考慮內部原理、僅做使用時無需考慮具體實現方法。resolve 函數能夠將 Promise 實例的狀態由 pending 變爲 resolved,其參數爲異步操做成功時的值 valuereject 函數能夠將 Promise 實例的狀態由 pending 變爲 rejected,其參數爲異步操做失敗時的緣由 reason

做爲 Promise 的實例,p 擁有 then 方法,該方法接收兩個函數做爲參數,分別爲 onResolvedonRejected,當 p 的狀態由 pending 變爲 resolvedrejected 時,會調用相應的 onResolvedonRejected,調用時的參數爲上一段中的 valuereason

在這個例子中,在 500ms 後 p2333 爲緣由將狀態由 pending 變爲 rejected,並以 2333 爲參數調用 then 的第二個參數中的函數,即:

err => {
    console.log(`error: ${err}`)
}
複製代碼

因而打印出了 error: 2333(注意,定義 p 時的代碼是同步執行的,所以會先輸出 startend)。

3.2 Promise/A+規範

Promise 的實例有三種狀態:pendingfulfilledrejected。初始狀態爲pending,該狀態能夠變爲 fulfilledrejected,狀態一旦變化便不可再次改變;且 fulfilledvaluerejectedreason 不可再改變。(fulfilled 即爲 resolved

Promise 的實例會有一個 then 方法,該方法接收兩個參數,分別爲成功或失敗時的回調函數:promise.then(onFullfilled, onRejected)。promise 的 then 方法會返回一個新的 Promise 實例(所以能夠繼續使用 then 等方法進行鏈式調用)。

  • 當一個 promise 成功時,會調用其 then 方法中的成功回調,參數 valueresolve 的值
  • 當一個 promise 失敗時,會調用其 then 方法中的失敗回調,參數 reasonreject 的值

3.3 ES6 Promise

在 ES6 中,JavaScript 對 Promise/A+ 規範進行了實現,還增長了一些額外的方法,如Promise.prototype.catchPromise.prototype.finallyPromise.resolvePromise.rejectPromise.allPromise.anyPromise.race 等等。

3.4 一個小小的思考題

上面提到,then 方法會返回一個新的 Promise 實例,其實 catch 方法也會返回一個新的 Promise 實例。假設咱們有:

let p1 = Promise.reject(1)
	.catch(err => {
        console.log(err)
    })
複製代碼

那麼 p1 的狀態是什麼呢?resolvedrejected?思考並嘗試一下吧。

3.5 Promise 版的 load

回調函數一節中 load 的例子若是用 Promise 實現,則會簡潔不少:

// 此例子中省略了失敗回調函數 onRejected
load('score').then(data => {
    console.log(`score: ${data.score}`)
    if (data.score < 60) {
        return sendToMon(data.score)
    }
}).then(res => {
    console.log(`message: ${res}`)
    return sendToTeacher(res)
}).then(comment => {
    console.log(`comment: ${comment}`)
    return showComment(comment)
}).then(state => {
    if (state === 'success') {
        console.log('complete')
    }
})
複製代碼

再也不有多層的嵌套,再也不有數不過來的括號,邏輯更清晰,代碼再也不像回調函數那樣橫向發展。

3.6 小結

Promise 可以很好的解決回調函數存在的「回調地獄」問題,代碼更加簡潔明瞭。但仍然存在一些小問題,如:

  1. Promise 沒法取消:還以上述的 load 爲例子,在第一個 then 中,若是當 score 大於等於 60 時,咱們不想作後續操做了,則需「取消」掉下面的調用鏈,在這個場景下只能拋出一個錯誤並在後面 catch,這種寫法不夠優雅。

  2. 相對於回調函數的方法,Promise 的鏈式調用只是更好看一些,還不是咱們想要的「同步寫法」。還記得文章開頭處,咱們說的「小小的願望」嗎?以下面的例子,咱們但願,異步函數 asyncFuntion1 返回後,res1 拿到返回值,再繼續往下執行,若是能寫成下面的寫法就行了。

    let res1 = asyncFunction1()
    let res2 = asyncFunction2(res1)
    let res3 = asyncFunction3(res2)
    複製代碼

這個時候,就輪到 Generator / yield 出場了。

4、Generator / yield & co

Generator 是能夠分段執行的函數,執行期間遇到 yield 能夠暫停執行,返回中間狀態;而使用 next 方法能夠恢復執行,直到下一個 yieldreturn

4.1 Generator / yield 的簡單用法

function* gen() {
    console.log('start')
    let a = 1 + (yield Promise.resolve('b'))
    console.log(a)
    try {
        let b = yield 'OPPO'
    } catch(e) {
        console.log(`error: ${e}`)
    }
    console.log(typeof b)
    return 'wow'
}
let g = gen()
let res1 = g.next()
// start

console.log(res1)
// { value: Promise {<resolved>: "b"}, done: false }

let res2 = g.next(123)
// 124

console.log(res2)
// { value: "OPPO", done: false }

let res3 = g.throw(1024)
// error: 1024
// undefined (console.log(typeof b))

console.log(res3)
// { value: "wow", done: true }
複製代碼

function* gen() { // ... } 定義了一個 generator 函數,經過 let g = gen() 調用時不會執行其內部的代碼,而是返回一個迭代器對象,該對象擁有 nextthrowreturn 方法。當調用 next 方法時,generator 函數內部的語句會開始執行,直到下一個 yield 處(或 return),next 方法的返回值是一個對象,此對象有兩個屬性:value 和 done,分別爲 yield 後表達式的值以及表明是否執行完畢的布爾值。next 方法能夠接收一個參數,該參數會做爲 generator 函數內部上一條 yield 表達式的值。(首次調用 next 方法時,不存在「上一條 yield 表達式」,所以第一個 next 方法的參數會被忽略。)

以上述代碼爲例,經過 let res1 = g.next() 首次調用了 next 方法,generator 函數內部會執行到第一個 yield 處暫停,並將控制權交回主線程,此時打印出「start」,此時 res1{ value: Promise {<resolved>: "b"}, done: false };接着經過 let res2 = g.next(123) 再次調用 next 方法,generator 函數內部會繼續執行,因爲這次調用 next 方法時的參數爲 123,第一個 yield 表達式的值爲 123,故 a 的值爲 124,因而 console.log(a) 打印出 124,接下來代碼會暫停在 yield 'OPPO' 處,並將控制權交回主線程,此時 res2{ value: "OPPO", done: false };最後經過 let res3 = g.throw(1024) 繼續執行 generator 函數內部的代碼,throw 方法與 next 方法相似,都能使 generator 函數內部繼續執行,且能夠接收一個參數做爲上一個 yield 表達式的值,區別在於 throw 拋出一個錯誤,能夠被 try...catch 語句捕捉,所以打印出了 "error: 1024",而該賦值語句是沒有執行的,typeof bundefined,因爲錯誤已被處理,代碼能夠繼續執行到下一個 yieldreturn,最終返回了 "wow"res3{ value: "wow", done: true }

4.2 Generator / yield 實現異步操做

如今咱們知道,Generator 能夠在特定的地方暫停,還能夠經過 next 方法傳值並使其繼續執行。爲了完成異步操做,咱們能夠寫出這樣的代碼:

function* gen() {
    console.log('start')
    let a = yield asyncFunc()
    console.log(a)
    console.log('end')
}

function asyncFunc() {
    return new Promise((resolve, reject) => {
        setTimeout(_ => {
            resolve(5)
        }, 500)
    })
}

let g = gen()
let res

res = g.next().value	// 一個 Promise 實例
res.then(data => {
    g.next(data)
})

// start
// 5 (about 500ms later)
// end
複製代碼

咱們在 gen() 中使用了 let a = yield asyncFunc() ,而後 console.log(a),寫起來像是同步的,但執行起來是異步的,看起來 Generator 實現了咱們「小小的願望」。但這裏還有些小小的問題:

  1. 咱們這裏默認了返回值是個 Promise 實例,實際狀況中可能不是;
  2. 咱們須要手動寫 then 方法,並在其中調用 next 方法。

4.3 Generator / yield + co

若是能確保返回值是個 Promise 實例,而且能自動調用 next 方法就行了……很是幸運的是,已經有人寫了一個庫幫咱們實現了這兩點—— TJ 的 co 庫。它接收一個 generator 函數做爲參數,返回一個 Promise 實例,並可以自動執行其中的異步操做及相應回調。舉個例子:

function* gen() {
 console.log('a')
 let a = yield Promise.resolve('b')
 console.log(a)
 return 1
}

let p = co(gen())

// co 函數能夠將 generator 函數轉換爲以下的 Promise 實例:
let p = new Promise((resolve, reject) => {
  console.log('a')
  Promise.resolve('b').then(data => {
    let a = data
    console.log(a)
    resolve(1)
  }, err => {
    reject(err)
  })
})

// 接下來能夠調用
p.then(data => {
    console.log(data)
})
複製代碼

co 庫的代碼量很少,但思想是很巧妙的。其關鍵點是,在異步操做的回調函數中調用 generatornext 方法,以實現自動的流程以及值的傳遞。在這裏就不展開展開討論其實現細節了,感興趣的讀者能夠閱讀源碼學習。

4.4 小結

藉助 Generator / yield + co,咱們能夠很好地實現「用同步的寫法去寫異步」,到這裏看起來已經很棒了,只不過須要稍稍藉助一下 co 庫的幫助。

5、async/await

5.1 async/await 與 Generator/yield

ES2017 標準引入了 async 函數,async/await 能夠說是 JS 異步編程的終極解決方案,官方出品,品質保證。它實際上是 Generator 函數的語法糖,咱們能夠認爲 Generator/yield + co => async/await。以上面的 gen 函數爲例:

function* gen() {
    console.log('a')
    let a = yield Promise.resolve('b')
    console.log(a)
    return 1
}

let p = co(gen())

// 與之等價的 async/await 寫法:
async function gen() {
    console.log('a')
    let a = await Promise.resolve('b')
    console.log(a)
    return 1
}
let p = gen()

// 兩個 p 也都是 Promise 實例,接下來能夠調用
p.then(data => {
    console.log(data)
})
複製代碼

比較後能夠發現,只是 * 換成了 asyncyield 換成了 await,省去了 co,就這樣。

藉助 async/await,咱們能夠將回調函數一節中那個多層嵌套的例子改寫爲:

async function fun() {
    let data = await load('score')
    console.log(`score: ${data.score}`)
    if (data.score < 60) {
        let res = await sendToMon(data.score)
        console.log(`message: ${res}`)
        let comment = sendToTeacher(res)
        console.log(`comment: ${comment}`)
        let state = showComment(comment)
        if (state === 'success') {
            console.log('complete')
        }
    }
}
fun()	// 獲得一個 Promise 實例,能夠繼續 then
複製代碼

5.2 小結

雖然來得比較遲,但最終 async/await 仍是到來了,咱們藉助它能夠輕易地寫出邏輯清晰的優雅代碼。但須要注意一點,async 函數中的代碼是同步的,對於沒有依賴關係的異步代碼不該該放在同一個 async 函數中,不然會形成性能的損失。

6、總結

事出必有因,有因必有果。JavaScript 異步編程方法就這樣一步步演化,從最初的回調函數方法,到 ES6 的 Promise,再到配合 co 庫使用的 generator 函數,最後到 async 函數。其寫法愈來愈接近同步模式,最終也擺脫了對第三方庫的依賴,讓咱們可使用 async/await 和 Promise 寫出十分優雅的代碼。

最後

打個招聘廣告:

OPPO互聯網技術的前端團隊正在招人,咱們專一於廣告投放管理,快應用,快遊戲,H5頁面以及node.js的開發工做,誠邀具有以上技能的前端開發者加入咱們,共同建設智能廣告平臺。工做地在深圳,簡歷投遞:liuke#oppo.com

相關文章
相關標籤/搜索