Skip to content
Article
Authors
On this page
Published on

JavaScript 异步编程

Article
Authors

JavaScript的一个核心特性是单线程执行,这里的单线程是指代码的执行是单线程的(宿主环境的API可以多线程),其最初的目的是为了避免多线程的DOM操作可能带来的错乱。优点是更安全简单,缺点是当遇到一个耗时任务时,后面的任务会被阻塞,从而拖延整个程序的执行。

为了解决耗时任务阻塞线程的问题,JavaScript将任务的执行模式分为了同步模式和异步模式,运行环境也提供了以同步或者异步模式运行的API。

  • 同步模式: 任务排队执行,执行过程容易理解。
  • 异步模式: 对于耗时操作,开启过后就立即往后执行下一个任务,异步后续的逻辑一般通过回调函数的方式定义,在耗时任务执行过后就会自动执行传入的回调函数。

JavaScript引擎会先执行完调用栈中的任务,然后通过事件循环从“消息队列”中再取一个出来执行,在此过程中,可以随时再往消息队列中添加任务。也正是因为大量的异步模式的API导致代码一些复杂的异步逻辑不易阅读。

1. 回调函数

回调函数是所有异步编程方案的根基,回调函数由调用者定义,交给执行者执行,具体用法就是把函数作为参数传递罢了。

除了传递回调函数参数以外,还有几种常见的实现异步的方式如事件机制、发布订阅,可以理解为回调函数的变体。

当使用传统回调方式去完成复杂的异步流程时,会无法避免大量的回调函数嵌套,造成回调地狱问题

js
setTimeout(function () {
  console.log('1.执行任务1 -> 三秒后执行任务2')
  setTimeout(function () {
    console.log('2.执行任务2 -> 2秒后执行任务1')
    setTimeout(function () {
      console.log('3.执行任务3 -> 1秒后执行完毕')
      // 回调地狱...
    }, 1000)
  }, 2000)
}, 3000)

2. Promise

为了避免回调地狱问题,CommonJS社区提出了Promise规范,并在ES2015中被标准化。Promise实际上就是一个对象,用来表示一个异步任务执行过后究竟是成功还是失败,无论成功或失败只需要执行相应的回调函数即可。

2.1 Promise 的基本用法

  1. 构建Promise实例时传入一个函数,函数的参数为两个处理函数,在Promise里编写承诺的逻辑,即什么情况下成功,什么情况下失败,并在成功或失败时改变状态,执行提前注册的处理函数
  2. 使用then方法分别注册promise实例的onFulfilledon和onRejected回调函数
  3. 注意注册的函数不会立即执行,而是等待同步代码执行完才会执行(如下例)
js
let a = 1, b = 2

const promise = new Promise(function (res, rej) {
  if (a > b)
    res('success') // 成功时调用res函数
  else
    rej('fail')    // 失败时调用rej函数
})

promise.then(function (sucStr) {
  console.log(sucStr)
}, function (failStr) {
  console.log(failStr)
})

console.log('第一个同步任务执行才会执行提前注册的异步处理函数')

2.2 Promise 封装一个异步任务函数

js
// 使用promise封装一个ajax请求
function ajax(url) {
  return new Promise(function (resolve, reject) {
    let xhr = new XMLHttpRequest()
    xhr.open('GET', url)        // 指定请求方法和地址
    xhr.responseType = 'json'   // 指定相应类型为json
    xhr.onload = function () {  // 请求完成后执行onload函数
      if (this.status === 200) {
        resolve(this.response)
      }
      else {
        reject(new Error(this.status))
      }
      xhr.send()
    }
  })
}

ajax('/user.json').then(function (res) {
  console.log(res)
}, function (err) {
  console.log(err)
})

2.3 Promise 的链式调用

promise的本质也是使用回调函数,即通过then方法传递进去,而且promise将回调分成了两种即成功的回调和失败的回调。但是如果需要串联执行多个异步任务,还是会出现回调地狱的问题,这时候使用promise就没有任何意义了,还额外增加了复杂度。

所以嵌套使用promise是不可取的,正确的做法是使用promise方法链式调用的特点来尽量保证串联异步任务的 “扁平化”。

  • promise的then方法会返回一个全新的promise对象,用于实现promise的链式调用
  • 后面的then就是在为前面then返回的promise注册回调
  • 前面then方法中调用函数的返回值会作为后面then方法回调的参数
  • 若回调中返回的是一个promise对象,则后面then方法的回调会等待他的结束
js
ajax('api/url1')
	.then(value => {
		return ajax('api/url2')
  })
	.then(value => {
		return ajax('api/url3')
  })
	.then(value => {
		return ajax('api/url4')
  })
	.catch(error => {
		console.log(error)
  })

多次catch的链式调用(for test):

js
const p = Promise.reject(new Error('fail1'))

p.catch(err => console.log(err))
	.then(()=>console.log('ok1'))
	.then(()=>console.log('ok2'))
	.then(()=>{throw new Error('fail2')})
	.catch(err => console.log(err))
	.then(()=>console.log('ok3'))
	.then(()=>console.log('ok4'))

2.4 Promise 的异常处理

除了promise的执行逻辑执行了reject函数,在promise在执行的过程中出现错误或者主动抛出异常也会执行reject函数,所以then方法中注册的onrejected函数就是为promise的异常做处理,即promise失败或者出现异常都会被执行。其实onrejected函数的注册还有一个更常见的用法,即使用promise实例的catch方法注册onrejected回调

js
ajax('/user.json')
  .then(function onFulfilled(res) {
    console.log(res)
  })
  .catch(function onRejcted(error) {
    console.log(error)
  })

// 等同于
ajax('/user.json')
  .then(function onFulfilled(res) {
    console.log(res)
  })
  .then(undefined, function onRejcted(error) {
    console.log(error)
  })

用catch方法更常见,因为更适合链式调用。错误会随着promise链条传递,所以最后使用catch方法更像是给整个promise链条注册的失败回调

2.5 Promise 的静态方法

js
Promise.resolve('success')  // 直接返回一个成功的Promise对象
Promise.resolve(promise)    // 如果接收到一个promise对象则原样返回

Promise.reject(new Error('rejected')) // 快速得到一个一定是失败的promise对象
	.catch(error => console.log(err))

2.6 Promise 并行执行

前面都是多个promise串联执行异步任务,而当多个任务彼此没有依赖时可以并行执行来提高执行速度,Promise.all方法可以将多个promise合并为一个promise统一管理。Promise.all()接收一个数组,数组中每个元素都是一个promise对象,并返回一个全新的promise对象,当其内部所有的promise都完成后,这个新的promise对象才会完成,且其拿到的结果也是一个数组,数组包含每个异步任务执行的结果。其中任何一个promise失败,这个新的promise也就失败了。

示例:先拿到要访问的url列表,再并行访问

js
ajax('/urls.json')
  .then(value => {
    const urls = Object.values(value)
    const tasks = urls.map(map => ajax(url))
    return Promise.all(tasks)
  })
  .then(values => {
    console.log(values)
  })

Promise.race()也可以将多个promise合并为一个promise对象,但Promise.all()等待所有任务结束才会结束,而Promise.race()只会等待第一个先结束的任务。

示例:一个请求promise+一个定时promise用于处理请求超时

js
const request = ajax('/urls.json')
const timeout = new Promise((resolve, reject) => {
  setTimeout(() => reject(new Error('timeout')), 500)
})

Promise.race([
  request,
  timeout
])
  .then(value => { console.log(value) })
  .catch(error => { console.log(error) })

2.7 Promise 的执行时序(宏任务&微任务)

即使Promise没有任何的异步操作,它的回调函数仍然不会立即执行而是进入回调队列中排队,即等待所有同步代码执行完后才会执行。

但在回调队列中排队等待的任务并不是平等的。回调队列中的任务称为宏任务,宏任务的执行过程中可以临时加上一些额外需求,这些临时需求可以作为新的宏任务进到队列排队(如下setTimeout回调),也可以作为当前任务的微任务,直接在本轮调用的末尾立即执行(如下Promise回调),微任务的概念是后来才提到JS中的,目的是为了提高整体响应能力。目前绝大多数异步调用的API都是作为宏任务执行,而作为微任务的有Promise & MutationObserver & Node中的process.nextTick

js
console.log('start')

setTimeout(() => console.log('settimeout'), 0)

Promise.resolve()
  .then(() => console.log('promise1'))
  .then(() => console.log('promise2'))
  .then(() => console.log('promise3'))

console.log('end')

// --------------------------
start
end
promise1
promise2
promise3
settimeout

3. Generator异步方案

相比于传统异步调用的方式,用Promise处理异步调用最大的优势就是可以通过链式调用解决回调嵌套的问题,可以实现异步任务的串联执行,但这样依然有大量的回调函数(尽管没嵌套),依然没有达到传统同步代码的可读性。

ES2015提供了Generator:

js
// 生成器函数的语法就是普通函数加个*
function* foo() {
  try {
    const res = yield 'foo'  // 遇到yield会暂停生成器的执行,并把后面的值返回出去
    console.log(res)
  } catch (e) {
    console.log(e)
  }
}

// 调用生成器函数不会立即执行,而是返回一个生成器对象
const generator = foo()

// 1.手动调用生成器对象的next方法,生成器函数的函数体才会开始执行
// 2.result可以接收yield返回的值
// 3.可以传递参数到函数中yield的位置
const result = generator.next()
console.log(result)

generator.next('bar')

// throw方法也可以让生成器继续执行,不过会向生成器中抛出异常
generator.throw(new Error('Generator error'))

可以利用yield暂停生成器函数执行的特点,实现更优的异步编程体验:

js
function* main() {
  const users = yield ajax('/api/users.json')
  console.log(users)

  const posts = yield ajax('/api/posts.json')
  console.log(posts)
}

const g = main()
const result = g.next()

// result.value拿到返回的promise对象
result.value.then(data => {
  const result2 = g.next(data)
  if (result2.done) return
  result2.value.then(data => {
    const result3 = g.next(data)
    if (result3.done) return
    result3.value.then(data => {
      g.next(data)
    }
	})
})

使用递归优化上述代码,实现更通用的生成器函数的执行器:

js
function * main() {
  try {
    const users = yield ajax('/api/users.json')
    console.log(users)
    const posts = yield ajax('/api/posts.json')
    console.log(posts)
  } catch (e) {
    console.log(e)
  }
}

function handleResult(result) {
  if (result.done) return
  result.value.then(data => {
    handleResult(g.next(data))
  }, error => {
    g.throw(error)
  })
}

const g = main()
handleResult(g.next())
/*---------------------------------------------*/

// 进一步封装
function co(generator) {
  const g = generator()
  
  function handleResult(result) {
    if (result.done) return
    result.value.then(data => {
      handleResult(g.next(data))
    }, error => {
      g.throw(error)
    })
  }
  handleResult(g.next())
}

co(main)

💡 这种co的实现在2015年前流行,后来出现Async/Await后,就不太用了

4. Async/Await语法糖

有了Generator,JS的异步编程就有类似同步的编程体验了,但用Generator方案还需要自己编写一个执行器函数(co),比较麻烦。而在ES2017中新增了Async函数同样提供了扁平化的异步编程体验,使用起来更方便。

js
async function main() {
  try {
    const users = await ajax('/api/users.json')
    console.log(users)
    const posts = await ajax('/api/posts.json')
    console.log(posts)
  } catch (e) {
    console.log(e)
  }
}

main()

此外,async还会返回一个promise对象,更加利于对整体代码进行控制:

js
const promise = main()

promise.then(() => {
	console.log('all completed')
})

INFO

目前await关键字只能在async函数内部使用,在新标准中可能在外部使用