欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

JavaScript 异步编程(2)Promise

程序员文章站 2022-05-08 16:22:52
...

概念

直接使用传统回调方式去完成复杂的异步流程,会造成大量的回调函数嵌套,这称为回调地狱。

$.get('/ur1', function (data1) {
	$.get('/ur2', function (data2) {
  	$.get('/ur3', function (data2) {
      // ...
    }
  }
}

CommonJS 社区提出了 Promise 的规范,在 ES2015 中被标准化,成为语言规范。

Promise 是一种更优的异步编程统一方案。

Promise 是一个对象,用来表示异步任务最终是成功还是失败。

Promise 有三种状态:Pending(等待)、Fulfilled(成功)、失败(Rejected)。
JavaScript 异步编程(2)Promise
不论是 Fulfilled(成功)还是 Rejected(失败),都会有相应的任务自动执行。

在承诺明确了结果之后,就不能再发生改变了。

基本用法

搭建简单的环境,使用 webpack-dev-server 运行而不用将 JavaScript 引入到 HTML。

const promise = new Promise(function (resolve, reject) {
  // 这里用于“兑现”承诺

  resolve(100) // 承诺达成

  // reject(new Error('promise rejected')) // 承诺失败
})

promise.then(function (value) {
  // 即便没有异步操作,then 方法中传入的回调仍然会被放入队列,等待下一轮执行
  console.log('resolved', value)
}, function (error) {
  console.log('rejected', error)
})

console.log('end')

由于 promise 是异步操作,console.log 会比 promise.then 方法内的 console.log 先打印,具体的先后顺序在后面的章节再进行讨论。

使用案例

使用 promise 封装 AJAX 的例子。

// Promise 方式的 AJAX
function ajax (url) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    xhr.open('GET', url)
    xhr.responseType = 'json'
    xhr.onload = () => {
      if (xhr.status === 200) {
        resolve(xhr.response)
      } else {
        reject(new Error(xhr.statusText))
      }
    }

    xhr.send()
  })
}

ajax('/api/urls.json').then(function (res) {
  console.log(res)
}, function (error) {
  console.log(error)
})

常见误区

Promise 的本质上也是使用回调函数的方式去定义异步任务结束后所需要执行的任务。只不过回调函数是通过 then 方法传递进去的。

Promise 将回调分成了两种,成功的回调 onFulfilled 和失败的回调 onRejected。

Promise 既然也是回调函数,那么也会出现回调函数嵌套的问题,且还额外增加了复杂度。

以 AJAX 为例:

ajax('/api/urls.json').then(function (urls) {
  ajax(urls.users).then(function (users) {
    // ...
  })
})

嵌套使用的方式是使用 Promise 最常见的错误。正确的做法是借助 Promise then 方法链式调用的特点,尽可能保证异步任务扁平化。

链式调用

Promise 的链式调用可以最大程度的避免链式嵌套。Promise 的 then 方法会返回一个新的 Promise 对象。

步骤

var promise = ajax('/api/users.json')

var promise2 = promise.then(
  function onFulfilled (value) {
    console.log('onFulfilled', value)
  },
  function onRejected (error) {
    console.log('onRejected', error)
  }
)

console.log(promise2 === promise)

每一个 Promise 都可以负责一个异步任务。每一个 then 方法都在为上一个 then 返回的 promise 对象添加状态明确过后的回调,这些 promise 会依次执行。

ajax('/api/users.json')
  .then(function (value) {
    console.log(111)
  }) // => Promise
  .then(function (value) {
    console.log(222)
  }) // => Promise
  .then(function (value) {
    console.log(333)
  }) // => Promise
  .then(function (value) {
    console.log(444)
  })

我们可以在 then 的回调中手动返回一个 promise 对象。下一个 then 方法便会在这个 return 的 promsie 添加状态明确过后的回调。我们可以使用这样链式的方式避免回调嵌套。

ajax('/api/users.json')
  .then(function (value) {
    console.log(111)
    return ajax('/api/urls.json')
  }) // => Promise
  .then(function (value) {
    console.log(222)
  }) // => Promise
  .then(function (value) {
    console.log(333)
  }) // => Promise
  .then(function (value) {
    console.log(444)
  })

回调中若返回的不是一个 promise,而是普通的值,这个值会作为当前 then 方法返回的 promise 的值。

ajax('/api/users.json')
  .then(function (value) {
    console.log(111)
    return ajax('/api/urls.json')
  }) // => Promise
  .then(function (value) {
    console.log(222)
  }) // => Promise
  .then(function (value) {
    console.log(333)
  	// 即使返回一个普通值,这个值也会被 promise 对象包裹。
    return 'foo'
  }) // => Promise
  .then(function (value) {
    console.log(444)
    console.log(value)
  })

总结

  • Promise 对象的 then 方法会返回一个全新的 Promise 对象
  • 后面的 then 方法为上一个 then 返回的 Promise 注册回调
  • 前面 then 方法中回调函数的返回值会作为后面 then 方法回调的参数
  • 如果回调中返回的是 Promise,那后面的 then 方法的回调会等待它的结束。

异常处理

在 Promise 执行的过程中出现异常,或者手动抛出一个异常,onRejected 回调也会被执行。

用 Promise 实例的 catch 方法调用 onRejected 回调更为常见,这种方式更适合链式调用。

ajax('/api/users.json')
  .then(function (value) {
    console.log('onFulfilled', value)
  }).catch(function (error) {
    console.log('onRejected', error)
  })

catch 方法与 then 方法第二个参数使用失败回调的区别

catch 失败回调可以捕获整个 Promise 链条上的异常,而 then 方法第二个回调参数只能捕获上一个 Promise 的异常。

Promise 链条上任何一个异常都会被向后传递直至捕获,catch 方法是给整个 Promise 链条注册的失败回调,更为通用。

在代码中明确捕获每一个可能的异常

我们可以在全局对象上注册 unhandledrejection 事件处理代码中没有被手动捕获的异常。

浏览器环境

window.addEventListener('unhandledrejection', event => {
	const { reason, promise } = event
  
  console.log(reason, promise)
  // reason => Promise 失败原因,一般是一个错误对象
  // promise 出现异常的 Promise 对象
})

Node 环境

process.on('unhandledrejection', (reason, promise) => {
  console.log(reason, promise)
  // reason => Promise 失败原因,一般是一个错误对象
  // promise 出现异常的 Promise 对象
})

但是并不推荐全局统一处理,而是应该在各个代码部分捕获异常。

静态方法

Promise.resolve()

把一个值转换为 Promise 对象

// 'foo' 字符串作为 Promise 对象所返回的值
Promise.resolve('foo')
  .then(function (value) {
    console.log(value)
  })

Promise.resolve() 等价于

new Promise(function (resolve, reject) {
  resolve('foo')
})

如果 Promise.resolve 收到的是另一个 Promise,则会原样返回这个 Promise 对象。

const promise = ajax('/api/users.json')
const promise2 = Promise.resolve(promise)

console.log(promise === promise2)

若传入 Promise.resolve 的是一个对象,这个对象也有跟 Promise 一样的 then 方法,这个方法中可以接收 onFulfilled 和 onRejected 回调。

Promise.resolve({
  then: function (onFulfilled, onRejected) {
    onFulfilled('foo')
  }
})
  .then(function (value) {
    console.log(value)
  })

还有一个与之对应的 Promise.reject 方法,快速创建一个失败的 Promsie 对象。

// 无论传入什么参数,都会作为失败的原因
Promise.reject(new Error('rejected'))
  .catch(function (error) {
    console.log(error)
  })

并行执行

在一个页面需要请求多个接口的情况,当这些接口相互之间没有依赖,我们可以并行请求避免消耗时间。

// ...
ajax('/api/users.json')
ajax('/api/posts.json')

某些情况下我们需要在两个接口都请求完成之后执行其他操作。

传统的做法是计数器,每结束一次计数器累加一次,知道计数器和我们的任务数量相等。

Promise.all

接收一个数组,数组中的元素每个都是 Promise 对象,当内部所有的 Promise 都成功结束时,这个全新的 Promise 对象以成功结束,返回一个数组,否则以失败结束。

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

Promise.race

接收一个 Promise 对象数组,只要有任何一个任务完成,Promise 对象就会完成,无论结果是成功状态还是失败失败。

需要在开发者工具中设置一个较慢的网络进行测试。

// ...
const request = ajax('/api/posts.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)
})

// outout: Error: timeout ...

执行时序

console.log('global start')

setTimeout(() => {
  console.log('setTimeout')
}, 0);

Promise.resolve()
  .then(() => {
    console.log('promise')
  })
    .then(() => {
    console.log('promise 2')
  })
    .then(() => {
    console.log('promise 3')
  })

console.log('global end')

如果按照先调用 setTimeout 后调用 Promise 的顺序进入消息队列 Queue,那么也应该是先打印 setTimeout 再打印 promise,但是 Promise 的执行时序有些特殊。

宏任务与微任务

回调队列中的任务称之为“宏任务”,宏任务执行过程中可以临时加上一些额外需求,对于这些需求可以选择一个新的宏任务进到队列中排队。setTimeout 便是宏任务。

也可以作为当前任务的“微任务”,直接在当前任务结束过后立即执行,而不需要重新排队。Promise 的回调会作为微任务执行。

所以我们可以回答上面的问题,为什么先打印 Promise,再打印 setTimeout

微任务是 JavaScript 的新概念,为了提高整体的响应能力。

目前绝大多数异步调用都是作为宏任务执行,Promise 对象和 MutationObserver 以及 Node 环境的 process.nextTick 都是微任务。