本文来自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