简述
我们知道,异步操作在 JavaScript
中是非常常见的,而编写健壮且易于维护的异步代码是非常重要的,在 ES6
之前,人们常常需要花费大量时间精力来对异步代码进行优化测试,而 ES6 Promise
的推出,让我们能够更优雅的编写异步代码。
文章理解并讲述 ES6
新特性:Promise
,内容有误请指出,内容有缺请补充。
概念
Promise
,翻译为承诺、诺言,在 JS
中表示为一个未来的值,适用于处理耗时、异步任务,一个 Promise
只有三种状态:待定、已完成和已拒绝,状态一旦敲定便无法再次更改,当 Promise
状态落定到已完成时,会接收到一个完成的值,落定到已拒绝时则会收到一个拒绝的原因。
Promise 之前
关于异步编程,我们先了解一下 JS
中同步、异步的区别
同步任务在得出结果前会一直等待,而异步任务与之相反
console.log(1); // 1
setTimeout(() => {
console.log(2); // 2
}, 0);
console.log(3); // 3
复制代码
上述代码的打印结果为 1、3、2
,也验证了我们的说法,第一个 console.log
是同步任务[^1],在得出结果前不会向下执行,而第二个 console.log
包裹在 setTimeout
里面,它用来告诉浏览器这个任务在未来执行,而第三个 console.log
与第一个一样,也是同步任务,所以打印结果为 1、3、2
。
说完了同步与异步的区别,我们思考一个问题:既然异步任务会在未来得出结果,那怎么能拿到这个结果或者说什么时候拿呢,像平时一样吗?
// 假设 ajax 是一个异步网络请求库
const data = ajax('https://yuanyxh.com');
console.log(data); // undefined
复制代码
有经验的 JavaScript
程序员一眼就能看出 data
的打印结果为 undefined
,因为 ajax
是异步执行的,它只是在内部完成了它该做的事,然后告诉 JS
引擎:我完事了,你去干其他活吧,东西我以后交给你。
看来不能用平时的方法了。没办法了吗?当然不是,我们还有 JavaScript
世界中的一等公民:函数。我们并不需要关心什么时候去取这个未来的值,只需要给异步任务指定一个回调函数,在未来,值可用的时候浏览器会通知 JS
引擎,JS
引擎会在合适的时机调用回调并传递结果
// 假设 ajax 是一个异步网络请求库
ajax('https://yuanyxh.com', function (res) {
// do something...
console.log(res); // response
});
复制代码
我们使用伪代码模拟一下上述代码内部发生了什么
function ajax(url, callback) {
// 实例化网络请求对象
const xhr = new XMLHttpRequest();
// 打开流
xhr.open('get', url);
// 监听网络请求状态变化事件
xhr.onreadystatechange = function (e) {
// 网络请求成功
if (xhr.readystate === 4 && xhr.status === 200) {
// 调用回调函数
callback && callback(xhr.responseText);
}
};
// 发送请求
xhr.send();
}
复制代码
在 JS
中,异步任务跟回调函数是息息相关的,我们无法确定什么时候能取得异步任务所产生的值,只能通过回调函数,让浏览器告诉我们。
这种异步编程方式虽然常见,但也有它的不足与局限性,其中最经典的问题就是 callback hell
,即回调地狱。
思考一下这样一个业务需求:一个三级列表,我们需要先获取到一级列表的 id,再根据这个 id 去获取二级列表,又根据二级列表的 id 获取三级列表
// 假设 ajax 是一个异步网络请求库
ajax('https://yuanyxh.com/', function (res_1) {
ajax('https://yuanyxh.com/' + res_1.id, function (res_2) {
ajax('https://yuanyxh.com/' + res_2.id, function (res_3) {
// do something...
console.log(res_3); // response
});
});
});
复制代码
可以看到,因为数据之间的依赖关系,回调地狱的雏形已经出来了,我们不得不嵌套多个回调函数以满足业务需求,如果依赖关系更深,那么回调函数的嵌套也会更深。
如果回调地狱影响的是代码的可读性与可维护性,那么异常处理影响的就是代码的健壮性,在 JS
中,我们通常使用 try{...} catch (err) {...}
对可能出错的代码进行异常捕获
try {
(function unlimited(i) {
unlimited(++i);
})(0);
} catch (err) {
console.log(err); // Maximum call stack size exceeded
}
复制代码
上述代码没有添加终止的条件,所以会无限递归下去,最终超出浏览器允许的最大栈帧数而抛出错误,然后被 try {...} catch (err) {...}
捕获。
但这仅适用于同步任务的错误处理,我们回想一下异步任务与同步任务的区别:异步任务虽然会立即执行一次,但不会立即得出结果,也不会阻塞代码的执行。如果在立即执行的阶段代码没有抛出异常,但在求值过程中出现错误,这样的错误我们该怎么捕获呢?
try {
// 假设 ajax 是一个异步网络请求库
ajax('https://yuanyxh.com', function (res) {
console.log(res);
});
} catch (err) {
console.log(err);
}
复制代码
如果你在网络请求未完成之前将网络断开,你会发现回调函数没有被调用,而是在控制台输出了错误,但这个错误并没有被 try {...} catch (err) {...}
块所捕获,这也证实了 try {...} catch (err) {...}
并不适合处理异步任务产生的错误。
事实上,就如同无法确定何时去取未来的值一样,在异步任务过程中产生的错误我们也无法以同步方式去处理,异步产生的错误就跟未来值一样,会交由浏览器告知我们
// 假设 ajax 是一个异步网络请求库
ajax('https://yuanyxh.com', function (err, res) {
// 如果 err 有值
if (err) {
// do something
return;
}
// do something...
console.log(res); // response
});
复制代码
而对应的伪代码可能是下面这样
function ajax(url, callback) {
// 实例化网络请求对象
const xhr = new XMLHttpRequest();
// 打开流
xhr.open('get', url);
// 监听网络请求状态变化事件
xhr.onreadystatechange = function () {
// 网络请求成功
if (xhr.readystate === 4 && xhr.status === 200) {
// 调用回调并传递响应数据
callback && callback(null, xhr.responseText);
}
};
// 监听网络请求错误事件
xhr.onerror = function (e) {
// 调用回调并传递错误信息
callback && callback(new Error('request error'), null);
};
// 发送请求
xhr.send();
}
复制代码
除了这种方式,有些异步封装库可能还会让你传递两个回调函数,一个成功回调和一个失败回调,根据异步任务的结果来调用。
虽然有多种异步错误处理的方式,但不论哪种方式都不是最好的,我们总是需要针对每一个异步任务编写错误处理的逻辑。
思考一下:一个数据相互依赖的场景,相互嵌套的异步任务,这些任务中都需要有错误处理的逻辑,尽管在其中一个任务出错后后续任务都不应该再执行,但由于我们不知道会在哪个阶段出现错误,所以所有的异步任务都需要编写一次错误处理的逻辑
// 假设 ajax 是一个异步网络请求库
ajax('https://yuanyxh.com/', function (err_1, res_1) {
// 如果第一个网络请求失败
if (err_1) {
// do something
return;
}
ajax('https://yuanyxh.com/' + res_1.id, function (err_2, res_2) {
// 如果第二个网络请求失败
if (err_2) {
// do something
return;
}
ajax('https://yuanyxh.com/' + res_2.id, function (err_3, res_3) {
// 如果第三个网络请求失败
if (err_3) {
// do something
return;
}
// do something...
console.log(res_3); // response
});
});
});
复制代码
可以看到,我们编写的代码更像 地狱 了,我们会需要大量时间精力来对这样的代码进行优化,或寻找其他更好的方式来进行异步流程控制,Promise
由此而生。
Promise
Promise
,其实并不只是指 ES6
中的 Promise Api
,任何符合 Promises/A+ 规范 的接口都可以成为 Promise
,事实上,在 ES6
的 Promise Api
推出前就已经存在很多优秀的 Promise
库,而 ECMA
在 ES6
中才正式将其纳入规范[^2]。
当然,本文的目的还是讲述 ES6
中的 Promise Api
,ES6
中的 Promise
并不完全按规范实现,而是基于规范添加了更加强大的功能。
构造 Promise
要使用一个 Promise
,我们首先需要先构造一个 Promise
实例,在 new
一个 Promise
时,需要传递一个回调函数,这个回调会以同步方式被调用,并接收两个参数,都为函数,通常被命名为 resolve
和 reject
new Promise(function executor(resolve, reject) {});
复制代码
就像文章开始所说,一个 Promise
只有三种状态,初始状态总是待定,当调用 resolve
时,Promise
状态可能[^3]变为已完成,且已完成的值为调用 resolve
时传递的参数,当 reject
被调用或代码抛出错误时,状态落定为已拒绝,且拒绝的原因为调用 reject
时传递的参数或抛出的错误值
new Promise(function executor(resolve, reject) {
resolve('yes'); // 状态落定为已完成,已完成的值为 yes
throw Error('no'); // 状态已落定,忽视抛出的错误
});
new Promise(function executor(resolve, reject) {
throw Error('no'); // 状态落定为已拒绝,拒绝的原因为一个 Error 对象
resolve('yes'); // 到不了这里
});
new Promise(function executor(resolve, reject) {
reject('no'); // 状态落定为已拒绝,拒绝的原因为 no
resolve('yes'); // 状态已拒绝,修改状态失败
});
复制代码
关于 resolve
和 reject
,有一些需要注意的地方
// 构造一个状态为已失败的 Promise
const p1 = new Promise(function executor(resolve, reject) {
reject('no');
});
new Promise(function executor(resolve, reject) {
resolve(p); // 当前 Promise 实例状态落定为已拒绝,拒绝原因为 no
});
// ---------------------------------
// 构造一个状态为已完成的 Promise
const p2 = new Promise(function executor(resolve, reject) {
resolve('yes');
});
new Promise(function executor(resolve, reject) {
reject(p); // 当前 Promise 实例状态落定为已拒绝,拒绝原因为 p2 Promise 实例
});
复制代码
then
then
是一个 Promise
实例方法,最多接受两个参数,都为函数,第一个参数在 Promise
状态为已完成时调用,调用时会传入已完成的值;第二个参数在 Promise
状态为已拒绝时调用,调用时会传入拒绝的原因;then
能够被调用任意多次,哪怕 Promise
状态已经敲定,也能够通过 then
取出对应的值。
// 已完成的 Promise
const p1 = new Promise((resolve, reject) => resolve('yes'));
p1.then(
function fulfilled(value) {
// 调用成功回调
console.log(value); // yes
},
function rejected(reason) {
console.log(reason);
}
);
// --------------------------
// 已拒绝的 Promise
const p2 = new Promise((resolve, reject) => reject('no'));
p2.then(
function fulfilled(value) {
console.log(value);
},
function rejected(reason) {
// 调用失败回调
console.log(reason); // no
}
);
复制代码
then
的两个参数都不是必须的,当参数不为函数时会被忽略,且会使用默认函数代替,默认成功函数将已完成的值向后传递,默认失败函数将拒绝的原因抛出,类似以下伪代码
function fulfilled(value) {
return value;
}
function rejected(reason) {
throw reason;
}
复制代码
调用 then
方法后,会返回一个新的 Promise
实例,以此可以做到链式调用,返回的 Promise
实例依据上一个 then
链的处理结果决定状态
// 已完成的 Promise
const p1 = new Promise((resolve, reject) => resolve('yes'));
const p2 = p1.then(
function fulfilled(value) {
// 调用 p1 成功回调
throw 'next Promise rejected'; // 抛出失败值,p2 状态落定到已拒绝
},
function rejected(reason) {
console.log(reason);
}
);
p2.then(
function fulfiled(value) {
console.log(value);
},
function rejected(reason) {
// 调用 p2 失败回调
console.log(reason); // next Promise rejected
}
);
// ------------------------------
// 已拒绝的 Promise
const p3 = new Promise((resolve, reject) => reject('no'));
p3.then(
function fulfilled(value) {
console.log(value);
},
function rejected(reason) {
// 调用 p3 失败回调
// 注意,失败回调中并没有抛出异常,也没有返回一个已被拒绝的 Promise
// 所以后面的 Promise 实例状态会落定到已完成
console.log(reason); // no
}
).then(
// 返回新的 Promise,链式调用
function fulfilled(value) {
// 调用成功回调
console.log(value); // undefined
},
function rejected(reason) {
console.log(reason);
}
);
复制代码
关于 then
链中的成功与失败回调,好像有很多看似匪夷所思的地方,对于此,我们需要牢记以下细节
不管是成功还是失败回调,执行过程中抛出错误则下一个 Promise
实例落定为已拒绝状态,拒绝原因为错误值
不管是成功还是失败回调,返回一个 Promise
则下一个 Promise
实例状态根据返回的 Promise
状态决定,值亦然
除以上条件外,下一个 Promise
的状态都会变为已完成,已完成的值为成功或失败回调返回的值
为什么失败回调不抛出错误或返回一个已被拒绝的 Promise
,后续 Promise
状态就会落定为已完成呢?其实这很好理解,既然当前 Promise
实例被拒绝,且调用了失败回调,则 JS
引擎会认为当前 Promise
产生的错误已被处理,不需要向后传递。
Promise 之后
对于 ES6 Promise
中的其他 Api
,读者可自行查看 MDN 文档,文章不再过多介绍。接下来,我们试着使用 Promise
来优化异步代码。
还记得之前的一个小案例吗?数据依赖导致多重回调嵌套的回调地狱问题,我们添加上 Promise
试试
// 假设 ajax_promise 是一个基于 Promise 封装的异步网络请求库
// 请求一级列表
ajax_promise('https://yuanyxh.com').then(
function fulfilled(value_1) {
// 请求二级列表
ajax_promise('https://yuanyxh.com' + value_1.id).then(
function fulfilled(value_2) {
// 请求三级列表
ajax_promise('https://yuanyxh.com' + value_2.id).then(
function fulfilled(value_3) {
// do something
console.log(value_3); // response
},
function rejected(reason) {
// do something
}
);
},
function rejected(reason) {
throw reason;
}
);
},
function rejected(reason) {
throw reason;
}
);
复制代码
怎么回事?回调嵌套的问题并没有得到解决,甚至更遭了!!!
不要着急,我们再来看看以下代码
// 假设 ajax_promise 是一个基于 Promise 封装的异步网络请求库
// 请求一级列表
ajax_promise('https://yuanyxh.com')
.then(
function fulfilled(value_1) {
// 请求二级列表
return ajax_promise('https://yuanyxh.com' + value_1.id);
},
function rejected(reason) {
throw reason;
}
)
.then(
function fulfilled(value_2) {
// 请求三级列表
return ajax_promise('https://yuanyxh.com' + value2_.id);
},
function rejected(reason) {
throw reason;
}
)
.then(
function fulfilled(value_3) {
// do something
console.log(value_3); // response
},
function rejected(reason) {
// do something
}
);
复制代码
还记得吗,then
添加的成功与失败回调如果返回 Promise
,则后续 Promise
状态会根据返回的 Promise
状态而定,也意味着如果返回的 Promise
状态还未敲定,后续 Promise
也会一直等待,这也是我们能够串联多个 Promise
而不是一层层嵌套的原因。
解决了回调嵌套的问题,我们再想想异常处理如何解决,可以看到上述代码在每个 then
调用中都传入了一个失败回调,除了最后一个失败回调,其他的都只是将拒绝原因手动抛出,这是为了保证后续的 Promise
状态不会落定为已完成,这些代码其实是可以省略的
// 假设 ajax_promise 是一个基于 Promise 封装的异步网络请求库
// 请求一级列表
ajax_promise('https://yuanyxh.com')
.then((value_1) => ajax_promise('https://yuanyxh.com' + value_1.id)) // 请求二级列表
.then((value_2) => ajax_promise('https://yuanyxh.com' + value2_.id)) // 请求三级列表
.then(
(value_3) => {
// do something
console.log(value_3); // response
},
(reason) => {
// do something
}
);
复制代码
我们删除了除最后一个 then
调用外的失败处理回调,并将所有函数替换为箭头函数,代码量就只剩下几行,可读性却大大提高,但最后一个 then
调用中的失败回调也是不必要的,我们应该使用 catch
进行统一的异常处理
// 假设 ajax_promise 是一个基于 Promise 封装的异步网络请求库
// 请求一级列表
ajax_promise('https://yuanyxh.com')
.then((value_1) => ajax_promise('https://yuanyxh.com' + value_1.id)) // 请求二级列表
.then((value_2) => ajax_promise('https://yuanyxh.com' + value2_.id)) // 请求三级列表
.then((value_3) => {
// do something
console.log(value_3); // response
})
.catch((reason) => {
// do something
});
复制代码
catch
接受一个参数,返回一个新的 Promise
,参数应为函数,这个函数在当前 Promise
被拒绝时被调用,并接收拒绝原因。上述代码中所有 then
调用都没有传入失败处理回调,所以当其中一个 Promise
被拒绝时会调用默认失败回调抛出拒绝原因,导致后续的 Promise
状态也被落定为已拒绝,直到被 catch
捕获。
实现自己的 Promise
想要加深自己对 Promise
的理解,最好的方式便是自己动手实现一个,以下代码是带着本人的理解实现的简陋版 Promise
,then
链的处理思路来自 手写Promise教程,若有不合理之处请指出
enum EState {
pending = 'pending',
fulfilled = 'fulfilled',
rejected = 'rejected'
}
type Resolve = <T>(value?: T) => void;
type Reject = <T>(reason?: T) => void;
type Executor = (resolve: Resolve, reject: Reject) => void;
type StackItem = {
onFulfilled: Resolve | null;
onRejected: Reject;
afterResolve: Resolve;
afterReject: Reject;
};
class Promise_ {
// Promise 状态
private state = EState.pending;
// 成功值
private value: unknown = null;
// 失败值
private reason: unknown = null;
// then 回调队列
private queue: StackItem[] = [];
// 是否是 Promise 或 thenable
private isPromise_(target: any): target is Promise_ {
return (
target instanceof Promise_ ||
(((target && typeof target === 'object') ||
typeof target === 'function') &&
typeof target.then === 'function')
);
}
// 消耗 then 队列,取出成功回调并执行
private __Fulfilled<T>(result: T) {
this.startTask(result, (item) => item.onFulfilled);
}
// 消耗 then 队列,取出失败回调并执行
private __Rejected<T>(reason: T) {
this.startTask(reason, (item) => item.onRejected);
}
// 消耗 then 队列
private startTask<T>(
arg: T,
callback: (item: StackItem) => Resolve | null | Reject
) {
const queue = this.queue;
let item, data;
// 取出任务
while ((item = queue.shift())) {
const { afterResolve, afterReject } = item;
const func = callback(item);
// 尝试调用
try {
data = func && func(arg);
// 失败则 next Promise reject
} catch (err) {
afterReject(err);
}
// 不是 thenable 直接 resolve
if (!this.isPromise_(data)) return afterResolve(data);
const next = data;
// 下一个 Promise then 队列添加一个任务
this.addTask(next, afterResolve, afterReject);
}
}
// 添加任务
private addTask(target: Promise_, resolve: Resolve, reject: Reject) {
target.then(
(value) => resolve(value),
(reason) => reject(reason)
);
}
constructor(executor: Executor) {
// 创建 resolve 或 reject 函数
const createFulfillOrReject = (
state: EState.fulfilled | EState.rejected,
isResolve: boolean
) => {
return <T>(data: T): any => {
// 如果参数是 Promise 且是 resolve 调用则等待
if (this.isPromise_(data) && state !== EState.rejected) {
return this.addTask(data, resolve, reject);
}
// 如果状态为待定则改变状态
if (this.state === EState.pending) {
this.state = state;
isResolve ? (this.value = data) : (this.reason = data);
}
// 如果状态已落定,则无视修改
if (this.state !== state) return;
// 添加异步微任务
queueMicrotask(
(this.state === EState.fulfilled
? this.__Fulfilled
: this.__Rejected
).bind(this, data)
);
};
};
const resolve = createFulfillOrReject(EState.fulfilled, true),
reject = createFulfillOrReject(EState.rejected, false);
// 尝试执行
try {
executor(resolve, reject);
} catch (err) {
// 出错直接 reject
reject(err);
}
}
then(onFulfilled?: Resolve | null, onRejected?: Reject) {
// 不为函数使用默认值
onFulfilled =
typeof onFulfilled === 'function' ? onFulfilled : (value) => value;
onRejected =
typeof onRejected === 'function'
? onRejected
: (reason) => {
throw reason;
};
// 状态已落定则添加异步微任务,用于多次 then 调用处理
if (this.state !== EState.pending) {
const isFulfilled = this.state === EState.fulfilled;
// 添加异步微任务
queueMicrotask(
(isFulfilled ? this.__Fulfilled : this.__Rejected).bind(
this,
isFulfilled ? this.value : this.reason
)
);
}
// 构造 next Promise 并将 next Promise 的 resovle,reject 添加到当前 Promise 实例的 then 队列
let afterResolve!: Resolve, afterReject!: Reject;
const next = new Promise_((resolve, reject) => {
afterResolve = resolve;
afterReject = reject;
});
this.queue.push({ onFulfilled, onRejected, afterResolve, afterReject });
// 返回 next Promise
return next;
}
// 失败处理
catch(onRejected?: Reject) {
return this.then(null, onRejected);
}
// 静态 resolve 方法,参数为 Promise 则直接返回,否则构造一个指定值的 Promise
static resolve(target?: any): Promise_ {
const { isPromise_ } = Promise_.prototype;
if (isPromise_.call(null, target)) {
return target;
}
return new Promise_((resolve) => resolve(target));
}
// 静态 reject 方法,构造一个指定失败原因的 Promise
static reject(target?: any): Promise_ {
return new Promise_((_, reject) => reject(target));
}
}
复制代码
结语
ES6
中的 Promise
给我们带来了强大的异步编程方式,使我们能够编写更健壮的异步代码,但它并不是没有缺点,如不支持 Promise
取消和进度通知等,可这并不影响我们去拥抱它并基于它去实现更强大的异步编程方式。
参考资料
[^1]: 根据浏览器厂商实现,console.log
方法其实并不总是同步,具体可看: console.log()输出时是同步还是异步的问题[^2]: 不仅仅是 Promise
,还存在着很多早已流行起来的模式后续被 ECMA
纳入规范。[^3]: 取决于 resolve
时传递的参数,如果参数是一个状态为已拒绝的 Promise
,那么当前 Promise
也会被拒绝。
评论