本文來自OPPO互聯網技術團隊,轉載請註名做者。同時歡迎關注咱們的公衆號:OPPO_tech,與你分享OPPO前沿互聯網技術及活動。javascript
咱們知道,JavaScript 語言的一大特色是單線程,這是由它最初的應用場景決定的。它最初做爲瀏覽器的腳本語言,用來與用戶進行交互,而且能夠用來操做 DOM。若是它是多線程的,可能會帶來複雜的衝突,所以 JavaScript 最初被設計時即爲單線程的。前端
雖然在 HTML5 標準中新增了 Web Worker 的概念,它容許 JavaScript 建立多個線程,但這些子線程徹底受主線程的控制,且不能操做 DOM,所以本質上 JavaScript 仍是單線程的。在 JavaScript 中,除主線程外,還存在一個任務隊列,主線程循環不斷地從任務隊列中讀取事件,這整個運行機制被稱爲事件循環,事件循環的過程在這裏就不展開討論了。java
在主線程上的任務是排隊執行的,只有前一個任務完成了纔會執行後一個任務,這些任務是「同步」的;而任務隊列中的任務(如定時器、網絡請求、Promise 等)只有在知足條件時纔會被加入到主線程中執行,在知足條件以前不會阻塞主線程中的任務,這些任務是「異步」的。從執行順序來講,同步和異步的特色是:node
所以咱們有個小小的願望——若是能用同步的寫法來實現異步就行了。下面開始介紹 JavaScript 異步編程方法的發展之路。git
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
舉一個異步網絡請求的例子,假設有一個 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
}
複製代碼
當連續出現「後一個異步操做依賴上一個異步操做的返回結果」時,回調函數會變得難以使用。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')
}
})
})
})
}
})
複製代碼
回調函數可以實現異步處理,但存在一些問題(「回調地獄」):瀏覽器
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 接收一個函數做爲參數,該函數有兩個參數,分別爲 resolve
和 reject
,他們也都是函數,由 JS 內部實現,在不考慮內部原理、僅做使用時無需考慮具體實現方法。resolve
函數能夠將 Promise 實例的狀態由 pending
變爲 resolved
,其參數爲異步操做成功時的值 value
;reject
函數能夠將 Promise 實例的狀態由 pending
變爲 rejected
,其參數爲異步操做失敗時的緣由 reason
。
做爲 Promise 的實例,p
擁有 then
方法,該方法接收兩個函數做爲參數,分別爲 onResolved
和 onRejected
,當 p
的狀態由 pending
變爲 resolved
或 rejected
時,會調用相應的 onResolved
或 onRejected
,調用時的參數爲上一段中的 value
或 reason
。
在這個例子中,在 500ms 後 p
以 2333
爲緣由將狀態由 pending
變爲 rejected
,並以 2333
爲參數調用 then
的第二個參數中的函數,即:
err => {
console.log(`error: ${err}`)
}
複製代碼
因而打印出了 error: 2333
(注意,定義 p
時的代碼是同步執行的,所以會先輸出 start
和 end
)。
Promise 的實例有三種狀態:pending
、fulfilled
和 rejected
。初始狀態爲pending
,該狀態能夠變爲 fulfilled
或 rejected
,狀態一旦變化便不可再次改變;且 fulfilled
的 value
和 rejected
的 reason
不可再改變。(fulfilled
即爲 resolved
)
Promise 的實例會有一個 then
方法,該方法接收兩個參數,分別爲成功或失敗時的回調函數:promise.then(onFullfilled, onRejected)
。promise 的 then
方法會返回一個新的 Promise 實例(所以能夠繼續使用 then
等方法進行鏈式調用)。
then
方法中的成功回調,參數 value
爲 resolve
的值then
方法中的失敗回調,參數 reason
爲 reject
的值在 ES6 中,JavaScript 對 Promise/A+ 規範進行了實現,還增長了一些額外的方法,如Promise.prototype.catch
、Promise.prototype.finally
、Promise.resolve
、Promise.reject
、Promise.all
、Promise.any
和 Promise.race
等等。
上面提到,then
方法會返回一個新的 Promise 實例,其實 catch
方法也會返回一個新的 Promise 實例。假設咱們有:
let p1 = Promise.reject(1)
.catch(err => {
console.log(err)
})
複製代碼
那麼 p1
的狀態是什麼呢?resolved
?rejected
?思考並嘗試一下吧。
回調函數一節中 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')
}
})
複製代碼
再也不有多層的嵌套,再也不有數不過來的括號,邏輯更清晰,代碼再也不像回調函數那樣橫向發展。
Promise 可以很好的解決回調函數存在的「回調地獄」問題,代碼更加簡潔明瞭。但仍然存在一些小問題,如:
Promise 沒法取消:還以上述的 load
爲例子,在第一個 then
中,若是當 score
大於等於 60 時,咱們不想作後續操做了,則需「取消」掉下面的調用鏈,在這個場景下只能拋出一個錯誤並在後面 catch
,這種寫法不夠優雅。
相對於回調函數的方法,Promise 的鏈式調用只是更好看一些,還不是咱們想要的「同步寫法」。還記得文章開頭處,咱們說的「小小的願望」嗎?以下面的例子,咱們但願,異步函數 asyncFuntion1
返回後,res1
拿到返回值,再繼續往下執行,若是能寫成下面的寫法就行了。
let res1 = asyncFunction1()
let res2 = asyncFunction2(res1)
let res3 = asyncFunction3(res2)
複製代碼
這個時候,就輪到 Generator / yield 出場了。
Generator 是能夠分段執行的函數,執行期間遇到 yield
能夠暫停執行,返回中間狀態;而使用 next
方法能夠恢復執行,直到下一個 yield
或 return
。
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()
調用時不會執行其內部的代碼,而是返回一個迭代器對象,該對象擁有 next
、throw
和 return
方法。當調用 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 b
爲 undefined
,因爲錯誤已被處理,代碼能夠繼續執行到下一個 yield
或 return
,最終返回了 "wow"
,res3
爲 { value: "wow", done: true }
。
如今咱們知道,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 實現了咱們「小小的願望」。但這裏還有些小小的問題:
then
方法,並在其中調用 next
方法。若是能確保返回值是個 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 庫的代碼量很少,但思想是很巧妙的。其關鍵點是,在異步操做的回調函數中調用 generator
的 next
方法,以實現自動的流程以及值的傳遞。在這裏就不展開展開討論其實現細節了,感興趣的讀者能夠閱讀源碼學習。
藉助 Generator / yield + co,咱們能夠很好地實現「用同步的寫法去寫異步」,到這裏看起來已經很棒了,只不過須要稍稍藉助一下 co 庫的幫助。
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)
})
複製代碼
比較後能夠發現,只是 *
換成了 async
,yield
換成了 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
複製代碼
雖然來得比較遲,但最終 async/await 仍是到來了,咱們藉助它能夠輕易地寫出邏輯清晰的優雅代碼。但須要注意一點,async 函數中的代碼是同步的,對於沒有依賴關係的異步代碼不該該放在同一個 async 函數中,不然會形成性能的損失。
事出必有因,有因必有果。JavaScript 異步編程方法就這樣一步步演化,從最初的回調函數方法,到 ES6 的 Promise,再到配合 co 庫使用的 generator 函數,最後到 async 函數。其寫法愈來愈接近同步模式,最終也擺脫了對第三方庫的依賴,讓咱們可使用 async/await 和 Promise 寫出十分優雅的代碼。
打個招聘廣告:
OPPO互聯網技術的前端團隊正在招人,咱們專一於廣告投放管理,快應用,快遊戲,H5頁面以及node.js的開發工做,誠邀具有以上技能的前端開發者加入咱們,共同建設智能廣告平臺。工做地在深圳,簡歷投遞:liuke#oppo.com