先记住这个答案
写成 fns.reduce((prev, fn) => prev.then(fn), Promise.resolve(initial))。累加器 prev 始终是上一步返回的 Promise,prev.then(fn) 会等它兑现后再调用 fn,并把兑现值作为参数传进去;fn 返回的 Promise 又成为下一步的 prev。这样每个函数都在前一个完成后才开始,而且拿到的是前一个的结果。最常见的错误是把 fn 写成 fn(),那样所有函数会在 reduce 遍历时同时启动,串行就失效了。
reduce的初始值用Promise.resolve(initial)- 每一步
prev.then(fn),上一步的结果自动成为参数 - 写成
prev.then(fn())会让所有任务同时启动 - 任何一步失败,后面的都跳过,在末尾
catch一次即可
标准写法和执行过程
下面三个函数,第一个最慢、第二个最快,如果是并发执行,完成顺序会乱。
then 把函数一个个接起来JavaScriptconst tasks = [
(x) => new Promise((r) => setTimeout(() => r(x + 1), 30)),
(x) => new Promise((r) => setTimeout(() => r(x * 2), 10)),
(x) => Promise.resolve(x - 3)
]
function runSerial(fns, initial) {
return fns.reduce((prev, fn) => prev.then(fn), Promise.resolve(initial))
}
runSerial(tasks, 5).then((v) => console.log('结果', v)) // (5 + 1) * 2 - 3查看输出与解释
结果 9reduce 同步地把链搭成了 Promise.resolve(5).then(t1).then(t2).then(t3)。然后链开始执行:30 毫秒后 t1 得到 6,t2 这时才被调用,再过 10 毫秒得到 12,最后 t3 得到 9。t2 虽然最快,也要等 t1 结束才开始。
初始值一定要是 Promise。如果写成 reduce(fn, 5),第一步的 prev 是数字 5,数字上没有 then 方法,会直接报 TypeError。
- 用
async/await写同样的事 fns.reduce(async (prev, fn) => fn(await prev), initial)效果一样。每个async回调会先await上一步的 Promise,拿到结果后才调用fn。不过面试之外,更推荐直接写for...of循环加await,读的人一眼就能看出是串行,调试时也能逐步看到中间值。
经典错误:then 里写成了 fn()
这个错误的迷惑性在于代码能跑、不报错,只是顺序和结果都不对。
const make = (name, ms, log) => () =>
new Promise((r) => setTimeout(() => { log.push(name); r(name) }, ms))
const log = []
const fns = [make('A', 30, log), make('B', 10, log), make('C', 20, log)]
fns.reduce((prev, fn) => prev.then(fn()), Promise.resolve()) // fn() 立刻执行
.then(() => console.log('链结束时完成了', log.length, '个'))
setTimeout(() => console.log('实际完成顺序', log.join(',')), 100)查看输出与解释
链结束时完成了 0 个
实际完成顺序 B,C,Afn() 在 reduce 遍历时就被调用了,三个定时器同时启动,完成顺序变成了按耗时排的 B、C、A。更糟的是,then 收到的不是函数而是一个 Promise,规范规定非函数参数直接忽略,所以这条链根本没在等任何任务,一开始就结束了。
改法就是把 fn 本身交给 then。如果还需要额外参数,写成 prev.then((result) => fn(result, extra)),同样是传一个函数。
容易答错的地方
- 以为
reduce会等每一步完成 reduce是同步方法,它不认识 Promise,几乎瞬间就遍历完了。它做的只是把then链搭起来,等待是由这条链完成的。所以在reduce回调里直接调用fn(),任务就在搭链时启动了。- 忘了处理中间某一步失败
- 链上任何一步失败,后面的
then(fn)都会被跳过,错误一路传到最后。只要在runSerial返回的 Promise 上接一个catch就能拿到。如果希望某一步失败了也继续,要在那一步自己catch并返回一个默认值。
面试官还会怎么问?
如果要收集每一步的结果,而不只是最后一个?
让累加器兑现为一个数组:初始值用 Promise.resolve([]),每一步写成 prev.then((list) => fn(list[list.length - 1]).then((v) => [...list, v]))。最后得到的数组就是每一步的结果,顺序和函数数组一致。
和 Promise.all 有什么区别?
Promise.all 接收的是已经启动的 Promise,任务是并发执行的,只是等全部完成。reduce 串行接收的是函数,前一个完成才启动下一个。任务之间有依赖,或者必须控制对服务端的压力时,用串行。
想要既不是全串行也不是全并发,比如同时最多 3 个,怎么写?
维护一个正在执行的任务计数。启动时先开 3 个,每完成一个就从队列里取下一个补上,全部完成后再兑现总的 Promise。这就是常考的并发控制题,本质是把串行的「一个接一个」换成「三条线各自串行」。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。