感谢拉勾教育提供的学习环境:拉勾前端高薪训练营
文章首发于语雀:JavaScript 异步编程中回调函数的替代方案:Promise
回调函数:由调用者定义,交给执行者执行的函数。
回调函数的缺点:不利于阅读,执行顺序混乱。
异步模式对于单线程的 JavaScript 非常重要,同时也是 JavaScript 的核心特点。
而回调函数则是 JavaScript 中所有异步编程方式的根基 。
如果我们直接使用传统回调方式去处理复杂的异步逻辑,那么我们就一定避免不了大量的回调函数嵌套问题(回调地狱)。
同时,我们写出的代码的可读性就变得非常差,往往没过三两天我们再翻看代码就很头疼。
那么,为了解决这个问题,提升我们在使用异步模式编程的代码可读性及可维护性,我们这篇主要介绍 Promise 的语法和特性,用于弥补 JavaScript 在异步编程模式下使用回调函数的不便之处。
Promise 实际上就是一个对象,用于表示一个异步任务结束后究竟是成功还是失败。Promise 一共有三种状态:Pending、Fulfilled 和 Rejected。
当 Promise 状态转为 Fulfilled 时,会自动触发 onFulfilled 回调函数。
当 Promise 状态转为 Rejected 时,会自动触发 onRejected 回调函数。
一旦 Promise 的状态 转为 Fulfilled 或 Rejected,状态不会再发生转变。也就是说 Promise 的状态变化是不可逆的。
Promise 代码示例:
const promise = new Promise(function(resolve, reject) {
// 在 Promise 构造过程中同步执行
resolve(100) // Promise 状态转变为 Fulfilled
reject(new Error('异常原因')) // Promise 状态转变为 Rejected
})
promise.then(function (value) {
// 成功的回调函数
console.log('resolved', value)
}, function (error) {
// 失败的回调函数
console.log('rejected', error)
})
注意,上面 resolve 和 reject 是只能执行一个的,因为状态发生变化后是不会再发生转变的。
onFulfilled 函数会在 Promise 构造过程中同步执行,且会接收两个参数:resolve 和 reject。
resolve 执行后的主要作用是将 Promise 的状态修改为 Fulfilled,也就是成功。同时,我们一般会把异步任务的结果通过 resolve 的参数传递出去。
reject 执行后的主要作用是将 Promise 的状态修改为 Rejected,也就是失败。同时,我们一般会把异步任务的异常对象通过 reject 的参数传递出去。注意,这里参数是一个异常对象:new Error(‘失败理由’)。
Promise 实例创建完成后,我们就可以使用实例的 then 方法去分别指定 onFulfilled 和 onRejected 回调函数,分别是 then 方法的第 1 位置和第 2 位置参数。
同时要注意的是,即使 Promise 里没有任何的同步操作逻辑需要执行,then 方法指定的回调函数都会放到消息队列中,直到当前调用栈清空时,再从消息队列里取出执行。
Promise 的规范率先由 CommonJS 社区提出。在 ES2015 被标准化,成为语言规范。
const ajax = (url: string) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.responseType = 'json'
xhr.open('GET', url)
xhr.onload = function () {
console.log(this);
if (this.status === 200) {
resolve(this.response)
} else {
reject(new Error(this.statusText))
}
}
xhr.send()
})
}
ajax('./1-ajax.json').then(res => {
console.log('onFulfilled', res);
}).catch(error => {
console.log('onRejected', error);
})
从表象上来看,Promise 的本质也就是使用回调函数的方式去定义异步任务结束后所需要执行的任务,只不过这里的回调函数是通过 then 方法传递进去的,而且 Promise 把回调函数分为了两种,分别是成功回调 onFulfilled 和失败回调 onRejected。
那么既然 Promise 仍然还是使用的回调函数的方式,那么多个任务发起时不是同样会出现回调函数嵌套的问题吗?
你可能会想到如下代码(仍然存在回调嵌套问题
ajax('/api/url.json').then(function (url) {
ajax('/api/url.json').then(function (url) {
ajax('/api/url.json').then(function (url) {
ajax('/api/url.json').then(function (url) {
})
})
})
})
如果这样写,Promise 确实就没有存在的意义了,不仅没有解决问题,还增加了代码复杂度。
Promise 最大的优势就是可以链式调用。 链式调用能够尽可能保证异步任务扁平化,这样就可以尽量的避免出现回调嵌套的问题。
Promise 的 onRejected 失败回调是可以省略的。
Promise then 方法的特点是:内部也会返回一个 Promise 对象。
const promiseObj = ajax('./1-ajax.json')
const promiseResult = promiseObj.then(res => {
console.log('onFulfilled', res);
}).catch(error => {
console.log('onRejected', error);
})
console.log(promiseResult)
console.log(promiseObj === promiseResult)
那么我们可以打印以下代码的变量值,就会发现确实 promiseResult 也是一个 Promise 对象。
需要明确的是,promiseObj 和 promiseResult 是完全不同的 Promise对象。then 方法返回的是一个全新的 Promise 对象, 这样做的目的是为了去实现一个 Promise 链条,这样每一个 Promise 对象都可以负责一个异步任务,且相互之间不会有任何影响。
ajax('./1-ajax.json').then(res => {
console.log(1);
}).then(res => {
console.log(2);
}).then(res => {
console.log(3);
}).then(res => {
console.log(4);
})
// 1
// 2
// 3
// 4
这里每一个 then 方法,实际上都是在为上一个 then 返回的 Promise 对象添加状态明确过后的回调,这些 Promise 会依次执行,因此 then 里的回调也是依次执行。
then 内部可以手动返回一个 Promise 对象。
ajax('/api/url.json').then(function (url) {
return ajax('/api/url.json')
}).then(res => {
console.log(res);
return 'foo'
}).then(value => {
console.log(value)
})
这样我们就可以做到上述代码这样:链式调用以避免回调嵌套,以尽量保证代码的扁平化。
而且,如果 then 方法中返回的不是 Promise 对象而是一个值,那么该值将会作为下一个 Promise 响应的值。
如果 then 方法中没有返回任何东西,那么就会默认返回一个值为 undefined 的 Promise 对象。
总结:
在 Promise 任务执行过程中出现异常,将会调用 onRejected 回调函数。
ajax('/api/url.json').then(res => {
throw false
}, error => {
// onRejected
console.log('onRejected')
}).catch(error => {
// catch
console.log('catch')
})
上述代码将执行哪行? 答案是 8。
ajax('/api/url.json').then(res => {
throw false
}).catch(error => {
console.log('rejected')
})
ajax('/api/url.json').then(res => {
throw false
}).then(() => {
console.log('fulfilled')
}, error => {
console.log('rejected')
})
上面两种写法是等价的。也就是说,catch 方法只不过是下一个 then 方法的 onRejected 的执行简写。
因此上个问题的代码也可以这样写:
ajax('/api/url.json').then(res => {
throw false
}, error => {
// onRejected
console.log('onRejected')
}).then(() => {
console.log('fulfilled');
}, error => {
// catch
console.log('catch')
})
再结合 一旦 Promise 的状态 转为 Fulfilled 或 Rejected,状态不会再发生转变 这个概念,那么显而易见,异常只能通过下一个 then 方法来捕获,因此上个答案是 8,在这里就是第 10 行将被执行输出。
因此,catch 方法实际上是在为上一个 then 方法返回的 Promise 对象添加异常回调。
此外,我们观察以下代码,还能得出一个 Promise 链式调用的特性。
ajax('/api/url.json').then(res => {
console.log(1);
}).then(() => {
console.log(2);
}).then(() => {
throw false
}).then(() => {
console.log(4);
}).catch(error => {
console.log('rejected')
})
同一个 Primise 链条上,前面 Promise 中的异常会一直被往后传递。 所以 catch 就可以捕获到第 6 行也就是第 4 个 Promise 中的异常。此外我们还需要区分的是,then 方法的第二个参数 onRejected 回调函数所能捕获的异常仅限于当前 Promise。
不推荐。我们应该在代码中明确的捕获每一个可能的异常,而不是丢给全局。
// Web
window.addEventListener('unhandledrejection', event => {
const {
reason, promise } = event
// reason => Promise 失败原因,一般是一个错误对象
// promise => 出现异常的 Promise 对象
console.log(reason, promise);
event.preventDefault()
}, false)
// Node
process.on('unhandledRejection', (reason, promise) => {
console.log(reason, promise);
})
Promise.resolve('foo').then(value => {
console.log(value) // foo
})
new Promise(function (resolve, reject) {
resolve('foo')
}).then(value => {
console.log(value) // foo
})
Promise.resolve 能够快速的把某个值转换为一个 Promise 对象。
同样的如下代码所示,我们通过 Promise.resolve 包装一个 Promise 对象,那么实质上两个结果是没什么不一样的。
const promise_1 = ajax('./1-ajax.json')
const promise_2 = Promise.resolve(promise_1)
console.log(promise_1 === promise_2); // true
值得一提的是,我们也可以给 Promise.resolve 传递一个包含 then 属性的对象,这样的写法实际上是利用了 thenable 的模式实现的,代码如下:
Promise.resolve({
then: function (onFulfilled, onRejected) {
onFulfilled('foo')
}
}).then(value => {
console.log(value);
})
与之同样的是 Promise.reject 方法,我们不再过多赘述,如下示例:
Promise.reject(new Error('rejected')).catch(error => {
console.log(error)
})
快速创建失败的 Promise 对象,参数值为 Promise 失败原因。
通常来讲,Promise 的使用方式正如前面案例那样进行链式调用,每一个 Promise 对象亦或说每一个 then 方法都是顺序依次执行的。那我们如果希望同时并行执行多个 Promise 呢?
正如我们在项目中经常遇到的情况:我们需要同时请求多个接口来获取数据渲染页面。那么我们使用 Promise 的 all 方法就会简单许多。
const promiseAll = Promise.all([ajax('./1-ajax.json'), ajax('./1-ajax.json')])
promiseAll.then(res => {
console.log(res)
})
Promise.all 会把多个 Promise 合并为一个 Promise 统一管理。**当内部所有的 Promise 都完成后,Promise.all 返回的全新 Promise 才会完成。**该 Promise 拿到的结果就是数组,里面包含每一个异步任务执行的结果。
需要注意的是,只要管理的多个 Promise 中存在一个失败的结果,那么 promiseAll 同样也会是失败结果。
并行请求相比顺序依次请求会消耗更少的时间。
Promise.race 和 Promise.all 一样,也会把多个 Promise 合并为一个全新的 Promise 管理。不同的是,Promise.all 会等待所有任务结束后才完成,而 Promise.race 只会等待第一个任务完成后就完成了。
const request = ajax('./1-ajax.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)
})
console.log('global start');
setTimeout(()=>{
console.log('setTimeout')
},0)
Promise.resolve().then(() => {
console.log('promise')
}).then(() => {
console.log('promise 2')
});
console.log('global end');
// global start
// global end
// promise
// promise 2
回调队列中的任务称之为【宏任务】。
宏任务执行过程中可以临时加上一些额外需求,这些额外的需求有两种处理方案:第一种是可以选择作为一个新的宏任务进到队列中排队,第二种就是可以作为当前任务的【微任务】。
微任务指的是在当前任务结束过后立即执行。
微任务是后来才加入到 js 中的,就是为了提高整体的响应能力。
Promise 的回调会作为微任务执行,所以会在本轮调用的末尾去自动执行。而 setTimeout 是以宏任务的形式进入到回调队列的末尾。
目前绝大多数异步调用都是作为宏任务执行。而 Promise、MutationObserver、process.nextTick 是会作为微任务在本轮任务末尾执行的。