Cancel a vanilla ECMAScript 6 Promise chain

2019-01-01 15:54发布

问题:

Is there a method for clearing the .thens of a JavaScript Promise instance?

I\'ve written a JavaScript test framework on top of QUnit. The framework runs tests synchronously by running each one in a Promise. (Sorry for the length of this code block. I commented it as best I can, so it feels less tedious.)

/* Promise extension -- used for easily making an async step with a
       timeout without the Promise knowing anything about the function 
       it\'s waiting on */
$$.extend(Promise, {
    asyncTimeout: function (timeToLive, errorMessage) {
        var error = new Error(errorMessage || \"Operation timed out.\");
        var res, // resolve()
            rej, // reject()
            t,   // timeout instance
            rst, // reset timeout function
            p,   // the promise instance
            at;  // the returned asyncTimeout instance

        function createTimeout(reject, tempTtl) {
            return setTimeout(function () {
                // triggers a timeout event on the asyncTimeout object so that,
                // if we want, we can do stuff outside of a .catch() block
                // (may not be needed?)
                $$(at).trigger(\"timeout\");

                reject(error);
            }, tempTtl || timeToLive);
        }

        p = new Promise(function (resolve, reject) {
            if (timeToLive != -1) {
                t = createTimeout(reject);

                // reset function -- allows a one-time timeout different
                //    from the one original specified
                rst = function (tempTtl) {
                    clearTimeout(t);
                    t = createTimeout(reject, tempTtl);
                }
            } else {
                // timeToLive = -1 -- allow this promise to run indefinitely
                // used while debugging
                t = 0;
                rst = function () { return; };
            }

            res = function () {
                clearTimeout(t);
                resolve();
            };

            rej = reject;
        });

        return at = {
            promise: p,
            resolve: res,
            reject: rej,
            reset: rst,
            timeout: t
        };
    }
});

/* framework module members... */

test: function (name, fn, options) {
    var mod = this; // local reference to framework module since promises
                    // run code under the window object

    var defaultOptions = {
        // default max running time is 5 seconds
        timeout: 5000
    }

    options = $$.extend({}, defaultOptions, options);

    // remove timeout when debugging is enabled
    options.timeout = mod.debugging ? -1 : options.timeout;

    // call to QUnit.test()
    test(name, function (assert) {
        // tell QUnit this is an async test so it doesn\'t run other tests
        // until done() is called
        var done = assert.async();
        return new Promise(function (resolve, reject) {
            console.log(\"Beginning: \" + name);

            var at = Promise.asyncTimeout(options.timeout, \"Test timed out.\");
            $$(at).one(\"timeout\", function () {
                // assert.fail() is just an extension I made that literally calls
                // assert.ok(false, msg);
                assert.fail(\"Test timed out\");
            });

            // run test function
            var result = fn.call(mod, assert, at.reset);

            // if the test returns a Promise, resolve it before resolving the test promise
            if (result && result.constructor === Promise) {
                // catch unhandled errors thrown by the test so future tests will run
                result.catch(function (error) {
                    var msg = \"Unhandled error occurred.\"
                    if (error) {
                        msg = error.message + \"\\n\" + error.stack;
                    }

                    assert.fail(msg);
                }).then(function () {
                    // resolve the timeout Promise
                    at.resolve();
                    resolve();
                });
            } else {
                // if test does not return a Promise, simply clear the timeout
                // and resolve our test Promise
                at.resolve();
                resolve();
            }
        }).then(function () {
            // tell QUnit that the test is over so that it can clean up and start the next test
            done();
            console.log(\"Ending: \" + name);
        });
    });
}

If a test times out, my timeout Promise will assert.fail() on the test so that the test is marked as failed, which is all well and good, but the test continues to run because the test Promise (result) is still waiting to resolve it.

I need a good way to cancel my test. I can do it by creating a field on the framework module this.cancelTest or something, and checking every so often (e.g. at the beginning of each then() iteration) within the test whether to cancel out. However, ideally, I could use $$(at).on(\"timeout\", /* something here */) to clear the remaining then()s on my result variable, so that none of the rest of the test is run.

Does something like this exist?

Quick Update

I tried using Promise.race([result, at.promise]). It didn\'t work.

Update 2 + confusion

To unblock me, I added a few lines with the mod.cancelTest/polling within the test idea. (I also removed the event trigger.)

return new Promise(function (resolve, reject) {
    console.log(\"Beginning: \" + name);

    var at = Promise.asyncTimeout(options.timeout, \"Test timed out.\");
    at.promise.catch(function () {
        // end the test if it times out
        mod.cancelTest = true;
        assert.fail(\"Test timed out\");
        resolve();
    });

    // ...

}).then(function () {
    // tell QUnit that the test is over so that it can clean up and start the next test
    done();
    console.log(\"Ending: \" + name);
});

I set a breakpoint in the catch statement, and it\'s being hit. What\'s confusing me now is that the then() statement isn\'t being called. Ideas?

Update 3

Figured the last thing out. fn.call() was throwing an error which I didn\'t catch, so the test promise was rejecting before at.promise.catch() could resolve it.

回答1:

Is there a method for clearing the .thens of a JavaScript Promise instance?

No. Not in ECMAScript 6 at least. Promises (and their then handlers) are uncancellable by default (unfortunately). There is a bit of discussion on es-discuss (e.g. here) about how to do this in the right way, but whatever approach will win it won\'t land in ES6.

The current standpoint is that subclassing will allow to create cancellable promises using your own implementation (not sure how well that\'ll work).

Until the language commitee has figured out the best way (ES7 hopefully?) you can still use userland Promise implementations, many of which feature cancellation.

Current discussion is in the https://github.com/domenic/cancelable-promise and https://github.com/bergus/promise-cancellation drafts.



回答2:

While there isn\'t a standard way of doing this in ES6, there is a library called Bluebird to handle this.

There is also a recommended way described as part of the react documentation. It looks similar to what you have in your 2 and 3rd updates.

const makeCancelable = (promise) => {
  let hasCanceled_ = false;

  const wrappedPromise = new Promise((resolve, reject) => {
    promise.then((val) =>
      hasCanceled_ ? reject({isCanceled: true}) : resolve(val)
    );
    promise.catch((error) =>
      hasCanceled_ ? reject({isCanceled: true}) : reject(error)
    );
  });

  return {
    promise: wrappedPromise,
    cancel() {
      hasCanceled_ = true;
    },
  };
};

const cancelablePromise = makeCancelable(
  new Promise(r => component.setState({...}}))
);

cancelablePromise
  .promise
  .then(() => console.log(\'resolved\'))
  .catch((reason) => console.log(\'isCanceled\', reason.isCanceled));

cancelablePromise.cancel(); // Cancel the promise

Taken from: https://facebook.github.io/react/blog/2015/12/16/ismounted-antipattern.html



回答3:

I\'m really surprised that no-one mentions Promise.race as a candidate for this:

const actualPromise = new Promise((resolve, reject) => { setTimeout(resolve, 10000) });
let cancel;
const cancelPromise = new Promise((resolve, reject) => {
    cancel = reject.bind(null, { canceled: true })
})

const cancelablePromise = Object.assign(Promise.race([actualPromise, cancelPromise]), { cancel });


回答4:

simple version:

just give out the reject function.

function Sleep(ms,cancel_holder) {

 return new Promise(function(resolve,reject){
  var done=false; 
  var t=setTimeout(function(){if(done)return;done=true;resolve();}, ms);
  cancel_holder.cancel=function(){if(done)return;done=true;if(t)clearTimeout(t);reject();} 
 })
}

a wraper solution (factory)

the solution I found is to pass a cancel_holder object. it will have a cancel function. if it has a cancel function then it is cancelable.

This cancel function rejects the promise with Error(\'canceled\').

Before resolve, reject, or on_cancel prevent the cancel function to be called without reason.

I have found convenient to pass the cancel action by injection

function cancelablePromise(cancel_holder,promise_fn,optional_external_cancel) {
  if(!cancel_holder)cancel_holder={};
  return new Promise( function(resolve,reject) {
    var canceled=false;
    var resolve2=function(){ if(canceled) return; canceled=true; delete cancel_holder.cancel; resolve.apply(this,arguments);}
    var reject2=function(){ if(canceled) return; canceled=true; delete cancel_holder.cancel; reject.apply(this,arguments);}
    var on_cancel={}
    cancel_holder.cancel=function(){
      if(canceled) return; canceled=true;

      delete cancel_holder.cancel;
      cancel_holder.canceled=true;

      if(on_cancel.cancel)on_cancel.cancel();
      if(optional_external_cancel)optional_external_cancel();

      reject(new Error(\'canceled\'));
    };

    return promise_fn.call(this,resolve2,reject2,on_cancel);        
  });
}

function Sleep(ms,cancel_holder) {

 return cancelablePromise(cancel_holder,function(resolve,reject,oncacnel){

  var t=setTimeout(resolve, ms);
  oncacnel.cancel=function(){if(t)clearTimeout(t);}     

 })
}


let cancel_holder={};

// meanwhile in another place it can be canceled
setTimeout(function(){  if(cancel_holder.cancel)cancel_holder.cancel(); },500) 

Sleep(1000,cancel_holder).then(function() {
 console.log(\'sleept well\');
}, function(e) {
 if(e.message!==\'canceled\') throw e;
 console.log(\'sleep interrupted\')
})


回答5:

If you want to stop all thens/catchs from being executed you can do this by injecting a promise that will never resolve. It probably has memory leak reprocusions but it will fix the issue and shouldn\'t cause too much wasted memory in most applications.

new Promise((resolve, reject) => {
    console.log(\'first chain link executed\')
    resolve(\'daniel\');
}).then(name => {
    console.log(\'second chain link executed\')
    if (name === \'daniel\') {
        // I don\'t want to continue the chain, return a new promise
        // that never calls its resolve function
        return new Promise((resolve, reject) => {
            console.log(\'unresolved promise executed\')
        });
    }
}).then(() => console.log(\'last chain link executed\'))

// VM492:2 first chain link executed
// VM492:5 second chain link executed
// VM492:8 unresolved promise executed


回答6:

Here\'s our implementation https://github.com/permettez-moi-de-construire/cancellable-promise

Used like

const {
  cancellablePromise,
  CancelToken,
  CancelError
} = require(\'@permettezmoideconstruire/cancellable-promise\')

const cancelToken = new CancelToken()

const initialPromise = SOMETHING_ASYNC()
const wrappedPromise = cancellablePromise(initialPromise, cancelToken)


// Somewhere, cancel the promise...
cancelToken.cancel()


//Then catch it
wrappedPromise
.then((res) => {
  //Actual, usual fulfill
})
.catch((err) => {
  if(err instanceOf CancelError) {
    //Handle cancel error
  }

  //Handle actual, usual error
})

which :

  • Doesn\'t touch Promise API
  • Let us make further cancellation inside catch call
  • Rely on cancellation being rejected instead of resolved unlike any other proposal or implementation

Pulls and comments welcome



回答7:

Set a \"cancelled\" property on the Promise to signal then() and catch() to exit early. It\'s very effective, especially in Web Workers that have existing microtasks queued up in Promises from onmessage handlers.

// Queue task to resolve Promise after the end of this script
const promise = new Promise(resolve => setTimeout(resolve))

promise.then(_ => {
  if (promise.canceled) {
    log(\'Promise cancelled.  Exiting early...\');
    return;
  }

  log(\'No cancelation signaled.  Continue...\');
})

promise.canceled = true;

function log(msg) {
  document.body.innerHTML = msg;
}



回答8:

@Michael Yagudaev \'s answer works for me.

But the original answer did not chain the wrapped promise with .catch() to handle reject handling, here is my improvement on top of @Michael Yagudaev\'s answer:

const makeCancelablePromise = promise => {
  let hasCanceled = false;
  const wrappedPromise = new Promise((resolve, reject) => {
    promise
      .then(val => (hasCanceled ? reject({ isCanceled: true }) : resolve(val)))
      .catch(
        error => (hasCanceled ? reject({ isCanceled: true }) : reject(error))
      );
  });

  return {
    promise: wrappedPromise,
    cancel() {
      hasCanceled = true;
    }
  };
};

// Example Usage:
const cancelablePromise = makeCancelable(
  new Promise((rs, rj) => {
    /*do something*/
  })
);
cancelablePromise.promise.then(() => console.log(\'resolved\')).catch(err => {
  if (err.isCanceled) {
    console.log(\'Wrapped promise canceled\');
    return;
  }
  console.log(\'Promise was not canceled but rejected due to errors: \', err);
});
cancelablePromise.cancel();


回答9:

There are a few npm libraries for cancellable promises.

  1. p-cancelable https://github.com/sindresorhus/p-cancelable

  2. cancelable-promise https://github.com/alkemics/CancelablePromise



回答10:

If p is a variable that contains a Promise, then p.then(empty); should dismiss the promise when it eventually completes or if it is already complete (yes, I know this isn\'t the original question, but it is my question). \"empty\" is function empty() {} . I\'m just a beginner and probably wrong, but these other answers seem too complicated. Promises are supposed to be simple.