共计 4910 个字符,预计需要花费 13 分钟才能阅读完成。
引言
错误理解精心组织起来的异步代码还不如使用一团乱麻的回调函数。
在处理异步的问题上,回调基本上能够胜任,不过这都是建立在一切正常运转的基础上。
然而事与愿违,回调受到控制反转的影响,把控制权交给了第三方,这种控制转移导致了一系列的信任问题(回调调用过早、回调调用过晚、回调不被调用、回调调用次数过少或过多等问题)。同时,基于回调的异步表达又是无序性的,回调地狱的使用,让我们正确理解代码的难度加大。
函数的确可以规避以上的问题,但是,毋庸置疑,这会再次加大代码的理解难度。与其交给不信任的第三方,倒不如转交给一个位于我们和第三方间的可信任的中介机制,这里就是我们要说的 Promise。
回调的转变
如何把回调交给 Promise,其实很简单。使用 Promise 后我们就无需再关心大部分的信任问题和无序性。因为 Promise 机制已经为我们处理好了,我们不需要写些特定逻辑来解决一些信任问题和并发带来的竞态问题,只要我们按照 Promise 规范正确执行即可。现在,以 setTimeout 代表异步操作来进行 Promise 改造。
// callback async
const callback_async = (x = Date.now(), callback) => {
// do something now
console.log(‘callback_async: 初始时间戳 ’, x)
setTimeout(() => {
// do something in the future
let interval = Date.now() – x
callback && callback(`callback_async: 在 ${interval} 毫秒后异步完成 `)
}, 1000)
}
callback_async(undefined, res => {
console.log(‘callback_async:’, res)
})
在 Promise 中我们依然能够看到回调的身影,只是回调作为参数传递的位置发生了变化。我们不再把回调交给第三方,而是让 Promise 从第三方获取某些数据,然后回调作为参数传递进去。
const promise_async = (x = Date.now()) => {
return new Promise(resolve => {
// do something now
console.log(‘promise_async: 初始时间戳 ’, x)
setTimeout(() => {
// do something in the future
let interval = Date.now() – x
resolve(`promise_async: 在 ${interval} 毫秒后异步完成 `)
}, 1000)
})
}
promise_async(undefined).then(res => {
console.log(res)
})
不同之前的把回调直接传给第三方的做法,这次是靠着 Promise 这个中间机制来替异步任务管理着回调。
错误的处理
使用 Promise 后,怎么就会好了很多呢?首先说说在错误的处理上。JavaScript 代码在执行的过程中若遇到错误就不会执行下去的。作为传入第三方的回调(同步回调或异步回调),如果在此之前就已经报错了,回调压根不会执行。在这种情况下,能通过回调捕获错误,也是很有意义的。我们很自然地想到了 try…catch , 不过在异步回调中,回调函数的执行栈与原函数分离开,导致外部是无法抓住异常。不过没关系,我们就多捕捉一遍。
在此,我们就用“error-first 风格”模拟一下。
// callback async
const callback_async = (x = Date.now(), callback) => {
try {
console.log(‘callback_async: 初始时间戳 ’, x)
// do something now
// throw ‘callback-outer: error’
setTimeout(() => {
try {
// do something in the future
// throw ‘callback-inner: error’
let interval = Date.now() – x
callback && callback(null, `callback_async: 在 ${interval} 毫秒后异步完成 `)
} catch (error) {
callback(error)
}
}, 1000)
} catch (error) {
callback(error)
}
}
callback_async(undefined, (error, res) => {
error?console.log(‘asyncError:’, error):console.log(‘async:’, res)
})
依次解开注释 throw …,我们就可以成功地捕获到错误或异常。但同时也发现,对于一个不断嵌套的异步回调,就回调地狱那样,我们会为每一个异步回调做 try…catch 的错误处理,这会使原有的代码更加混乱。
“幸运”的是,Promise 已经为我们处理好了这个问题。对于错误或异常,我们只需要注册 rejected 或 catch 的回调即可。不过 Promise 也存在着和上面相同的问题,无法捕获脱离上下文环境的错误或异常,我们只能收到手动 reject。
const promise_async = (x = Date.now()) => {
return new Promise((resolve, reject) => {
// do something now
// throw ‘promise-outer: error’
console.log(‘promise_async: 初始时间戳 ’, x)
setTimeout(() => {
try {
// do something in the future
// throw ‘promise-inner: error’
let interval = Date.now() – x
resolve(`promise_async: 在 ${interval} 毫秒后异步完成 `)
} catch (error) {
reject(error)
}
}, 1000)
})
}
promise_async(undefined).catch(error => {
console.log(error)
})
对于多个异步任务,Promise 仍然能够很好的处理错误,因为 Promise 使用的 this-then-that 的流程控制,默认处理函数只是把错误重新抛出,这使得错误可以继续沿着 Promise 链传播下去,直到显式的 rejected 或 catch 捕获错误。
Promise 化
Promise 带来的好处远远不止这些。一旦 Promise 决议,它就永远保持这个状态,这个 Promise 的 .then(…) 注册的回调就会被自动调用,且只会被调用一次。这也算解决了回调调用过少、过多及不被调用的问题。即使不能解决,但也可以在此基础上再做处理。你要是问为什么,我只能说人家就是干这个的,作为一个可信任的中间协商机制。
说到一旦决议就不能改变,这个很重要么,是的,真的很重要。在基于回调模式的异步处理中,JavaScript 代码执行后会一直走下去,遇到回调就直接执行了。但是 Promise 决议后,可以一直保留着这个结果,通过 .then(..) 形式添加的回调函数,甚至在异步操作完成之后才添加的回调函数,都会被执行调用。这也是上一个 Promise 里的错误只能在 Promise 链的下一个回调里捕获的原因。
知道了 Promise 的好处,也知道了基于回调模式的异步处理方式,我们就可以尝试把“error-first 风格”的回调 Promise 化。
// Promise Wrap
var promise_wrap = function(fn){
return function() {
let args = Array.from(arguments);
return new Promise((resolve, reject) => {
fn.apply(null, args.concat((error, value) => {
error ? reject(error): resolve(value)
}))
})
}
}
在这里我们可以看到,为了统一处理现在和将来,我们把它们都变成了将来,即所有的操作都成了异步,同步回调也变成了异步回调。
JavaScript 异常错误也是如此,在 Promise 创建过程中或查看决议结果过程中出现的异常错误,这个异常错误被捕捉都会变成异步行为。这样做减少了由函数顺序不确定性(竞态条件)带来的诸多问题。
保持扁平化
从回调模式跨到 Promise,总会不小心保留着原来的风格,比如嵌套。Promise 链式编程最好保持扁平化,不然不就变成另一个回调地狱了?关键是还没有返回或终止 Promise 链。
// parallel Promise
var parallel_promise = (x = Date.now()) => {
Promise.resolve().then(() => {
new Promise(resolve => {
setTimeout(() => {
let interval = Date.now() – x;
resolve(`parallel-inner: 在 ${interval} 毫秒后完成 `)
}, 3000)
}).then(res => {
console.log(res)
})
}).then(res => {
let interval = Date.now() – x;
console.log(`parallel-outer: 在 ${interval} 毫秒后完成; res: ${res}`)
})
}
parallel_promise(undefined)
从上面的执行结果可以看出,parallel-outer 并非在 parallel-inner 后执行。这是没有正确将 Promise 相连接的结果。
实际上,这里就是两个独立竞争的 Promise(同时在执行异步任务而不是一个接着一个)。同时我们也会注意到外层 then(…) 注册回调中 res 为 undefined,因为对于没有任何显式的决议,这个值就是 undefined。
// serial Promise
var serial_promise = (x = Date.now()) => {
Promise.resolve().then(() => {
return new Promise(resolve => {
setTimeout(() => {
let interval = Date.now() – x;
resolve(`serial-1: 在 ${interval} 毫秒后完成 `)
}, 3000)
}).then(res => {
console.log(res)
return res
})
}).then(res => {
let interval = Date.now() – x;
console.log(`serial-2: 在 ${interval} 毫秒后完成; res: ${res}`)
})
}
serial_promise(undefined)
所以说,
一个好的经验法则是总是返回或终止 Promise 链,并且一旦得到一个新的 Promise,返回它。
小结
用 Promise 来表达异步和管理并发无疑是种进步,它在程序的顺序性和可信任性上提供了自己的解决方案。它不是回调的替代品,只是帮着异步任务管理回调的可信任的中间机制。
相对于直接粗暴的回调,Promise 并不会带来性能上的提升,但是它会让我们的程序更加健壮,也使得代码更加简洁,更加符合我们有序的思维方式。
当然,Promise 也有自己的局限性。在并发 Promise.race(…) 上,我们只要第一个决议即可。当出现第一个决议的 Promise 时,其它的 Promise 就没有必要进行下去了。然而,我们没把法终止。
在错误处理上,Promise 链中错误总是由下一个 Promise 捕获。如果错误发生在最后一个 Promise 呢?还有,对于嵌套的 Promise,内部 Promise 已经进行了错误处理,但是外部 Promise 却捕获不到,这样真的好么?
Promise 恢复了可信任性,但我们还想让异步流程的表达风格更贴近同步的形式,链式调用不说不好,只是我们带着同步操作的惯性。还好,ES6、ES7 已经给出了方案。