Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

从如何停掉 Promise 链说起 #5

Open
xieranmaya opened this issue Jul 26, 2016 · 26 comments
Open

从如何停掉 Promise 链说起 #5

xieranmaya opened this issue Jul 26, 2016 · 26 comments

Comments

@xieranmaya
Copy link
Owner

在使用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类以达到几乎相同的效果,此处不再熬述。

多谢各位同僚的阅读,如有纰漏之处还请留言指正~

@savoygu
Copy link

savoygu commented Jul 26, 2016

小菜来了,膜拜maya大神

@riskers
Copy link

riskers commented Jul 27, 2016

还是 async/await 比较顺手

@alcat2008
Copy link

alcat2008 commented Jul 29, 2016

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

@Asher-Tan
Copy link

Asher-Tan commented Aug 5, 2016

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

@Asher-Tan
Copy link

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

@xieranmaya
Copy link
Owner Author

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

@merfais
Copy link

merfais commented Oct 28, 2016

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

@xieranmaya
Copy link
Owner Author

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

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

@merfais
Copy link

merfais commented Nov 1, 2016

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

@lgy87
Copy link

lgy87 commented Dec 29, 2016

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

@xieranmaya
Copy link
Owner Author

@lgy87

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

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

@theharveyz
Copy link

占个坑,感觉确实不错~

@theharveyz
Copy link

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

@0xleizhang
Copy link

0xleizhang commented Mar 29, 2017

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

@foxpsd
Copy link

foxpsd commented Mar 31, 2017

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

@xieranmaya
Copy link
Owner Author

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

@0xleizhang
Copy link

@xieranmaya

@sxcooler
Copy link

sxcooler commented Apr 9, 2017

真·大牛

@gggllm
Copy link

gggllm commented Jun 23, 2017

一直pending不会内存泄漏。

@tianfanfan
Copy link

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

@toyang
Copy link

toyang commented Aug 6, 2018

image

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

@Wangszzju
Copy link

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

@735759079
Copy link

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

@kwoktung
Copy link

mark

@havewego
Copy link

学习一下大佬的思维

@fantasy9571
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests