Promise + async/await 推荐实践

异步任务是我们日常开发中离不开的一环,例如用户操作后的网络请求、动画延时回调、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 微任务之后执行。