写点什么

ES6 新特性详解 - Promise

作者:yuanyxh
  • 2024-09-14
    中国香港
  • 本文字数:10158 字

    阅读完需:约 33 分钟

ES6 新特性详解 - Promise

简述

我们知道,异步操作在 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,事实上,在 ES6Promise Api 推出前就已经存在很多优秀的 Promise 库,而 ECMAES6 中才正式将其纳入规范[^2]。


当然,本文的目的还是讲述 ES6 中的 Promise ApiES6 中的 Promise 并不完全按规范实现,而是基于规范添加了更加强大的功能。

构造 Promise

要使用一个 Promise,我们首先需要先构造一个 Promise 实例,在 new 一个 Promise 时,需要传递一个回调函数,这个回调会以同步方式被调用,并接收两个参数,都为函数,通常被命名为 resolvereject


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'); // 状态已拒绝,修改状态失败});
复制代码


关于 resolvereject,有一些需要注意的地方


  • 调用 resolve 时,Promise 状态并不总是落定为已完成,而是根据传递的参数决定,如传递的参数也是一个 Promise,那么当前 Promise 实例状态根据传递的 Promise 状态决定,当前 Promise 实例已完成或已拒绝的值亦然

  • 调用 reject 时,不管传入的值是什么,当前 Promise 状态总是落定为已拒绝,且拒绝的原因为传入的值,即便参数也是一个 Promise


// 构造一个状态为已失败的 Promiseconst p1 = new Promise(function executor(resolve, reject) {  reject('no');});
new Promise(function executor(resolve, reject) { resolve(p); // 当前 Promise 实例状态落定为已拒绝,拒绝原因为 no});
// ---------------------------------
// 构造一个状态为已完成的 Promiseconst 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 取出对应的值。


// 已完成的 Promiseconst p1 = new Promise((resolve, reject) => resolve('yes'));
p1.then( function fulfilled(value) { // 调用成功回调 console.log(value); // yes }, function rejected(reason) { console.log(reason); });
// --------------------------
// 已拒绝的 Promiseconst 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 链的处理结果决定状态


// 已完成的 Promiseconst 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 });
// ------------------------------
// 已拒绝的 Promiseconst 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 的理解,最好的方式便是自己动手实现一个,以下代码是带着本人的理解实现的简陋版 Promisethen 链的处理思路来自 手写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 也会被拒绝。


发布于: 刚刚阅读数: 4
用户头像

yuanyxh

关注

站在巨人的肩膀上 2023-08-19 加入

web development

评论

发布
暂无评论
ES6 新特性详解 - Promise_js_yuanyxh_InfoQ写作社区