IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    Promise + async/await 推荐实践

    krimeshu发表于 2023-09-24 03:02:00
    love 0

    异步任务是我们日常开发中离不开的一环,例如用户操作后的网络请求、动画延时回调、node.js 中各种异步 IO/进程操作等等。

    过去通常是通过传递回调函数的形式使用,如今我们通常使用 Promise,配合 async/await,让日常这些异步处理方便了很多。

    不过对于刚接触 Promise 的新同学来说,日常可能只接触和使用过其中比较基础的使用形式,又没有花时间去了解其中的实现原理,这就可能会导致一些错误理解和反模式实践。

    这里将平时遇见过的问题列举出来,结合自己的理解,希望能帮新同学们绕开一些可以避免的坑。

    1. 简要介绍

    (1) 什么是 Promise

    个人认为,Promise 是一种 可链式触发的单向异步任务单元。

    • 它基于 异步任务 进行封装,内部维护一个任务进行状态:进行中、已完成、已拒绝。
    • 初始状态为 进行中,可 单向流转:进行中 → 已完成/已拒绝;不可以逆向流转。
    • 一个 Promise 实例在 进行中 状态下,可以通过它的 then(onResolved?, onRejected?) 函数指定 完成/拒绝状态回调函数。
    • 异步任务执行完毕时,可以执行以下 A/B 操作之一:
      • (A) 给 完成状态回调函数 传递一个 结果值,进入 已完成 状态;
      • (B) 给 拒绝状态回调函数 传递一个 理由,进入 已拒绝 状态。

    上面是 Promise 基本概念,看起来似乎“平平无奇”。然而它又通过以下机制实现了链式触发的效果:

    • then 函数中,将自动创建另一个临时 Promise 实例:
      • 它将在 完成/拒绝状态回调函数 执行完毕时变为 已完成 状态。
      • 状态回调函数的同步返回值将被作为其 结果值。
    • 若一个 Promise 完成时的 结果值 也是一个 Promise 时:
      • 结果值的 Promise 将被当作 后续任务 处理。
      • 直到后续任务被 完成/拒绝 后,当前任务才会真正被 完成/拒绝。

    而其中 then 函数的状态回调函数还存在特殊情况:

    • then 的两个回调函数参数中,不存在对应当前 Promise 状态的回调函数时:
      • 当前 Promise 被完成,却没有 完成状态回调函数 时,临时 Promise 将被以相同的 结果值 完成。
      • 当前 Promise 被拒绝,却没有 拒绝状态回调函数 时,临时 Promise 将被以相同的 理由 拒绝。

    这样,我们就可以在日常开发中通过 then 不断地链式创建临时 Promise,让我们的多个异步任务按照预期地逐个触发了。

    (2) 什么是 async/await

    async/await 被我们日常作为 Promise 状态回调函数函数的语法糖使用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    function createTask(factor) {
    return new Promise((rs) => {
    const start = new Date();
    setTimeout(() => {
    const end = new Date();
    rs(end - start);
    }, factor);
    });
    }

    const work = () => {
    console.log('Task start...');
    const task = createTask(500);
    task.then((cost) => {
    console.log(`Task end. (${cost}ms)`);
    }).catch((err) => {
    console.error(err);
    });
    };

    work();

    上面的 work 可以使用 async/await 改写为:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const work = async () => {
    console.log('Task start...');
    try {
    const task = createTask(500);
    const cost = await task;
    console.log(`Task end. (${cost}ms)`);
    } catch (err) {
    console.error(err);
    }
    };

    只需要对 Promise 实例使用 await 操作符,就可以将异步任务的后续处理方式从嵌套的回调函数,彻底改变成仿佛是顺序执行的相同层级语句。甚至还可以使用 try/catch 同时捕获异步任务前后的异常。

    尤其是对于多个异步任务逐个执行的情况,代码会简单和清晰很多,减轻业务开发中不必要的思维负担。

    而对于暂时不支持 async/await 的浏览器环境,可以通过 babel+regeneratorRuntime 对项目代码进行转换,从而在日常开发中放心的使用这项新语法糖。

    2. 不良实践与改进

    (1) 嵌套的 Promise 回调

    对于初次使用 Promise 的新手,可能会因为不知道可以在 then 回调内直接传递新的 Promise 作为 结果值,从而把 Promise 当作过去的回调函数使用,重新陷入回调地狱:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    // Bad:
    new Promise((rs) => {
    console.log('Step 1 start...');
    doSomething(() => {
    console.log('Step 1 finished.');
    rs();
    });
    }).then(() => {
    new Promise((rs) => {
    console.log('Step 2 start...');
    doSomething(() => {
    console.log('Step 2 finished.');
    rs();
    });
    }).then(() => {
    new Promise((rs) => {
    console.log('Step 3 start...');
    doSomething(() => {
    console.log('Step 3 finished.');
    rs();
    });
    }).then(() => {
    console.log('All steps finished');
    });
    });
    });

    得益于 Promise 递归等待的机制,我们可以直接在最外层的 then 后面链式追加后续任务,并不需要反复嵌套:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    new Promise((rs) => {
    console.log('Step 1 start...');
    doSomething(() => {
    console.log('Step 1 finished.');
    rs();
    });
    }).then(() => new Promise((rs) => {
    console.log('Step 2 start...');
    doSomething(() => {
    console.log('Step 2 finished.');
    rs();
    });
    })).then(() => new Promise((rs) => {
    console.log('Step 3 start...');
    doSomething(() => {
    console.log('Step 3 finished.');
    rs();
    });
    })).then(() => {
    console.log('All steps finished');
    });

    当然,还可以使用 async/await 处理:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    (async () => {
    await new Promise((rs) => {
    console.log('Step 1 start...');
    doSomething(() => {
    console.log('Step 1 finished.');
    rs();
    });
    });
    await new Promise((rs) => {
    console.log('Step 2 start...');
    doSomething(() => {
    console.log('Step 2 finished.');
    rs();
    });
    });
    await new Promise((rs) => {
    console.log('Step 3 start...');
    doSomething(() => {
    console.log('Step 3 finished.');
    rs();
    });
    });
    console.log('All steps finished');
    })();

    (2) 忽视异常处理

    新同学使用日常使用 Promise 时,可能并不会留心给每次 Promise 调用的最后加上 catch() 进行异常捕获。

    或者直接使用 try/catch 尝试捕获 Promise 异步任务和状态回调内的异常,发现没能如预期地捕获到。

    这是由于 Promise 的异步函数执行时,已经脱离创建时的调用栈,其内部发生的错误没法直接被调用时的 try/catch 捕捉到。

    可以通过以下例子模拟类似的情形:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function doItLater(fn, delay) {
    setTimeout(fn, delay);
    }

    try {
    doItLater(() => {
    // 这个异常无法被这里的 try/catch 捕获到
    throw new Error('Out of catch.');
    }, 100);
    } catch(ex) {
    console.error(ex);
    }

    将 doItLater() 中的 setTimeout(fn, delay) 改为 fn() 同步调用,就能在外层捕获到异常。而 Promise 的状态回调并非同步执行,所以无法在外层直接捕获异常。

    对于异步任务,我们需要通过 catch() 进行异常捕获,以便在外层做好任务被拒绝或者其它意外的处理:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    new Promise((rs) => {
    console.log('Task start...');
    doSomething(() => {
    console.log('Task finished.');
    rs();
    });
    }).then(() => {
    console.log('Done');
    }).catch((ex) => {
    console.error(ex);
    reportError(ex);
    });

    不过 catch() 只能捕获到 Promise 内部的异常,如果需要同时捕获异步任务之前的某些同步处理异常,还得把相同的异常处理再用 try/catch 写一遍:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    try {
    doSomePreprocessing();
    } catch (ex) {
    // 异常处理
    console.error(ex);
    reportError(ex);
    }
    new Promise((rs) => {
    try {
    console.log('Task start...');
    doSomething(() => {
    console.log('Task finished.');
    rs();
    });
    } catch (ex) {
    // 异常处理
    console.error(ex);
    reportError(ex);
    }
    }).then(() => {
    console.log('Done');
    }).catch((ex) => {
    // 异常处理
    console.error(ex);
    reportError(ex);
    });

    相同的异常处理写了三遍,有些可怕……不过上面的例子有点刻意了,doSomePreprocessing() 其实可以放在 Task start 相同的 try/catch 里。

    但有时候也不一定能这样重新组织代码,不如直接使用 async/await 避免这样的冗余情况:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    (async () => {
    try {
    doSomePreprocessing();
    await new Promise((rs) => {
    console.log('Task start...');
    doSomething(() => {
    console.log('Task finished.');
    rs();
    });
    });
    console.log('Done');
    } catch (ex) {
    // 异常处理
    console.error(ex);
    reportError(ex);
    }
    })();

    (3) await 一把梭

    日常开发中,如果涉及到多个异步任务的情况,新同学可能没有多想就直接使用 await 让它们逐个执行了:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    (async () => {
    // 展示 loading 动画
    setLoading(true);
    try {
    // 加载商品类别信息
    await loadGoodsCatalogs();
    // 加载地区信息
    await loadGeoData();
    // 加载用户信息
    await loadUserInfo();
    // 加载用户绑定的收货地址
    await loadUserAddress();
    // 加载用户绑定的支付方式
    await loadUserPayingMethods();

    // 更新表单
    refreshForm();
    } catch (ex) {
    showErrorInfo(ex);
    }
    // 关闭 loading 动画
    setLoading(false);
    })();

    然而稍微观察就会发现,上面的请求的数据中可能存在前后依赖关系的情况,但也有不少可以并行处理的数据。

    而让所有请求一股脑排队串行处理,既浪费现在日新月异的终端性能,又浪费用户宝贵的等待时间,未免有些暴殄天物。

    对于并行处理的任务,我们可以使用 Promise.all() 方法:

    • 它接收一个 Promise 数组参数,返回一个新的 Promise;
    • 同时启动其中的异步任务,直到它们全部结束时转为 已完成 状态。

    让我们用它重新组织上面的异步任务,提高一下页面效率吧:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    (async () => {
    // 展示 loading 动画
    setLoading(true);
    try {
    // 1. 需要逐个串行获取的用户相关数据
    const loadUserData = async () => {
    // 加载用户信息
    await loadUserInfo();
    // 加载用户绑定的收货地址
    await loadUserAddress();
    // 加载用户绑定的支付方式
    await loadUserPayingMethods();
    };
    // 2. 可以并行处理的各类数据
    await Promise.all([
    // 加载商品类别信息
    loadGoodsCatalogs(),
    // 加载地区信息
    loadGeoData(),
    // 加载用户相关数据
    loadUserData(),
    ]);

    // 更新表单
    refreshForm();
    } catch (ex) {
    showErrorInfo(ex);
    }
    // 关闭 loading 动画
    setLoading(false);
    })();

    (4) race 与 any

    除了 Promise.all(),还有两个类似的 Promise.race() 和 Promise.any() 方法。

    Promise.race():

    • 参数中的所有 Promise 同时启动,并进行竞赛。
    • 任何一个异步任务 发生状态改变时,当前 Promise.race 封装的任务转为其相同的 已完成/已拒绝 状态。

    Promise.any():

    • 参数中的所有 Promise 同时启动。
    • 其中任何一个异步任务完成时,当前 Promise.any 转为 已完成。
    • 如果所有异步任务最终都未完成,则转为 已拒绝 并返回它们的异常集合,亦即所有 拒绝理由。

    注意! Promise.any() 方法依然是实验特性,尚未被浏览器完全支持。

    3. 更多

    (1) 复杂任务

    对于类似 IO 任务的情况,可能需要反复确认完成进度的情况。

    直接封装为只有开始结束态的 Promise 的话,会让用户长时间等待中无法获得任何感知,用户体验较差。

    需要配合传统回调函数,结合具体的业务需求和页面交互进行实现。

    (2) 宏任务与微任务

    推荐仔细阅读:Jiasm 的 《微任务、宏任务与Event-Loop》 - https://juejin.cn/post/6844903657264136200

    在 Promise/A+ 的规范中,Promise 的实现可以是微任务,也可以是宏任务。不过普遍的共识一般将 Promise.then 的状态回调作为微任务实现。

    相比之下,setTimeout 的宏任务将会在同一批创建的 Promise.then 微任务之后执行。



沪ICP备19023445号-2号
友情链接