toFrankie / blog

种一棵树,最好的时间是十年前。其次,是现在。
21 stars 1 forks source link

细读 ES6 | Promise 上篇 #255

Open toFrankie opened 1 year ago

toFrankie commented 1 year ago

配图源自 Freepik

我认为 Promise 应该算是 ES6 标准最大的亮点,它提供了异步编程的一种解决方案。比传统的回调函数和事件解决方案,它更合理、更强大。

一、简介

Promise 是一个容器,里面保存着某个未来才会结束的事件(一般为异步操作)的结果。从语法上来说,Promise 是一个对象,它可以获取异步操作的消息。

Promise 对象的特点:

这种以“同步的方式”去表达异步流程,可以避免层层嵌套的回调函数,避免出现“回调地狱”(Callback Hell)。

BTW,网上有些文章把 fulfilled 状态,叫成 resolved,尽管我们可能知道他想表达的意思,但其实是不对的。

Promise 对象的缺点:

一是无法取消 Promise,一旦创建它就会立即执行,无法中途取消;二是若不设置回调函数情况下,Promise 内部抛出错误,不会反馈到外部;三是当处于 pending 状态,无法得知目前进展到哪个阶段。

二、Promise 用法

根据规定,Promise 是一个构造函数,用来生成 Promise 实例对象。

1. 创建 Promise 对象

示例:

const handler = (resolve, reject) => {
  // some statements...

  // 根据异步操作的结果,通过 resolve 或 reject 函数去改变 Promise 对象的状态
  if (true) {
    // pending -> fulfilled
    resolve(...)
  } else {
    // pending -> rejected
    reject(...)
  }

  // 需要注意的是:
  // 1. 在上面 Promise 状态已经定型(fulfilled 或 rejected),
  //    因此,我们再使用 resolve() 或 reject() 或主动/被动抛出错误的方式,
  //    试图再次修改状态,是没用的,状态不会再发生改变。
  // 2. 当 Promise 对象的状态“已定型”后,若未使用 return 终止代码往下执行,
  //    后面代码出现的错误(主动抛出或语法错误等),在外部都不可见,无法捕获到。
  // 3. hander 函数的返回值是没意义的。怎么理解?
  //    假设内部不包括 resolve() 或 reject() 或内部不出现语法错误,
  //    或不主动抛出错误,仅有类似 `return 'anything'` 语句,
  //    那么 promise 对象永远都是 pending 状态。
}

const promise = new Promise(handler)

Promie 构造函数接受一个函数作为参数,该函数的两个参数分别是 resolvereject。而 resolverejeact 也是函数,其作用是改变 Promise 对象的状态,分别是 pending -> fulfilledpending -> rejected

假设构造函数内不指定 resolvereject 函数,那么 Promise 的对象会一直保持着 pending 待定的状态。

2. Promise 实例

Promise 实例生成以后,当 Promise 内部状态发生变化,可以使用 Promise.prototype.then() 方法获取到。

const success = res => {
  // 当状态从 pending 到 fulfilled 时,执行此函数
  // some statements...
}

const fail = err => {
  // 当状态从 pending 到 rejected 时,执行此函数
  // some statements...
}

promise.then(success, fail)

then() 方法接受两个回调函数作为参数,第一个回调函数在 Promise 对象状态变为 fulfilled 时被调用。第二回调函数在状态变为 rejected 时被调用。then() 方法的两个参数都是可选的。

注意,由于 Promise 实例对象的 Promise.prototype.then()Promise.prototype.catch()Promise.prototype.finally() 方法属于异步任务中的微任务。注意它们的执行时机,会在当前同步任务执行完之后,且在下一次宏任务执行之前,被执行。

还有,Promise 构造函数(即上述示例的 handler 函数)内部,仍属于同步任务,而非异步任务。

所以,那个经典的面试题就是,包括 setTimeoutPromise 等,然后问输出顺序是什么?本质就是考察 JavaScript 的事件循环机制(Event Loop)嘛。这块内容可以看下文章:JavaScript 事件循环

插了个话题,回来

then() 方法的两个参数 success()fail(),它们接收的实参就是传递给 resolve()reject() 的值。

例如:

function timeout(delay, status = true) {
  const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
      // 一般 reject 应返回一个 Error 实例对象,如:new Error('Oops')
      status ? resolve('Success') : reject('Oops')
    })
  }, delay)
  return promise
}

// 创建两个 Promise 实例对象
const p1 = timeout(1000)
const p2 = timeout(1000, false)

// pending -> fulfilled
p1.then(res => {
  console.log(res) // "Success"
})

// pending -> rejected
p2.then(null, err => {
  console.warn(err) // "Oops"
})

上面示例中,根据 timeout 函数的逻辑,p1 实例的 Promise 状态会从 pending -> fulfilled,而 p2 实例则是从 pending -> rejected。因此会分别打印出 "Success""Oops"

例如,异步加载图片的例子。

function loadImage(url) {
  return new Promise((resolve, reject) => {
    const image = new Image()
    image.onload = function () {
      resolve(image)
    }
    image.onerror = function () {
      reject(new Error(`Could not load image at ${url}.`))
    }
    image.src = url
  })
}

loadImage('https://jquery.com/jquery-wp-content/themes/jquery/images/logo-jquery@2x.png')
  .then(
    res => {
      console.log('Image loaded successfully:', res)
    },
    err => {
      console.warn(err)
    }
  )

因此,Promise 的用法还是很简单的,是预期结果的话,使用 resolve() 修改状态为 fulfilled,非预期结果使用 reject() 修改状态为 rejected。具体返回值根据实际场景返回就好。

3. Promise 注意事项

在构建 Promise 对象的内部,使用 resolve()reject() 去改变 Promise 的状态,并不会终止 resolvereject 后面代码的执行。

例如:

const promise = new Promise((resolve, reject) => {
  resolve(1)
  // 以下代码仍会执行,且会在 then 之前执行。
  // reject() 同理。
  console.log(2)
})

promise.then(res => { console.log(res) }) // 先后打印出 2、1

若要终止后面的执行,只要使用 return 关键字即可,类似 return resolve(1)return reject(1)。但如果这样,其实后面的代码就没意义,因此也就没必要写了。千万别在工作中写出这样的代码,我怕你被打。这里只是为了说明 resolvereject 不会终止后面的代码执行而已。

一般来说,调用 resolve()reject() 说明异步操作有了结果,那么 Promise 的使命就完成了,后续的操作应该是放到 then() 方法里面,而不是放在 resolve()reject() 后面。

在前面的示例中,resolve()reject() 都是返回一个“普通值”。如果我们返回一个 Promise 对象,会怎样呢?

首先,它是允许返回一个 Promise 对象的,但是有些区别。

const p1 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('p1 success')
    // reject('p1 fail')
  }, 3000)
})

const p2 = new Promise((resolve, reject) => {
  // 这时返回一个 Promise 对象
  // ⚠️ 注意,这里 resolve(p1) 或 reject(p1) 执行的逻辑会有所不同。
  resolve(p1)
  // reject(p1)
})

p2.then(
  res => {
    console.log('p2 then:', res)
  },
  err => {
    console.log('p2 catch:', err)
  }
)

分析如下:

1. 若 p2 内里面 `resolve(p1)` 时:

   当代码执行到 `resolve(p1)` 时,由于 p1 的状态仍是 pending,
   这时 p1 的状态会传递给 p2,也就是说 p1 的状态决定了 p2 的状态,
   因此 `p2.then()` 需要等 p1 的状态发生变化,才会被调用,
   且 `p2.then()` 获取到的状态就是 p1 的状态

   假设代码执行到 `resolve(p1)` 时,若 p1 的状态已定型,即 fulfilled 或 rejected,
   会立即调用 `p2.then()` 方法。
   PS:这里“立即”是指,当前同步任务已执行完毕的前提下。第 2 点也是如此。

2. 若 p2 内是 `reject(p1)` 时,情况会有所不同:

   当代码执行到 `reject(p1)` 时,由于 p2 的状态会变更为 rejected,
   接着会立即调用 `p2.then()` 方法,由于是 rejected 状态,
   因此,会触发 `p2.then()` 的第二个参数,此时 err 的值就是 p1(一个 Promise 对象)。

   假设 p1 的状态最终变成了 rejected,那么 err 还要捕获异常,
   例如 `err.catch(err => { /* do something... */ })`,
   否则的话,在控制台会报错,类似:"Uncaught (in promise) p1 fail",
   原因就是 Promise 对象的 rejected 状态未处理,导致的。

   假设 p1 的状态最终变成 fulfilled,那么不需要做上一步类似的处理。

上面两种情况,其实相当于 Promise.resolve(p1)Promise.reject(p1)。我们来打印一下两种结果:

p1 状态为 fulfilled 时,p2 状态如图:

p1 状态为 rejected 时,p2 状态如图:

三、Promise.prototype.then()

Promise 的实例具有 then() 方法,它是定义在原型对象 Promise.prototype 上的。当 Promise 实例对象的状态发生变化,此方法就会被触发调用。

前面提到 Promise.prototype.then() 接受两个参数,两者均可选,这里不再赘述。

then() 方法返回一个新的 Promise 实例对象(注意,不是原来那个 Promise 实例),也因此可以采用链式写法,即 then() 方法后面可以再调用另一个 then() 方法。

例如,以下示例使用 Fetch API 进行网络请求:

window.fetch('/config')
  .then(response => response.json())
  .then(
    res => {
      // do something...
    },
    err => {
      // do something...
    }
  )
  // .then() // ...

以上链式调用,会按照顺序调用回调函数,后一个 then() 的执行,需等到前一个 Promise 对象的状态定型。

四、Promise.prototype.catch()

Promise.prototype.catch() 方法是 then(null, rejection)then(undefined, rejection) 的别名,用于指定发生错误时的回调函数。

同样地,它会返回一个新的 Promise 实例对象。

const promise = new Promise((resolve, reject) => {
  reject('Oops') // 或通过 throw 方式主动抛出错误,使其变成 rejected 状态
  // 但注意的是,前面状态“定型”之后,状态是不会再变的。
  // 这后面试图改变状态,或主动抛出错误,或出现其他语法错误,
  // 不会被外部捕获到,即无意义。
})

promise.catch(err => {
  console.log(err) // "Oops"
})

// 相当于
promise.then(
  null,
  err => {
    console.log(err) // "Oops"
  }
)

通过 throw 等方式使其变成 rejected 状态,相当于:

const promise = new Promise((resolve, reject) => {
  try {
    throw 'Oops' 
    // 一般地,是抛出一个 Error(或派生)实例对象,如 throw new Error('Oops')
  } catch (e) {
    reject(e)
  }
})

五、捕获 rejected 状态的两种方式比较

前面提到有两种方式,可以捕获 Promise 对象的 rejected 状态。那么孰优孰劣呢?

建议如下:

尽量不要在 Promise.prototype.then() 方法里面定义 onRejection 回调函数(即 then() 的第二个参数),总使用 Promise.prototype.catch() 方法。

const promise = new Promise((resolve, reject) => {
  // some statements
})

// bad
promise.then(
  res => { /* some statements */ },
  err => { /* some statements */ }
)

// good
promise
  .then(res => { /* some statements */ })
  .catch(err => { /* some statements */ })

上面示例中,第二种写法要好于第一种写法。理由是第二种写法可以捕获前面 then() 方法中的异常或错误,也更接近同步写法(try...catch)。因此,建议总是使用 Promise.prototype.catch() 方法。

与传统的 try...catch 代码块不同的是,即使 Promise 内部出现错误,也不会影响 Promise 外部代码的执行。

const promise = new Promise((resolve, reject) => {
  say() // 这行会报错:ReferenceError: say is not defined
})

promise.then(res => { /* some statements */ })

setTimeout(() => {
  console.log(promise) // 这里仍会执行,打印出 promise 实例对象
})

上面的示例中,在 Promise 内部就会发生引用错误,因为 say 函数并没有定义,但并未终止脚本的执行。接着还会输出 promise 对象。也就是说,Promise 内部的错误并不会影响到 Promise 外部代码,通俗的说法就是“Promise 会吃掉错误”。

但是,如果脚本放在服务器上执行,退出码就是 0(表示执行成功)。不过 Node.js 有一个 unhandledRejection 事件,它专门监听未捕获的 reject 错误,脚本会触发这个事件的监听函数,可以在监听函数里面抛出错误。如下:

注意,Node.js 有计划在未来废除 unhandledRejection 事件。如果 Promise 内部由未捕获的错误,会直接终止进程,并且进程的退出码不为 0

catch() 方法中,也可以抛出错误。而且由于 then()catch() 方法均返回一个新的 Promise 实例对象,因此可以采用链式写法,写出一系列的...

const promise = new Promise((resolve, reject) => {
  reject('Oops')
})

promise
  .then(res => { /* some statements */ })
  .catch(err => { throw new Error('Oh...') })
  .catch(err => { /* 这里可以捕获上一个 rejected 状态 */ })
  // ... 还可以写一系列的 then、catch 方法

六、Promise.prototype.finally()

在 ES9 标准中,引入了 Promise.prototype.finally() 方法,用于指定 Promise 对象状态发生改变(不管 fulfilled 还是 rejected)后,都会触发此方法。

const promise = new Promise((resolve, reject) => {
  // some statements
})

promise
  .then(res => { /* some statements */ })
  .catch(err => { /* some statements */ })
  .finally(() => {
    // do something...
    // 注意,finally 不接受任何参数,自然也无法得知 Promise 对象的状态。
  })

若 Promise 内部不写任何 resovle()、或 rejected()、或无任何语法错误(如上述示例),Promise 实例对象的状态并不会发生变化,即一直都是 pending 状态,它都不会触发 then()catch()finally() 方法。这点就怕有人会误解,状态不发生变化时也会触发 finally() 方法,这是错的。

Promise.prototype.finally() 也是返回一个新的 Promise 实例对象,而且该实例对象的值,就是前面一个 Promise 实例对象的值。

const p1 = new Promise(resolve => resolve(1))
const p2 = p1.then().finally()
const p3 = p1.then(() => { }).finally()
const p4 = p1.then(() => { return true }).finally()
const p5 = p1.then(() => { throw 'Oops' /* 当然这里没处理 rejected 状态 */ }).finally()
const p6 = p1.then(() => { throw 'Oh...' }).catch(err => { return 'abc' }).finally()
const p7 = p1.finally(() => { return 'finally' })
const p8 = p1.finally(() => { throw 'error' })

setTimeout(() => {
  console.log('p1:', p1)
  console.log('p2:', p2)
  console.log('p3:', p3)
  console.log('p4:', p4)
  console.log('p5:', p5)
  console.log('p6:', p6)
  console.log('p7:', p7)
  console.log('p8:', p8)
})

// 解释一下 `p1` 和 `p1.then()`:
// 当 `then()` 方法中不写回调函数时,会发生值的穿透,
// 即 `p1.then()` 返回的新实例对象(假设为 `x`)的值跟 p1 实例的值是一样的,
// 但注意 `p1` 和 `x` 是两个不同的 Promise 实例对象。
// 关于值穿透的问题,后面会给出示例。

根据打印结果可以验证: finally() 方法返回的 Promise 实例对象的值与前一个 Promise 实例对象的值是相等的,但尽管如此,两者是两个不同的 Promise 实例对象。可以打印一下 p1 === p7,比较结果为 false

关于 Promise.prototype.finally() 的实现,如下:

Promise.prototype.finally = function (callback) {
  let P = this.constructor
  return this.then(
    value => P.resolve(callback && callback()).then(() => value),
    reason => P.resolve(callback && callback()).then(() => { throw reason })
  )
}

七、总结

关于 Promise.prototype.then()Promise.prototype.catch()Promise.prototype.finally() 方法,总结以下特点:

  • 三者均返回一个全新的 Promise 实例对象。

  • 即使 then()catch()finally() 方法在不指定回调函数的情况下,仍会返回一个全新的 Promise 实例对象,但此时会出现“值穿透”的情况,即实例值为前一个实例的值。

  • 假设三者的回调函数中无语法错误(包括不使用 throw 关键字) 时,then()catch() 方法返回的实例对象的值,依靠 return 关键字来指定,否则为 undefined

    finally() 方法稍有不同,即使使用了 return 也是无意义的,因为它返回的 Promise 实例对象的值总是前一个 Promise 实例的值。

    三个方法的返回操作 return any,相当于 Promise.resolve(any)(这里 any 是指任何值)。

  • then()catch()finally() 方法中出现语法错误或者利用 throw 关键字主动抛出错误,它们返回的 Promise 实例对象的状态会变成 rejected,而且实例对象的值就是所抛出的错误原因。

  • Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个 catch() 方法捕获。

关于值的穿透,请看示例:

const person = { name: 'Frankie' } // 使用引用值更能说明问题
const p1 = new Promise(resolve => resolve(person))
const p2 = new Promise((resolve, reject) => reject(person))

// 情况一:fulfilled
p1.then(res => {
  console.log(res === person) // true
})

// 情况二:fulfilled
p1
  .then()
  .then(res => {
    console.log(res === person) // true
  })

// 情况三:rejected
p2
  .catch()
  .then(res => { /* 不会触发 then */ })
  .catch(err => {
    console.log(err === person) // true
  })

// 情况四:fulfilled
p1
  .finally()
  .then(res => {
    console.log(res === person) // true
  })

从结果上看,尽管三者在不指定回调函数的情形下,“似乎”是不影响结果的。但前面提到 p1p1.then()p1.catch()p1.finally() 都是两个不同的 Promise 实例对象,尽管这些实例对象的值是相等的。

在实际应用场景中,我们应该避免写出这些“无意义”的代码。但是我们在去学习它们的时候,应该要知道。就是“用不用”和“会不会”是两回事。

下一篇接着介绍 Promise.all()Promise.race() 等,未完待续...