1. 90前端首页
  2. 前端开发
  3. JavaScript

JavaScript 异步编程之路

本文来自OPPO互联网技术团队,转载请注名作者。同时欢迎关注我们的公众号:OPPO_tech,与你分享OPPO前沿互联网技术及活动。

一、基本介绍

我们知道,JavaScript 语言的一大特点是单线程,这是由它最初的应用场景决定的。它最初作为浏览器的脚本语言,用来与用户进行交互,并且可以用来操作 DOM。如果它是多线程的,可能会带来复杂的冲突,因此 JavaScript 最初被设计时即为单线程的。

虽然在 HTML5 标准中新增了 Web Worker 的概念,它允许 JavaScript 创建多个线程,但这些子线程完全受主线程的控制,且不能操作 DOM,因此本质上 JavaScript 还是单线程的。在 JavaScript 中,除主线程外,还存在一个任务队列,主线程循环不断地从任务队列中读取事件,这整个运行机制被称为事件循环,事件循环的过程在这里就不展开讨论了。

在主线程上的任务是排队执行的,只有前一个任务完成了才会执行后一个任务,这些任务是“同步”的;而任务队列中的任务(如定时器、网络请求、Promise 等)只有在满足条件时才会被加入到主线程中执行,在满足条件之前不会阻塞主线程中的任务,这些任务是“异步”的。从执行顺序来说,同步和异步的特点是:

  • 同步:从上到下执行,便于理解,写起来方便,但下一条语句需要等待上一条完成后才能执行;
  • 异步:遇到异步任务可以继续往下执行,等到异步任务完成了再执行特定的语句,但代码写起来稍微复杂一些。

因此我们有个小小的愿望——如果能用同步的写法来实现异步就好了。下面开始介绍 JavaScript 异步编程方法的发展之路。

二、回调函数

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 小结

回调函数能够实现异步处理,但存在一些问题(“回调地狱”):

  1. 一层层回调函数堆叠起来,不利于代码的维护;
  2. 结构混乱,逻辑耦合强,不利于错误处理;
  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.any Promise.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 出场了。

四、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 }

JavaScript 异步编程之路

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 库的帮助。

五、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 函数中,否则会造成性能的损失。

六、总结

事出必有因,有因必有果。JavaScript 异步编程方法就这样一步步演化,从最初的回调函数方法,到 ES6 的 Promise,再到配合 co 库使用的 generator 函数,最后到 async 函数。其写法越来越接近同步模式,最终也摆脱了对第三方库的依赖,让我们可以使用 async/await 和 Promise 写出十分优雅的代码。

本文来自网络整理,转载请注明原出处:https://segmentfault.com/a/1190000021729432

展开阅读全文

发表评论

登录后才能评论