logo

JavaScript Promise - 基础中的细节 (1/6)

JavaScript Promise 的推出是为了解决回调 (callback) 造成的各种问题,如难以扩展、参数顺序缺乏一致性、对错误没有统一的处理方式以及大名鼎鼎的 “回调地狱” 问题。要了解 Promise,先来了解一下它的定义以及常见术语。

Promise

Promise 是一个对象,函数可以通过它在 “将来” 传播错误或结果。在任何时候,Promise 都处于以下三种状态之一:pending, resolved, rejected

then() 方法

then() 是 Promise 的实例方法,它带有两个回调函数作为参数:第一个参数被称为 fulfillment handler (成功), 第二个被称为 rejection handler (失败) ,这两个参数都是可选的。

任何以这种方式实现了 then() 方法的对象都被称为 thenable,所有的 Promise 都是 thenable,但不是所有的 thenable 都是 Promise。

const promise = fetch('books.json')

promise.then(response => {}, reason => {})

promise.then(response => {})

// 省略 fulfillment handler
promise.then(null, reason => {})

题外话,关于 fetch() 方法有点直觉上不合理细想之下也还可以的细节:只要 fetch() 能接收到一个 HTTP 状态码,即便是 404 或 500,它返回的 promise 都被认为是成功的,只有在网络连接失败或者其他一些原因才会返回失败的 promise,因此如果需要确定响应状态码在 200 - 299 之间,需要检查 response.ok 属性。

catch() 方法

Promise 实例还有一个 catch() 方法用来捕获错误,它等价于只有 rejection handler 的 then() 方法。它存在的原因是让 Promise 的成功或失败一目了然。

如果一个 promise 的失败没有被捕获,那么它可能输出日志到 console,或者抛出错误,或者两者兼有,取决于 JavaScript 的运行时实现。

finally() 方法

Promise 实例的 finally() 方法无论 promise 成功还是失败都会运行。它接受一个回调函数作为参数,这个函数被称为 settlement handler,不接受任何参数,因为 finally() 无论如何都会运行,所以无法得知 promise 是成功还是失败,参数显得毫无意义。

注意,finally() 的回调不会阻止抛出错误,错误的处理仍然需要 rejection handler。

即便一个 promise 处于 settled 状态,我们也可以随时添加新的 handler,并保证它们将被调用。

const promise = fetch('books.json')

// 原 fulfillment handler
promise.then(response => {
    // promise 已经是 fulfillment 状态,但仍然可以添加新的 fulfillment handler
    // 这个 handler 被添加到 microtask 队列,在准备好时被执行
    promise.then(response => {})
})

Handler 和 Microtask

在程序的常规流程中执行的 JavaScript 作为 task 执行,即 JavaScript 运行时创建一个新的执行上下文并完全执行代码,完成后退出。如网页中按钮的 onclick handler 即作为 task 执行。单击按钮时,将创建一个新的 task,并执行 onclick 的 handler。完成后,JavaScript 运行时等待下一个交互来执行更多代码。但 Promise 的 handler 不同。

在 JavaScript 引擎中,Promise 的所有 handler 都是作为 microtask 执行的。Microtask 加入队列,然后在当前 task 执行完毕之后立即执行。调用 then()catch() finally() 即告诉 promise 在其成为 settled 状态之后将指定的 microtask 排队。

这和 setTimeout() 以及 setInterval() 不同,后两个方法都会创建新的 task 然后在以后的某个时间执行。而在同一个 task 中排队的 promise handler 总是比这些 timer 先执行。可以用全局函数 queueMicrotask() (用来在 promise 之外创建 microtask) 来证明这一点:

// microtask.mjs
setTimeout(() => {
    console.log('timer')
    queueMicrotask(() => console.log('microtask inside timer'))
}, 0)

queueMicrotask(() => console.log('microtask'))

/*
执行 `node microtask.mjs` 获得如下结果:

microtask
timer
microtask inside timer
 */

即便 setTimeout() 设置为 0 微秒后执行,也依然是 microtask 先执行。

关于 microtask 和 promise handler 最重要的一点是:它们在一个 task 执行完毕之后立即执行,这最大限度地减少了创建 promise 和响应 promise 之间的时间,使 promise 适合于运行时性能很重要的情况。

创建 Promise

创建 Unsettled Promise

Promise 使用 Promise 构造函数来创建,这个构造函数接受一个被称为 executor 的函数作为参数。executor 函数接受两个函数参数:第一个参数当需要确定 executor 已成功执行时调用,表示 promise 执行成功;如果 executor 执行过程发生错误,调用第二个参数以表示 promise 失败。

function requestURL(url) {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest()
        
        xhr.addEventListener('load', () => {
            resolve({
                status: xhr.status,
                text: xhr.responseText,
            })
        })
        
        xhr.addEventListener('error', () => {
            reject(error)
        })
        
        xhr.open('get', 'url')
        xhr.send()
    })
}

const promise = requestURL('books.json')
promise.then(response => {}, reason => {})

一个重要的知识点:executor 在创建 promise 时即立即执行,但 handler 直到当前 task 执行完毕才会执行,这很容易证明:

// demo.mjs
const promise = new Promise((resolve, reject) => {
    console.log('Executor')
    resolve(2)
})

promise.then(result => console.log(result))
console.log('Hi!')

/*
上面的代码放入 demo.mjs 文件,执行 `node demo.mjs` 得到如下结果:

Executor
Hi!
2
 */

一个 promise 只能被 resolve 一次,如果在 executor 中多次调用 resolve() handler,那么第一次之后的调用都会被忽略。

每个 executor 内部都有一个隐式的 try-catch 来捕获错误,并将错误传递给 rejection handler。

const promise = new Promise((resolve, reject) => { throw new Error('error') })

// 等价于
const promise1 = new Promise((resolve, reject) => {
    try {
        throw new Error('error')
    } catch (e) {
        reject(e)
    }
})

创建 Settled Promise

有两种类级方法可以创建给定值的 settled 状态的 promise:Promise.resolve() 方法接受一个参数并返回一个成功状态的 promise;Promise.reject() 则返回一个失败状态的 promise。

const p1 = Promise.resolve(1)
p1.then(v => console.log(v)) // 1

const p2 = Promise.reject(2)
p2.catch(v => console.error(v)) // 2

如果给 Promise.resolve() 方法传递一个 promise 作为参数,这个 promise 会被原样返回。

const p1 = Promise.resolve(1)
const p2 = Promise.resolve(p1)

console.log(p1 === p2) // true

这两个方法都可以接受一个 thenable 作为参数,返回一个新的 promise。

const thenable = {
    then(resolve, reject) {
        resolve(2)
    }
}

const promise = Promise.resolve(thenable)
promise.then(v => console.log(v)) // 2

在 ES6 引入 promise 之前,很多库采用了 thenable 的方式,因此出于向后兼容性的目的保留了对 thenable 的使用。如果不确定一个对象是否是 promise,根据需要将其传递给 Promise.resolve()Promise.reject() 是最好的方式,因为 promise 总是被原样传递。

理解 promise 的一种方式是将其作为某个值的占位符看待,该值可能在稍后的某个异步操作中提供。用 promise 来表示操作的结果,以取代 event handler 和 callback 组合。

总结

对我自己来说,一些平时没了解/注意到的细节是:

  1. then() 方法接收第二个 reject 参数
  2. then(null, reason => {}) 等价于 catch(reason => {})
  3. thenable 概念
  4. fetch() 方法成功/失败的真实逻辑
  5. finally() 为什么不需要参数;以及并非 rejection handler 的替代品
  6. 已确定状态的 promise 仍可无限添加 handler
  7. task/microtask;以及 queueMicrotask() 的存在
  8. executor 的立即执行
  9. promise 的隐式 try-catch