Promise深入理解:探索相关高级用法

一. 前言

Promise是JavaScript中处理异步编程的一种重要机制,它可以避免回调地狱,使得异步操作更加可读、可维护。

在js项目中,promise的使用应该是必不可少的,但我发现在同事和面试者中,很多中级或以上的前端都还停留在promise.then()、promise.catch()、Promise.all等常规用法,连async/await也只是知其然,而不知其所以然。

但其实,promise还有很多巧妙的高级用法,也将一些高级用法在alova请求策略库内部大量运用。本文将探讨这些用法特性,并提供相应的源码示例。

二. Promise基础

代码结构
typescript 复制代码
const promise = new Promise((resolve, reject) => {
    resolve("resolve")
    reject("reject")
})
 
promise.then(res => {
    console.log(res)
}).catch(err => {
    console.log(err)
})
//在通过new创建Promise对象时,需要传入一个回调函数,称之为executor
//这个回调函数会被立即执行,并且给传另外两个回调函数resolve、reject
//当调用resolve回调函数时,会执行Promise对象的then方法传入的回调函数
//当调用reject回调函数时,会执行Promise对象的catch方法传入的回调函数
状态

一个Promise必然会处于以下三个状态之一:

  • pending:初始状态,既没有被兑现,也没有被拒绝;

  • fullfilled:意味操作成功完成;

  • rejected:意味操作失败;

常用API
  1. Promise.resolve(value) : 类方法,该方法返回一个以 value 值解析后的 Promise 对象
  2. Promise.reject: 类方法,且与 resolve 唯一的不同是,返回的 promise 对象的状态为 rejected
  3. Promise.race: 类方法,多个 Promise 任务同时执行,返回最先执行结束的 Promise 任务的结果,不管这个 Promise 结果是成功还是失败。
  4. Promise.all: 类方法,多个 Promise 任务同时执行。如果全部成功执行,则以数组的方式返回所有 Promise 任务的执行结果。 如果有一个 Promise 任务 rejected,则只返回 rejected 任务的结果。
  5. Promise.prototype.then: 实例方法,为 Promise 注册回调函数,函数形式:fn(vlaue){},value 是上一个任务的返回结果,then 中的函数一定要 return 一个结果或者一个新的 Promise 对象,才可以让之后的then 回调接收。
  6. Promise.prototype.catch: 实例方法,捕获异常,函数形式:fn(err){}, err 是 catch 注册 之前的回调抛出的异常信息。
  7. Promise.prototype.finally: 实例方法,ES9中新增特性:表示无论Promise对象无论变成fulfilled还是rejected状态,最终都会被执行的代码。

三.常见问题(FAQ)

Q: then、catch 和 finally 序列能否顺序颠倒?

A: 可以,效果完全一样。但不建议这样做,最好按 then-catch-finally 的顺序编写程序。

Q: 除了 then 块以外,其它两种块能否多次使用?

A: 可以,finally 与 then 一样会按顺序执行,但是 catch 块只会执行第一个,除 非 catch 块里有异常。所以最好只安排一个 catch 和 finally 块。

Q: then 块如何中断?

A: then 块默认会向下顺序执行,return不能中断,可以通过 throw抛出错误来跳转至 catch 实现中断。

Q: 什么时候适合用 Promise 而不是传统回调函数?

A: 当需要多次顺序执行异步操作的时候,例如,如果想通过异步方法先后检测用户名和密码,需要先异步检测用户名,然后再异步检测密码的情况下就很适合 Promise。

Q: 什么时候我们需要再写一个 then 而不是在当前的 then 接着编程?

A: 当你又需要调用一个异步任务的时候。

四. Promise高级用法

1. promise数组串行执行

例如你有一组接口需要串行执行,首先你可能会想到使用await

javascript 复制代码
const requestAry = [() => api.request1(), () => api.request2(), () => api.request3()];
async function runPromises(requestAry) {
  for (const task of requestAry) {
    await task()
  }
}
runPromises(requestAry)

如果使用promise的写法,那么你可以使用then函数来串联多个promise,从而实现串行执行。

javascript 复制代码
const requestAry = [() => api.request1(), () => api.request2(), () => api.request3()];
const runPromises = requestAry.reduce(
    (promise, curTask) => promise.then(curTask), // 通过循环任务数组,不断在promise后使用.then(nextTask)拼接任务
    Promise.resolve(); // 创建一个初始promise,用于链接数组内的promise
);
runPromises.then()

2. 同时调用resolve和reject会怎么样?

大家都知道promise分别有pending/fullfilled/rejected三种状态,但例如下面的示例中,promise最终是什么状态?

scss 复制代码
const promise = new Promise((resolve, reject) => {
  resolve();
  reject();
});

正确答案是fullfilled状态,我们只需要记住,promise一旦从pending状态转到另一种状态,就不可再更改了,因此示例中先被转到了fullfilled状态,再调用reject()也就不会再更改为rejected状态了。

3. 在new Promise作用域外更改状态

假设你有多个页面的一些功能需要先收集用户的信息才能允许使用,在点击使用某功能前先弹出信息收集的弹框,你会怎么实现呢?

以下是不同水平的前端同学的实现思路:

初级前端:我写一个模态框,然后复制粘贴到其他页面,效率很杠杠的!

中级前端:你这不便于维护,我们要单独封装一下这个组件,在需要的页面引入使用!

高级前端:封什么装!!!写在所有页面都能调用的地方,一个方法调用岂不更好?

看看高级前端怎么实现的,以vue3为例来看看下面的示例。

xml 复制代码
<!-- App.vue -->
<template>


  <!-- 以下是模态框组件 -->
  <div class="modal" v-show="visible">
    <div>
      用户姓名:<input v-model="info.name" />
    </div>
    <!-- 其他信息 -->
    <button @click="handleCancel">取消</button>
    <button @click="handleConfirm">提交</button>
  </div>


  <!-- 页面组件 -->
</template>


<script setup>
import { provide } from 'vue';


const visible = ref(false);
const info = reactive({
  name: ''
});
let resolveFn, rejectFn;


// 将信息收集函数传到下面
provide('getInfoByModal', () => {
  visible.value = true;
  return new Promise((resolve, reject) => {
    // 将两个函数赋值给外部,突破promise作用域
    resolveFn = resolve;
    rejectFn = reject;
  });
})


const handleConfirm = info => {
  resolveFn && resolveFn(info);
};
const handleCancel = () => {
  rejectFn && rejectFn(new Error('用户已取消'));
};
</script>

接下来直接调用getInfoByModal即可使用模态框,轻松获取用户填写的数据。

xml 复制代码
<template>
  <button @click="handleClick">填写信息</button>
</template>


<script setup>
import { inject } from 'vue';


const getInfoByModal = inject('getInfoByModal');
const handleClick = async () => {
  // 调用后将显示模态框,用户点击确认后会将promise改为fullfilled状态,从而拿到用户信息
  const info = await getInfoByModal();
  await api.submitInfo(info);
}
</script>

这也是很多UI组件库中对常用组件的一种封装方式。

4. async/await的另类用法

很多人只知道在async函数调用时用await接收返回值,但不知道async函数其实就是一个返回promise的函数,例如下面两个函数是等价的:

scss 复制代码
const fn1 = async () => 1;
const fn2 = () => Promise.resolve(1);


fn1()和fn2()都返回一个值为1的promise对象

而await在大部分情况下在后面接promise对象,并等待它成为fullfilled状态,因此下面的fn1函数等待也是等价的:

ini 复制代码
const fn1 = async () => 1;
await fn1();


const promiseInst = fn1();
await promiseInst;

然而,await还有一个鲜为人知的秘密,当后面跟的是非promise对象的值时,它会将这个值使用promise对象包装,因此await后的代码一定是异步执行的。如下示例:

javascript 复制代码
Promise.resolve().then(() => {
  console.log(1);
});
await 2;
console.log(2);
// 打印顺序位:1  2

等价于

javascript 复制代码
Promise.resolve().then(() => {
  console.log(1);
});
Promise.resolve().then(() => {
  console.log(2);
});

5. promise实现请求共享

当一个请求已发出但还未响应时,又发起了相同请求,就会造成了请求浪费,此时我们就可以将第一个请求的响应共享给第二个请求,让两个请求其实只会真正发出一次,并且同时收到相同的响应值。

那么,请求共享会有哪几个使用场景呢?我认为有以下三个:

1.当一个页面同时渲染多个内部自获取数据的组件时;

2.提交按钮未被禁用,用户连续点击了多次提交按钮;

3.在预加载数据的情况下,还未完成预加载就进入了预加载页面;

这也是alova的高级功能之一,实现请求共享需要用到promise的缓存功能,即一个promise对象可以通过多次await获取到数据,简单的实现思路如下:

ini 复制代码
const pendingPromises = {};
function request(type, url, data) {
  // 使用请求信息作为唯一的请求key,缓存正在请求的promise对象
  // 相同key的请求将复用promise
  const requestKey = JSON.stringify([type, url, data]);
  if (pendingPromises[requestKey]) {
    return pendingPromises[requestKey];
  }
  const fetchPromise = fetch(url, {
    method: type,
    data: JSON.stringify(data)
  })
  .then(response => response.json())
  .finally(() => {
    delete pendingPromises[requestKey];
  });
  return pendingPromises[requestKey] = fetchPromise;
}

6. then函数的第二个回调和catch回调有什么不同?

then是实例状态发生改变时的回调函数,第一个参数是resolved状态的回调函数,第二个参数是rejected状态的回调函数

promise的then的第二个回调函数和catch在请求出错时都会被触发,咋一看没什么区别,但其实,前者不能捕获当前then第一个回调函数中抛出的错误,但catch可以。

typescript 复制代码
let p = new Promise((resolve, reject) => {
    resolve('成功');
    // reject('失败')
})
p.then(
    res => {
        console.log('then' + res);
        throw new Error('来自成功回调的错误');
    },
    err => {
        console.log('then' + err); // 捕获reject抛出的错误
    }
).catch(err => console.log('catch' + err))// 将打印出"来自成功回调的错误"

其原理也正如于上一点所言,catch函数是在then函数返回的rejected状态的promise上调用的,自然也就可以捕获到它的错误。

7. then/catch/finally返回值

先总结成一句话,就是以上三个函数都会返回一个新的promise包装对象,被包装的值为被执行的回调函数的返回值,回调函数抛出错误则会包装一个rejected状态的promise,好像不是很好理解,我们来看看例子:

typescript 复制代码
// then函数
Promise.resolve().then(() => 1); // 返回值为 new Promise(resolve => resolve(1))
Promise.resolve().then(() => Promise.resolve(2)); // 返回 new Promise(resolve => resolve(Promise.resolve(2)))
Promise.resolve().then(() => {
  throw new Error('err')
}); // 返回 new Promise(resolve => resolve(Promise.reject(new Error('err'))))
Promise.reject().then(() => 1, () => 2); // 返回值为 new Promise(resolve => resolve(2))


// catch函数
Promise.reject().catch(() => 3); // 返回值为 new Promise(resolve => resolve(3))
Promise.resolve().catch(() => 4); // 返回值为 new Promise(resolve => resolve(调用catch的promise对象))


// finally函数
// 以下返回值均为 new Promise(resolve => resolve(调用finally的promise对象))
Promise.resolve().finally(() => {});
Promise.reject().finally(() => {});

五.Promise相关执行顺序

js事件循环

JavaScript事件循环是一种处理异步事件和回调函数的机制,它是JavaScript实现异步编程的核心。它在浏览器或Node.js环境中运行,用于管理任务队列和调用栈,以及在适当的时候执行回调函数。

javascript 复制代码
async function async1() {
  console.log("async1 start"); 
  await async2();
  console.log("async1 end"); 
}


async function async2() {
  console.log("async2"); 
}


console.log("script start"); 


setTimeout(function() {
  console.log("setTimeout"); 
}, 0);


async1();


new Promise(resolve => {
  console.log("promise1"); 
  resolve();
}).then(function() {
  console.log("promise2"); 
});
console.log('script end') 


代码执行过程:
开头定义了async1和async2两个函数,但是并未执行,执行script中的代码,所以打印出script start;
遇到定时器Settimeout,它是一个宏任务,将其加入到宏任务队列;
之后执行函数async1,首先打印出async1 start;
遇到await,执行async2,打印出async2,并阻断后面代码的执行,将后面的代码加入到微任务队列;
然后跳出async1和async2,遇到Promise,打印出promise1;
遇到resolve,将其加入到微任务队列,然后执行后面的script代码,打印出script end;
之后就该执行微任务队列了,首先打印出async1 end,然后打印出promise2;
执行完微任务队列,就开始执行宏任务队列中的定时器,打印出setTimeout。
javascript 复制代码
const first = () => (new Promise((resolve, reject) => {
    console.log(3);
    let p = new Promise((resolve, reject) => {
        console.log(7);
        setTimeout(() => {
            console.log(5);
            resolve(6);
            console.log(p)
        }, 0)
        resolve(1);
    });
    resolve(2);
    p.then((arg) => {
        console.log(arg);
    });
}));
first().then((arg) => {
    console.log(arg);
});
console.log(4);


执行过程:
首先会进入Promise,打印出3,之后进入下面的Promise,打印出7;
遇到了定时器,将其加入宏任务队列;
执行Promise p中的resolve,状态变为resolved,返回值为1;
执行Promise first中的resolve,状态变为resolved,返回值为2;
遇到p.then,将其加入微任务队列,遇到first().then,将其加入任务队列;
执行外面的代码,打印出4;
这样第一轮宏任务就执行完了,开始执行微任务队列中的任务,先后打印出1和2;
这样微任务就执行完了,开始执行下一轮宏任务,宏任务队列中有一个定时器,执行它,打印出5,由于执行已经变为resolved状态,所以resolve(6)不会再执行;
最后console.log(p)打印出Promise{<resolved>: 1};
相关推荐
Lsx-codeShare20 分钟前
前端无感刷新token机制(一文说明白)
前端·javascript·axios
DT——2 小时前
前端基础面试题·第四篇——Vue(其二)
前端·javascript·vue.js
Jiaberrr2 小时前
如何在uniapp(vue2)中使用Vue Router和router-view进行页面管理
前端·javascript·vue.js·uni-app
就叫飞六吧2 小时前
dockerpull
前端·javascript·css·vue.js·css3
我不会画饼鸭2 小时前
vue快速上手
前端·javascript·vue.js
深情废杨杨2 小时前
前端vue-安装pinia,它和vuex的区别
前端·javascript·vue.js
&小胖&2 小时前
threejs学习
javascript·学习·threejs
TttHhhYy3 小时前
echarts的option,设置折线图鼠标悬浮显示数据
前端·javascript·echarts
程序员大金3 小时前
基于SpringBoot+Vue+MySQL的中医院问诊系统
java·javascript·vue.js·spring boot·后端·mysql·tomcat
Front思3 小时前
获取时隔半个钟的三天
前端·javascript·chrome