从如何停掉 Promise 链说起

发布于 2022-10-12 21:12:21 字数 11077 浏览 180 评论 26

在使用 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 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(26

俏︾媚 2022-05-04 13:46:46

真·大牛

我喜欢麦丽素 2022-05-04 13:46:46

一直pending不会内存泄漏。

允世 2022-05-04 13:46:46

一直pending有可能会内存泄露,
但是要看浏览器自己是怎么实现的了,
如果是 正常的类似事件监听一样,那就不算。
如果是用时间函数一直等待。
那就会导致这部分逻辑一直往后拖延,并且浏览器一直保持着这些变量,以及作用域。

梦途 2022-05-04 13:46:46

按照您的写法,最终会得到一个一直pending状态的primise,这个不知会有什么影响(是否会有您提到的内存泄漏问题),用的chrome68

半步萧音过轻尘 2022-05-04 13:46:46

@toyang 这个得到的Promise最终确实会一直pending,但在它自身的Promise链条上,它后面是没有任何Promise的,所以也就不会有对某些函数的引用。

回梦 2022-05-04 13:46:46

怎么用ts声明一个.d.ts的文件实现这样的过程,有人可以写一下吗

娇柔作态 2022-05-04 13:46:46

mark

软的没边 2022-05-04 13:46:46

学习一下大佬的思维

相思碎 2022-05-04 13:46:46

通过一天的研究楼主思路,经过实验证明,取消调用裢,只需要return 一个 永远pedding状态的 promise即可,花里胡哨的封装stop的本质一模一样,关于gc问题,return new Promise(function() { }),会被gc掉。https://zhuanlan.zhihu.com/p/385764204?ivk_sa=1024320u

世界等同你 2022-05-04 13:46:45

写的太好了,层层深入,清晰易懂,点赞~!

月亮坠入山谷 2022-05-04 13:46:45

@seven4x 多谢回复,不过感觉我们好像讨论的不是同一个问题,你的代码中并没有展示停掉Promise链的语义,建议你好好看看文章讨论的主题~

翻身的咸鱼 2022-05-04 13:46:43

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)
});

拥有 2022-05-04 13:46:34

@xieranmaya 感觉那个内存泄露问题,设定个单例也是可以的

比忠 2022-05-04 13:46:33

占个坑,感觉确实不错~

っ左 2022-05-04 13:44:13

@lgy87

请问 有没有实际场景 测出内存泄漏的例子?

其实很容易用代码测出来。
想想就知道一堆处于pending状态的promise里面的回调肯定不会被释放,所以必须要以某种方式让这些promise的状态确定下来,这样一来,该运行的回调运行完后释放掉引用,不该运行的回调直接释放

习ぎ惯性依靠 2022-05-04 13:38:36

请问 有没有实际场景 测出内存泄漏的例子?

欲拥i 2022-05-04 13:37:15

奥,我表达有误,是想这个样子的
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可以

囚你心 2022-05-04 13:29:32

stop了整个Promise chain,最后要catch到这个stop的,做后续处理,你这个貌似没有考虑这一层。
类似于
promise.resolve(1).then().catch(promise.stop).then().then().....whenStop(//do sth)

.catch(Promise.stop)与.catch(e=>Promise.stop())是不一样的,仔细考虑:)

笙痞 2022-05-04 13:25:12

stop了整个Promise chain,最后要catch到这个stop的,做后续处理,你这个貌似没有考虑这一层。
类似于
promise.resolve(1).then().catch(promise.stop).then().then().....whenStop(//do sth)

提笔落墨 2022-05-04 13:18:52

@Asher-Tan 对的,如果后面全是then的话,throw一下就可以了,但有时候后面即有then也有catch,或者有些then里有两个参数,这种方式就不太方便了

勿忘初心。 2022-05-04 06:06:43

不建议在每个promise对象后处理then和catch,promise链的最后使用catch处理错误即可,在链的then里判断有错时throw error就行(这样后面的then并不会执行),这样就可以停止promise链了吧

陈年往事 2022-05-04 06:00:14

我们逻辑一般都是这样写的
return new Promise (resolve, reject)->
Promise.resolve()
.then ()->
// if error
return Promise.reject(error)
.then ()->
// 不会执行了
.then ()->
.catch (error)->
reject(error)

夜司空 2022-05-03 03:06:53

万能的 prototype,js 天生的 middleware,赞一个!

顾挽 2022-05-02 04:12:12

还是 async/await 比较顺手

杯别 2022-05-01 04:17:24

小菜来了,膜拜maya大神

~没有更多了~

关于作者

0 文章
0 评论
25 人气
更多

推荐作者

醉城メ夜风

文章 0 评论 0

远昼

文章 0 评论 0

平生欢

文章 0 评论 0

微凉

文章 0 评论 0

Honwey

文章 0 评论 0

qq_ikhFfg

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文