从如何停掉 Promise 链说起
在使用 Promise 处理一些复杂逻辑的过程中,我们有时候会想要在发生某种错误后就停止执行 Promise 链后面所有的代码。然而 Promise 本身并没有提供这样的功能,一个操作,要么成功,要么失败,要么跳转到 then 里,要么跳转到 catch 里。
如果非要处理这种逻辑,一般的想法是抛出一个特殊的 Error 对象,然后在 Promise 链后面的所有 catch 回调里,检查传来的错误是否为该类型的错误,如果是,就一直往后抛,类似下面这样
doSth() .then(value => { if (sthErrorOccured()) { throw new Error('BIG_ERROR') } // normal logic }) .catch(reason => { if (reason.message === 'BIG_ERROR') { throw reason } // normal logic }) .then() .catch(reason => { if (reason.message === 'BIG_ERROR') { throw reason } // normal logic }) .then() .catch(reason => { if (reason.message === 'BIG_ERROR') { throw reason } // normal logic })
这种方案的问题在于,你需要在每一个 catch 里多写一个 if 来判断这个特殊的 Error,繁琐不说,还增加了耦合度以及重构的困难。
如果有什么办法能直接在发生这种错误后停止后面所有 Promise 链的执行,我们就不需要在每个 catch 里检测这种错误了,只需要编写处理该 catch 块本应处理的错误的代码就可以了。
有没有办法不在每个 catch 里做这种判断呢?
办法确实是有的,那就是在发生无法继续的错误后,直接返回一个始终不 resolve 也不 reject 的 Promise,即这个 Promise 永远处于 pending 状态,那么后面的 Promise 链当然也就一直不会执行了,因为会一直等着。类似下面这样的代码
Promise.stop = function() { return new Promise(function(){}) } doSth() .then(value => { if (sthBigErrorOccured()) { return Promise.stop() } // normal logic }) .catch(reason => {// will never get called // normal logic }) .then() .catch(reason => {// will never get called // normal logic }) .then() .catch(reason => {// will never get called // normal logic })
这种方案的好处在于你几乎不需要更改任何现有代码,而且兼容性也非常好,不管你使用的哪个 Promise 库,甚至是不同的 Promise 之间相互调用,都可以达到目的。
然而这个方案有一个不那么明显的缺陷,那就是会造成潜在的内存泄露。
试想,当你把回调函数传给 Promise 的 then 方法后,如果这时 Promise 的状态还没有确定下来,那么 Promise 实例肯定会在内部保留这些回调函数的引用;在一个 robust 的实现中,回调函数在执行完成后,Promise 实例应该会释放掉这些回调函数的引用。
如果使用上述方案,那么返回一个永远处于 pending 状态的 Promise 之后的 Promise 链上的所有 Promise 都将处于 pending 状态,这意味着后面所有的回调函数的内存将一直得不到释放。在简单的页面里使用这种方案也许还行得通,但在 WebApp 或者 Node 里,这种方案明显是不可接受的。
Promise.stop = function() { return new Promise(function(){}) } doSth() .then(value => { if (sthBigErrorOccured()) { return Promise.stop() } // normal logic }) .catch(reason => {// this function will never got GCed // normal logic }) .then() .catch(reason => {// this function will never got GCed // normal logic }) .then() .catch(reason => {// this function will never got GCed // normal logic })
那有没有办法即达到停止后面的链,同时又避免内存泄露呢。
让我们回到一开始的思路,我们在 Promise 链上所有的 catch 里都加上一句 if,来判断传来的错误是否为一个无法处理的错误,如果是则一直往后面抛,这样就达到了即没有运行后面的逻辑,又避免了内存泄露的问题。
这是一个高度一致的逻辑,我们当然可以把它抽离出来。我们可以实现一个叫 next 的函数,挂在 Promise.prototype 上面,然后在里面判断是否是我们能处理的错误,如果是,则执行回调,如果不是,则一直往下传:
var BIG_ERROR = new Error('BIG_ERROR') Promise.prototype.next = function(onResolved, onRejected) { return this.then(function(value) { if (value === BIG_ERROR) { return BIG_ERROR } else { return onResolved(value) } }, onRejected) } doSth() .next(function(value) { if (sthBigErrorOccured()) { return BIG_ERROR } // normal logic }) .next(value => { // will never get called })
进一步,如果把上面代码中 致命错误 的语义换成 跳过后面所有的 Promise,我们就可以得到跳过后续 Promise 的方式了:
var STOP_SUBSEQUENT_PROMISE_CHAIN = new Error() Promise.prototype.next = function(onResolved, onRejected) { return this.then(function(value) { if (value === STOP_SUBSEQUENT_PROMISE_CHAIN) { return STOP_SUBSEQUENT_PROMISE_CHAIN } else { return onResolved(value) } }, onRejected) } doSth() .next(function(value) { if (sthBigErrorOccured()) { return STOP_SUBSEQUENT_PROMISE_CHAIN } // normal logic }) .next(value => { // will never get called })
为了更明显的语义,我们可以把 跳过后面所有的 Promise 单独封装成一个 Promise:
var STOP = {} Promise.stop = function(){ return Promise.resolve(STOP) } Promise.prototype.next = function(onResolved, onRejected) { return this.then(function(value) { if (value === STOP) { return STOP } else { return onResolved(value) } }, onRejected) } doSth() .next(function(value) { if (sthBigErrorOccured()) { return Promise.stop() } // normal logic }) .next(value => { // will never get called })
这样就实现了在语义明确的情况下,不造成内存泄露,而且还停止了后面的 Promise 链。为了对现有代码尽量少做改动,我们甚至可以不用新增 next 方法而是直接重写 then:
(function() { var STOP_VALUE = Symbol()//构造一个Symbol以表达特殊的语义 var STOPPER_PROMISE = Promise.resolve(STOP_VALUE) Promise.prototype._then = Promise.prototype.then Promise.stop = function() { return STOPPER_PROMISE//不是每次返回一个新的Promise,可以节省内存 } Promise.prototype.then = function(onResolved, onRejected) { return this._then(function(value) { return value === STOP_VALUE ? STOP_VALUE : onResolved(value) }, onRejected) } }()) Promise.resolve(8).then(v => { console.log(v) return 9 }).then(v => { console.log(v) return Promise.stop()//较为明确的语义 }).catch(function(){// will never called but will be GCed console.log('catch') }).then(function(){// will never called but will be GCed console.log('then') })
以上对 then 的重写并不会造成什么问题,闭包里的对象在外界是访问不到,外界也永远也无法构造出一个跟闭包里 Symbol 一样的对象,考虑到我们只需要构造一个外界无法 === 的对象,我们完全可以用一个 Object 来代替:
(function() { var STOP_VALUE = {}//只要外界无法“===”这个对象就可以了 var STOPPER_PROMISE = Promise.resolve(STOP_VALUE) Promise.prototype._then = Promise.prototype.then Promise.stop = function() { return STOPPER_PROMISE//不是每次返回一个新的Promise,可以节省内存 } Promise.prototype.then = function(onResolved, onRejected) { return this._then(function(value) { return value === STOP_VALUE ? STOP_VALUE : onResolved(value) }, onRejected) } }()) Promise.resolve(8).then(v => { console.log(v) return 9 }).then(v => { console.log(v) return Promise.stop()//较为明确的语义 }).catch(function(){// will never called but will be GCed console.log('catch') }).then(function(){// will never called but will be GCed console.log('then') })
这个方案的另一个好处(好处之一是不会造成内存泄露)是可以让你非常平滑地(甚至是一次性的)从 返回一个永远 pending 的 Promise 过度到这个方案,因为代码及其语义都基本没有变化。在之前,你可以定义一个 Promise.stop() 方法来返回一个永远 pending 的 Promise;在之后,Promise.stop() 返回一个外界无法得到的值,用以表达 跳过后面所有的 Promise,然后在我们重写的 then 方法里使用。
这样就解决了停止 Promise 链这样一个让人纠结的问题。
在考察了不同的 Promise 实现后,我发现 Bluebird 和浏览器原生 Promise 都可以在 Promise.prototype 上直接增加实例方法,但 Q 和 $q(Angular) 却不能这么做,具体要在哪个子对象的原型上加或者改方法我就没有深入研究了,但相信肯定是有办法的。
可是这篇文章如果到这里就结束的话,就显得太没有意思了~~
顺着上面的思路,我们甚至可以实现 Promise 链的多分支跳转。我们知道,Promise 链一般来说只支持双分支跳转。
按照 Promise 链的最佳写法实践,处理成功的回调只用 then 的第一个参数注册,错误处理的回调只使用 catch 来注册。这样在任意一个回调里,我们可以通过 return 或者 throw(或者所返回 Promise 的最终状态的成功与否)跳转到最近的 then 或者 catch 回调里:
doSth() .then(fn1) .catch(fn2) .catch(fn3) .then(fn4) .then(fn5) .catch(fn6)
以上代码中,任意一个 fn 都只能选择往后跳到最近一 then 或者 catch 的回调里。
但在实际的使用的过程中,我发现双分支跳转有时满足不了我的需求。如果能在不破坏 Promise 标准的前提下让 Promise 实现多分支跳转,将会对复杂业务代码的可读性以及可维护性有相当程度的提升。
顺着上面的思路,我们可以在 Promise 上定义多个有语义的函数,在 Promise.prototype 上定义对应语义的实例方法,然后在实例方法中判断传来的值,然后根据条件来执行或者不执行该回调,当这么说肯定不太容易明白,我们来看代码分析:
(function() { var STOP = {} var STOP_PROMISE = Promise.resolve(STOP) var DONE = {} var WARN = {} var ERROR = {} var EXCEPTION = {} var PROMISE_PATCH = {} Promise.prototype._then = Promise.prototype.then//保存原本的then方法 Promise.prototype.then = function(onResolved, onRejected) { return this._then(result => { if (result === STOP) {// 停掉后面的Promise链回调 return result } else { return onResolved(result) } }, onRejected) } Promise.stop = function() { return STOP_PROMISE } Promise.done = function(value) { return Promise.resolve({ flag: DONE, value, }) } Promise.warn = function(value) { return Promise.resolve({ flag: WARN, value, }) } Promise.error = function(value) { return Promise.resolve({ flag: ERROR, value, }) } Promise.exception = function(value) { return Promise.resolve({ flag: EXCEPTION, value, }) } Promise.prototype.done = function(cb) { return this.then(result => { if (result && result.flag === DONE) { return cb(result.value) } else { return result } }) } Promise.prototype.warn = function(cb) { return this.then(result => { if (result && result.flag === WARN) { return cb(result.value) } else { return result } }) } Promise.prototype.error = function(cb) { return this.then(result => { if (result && result.flag === ERROR) { return cb(result.value) } else { return result } }) } Promise.prototype.exception = function(cb) { return this.then(result => { if (result && result.flag === EXCEPTION) { return cb(result.value) } else { return result } }) } })()
然后我们可以像下面这样使用:
new Promise((resolve, reject) => { // resolve(Promise.stop()) // resolve(Promise.done(1)) // resolve(Promise.warn(2)) // resolve(Promise.error(3)) // resolve(Promise.exception(4)) }) .done(value => { console.log(value) return Promise.done(5) }) .warn(value => { console.log('warn', value) return Promise.done(6) }) .exception(value => { console.log(value) return Promise.warn(7) }) .error(value => { console.log(value) return Promise.error(8) }) .exception(value => { console.log(value) return }) .done(value => { console.log(value) return Promise.warn(9) }) .warn(value => { console.log(value) }) .error(value => { console.log(value) })
以上代码中:
- 如果运行第一行被注释的代码,这段程序将没有任何输出,因为所有后面的链都被 停 掉了
- 如果运行第二行被注释的代码,将输出 1 5 9
- 如果运行第三行被注释的代码,将输出 2 6 9
- 如果运行第四行被注释的代码,将输出 3 8
- 如果运行第五行被注释的代码,将输出 4 7
即 return Promise.done(value) 将跳到最近的 done 回调里,依次类推。
这样就实现了 Promise 链的多分支跳转。针对不同的业务,可以封装出不同语义的静态方法和实例方法,实现任意多的分支跳转。但这个方案目前有一点不足,就是不能用then来捕获任意分支:
new Promise((resolve) => { resolve(Promise.warn(2)) }) .then(value => { }) .warn(value => { })
这种写法中,从语义或者经验上讲,then 应该捕获前面的任意值,然而经过前面的改动,这里的 then 将捕获到这样的对象:
{ flag: WARN, value: 2 }
而不是 2
,看看前面的代码就明白了:
Promise.prototype.then = function(onResolved, onRejected) { return this._then(result => { if (result === STOP) { return result } else { return onResolved(result)// 将会走这条分支,而此时result还是被包裹的对象 } }, onRejected) }
目前我还没有找到比较好的方案,试了几种都不太理想(也许代码写丑一点可以实现,但我并不想这么做)。所以只能在用到多分支跳转时不用 then 来捕获传来的值。不过从有语义的回调跳转到 then 是可以正常工作的:
doSth() .warn() .done() .exception() .then() .then() .catch()
同样还是可以根据上面的代码看出来。最后,此文使用到的一个 anti pattern 是对原生对象做了更改,这在一般的开发中是不被推荐的,本文只是提供一个思路。在真正的工程中,可以继承 Promise 类以达到几乎相同的效果,此处不再熬述。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
上一篇: 如何探测 JS 代码中的死循环
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论
评论(26)
@xieranmaya 嗯
真·大牛
一直pending不会内存泄漏。
一直pending有可能会内存泄露,
但是要看浏览器自己是怎么实现的了,
如果是 正常的类似事件监听一样,那就不算。
如果是用时间函数一直等待。
那就会导致这部分逻辑一直往后拖延,并且浏览器一直保持着这些变量,以及作用域。
按照您的写法,最终会得到一个一直pending状态的primise,这个不知会有什么影响(是否会有您提到的内存泄漏问题),用的chrome68
@toyang 这个得到的Promise最终确实会一直pending,但在它自身的Promise链条上,它后面是没有任何Promise的,所以也就不会有对某些函数的引用。
怎么用ts声明一个.d.ts的文件实现这样的过程,有人可以写一下吗
mark
学习一下大佬的思维
通过一天的研究楼主思路,经过实验证明,取消调用裢,只需要return 一个 永远pedding状态的 promise即可,花里胡哨的封装stop的本质一模一样,关于gc问题,return new Promise(function() { }),会被gc掉。https://zhuanlan.zhihu.com/p/385764204?ivk_sa=1024320u
写的太好了,层层深入,清晰易懂,点赞~!
@seven4x 多谢回复,不过感觉我们好像讨论的不是同一个问题,你的代码中并没有展示停掉Promise链的语义,建议你好好看看文章讨论的主题~
Q.fcall(function(){
return 2;
}).then(function(v){//f1
var deferred = Q.defer();
if(v == 1)
deferred.resolve("resolve");
else
deferred.reject("reject");
return deferred.promise;
}).then(function(){//f2
console.log("f2 resolve");
},function(){
console.log("f2 reject");
var defer = Q.defer()
defer.reject("reject333")
return defer.promise
}).then(function(){//f3
console.log('end');
},function(res){
console.log(res)
});
@xieranmaya 感觉那个内存泄露问题,设定个单例也是可以的
占个坑,感觉确实不错~
@lgy87
其实很容易用代码测出来。
想想就知道一堆处于pending状态的promise里面的回调肯定不会被释放,所以必须要以某种方式让这些promise的状态确定下来,这样一来,该运行的回调运行完后释放掉引用,不该运行的回调直接释放
请问 有没有实际场景 测出内存泄漏的例子?
奥,我表达有误,是想这个样子的
promise.resolve(1).then().catch((err)=>{promise.stop(err)}).then().then().....whenStop(//do sth)
就是说,不光要在需要stop的地方停止promise chain,我还要需要知道在哪里停止了,并且,停止后我需要后续处理
需要有whenstop((msg) => {//handle stop msg})
我试了各种重写then的方式,无果,不知阁下有何见解
ps:使用next可以
.catch(Promise.stop)与.catch(e=>Promise.stop())是不一样的,仔细考虑:)
stop了整个Promise chain,最后要catch到这个stop的,做后续处理,你这个貌似没有考虑这一层。
类似于
promise.resolve(1).then().catch(promise.stop).then().then().....whenStop(//do sth)
@Asher-Tan 对的,如果后面全是then的话,throw一下就可以了,但有时候后面即有then也有catch,或者有些then里有两个参数,这种方式就不太方便了
不建议在每个promise对象后处理then和catch,promise链的最后使用catch处理错误即可,在链的then里判断有错时throw error就行(这样后面的then并不会执行),这样就可以停止promise链了吧
我们逻辑一般都是这样写的
return new Promise (resolve, reject)->
Promise.resolve()
.then ()->
// if error
return Promise.reject(error)
.then ()->
// 不会执行了
.then ()->
.catch (error)->
reject(error)
万能的
prototype
,js 天生的 middleware,赞一个!还是 async/await 比较顺手
小菜来了,膜拜maya大神