从如何停掉 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.thenPromise.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 GCedconsole.log('catch') }).then(function(){// will never called but will be GCedconsole.log('then') })以上對then的重寫并不會造成什么問題,閉包里的對象在外界是訪問不到,外界也永遠也無法構造出一個跟閉包里Symbol一樣的對象,考慮到我們只需要構造一個外界無法“===”的對象,我們完全可以用一個Object來代替:
(function() {var STOP_VALUE = {}//只要外界無法“===”這個對象就可以了var STOPPER_PROMISE = Promise.resolve(STOP_VALUE)Promise.prototype._then = Promise.prototype.thenPromise.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 GCedconsole.log('catch') }).then(function(){// will never called but will be GCedconsole.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類以達到幾乎相同的效果,此處不再熬述。
多謝各位同僚的閱讀,如有紕漏之處還請留言指正~
原文鏈接:https://github.com/xieranmaya/blog/issues/5
總結
以上是生活随笔為你收集整理的从如何停掉 Promise 链说起的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: HTML 网页创建
- 下一篇: 去除IE10自带的清除按钮