取消一个纯ECMAScript 6 Promise链。

158

有没有一种方法可以清除JavaScript Promise实例的 .then 方法?

我在QUnit之上编写了一个JavaScript测试框架。该框架通过在Promise中运行每个测试来同步运行它们。(对于这段代码块的长度,我很抱歉。我尽力进行了注释,所以感觉不那么乏味。)

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

如果测试超时,我的超时Promise将在测试上assert.fail(),以便将测试标记为失败,这很好,但测试继续运行,因为测试Promise (result) 仍在等待解决它。
我需要一个良好的方法来取消我的测试。我可以通过在框架模块this.cancelTest上创建一个字段或类似的东西,并且每隔一段时间(例如在每个then()迭代的开始)在测试中检查是否要取消来完成它。然而,理想情况下,我可以使用$$(at).on("timeout", /* something here */)来清除剩余的result变量上的所有then(),以便不运行测试的其余部分。
有像这样的东西存在吗?
快速更新
我尝试使用Promise.race([result, at.promise])。 它没有起作用。
更新2 + 混淆
为了解除阻塞,我添加了一些具有mod.cancelTest/轮询测试思路的行。 (我还删除了事件触发器。)
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);
});

我在catch语句中设置了断点,并且它被命中了。现在让我感到困惑的是then()语句没有被调用。有什么想法吗?

更新 3

我解决了最后一个问题。fn.call()抛出了一个错误,我没有捕获到,因此测试 Promise 在at.promise.catch()处理之前就被拒绝了。


可以使用ES6 Promise进行取消操作,但这不是Promise的属性(而是返回它的函数的属性)。如果您感兴趣,我可以举一个简短的例子。 - Benjamin Gruenbaum
@BenjaminGruenbaum 我知道已经快一年了,但如果你有时间写一个例子,我仍然很感兴趣。 :) - dx_over_dt
1
一年前就已经开始讨论取消令牌和可取消的承诺移动到第一阶段,但直到前天才正式宣布。 - Benjamin Gruenbaum
5
ES6 对取消 Promise 的解决方案是 Observable。您可以在此处阅读更多信息:https://github.com/Reactive-Extensions/RxJS - Frank Goortani
将我在Stack Overflow上关于使用Prex库进行Promise取消的回答链接(https://dev59.com/C10a5IYBdhLWcg3whJDW#53093799)添加到文章中。 - noseratio - open to work
17个回答

105
在ECMAScript 6中没有清除JavaScript Promise实例的 .then 的方法。默认情况下,Promise(及其 then 处理程序)无法取消。关于如何以正确的方式做到这一点的讨论(例如此处)有一些争议,但无论采用什么方法,它都不会出现在ES6中。当前立场是子类化将允许使用自己的实现创建可取消的Promise(不确定这将有效果)。在语言委员会找到最佳方法之前(希望在ES7中?),您仍然可以使用用户地带的Promise实现,其中许多特色取消功能。目前的讨论在https://github.com/domenic/cancelable-promisehttps://github.com/bergus/promise-cancellation草案中进行。

3
“一点讨论” - 我可以链接到 esdiscuss 或 GitHub 上的大约30个帖子 :) (更不用提您在bluebird 3.0中协助取消方面的帮助了) - Benjamin Gruenbaum
@BenjaminGruenbaum:你有那些链接可以分享吗?我一直想总结意见和尝试,并向esdiscuss发布提案,所以如果我能回顾一下我没有遗漏任何东西,我会很高兴的。 - Bergi
我在工作中随时可以使用它们 - 所以我会在3-4天内拥有它们。您可以在promises-aplus下查看承诺取消规范,这是一个很好的开始。 - Benjamin Gruenbaum
1
@LUH3417:“普通”的函数在这方面很无聊。你启动一个程序并等待它完成 - 或者你“kill”它并忽略可能留下的副作用对你的环境造成的奇怪状态(因此你通常也会抛弃它,例如任何未完成的输出)。然而,非阻塞或异步函数是为交互式应用程序构建的,您希望对正在进行的操作的执行具有更好的控制。 - Bergi
8
Domenic已经删除了TC39提案...(https://github.com/tc39/proposal-cancelable-promises)...抄送@BenjaminGruenbaum - Sergio
显示剩余3条评论

60

尽管在ES6中没有标准的方法来处理此问题,但是有一个名为Bluebird的库可以处理此问题。

React文档中还推荐了一种方法,看起来类似于您第2和第3次更新中的内容。

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

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


5
“Canceled”的这个定义仅仅是拒绝一个承诺,并且这取决于“Canceled”的定义。 - Alexander Mills
1
如果您想取消一组Promises,会发生什么? - Matthieu Brucher
2
这种方法的问题在于,如果您有一个永远不会解决或拒绝的 Promise,它将永远无法被取消。 - DaNeSh
3
这部分是正确的,但如果你有一个很长的承诺链,这种方法将无效。 - Veikko Karsikko

28

我很惊讶没有人将Promise.race提名为这个的候选者:

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
我不相信这个有效。如果你将 Promise 更改为日志记录,则运行 cancel()仍将导致调用日志记录。const actualPromise = new Promise((resolve, reject) => { setTimeout(() => { console.log('actual called'); resolve() }, 10000) }); - shmck
7
问题是如何取消一个 promise(=> 停止执行链式的 then 函数),而不是如何取消 setTimeout (=> clearTimeout)或同步代码。除非在每一行后面放置一个 if 语句(if (canceled) return),否则无法实现取消同步代码的执行。(不要这么做) - Pho3nixHun
6
是的,这个机制有点微妙,但它是有效的。Promise 构造函数同步运行并已经运行过,无法被取消。它调用了 setTimeout 来安排一个事件来解决 Promise,但该事件不是 Promise 链的一部分。调用 cancel 会导致可取消的 Promise 被拒绝,因此任何在其解决时执行的任务都将被丢弃而没有运行。setTimeout 的事件处理程序最终仍然会运行,但不能解决已被拒绝的 Promise;如果想要取消超时本身,需要使用不同的机制。 - Ben
@ben 我可以想象一些包含计时器引用的顶级对象,可以在cancelPromise代码中清除,但我认为这超出了范围? - AncientSwordRage

25

通过使用AbortController,可以取消Promise。

是否有一种清除的方法: 是的,您可以使用AbortController对象拒绝Promise,然后Promise将跳过所有then块并直接进入catch块。

示例:

import "abortcontroller-polyfill";

let controller = new window.AbortController();
let signal = controller.signal;
let elem = document.querySelector("#status")

let example = (signal) => {
    return new Promise((resolve, reject) => {
        let timeout = setTimeout(() => {
            elem.textContent = "Promise resolved";
            resolve("resolved")
        }, 2000);

        signal.addEventListener('abort', () => {
            elem.textContent = "Promise rejected";
            clearInterval(timeout);
            reject("Promise aborted")
        });
    });
}

function cancelPromise() {
    controller.abort()
    console.log(controller);
}

example(signal)
    .then(data => {
        console.log(data);
    })
    .catch(error => {
        console.log("Catch: ", error)
    });

document.getElementById('abort-btn').addEventListener('click', cancelPromise);

Html


    <button type="button" id="abort-btn" onclick="abort()">Abort</button>
    <div id="status"> </div>

注意:需要添加polyfill,不支持所有浏览器。

实例

Edit elegant-lake-5jnh3


1
值得注意的是:fetch API 调用可以选择性地接受一个信号,允许中止请求。请参阅 https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal - PotatoesMaster

15
const makeCancelable = promise => {
    let rejectFn;

    const wrappedPromise = new Promise((resolve, reject) => {
        rejectFn = reject;

        Promise.resolve(promise)
            .then(resolve)
            .catch(reject);
    });

    wrappedPromise.cancel = () => {
        rejectFn({ canceled: true });
    };

    return wrappedPromise;
};

使用方法:

const cancelablePromise = makeCancelable(myPromise);
// ...
cancelablePromise.cancel();

10

实际上,停止执行承诺是不可能的,但您可以劫持拒绝并从承诺本身调用它。

class CancelablePromise {
  constructor(executor) {
    let _reject = null;
    const cancelablePromise = new Promise((resolve, reject) => {
      _reject = reject;
      return executor(resolve, reject);
    });
    cancelablePromise.cancel = _reject;

    return cancelablePromise;
  }
}

使用方法:

const p = new CancelablePromise((resolve, reject) => {
  setTimeout(() => {
    console.log('resolved!');
    resolve();
  }, 2000);
})

p.catch(console.log);

setTimeout(() => {
  p.cancel(new Error('Messed up!'));
}, 1000);

1
@dx_over_dt,你的编辑意见很好,但这并不是一次编辑。请让原作者自行处理此类实质性的编辑(当然,除非该帖子已被标记为社区维基)。 - TylerH
@TylerH,编辑的目的是纠正拼写错误等问题吗?还是在信息过时时更新信息?我刚获得编辑其他人帖子的特权,还不是很熟悉。 - dx_over_dt
@dx_over_dt 是的,编辑是通过修正拼写错误、语法错误以及添加语法高亮(例如,如果有人只是发布了一堆代码但没有缩进或标记为```)来改善帖子。添加实质性内容,如额外的解释或事物的理由/证明,通常是回答者的职责范围。您可以在评论中自由建议,OP将收到评论并可以回复它,或者他们可以将您的建议合并到帖子中。 - TylerH
@dx_over_dt,除非帖子被标记为“社区维基”,表示它旨在作为协作帖子(例如像维基百科一样),或者如果帖子存在严重问题,例如粗鲁/辱骂性语言、危险/有害内容(例如建议或代码可能会给您带来病毒或使您被捕等),或个人信息,如健康记录、电话号码、信用卡等;请随意自行删除这些内容。 - TylerH
1
值得注意的是,承诺内部无法停止执行的原因是JavaScript是单线程的。当承诺函数正在执行时,没有其他内容在运行,因此没有任何东西可以触发停止执行。 - dx_over_dt
如果executor返回一个带有catch的Promise,这个方法还能正常工作吗?假设在我的单元测试场景中,我期望出现错误并希望测试该功能是否能够恢复。那么调用cancel会从内部的catch继续执行,还是会像预期的那样取消测试呢? - dx_over_dt

4

3

在Promise上设置一个"cancelled"属性,以通知then()catch()提前退出。这非常有效,特别是在Web Workers中,因为它们已经存在从onmessage处理程序排队的Promise微任务。

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


1
只有一个赞?哥们,这个答案太棒了!已经4.5年了,但正是我需要的解决方案(其他的对于我的简单用例来说都太复杂了)。 - codepleb

2

简化版:

只需提供拒绝功能。

简单想法:

function MyPromise(myparams,cancel_holder) {
 return new Promise(function(resolve,reject){
  //do work here
  cancel_holder.cancel=reject
 }
}

or simple idea2:

function MyPromise() {
 var cancel_holder={};
 var promise=new Promise(function(resolve,reject){
  //do work here
  cancel_holder.cancel=reject;
 }
 promise.cancel=function(){ cancel_holder.cancel(); }
 return promise;
}

例子:

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

一个包装器解决方案(工厂)

我找到的解决方案是传递一个cancel_holder对象。它将有一个cancel函数。如果它有一个cancel函数,那么它是可取消的。

这个cancel函数会用Error('canceled')拒绝promise。

在resolve、reject或on_cancel之前,防止无故调用cancel函数。

我发现通过注入传递cancel操作很方便。

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

1
这是我们的实现https://github.com/permettez-moi-de-construire/cancellable-promise
使用方法如下:
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
})

这个库:

  • 不会触及 Promise API
  • 让我们在 catch 调用中进行进一步的取消操作
  • 依赖于取消被拒绝而不是解决,与任何其他提案或实现不同

欢迎提出请求和评论。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接