JavaScript 异步之路
1. 基本介绍
我们知道,JavaScript 语言的一大特点是单线程,这是由它最初的应用场景决定的。它最初作为浏览器的脚本语言,用来与用户进行交互,并且可以用来操作 DOM。如果它是多线程的,可能会带来复杂的冲突,因此 JavaScript 最初被设计时即为单线程的。
虽然在 HTML5 标准中新增了 Web Worker
的概念,它允许 JavaScript 创建多个线程,但这些子线程完全受主线程的控制,且不能操作 DOM,因此本质上 JavaScript 还是单线程的。在 JavaScript 中,除主线程外,还存在一个任务队列,主线程循环不断地从任务队列中读取事件,这整个运行机制被称为事件循环,事件循环的过程在这里就不展开讨论了。
在主线程上的任务是排队执行的,只有前一个任务完成了才会执行后一个任务,这些任务是“同步”的;而任务队列中的任务(如定时器、网络请求、Promise 等)只有在满足条件时才会被加入到主线程中执行,在满足条件之前不会阻塞主线程中的任务,这些任务是“异步”的。从执行顺序来说,同步和异步的特点是:
- 同步:从上到下执行,便于理解,写起来方便,但下一条语句需要等待上一条完成后才能执行;
- 异步:遇到异步任务可以继续往下执行,等到异步任务完成了再执行特定的语句,但代码写起来稍微复杂一些。
因此我们有个小小的愿望——如果能用同步的写法来实现异步就好了。下面开始介绍 JavaScript 异步编程方法的发展之路。
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!”。
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
函数。于是打印出了:
data: { "name": "Daniel", "score": 95 }
2.3 比较麻烦的情况
当连续出现“后一个异步操作依赖上一个异步操作的返回结果”时,回调函数会变得难以使用。
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 小结
回调函数能够实现异步处理,但存在一些问题(“回调地狱”):
- 一层层回调函数堆叠起来,不利于代码的维护;
- 结构混乱,逻辑耦合强,不利于错误处理;
- 代码横向发展,不利于阅读。
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 接收一个函数作为参数,该函数有两个参数,分别为 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
)。
3.2 Promise/A+规范
Promise 的实例有三种状态: pending
、 fulfilled
和 rejected
。初始状态为 pending
,该状态可以变为 fulfilled
或 rejected
,状态一旦变化便不可再次改变;且 fulfilled
的 value
和 rejected
的 reason
不可再改变。( fulfilled
即为 resolved
)
Promise 的实例会有一个 then
方法,该方法接收两个参数,分别为成功或失败时的回调函数: promise.then(onFullfilled, onRejected)
。promise 的 then
方法会返回一个新的 Promise 实例(因此可以继续使用 then
等方法进行链式调用)。
-
当一个 promise 成功时,会调用其
then
方法中的成功回调,参数value
为resolve
的值 -
当一个 promise 失败时,会调用其
then
方法中的失败回调,参数reason
为reject
的值
3.3 ES6 Promise
在 ES6 中,JavaScript 对 Promise/A+ 规范进行了实现,还增加了一些 额外的方法
,如 Promise.prototype.catch
、 Promise.prototype.finally
、 Promise.resolve
、 Promise.reject
、 Promise.all
、 Promise.any
和 Promise.race
等等。
3.4 一个小小的思考题
上面提到, then
方法会返回一个新的 Promise 实例,其实 catch
方法也会返回一个新的 Promise 实例。假设我们有:
let p1 = Promise.reject(1) .catch(err => { console.log(err) })
那么
p1
的状态是什么呢?
resolved
?
rejected
?思考并尝试一下吧。
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 能够很好的解决回调函数存在的“回调地狱”问题,代码更加简洁明了。但仍然存在一些小问题,如:
-
Promise 无法取消:还以上述的
load
为例子,在第一个then
中,如果当score
大于等于 60 时,我们不想做后续操作了,则需“取消”掉下面的调用链,在这个场景下只能抛出一个错误并在后面catch
,这种写法不够优雅。 -
相对于回调函数的方法,Promise 的链式调用只是更好看一些,还不是我们想要的“同步写法”。还记得文章开头处,我们说的“小小的愿望”吗?如下面的例子,我们希望,异步函数
asyncFuntion1
返回后,res1
拿到返回值,再继续往下执行,如果能写成下面的写法就好了。
let res1 = asyncFunction1() let res2 = asyncFunction2(res1) let res3 = asyncFunction3(res2)
这个时候,就轮到 Generator / yield 出场了。
4. Generator / yield & co
Generator 是可以分段执行的函数,执行期间遇到 yield
可以暂停执行,返回中间状态;而使用 next
方法可以恢复执行,直到下一个 yield
或 return
。
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 {: "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 {: "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 }
。
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 实现了我们“小小的愿望”。但这里还有些小小的问题:
- 我们这里默认了返回值是个 Promise 实例,实际情况中可能不是;
-
我们需要手动写
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 库的代码量不多,但思想是很巧妙的。其关键点是,在异步操作的回调函数中调用
generator
的
next
方法,以实现自动的流程以及值的传递。在这里就不展开展开讨论其实现细节了,感兴趣的读者可以阅读源码学习。
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) })
比较后可
以发现,只是
*
换成了
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
5.2 小结
虽然来得比较迟,但最终 async/await 还是到来了,我们借助它可以轻易地写出逻辑清晰的优雅代码。但需要注意一点,async 函数中的代码是同步的,对于没有依赖关系的异步代码不应该放在同一个 async 函数中,否则会造成性能的损失。
6. 总结
事出必有因,有因必有果。JavaScript 异步编程方法就这样一步步演化,从最初的回调函数方法,到 ES6 的 Promise,再到配合 co 库使用的 generator 函数,最后到 async 函数。其写法越来越接近同步模式,最终也摆脱了对第三方库的依赖,让我们可以使用 async/await 和 Promise 写出十分优雅的代码。
☆
END
☆
招聘信息
商业中心前端团队专注于广告投放管理,快应用,快游戏,H5页面以及node.js的开发工作。诚邀具备以上技能的前端开发者加入我们,共同建设智能广告平台。
简历投递:liuke#oppo.com
简历投递:liuxiang10#oppo.com
广告后台团队专注于广告投放管理、播放检索、计费统计等广告系统核心服务研发工作, 诚邀具备分布式系统架构设计与调优能力,对高可用/高并发系统有实践经验,对计算广告有浓厚兴趣的同学加入。
简历投递:chenquan#oppo.com
客户端团队
致力于研究Android手机上应用、游戏的商业化变现解决方案、协助应用、游戏通过商业化SDK快速实现变现盈利,诚邀对于Android应用、游戏商业化变现解决方案感兴趣、满三年开发经验的Android应用开发者加入,与团队和业务一起成长。
简历投递:liushun#oppo.com
数据标签团队致力于穿透大数据来理解每个OPPO用户的商业兴趣。数据快速拓展和深挖中,诚邀对数据分析、大数据处理、机器学习/深度学习、NLP等有两年以上经验的您加入我们,与团队和业务一同成长!
简历投递:ping.wang#oppo.com
你可能还喜欢
本文来自OPPO商业中心团队,你可能还喜欢他们的其他文章(点击阅读原文查看更多):
更多技术干货
扫码关注
OPPO互联网技术
我就知道你“在看”