深入Promise-2:Promise的进阶使用

在上一篇深入Promise-1(juejin.cn/post/732822...)中,讲解了Promise的基础概念和使用方法,这一篇让我们接着了解Promise的一些进阶用法吧。

Promise的其他方法

ES6为Promise提供了很多内置的方法,为Promise的操作提供了更多的操作空间。

我们先整一个用来示例的异步方法:

javascript 复制代码
// 模拟异步
function sync(log, time = 500, res = true) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (res) {
                resolve(log);
                console.log(log);
            } else {
                reject(log);
                console.log('error', log);
            }
        }, time);
    })
}

Promise.all

all方法可以接收一个由多个promise实例组成的参数,并返回一个新的promise实例,all会并发执行这些promise实例,获取所有的返回值,返回给新的promise实例。

参数

all的参数是由多个promise实例组成。一般都是用一个数组包裹,但是在all方法的规范中,参数其实是一个IteratorPromise.all(Iterator)),一个可迭代的对象。所以除了传入数组,也可以传入迭代器对象。

javascript 复制代码
// 传入数组
const p1 = Promise.all([
  sync(1),
  sync(2),
  sync(3)
]);
// 传入迭代器对象
const myIterable = {};
myIterable[Symbol.iterator] = function () {
  return {
    i: 1,
    next: function() {
      if (this.i < 4) {
        return {done: false, value: sync(this.i++)}; // 必须这么写,这是iterator返回值的规范
      }

      return {done: true};
    }
  }
};
const p2 = Promise.all(myIterable);

// 传入生成器函数
function* generate() {
  yield sync(1);
  yield sync(2);
  yield sync(3);
}

const p3 = Promise.all(generate());

当然,除非是特殊情况,还是建议传入数组。

需要注意的是传入的Iterator的元素必须是Promise实例,而像Map这样的,虽然也是可迭代,但是每次迭代返回的并非Promise实例是不生效的。

javascript 复制代码
// 传入Map
const pMap = Promise.all(new Map([
  [1, sync(1)],
  [2, sync(2)],
  [3, sync(3)],
]));

pMap.then(res => {
  console.log('pMap res', res);
});

console.log('pMap', pMap);

结果为:

可见Map被当做非Promise来处理。

如果传入的参数中,含有非promise的实例,会将其使用Promise.resolve包裹(关于Promise.resolve可以查看上篇文章)。

ini 复制代码
// 传入非promise
Promise.all([
    sync(1),
    { isSync: false},
    sync(2)
]).then(res => {
  console.log(res[1]); // {isSync: false}
});

那如果是传入一个函数呢?

javascript 复制代码
// 传入非promise函数
Promise.all([
  (function(){return 123})()
]).then(res => {
  console.log(res[0]); // 123
});

表现与Promise.resolve一样,但是如果在函数中抛出错误的话,也是一样,程序执行会被中断:

javascript 复制代码
Promise.all(
    [
        (function(){
            throw new Error('失败');  // Uncaught Error: 失败
        })()
    ]
).catch(res => {
    console.log('res', res);
});

假如我们传入空的 Iterator参数,all也会将其使用Promise.resolve包裹,并立即返回。

javascript 复制代码
Promise.race([])
 .then(res => {
    console.log(res); // []
  });

const myIterable = {};
myIterable[Symbol.iterator] = function () {
  return {
    next: function() {
      return {done: true};
    }
  }
};

Promise.all(myIterable)
  .then(res => {
    console.log(res); // [] 打印出来是[],但其实是一个可迭代对象
  });

function* generate() {}

Promise.all(generate())
  .then(res => {
    console.log(res); // []
  });

状态

Promise.all会返回一个新的promise,该promise的状态与传入的多个promise的状态有关。all是并发执行传入的promise,当所有promise的状态都发生变化后,其状态才会从pending变更。如果有一个promise是失败,则状态为rejected,如果都是成功,则为fulfilled

这里有一个疑问,正常情况下(全部promise成功的情况),所有promise都会被执行,但是如果中间有某些promise执行失败,那后续的promise还会执行吗?还未执行完的promise会继续执行吗?简单做个实验:

scss 复制代码
Promise.all([
    sync(1, 1000), 
    sync(2, 1000),
    sync(3, 500, false), // 第三个抛出错误
    sync(4, 600)   
]);

在第三个promise抛出错误,返回的promise的状态是rejected,第一个和第二个promise并没有被停止,第四个promise也被正常执行。

结论:传入的promise并行执行,即使中间的有promise执行失败,其他promise也会正常执行,但是返回的promise的状态还是会被置为失败。失败的错误也是通过catch捕获。

返回结果

在全部promise执行成功后,Promise.all会将所有promise的结果包装成数组返回,如果没有返回的结果的会使用undefined占位。结果的顺序与参数中的顺序一致。如果执行失败,会返回第一个失败的原因。

scss 复制代码
// 成功
Promise.all([
    sync([1]), 
    sync({log:2}),
    sync(3)
]).then(res => {
  console.log(res); // [[1],{log:2},3]
});

// 失败,执到catch
Promise.all([
    sync([1]), 
    sync({log:2}, 500, false),
    sync(3)
]).catch(err => {
  console.log(err); // {log:2}
});

// 多个失败,执到catch,返回第一个错误
Promise.all([
    sync([1]), 
    sync({log:2}, 500, false),
    sync({log:3}, 600, false)
]).catch(err => {
  console.log(err); // {log:2}
});

使用建议

all是并行执行,不是按顺序执行,如果执行的异步操作没有前后依赖的话,可以考虑使用all来做并行执行。基于all的特点,可以将需要一同获取并且不容纳失败的异步操作(例如:用户登录后获取用户基础信息和权限)包装到一起执行。假如,某些promise是允许失败的(例如那些对主流程无影响的),可以在promise上把错误catch掉,避免影响。

scss 复制代码
Promise.all([
    sync(1, 1000), 
    sync(2, 1000),
    sync(3, 500, false).catch(err => {console.log(err);}), // 将错误拦截掉
    sync(4, 600)   
]);

Promise.race

race方法同样可以接收一个由多个promise实例组成的参数,并返回一个新的promise实例。

scss 复制代码
const p = Promise.race([
  sync(1),
  sync(2),
  sync(3)
]);

all不同的是,race虽然也是并行运行,但当一个promise实例率先改变状态,返回的promise实例的状态会随之改变,并将返回其返回值。

race的参数all也是一样,规则也一样,这里就不赘述了。有一点不同的是,如果传入一个空数组[]all会将空的Iterator作为promise的返回值,但是race不会,返回的promise会处于pending状态。

javascript 复制代码
const p2 = Promise.race([]);
console.log(p2); // Promise { <pending> }

const myIterable = {};
myIterable[Symbol.iterator] = function () {
  return {
    next: function() {
      return {done: true};
    }
  }
};

const p3 = Promise.race(myIterable);
console.log(p3); // Promise { <pending> }

function* generate() {}
  
const p4 = Promise.race(generate());
console.log(p4); // Promise { <pending> }

状态&返回

前面说过,race的状态会跟随第一个改变状态的promise:

ini 复制代码
const p = Promise.race([
    sync(1, 600),
    sync(2),
    sync(3, 700)
]);

p.then(res => {
    console.log('res', res);
});

可以看到,第二个promise是最先完成的,p的状态也被置为fulfilled,返回值也置为2。而且与all一样,并不会停止其他promise,其他promise还是会执行完成。

如果有一个promise报错呢?

ini 复制代码
const p = Promise.race([
    sync(1, 600, false),
    sync(2),
    sync(3, 700)
]);

p.then(res => {
    console.log('res', res);
});

还是获取到第二个promise的结果。

如果第一个改变的状态的promise是失败的呢?

ini 复制代码
const p = Promise.race([
    sync(1, 600, false),
    sync(2, 500, false),
    sync(3, 700)
]);

p.catch(err => {
    console.log('err', err);
});

抛出一个失败的错误。其他promise继续执行。

如果所有的promise都是同时返回的呢?

ini 复制代码
const p = Promise.race([
    sync(1),
    sync(2),
    sync(3)
]);

p.then(res => {
    console.log('res', res);
});

按照执行机制(后面的文章聊),如果promise都是同时完成的话,理论上应该都是获取到第一个。

使用建议

利用race的特点,我们可以实现一些需要优先处理或者竞速的场景:

  • 优先渲染/加载,在并发获取数据或资源的场景,通过race可以判断第一个异步完成的时间点,这时候就可以做针对性的优先处理,然后其他的请求还会继续进行,可以继续完成后续的操作。
  • 超时处理,在一些对时间比较敏感的场景,可以这样来实现
javascript 复制代码
function timeout(time) {
  return new Promise((resolve, reject) => {
    setTimeout(() => reject('请求超时'), time);
  });
}

Promise.race(
  [
    ajax('xxxx'),
    timeout(500)
  ]
).then(() => {
  // 接口未超时
}).catch(() => {
  // 接口超时
})

上面的代码定义了一个timeout的函数,在到达一定的时间后会返回错误,通过race可以在超时时做出处理。

Promise.any

any方法也是接收多个promise实例,如果有一个promise率先成功,那么返回的promise的状态则跟随该promise的状态。这里跟race是不一样的,如果率先完成的promise是失败的,any会继续等待下一个成功的promise。也就是说,any会获取第一个成功的promise,并返回对应的值。但是如果没有一个成功的promise,那就返回失败。

ini 复制代码
Promise.any([
   sync(1, 500, false),
   sync(2, 700),
   sync(3, 600)
]).then(res => {
  console.log(res);  // 3
}).catch(err => {
  console.log(err);
});

上面的代码,第一个promise返回错误,第二第三个返回成功,但是因为第三个promise率先返回状态,所以结果返回3。

状态&返回

any很好理解,就是返回第一个成功的promise的内容,值得注意的是,如果传入的promise全部错误或者传入空的Iterator,会返回一个特定的错误,而不是某个promise的错误。

AggregateError

any返回的特定错误是一个AggregateError类型的错误,

javascript 复制代码
// 传入空
Promise.any([])
  .catch(err => {
    console.log(err); // AggregateError: All promises were rejected
    console.log(err.errors); // []
  });
// 全部错误
Promise.any([
    sync(1, 500, false),
    sync({error: true}, 700, false),
    sync({message: 'error'}, 600, false)
 ]).catch(err => {
     console.log(err); // AggregateError: All promises were rejected
     console.log(err.errors); // [1, { error: true }, { message: 'error' } ]
 });

从上面的例子可以看到,在全部失败的情况下,any会等待全部promise执传完再返回,并将所有错误都收集到一个AggregateError 对象中。直接访问这个错误对象,会告知All promises were rejected,这是一个固定的错误信息,同时可以通过AggregateError.errors来获取所有promise返回的错误。

关于AggregateError,可以参考MDN的说明:developer.mozilla.org/zh-CN/docs/...

使用建议

any比较适合一些竞速和容错的场景:

  • 容错: 为了提升用户体验,使用any可以在多个服务器获取同一数据或资源,可以避免单个请求失败后无法获取数据或资源的情况。这不仅提高了数据获取的效率,也增加了系统的容错能力,使得用户在使用应用时获得更加流畅和稳定的体验。此外,这种方法也有助于分散对单一服务器的依赖,减少单点故障的风险。例如实现一个在线播放器,可以从多个资源服务器获取同一个音频,即可保证最快的加载,又可以避免单个资源服务器失败后复杂的重试机制。
  • 竞速:race的类似的方式,但是any的好处就是具备容错性,除了全部失败,总能获得结果。

Promise.allSettled

allSettled同样是接收Iterator参数,该方法会在所有的promise状态都发生变化后(无论成功和失败)返回一个成功的promise实例,并将所有的promise实例的结果包装成一个数组返回。

ini 复制代码
const p1 = Promise.allSettled([
  sync(1),
  sync(2),
  sync(3, 500, false)
]);

p1.then(res => {
  console.log(res); 
});

上面的代码得到的res为:

lua 复制代码
[
  { status: 'fulfilled', value: 1 },
  { status: 'fulfilled', value: 2 },
  { status: 'rejected', reason: 3 }
]

p1则为fulfilled的promise实例。

状态

allSettled返回的promise实例的状态只会是fulfilled,无论参数是空的Iterator还是全部失败的promise实例,都是fulfilled

scss 复制代码
Promise.allSettled([
  sync(1, 500, false),
  sync(2, 500, false),
  sync(3, 500, false)
]);   // fulfilled

Promise.allSettled([]); // fulfilled

返回

allSettled回调的返回值则是固定的格式,会将所有promise实例的返回值包装进一个数组,数组的元素为固定格式的对象:

  • 成功:{ status: 'fulfilled', value: value }
  • 失败:{ status: 'rejected', reason: reason }

对象的属性意义如下:

  • status 对应的promise的状态,成功为fulfilled,失败为rejected
  • value 成功的promise会返回该属性,值为promise的返回值
  • reason 失败的promise会返回该属性,只为promise失败的原因

返回复杂结构的值或者错误对象,结果也是一样的:

less 复制代码
Promise.allSettled([
  sync({result:'success', data: [{id:1, value:1}]}),
  sync(new Error('失败'), 500, false)
]).then(res => {
  console.log(res);
}); 

使用建议

allSettled比较适合需要容错的情况:

  • 多并发容错 ,在一个需要多异步并发的场景,通过allSettled可以在then中统一处理
  • 初始化时的资源加载 ,在程序初始化时可能需要加载多个多种资源(图片、音频、配置文件等),这些资源即使加载失败也不应该影响程序的继续执行,可以使用allSettled来处理。
  • 回退或者重试 ,例如在node中做些数据库的写入操作,在批量写入或者是事务性操作中,使用allSettled可以获得所有异步读写操作的结果,通过对结果判断是否需要回退数据或者重试读写(使用all无法实现这种效果,如果出现错误只能得到一个错误)。

其他用法

Promise进阶用法,最基础就是链式调用,另外就是前面聊得内置方法的使用。哪还有其他使用方法吗?

Deferred式外部控制

Deferred是早期Promise还未纳入ES规范中,第三方库实现Promise提供的一种使用方法,可以在Promise外部控制Promise的状态:

ini 复制代码
function defered() {
  const dfd = {
    promsie: null,
    resolve: null,
    reject: null
  };
  dfd.promise = new Promise(function(resolve, reject) {
    dfd.resolve = resolve;
    dfd.reject = reject;
  });
  return dfd;
}

function demo() {
  const dfd = defered();
  // dosomething
  if (success) {
    dfd.resolve();
  } else {
    dfd.reject();
  }
  return dfd.promise;
}

上面的代码应该是最简单的用法defered函数返回了一个包含了空的Promise实例(没有包含任何异步操作)、resolve方法、reject方法的对象,在demo函数中返回Promise实例,在某些的操作后执行再改变其状态。这种写法可以将demo内部的异步操作与返回Promise隔离开来,这样通过demo得到的就是一个属于demo的promise,调用方不会感知到demo内部的异步操作也不会受其影响。另外就是可以根据需要在未来某个时间点去修改返回的promise的状态,已达到根据需要修改状态的需求。

在Promise纳入ES规范后,上面的方法可以直接使用Promise.resovle()Promise.reject()来实现,比Deferred更简洁理解难度更低。

当然,Deferred还是可用的地方,例如在页面上的一些回调,无法只直接传递Promise,可以用Deferred来实现。例如在Vue中实现一个全局确认弹窗:

弹窗有两个按钮:确认和取消,希望实现这样的效果,通过一个全局的方法来唤醒弹窗,方法返回一个Promise,在点击确定后通过then回调,取消通过catch回调。这样就可以直接在js中调用又不需要写一堆监听方法。\

dialog.vue代码

xml 复制代码
<template>
  <div
    class="dialog"
    v-show="visible"
  >
    <div>
      {{ message }}
    </div>

    <button @click="cancel">取消</button>
    <button @click="submit">确认</button>
  </div>
</template>

<script setup>
defineProps({
  visible: Boolean,
  message: String,
  cancel: Function,
  submit: Function
})
</script>

入口文件index.js代码

ini 复制代码
import { createApp } from 'vue'
import MessageDialog from './dialog.vue';

function defered() {
    const dfd = {
      promsie: null,
      resolve: null,
      reject: null
    };
    dfd.promise = new Promise(function(resolve, reject) {
      dfd.resolve = resolve;
      dfd.reject = reject;
    });
    return dfd;
  }
  

export default (message) => {
    const dfd = defered();
    const mountNode = document.createElement('div');
    const Instance = createApp(MessageDialog, {
        visible: true,
        message,
        cancel: () => {
            dfd.reject();
        },
        submit: () => {
            dfd.resolve();
        }
    });

    document.body.appendChild(mountNode);
    Instance.mount(mountNode);
    return dfd.promise;
}

使用

javascript 复制代码
import messageDialog from '@/components/message-dialog';

messageDialog('测试dialog')
  .then(() => {
    console.log('submit')
  })
  .catch(() => {
    console.log('cancel')
  })

OK,上面的代码只是一个简单的示例,展示下这种外部修改Promise状态的用法,提供一些参考。

异步结果共享

在项目运行中,可能会出现多个调用方调用同一个异步操作获取相同的结果的情况,例如token过期后,同一时间有多个曹邹调用刷新token的接口。这种情况会造成大量的资源浪费,我们可以利用Promise状态确认后不会改变的特性来实现一定时间内的异步结果共享。

ini 复制代码
function ajaxShare(url = '', data = {}, type = 'GET', duration = 0) 
{
    const key = JSON.stringify(url, data, type);
    const curPromise = promises[key];
    if (curPromise) {
        if (curPromise.expiration > +new Date()) {
            return curPromise.promise;
        }

        delete promises[key];
    }

    promises[key] = {
        promise: ajax(url, data, type),
        expiration: +new Date() + duration
    };

    return promises[key].promise;
}

上面的代码对上篇的ajax方法做了简单的封装,对于同一个请求,可以在指定的时间内都是返回同一个promise,实现同一请求的结果共享:

ini 复制代码
const p1 = ajaxShare('url', {}, 'GET', 500);
const p2 = ajaxShare('url', {}, 'GET', 500);
console.log(p1 === p2); // true

总结

本文总结了一些Promise的进阶方法,结合个人的经验给了一些使用的建议。希望对大家有帮助。接下来会研究下Promise的原理,敬请期待。

相关推荐
2分钟速写快排34 分钟前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60611 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角2 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!2 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰3 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi4 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen5 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马5 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu6 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss
IT_陈寒6 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端