细读 ES6 | Promise 上篇
我认为 Promise 应该算是 ES6 标准最大的亮点,它提供了异步编程的一种解决方案。比传统的回调函数和事件解决方案,它更合理、更强大。
一、简介
Promise 是一个容器,里面保存着某个未来才会结束的事件(一般为异步操作)的结果。从语法上来说,Promise
是一个对象,它可以获取异步操作的消息。
Promise
对象的特点:
Promise
对象有且只有三种状态:pending
、fulfilled
、rejected
,分别表示进行中、已成功、已失败。一旦状态发生改变,就不会再变。状态的改变只有两种可能:
pending -> fulfilled
或pending -> rejected
。若发生了其中一种情况,状态就会一直保存这个结果,这时就成为resolved
(已定型)。
这种以“同步的方式”去表达异步流程,可以避免层层嵌套的回调函数,避免出现“回调地狱”(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
构造函数接受一个函数作为参数,该函数的两个参数分别是 resolve
和 reject
。而 resolve
和 rejeact
也是函数,其作用是改变 Promise
对象的状态,分别是 pending -> fulfilled
和 pending -> rejected
。
假设构造函数内不指定 resolve
或 reject
函数,那么 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
函数)内部,仍属于同步任务,而非异步任务。所以,那个经典的面试题就是,包括
setTimeout
、Promise
等,然后问输出顺序是什么?本质就是考察 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
的状态,并不会终止 resolve
或 reject
后面代码的执行。
例如:
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)
。但如果这样,其实后面的代码就没意义,因此也就没必要写了。千万别在工作中写出这样的代码,我怕你被打。这里只是为了说明 resolve
或 reject
不会终止后面的代码执行而已。
一般来说,调用
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
})
从结果上看,尽管三者在不指定回调函数的情形下,“似乎”是不影响结果的。但前面提到 p1
跟 p1.then()
、p1.catch()
、p1.finally()
都是两个不同的 Promise
实例对象,尽管这些实例对象的值是相等的。
在实际应用场景中,我们应该避免写出这些“无意义”的代码。但是我们在去学习它们的时候,应该要知道。就是用不用和会不会是两回事。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论