Promise - 是否可以强制取消一个 Promise?

176
我使用ES6 Promise来管理我的所有网络数据获取,但有些情况下我需要强制取消它们。
基本上场景是这样的:我在UI上使用了一个自动完成搜索功能,该请求被委托给后端进行搜索。虽然这个网络请求(#1)可能需要一点时间,但用户继续输入,最终触发了另一个后端调用(#2)。
在这里,#2自然优先于#1,因此我想取消Promise封装的#1请求。我已经在数据层中有了所有Promise的缓存,因此理论上,当我尝试提交#2的Promise时,可以检索到#1的Promise。
但是,我如何取消从缓存中检索出来的Promise #1呢?
有人能提供一种方法吗?

2
有没有使用防抖函数的等效选项,以避免触发过于频繁和变得过时的请求?比如说300毫秒的延迟就可以解决问题。例如,Lodash有一个实现-https://lodash.com/docs#debounce - shershen
这就是Bacon和Rx这样的东西派上用场的时候。 - elclanrs
@shershen 是的 - 我们有这个功能,但这不是关于用户界面问题的。服务器查询可能需要一些时间,因此我希望能够取消 Promises... - Moonwalker
.catch(_=>_)附加到代码中以避免未来可能出现的未捕获Promise错误,这样做有什么问题吗?它会在虚空中悬浮吗? - Redu
显示剩余3条评论
13个回答

278

在现代JavaScript中 - 不行

承诺已经解决(哈哈),似乎永远不可能取消一个(未决定的)承诺。

相反,有一个跨平台(Node,浏览器等)取消原语作为WHATWG的一部分(一个还构建HTML的标准机构),称为AbortController。您可以将其用于取消返回承诺而不是承诺本身的函数

// Take a signal parameter in the function that needs cancellation
async function somethingIWantToCancel({ signal } = {}) {
  // either pass it directly to APIs that support it
  // (fetch and most Node APIs do)
  const response = await fetch('.../', { signal });
  // return response.json;

  // or if the API does not already support it -
  // manually adapt your code to support signals:
  const onAbort = (e) => {
    // run any code relating to aborting here
  };
  signal.addEventListener('abort', onAbort, { once: true });
  // and be sure to clean it up when the action you are performing
  // is finished to avoid a leak
  // ... sometime later ...
  signal.removeEventListener('abort', onAbort);
}

// Usage
const ac = new AbortController();
setTimeout(() => ac.abort(), 1000); // give it a 1s timeout
try {
  await somethingIWantToCancel({ signal: ac.signal });
} catch (e) {
  if (e.name === 'AbortError') {
    // deal with cancellation in caller, or ignore
  } else {
    throw e; // don't swallow errors :)
  }
}

不行,目前还不能做到。

ES6的Promise目前还不支持取消操作。这个功能正在开发中,很多人都在为此努力工作。正确实现取消语义是很困难的,这需要时间来完善。在“fetch”仓库、esdiscuss和其他一些仓库上都有有趣的辩论,但如果我是你,我会耐心等待。

但是,但是,但是... 取消操作真的很重要!

确实很重要,事实上,在客户端编程中,取消操作非常重要。像中止Web请求之类的场景非常普遍。

所以... 语言让我失望了!

是的,对此我们深表歉意。在进一步规定之前,Promise必须先得到实现,因此它们被引入时缺少了一些有用的东西,比如finally和cancel方法。但这个问题正在通过DOM规范得到解决。取消操作并不是事后想到的,只是受时间限制和API设计更迭的影响。

那么我该怎么办呢?

你有几种选择:

  • 使用第三方库,例如bluebird,它们可以比规范更快地推出取消操作以及其他一些好用的功能。这也是大公司如WhatsApp所采取的方法。
  • 传递一个取消令牌。

使用第三方库显然很容易。至于令牌,你可以让你的方法接受一个函数并调用它,例如:

function getWithCancel(url, token) { // the token is for cancellation
   var xhr = new XMLHttpRequest;
   xhr.open("GET", url);
   return new Promise(function(resolve, reject) {
      xhr.onload = function() { resolve(xhr.responseText); });
      token.cancel = function() {  // SPECIFY CANCELLATION
          xhr.abort(); // abort request
          reject(new Error("Cancelled")); // reject the promise
      };
      xhr.onerror = reject;
   });
};

这将使您能够:

var token = {};
var promise = getWithCancel("/someUrl", token);

// later we want to abort the promise:
token.cancel();

真实应用场景 - last

使用令牌方法并不难:

function last(fn) {
    var lastToken = { cancel: function(){} }; // start with no op
    return function() {
        lastToken.cancel();
        var args = Array.prototype.slice.call(arguments);
        args.push(lastToken);
        return fn.apply(this, args);
    };
}

这将让你可以:

var synced = last(getWithCancel);
synced("/url1?q=a"); // this will get canceled 
synced("/url1?q=ab"); // this will get canceled too
synced("/url1?q=abc");  // this will get canceled too
synced("/url1?q=abcd").then(function() {
    // only this will run
});

不,像Bacon和Rx这样的库在这里并没有“闪光点”,因为它们只是可观察的库,它们与用户级别的promise库具有相同的优势,即不受规范限制。我猜我们将在ES2016中等待并看到observables成为本地支持。然而,在输入提示方面,它们确实很棒。


1
我们在哪里可以了解关于基于令牌的取消的内容?提案在哪里? - harm
@harm,该提案在第一阶段已经失败。 - Benjamin Gruenbaum
1
我喜欢Ron的工作,但我认为在为人们尚未使用的库做出推荐之前,我们应该再等一会儿:] 谢谢你提供的链接,我会去看看! - Benjamin Gruenbaum
@BenjaminGruenbaum,我想知道您对https://dev59.com/C10a5IYBdhLWcg3whJDW#72503562的看法,谢谢。 - Miguel Sánchez Villafán
我们称其为“特洛伊木马”,而不是“令牌”。 - echefede
显示剩余4条评论

37
使用AbortController 可以使用AbortController来根据您的需求拒绝或解决promise。
let controller = new AbortController();

let task = new Promise((resolve, reject) => {
  const abortListener = ({target}) => {
    controller.signal.removeEventListener('abort', abortListener);
    reject(target.reason);
  }
  controller.signal.addEventListener('abort', abortListener);

  // some logic ...
});

controller.abort('cancelled reason'); // task is now in rejected state

同时最好在中止时移除事件监听器,以防止内存泄漏。
而且你可以通过检查 `controller.signal.aborted` 布尔属性来判断是否由中止引发了错误。
const res = task.catch((err) => (
  controller.signal.aborted 
    ? { value: err } 
    : null
));

中止控制器不会取消 Promise 内部逻辑的运行。但是如果不调用 resolve 或 reject,Promise 将永远处于 pending 状态,在这种情况下,如果这是你的意图,你也不会收到任何错误的 .catch 被触发(但这会导致内存泄漏),所以例如取消的 fetch 会触发 reject。
xhr.onload = () => {
  if(controller.signal.aborted) reject(controller.signal.reason);
  resolve(xhr.responseText);
}

同样适用于取消获取操作:
let controller = new AbortController();
fetch(url, {
  signal: controller.signal
});

或者只是传递控制器:
let controller = new AbortController();
fetch(url, controller);

调用abort方法来取消一个或无限数量的获取操作,其中你传递了这个控制器 controller.abort();

我不确定是否需要并且可能删除“abort”事件监听器...? - Artemee Lemann
1
需要将其删除 - 否则它就是内存泄漏(我曾经在Node核心中犯过这样的错误,忘记自己删除其中一个!)。另外:永远不要使用字符串拒绝:] - Benjamin Gruenbaum
很遗憾,没有一种方法可以移除事件监听器,所以我在想 - Artemee Lemann
我已经研究了几天,仍然觉得只拒绝事件处理程序中的承诺只是方法的一部分。问题在于,正如您所知,只有拒绝承诺会使代码等待它以恢复执行,但如果在承诺被拒绝或解决后还有任何代码运行,它将继续运行,浪费循环甚至可能浪费内存在不再需要的东西上。 - Miguel Sánchez Villafán
1
在你的第一个例子中,先添加事件监听器再运行逻辑更有意义...值得一提的是,解决或拒绝一个Promise并不会停止Promise内部代码的运行。 - undefined
显示剩余2条评论

28

可取消的promise的标准提案已经失败。

Promise不是异步动作控制面板,会混淆所有者和消费者。相反,创建可以通过传入的令牌被取消的异步函数

另一个promise成为了很好的令牌,使用Promise.race使取消变得容易:

示例: 使用Promise.race取消前一链的效果:

let cancel = () => {};

input.oninput = function(ev) {
  let term = ev.target.value;
  console.log(`searching for "${term}"`);
  cancel();
  let p = new Promise(resolve => cancel = resolve);
  Promise.race([p, getSearchResults(term)]).then(results => {
    if (results) {
      console.log(`results for "${term}"`,results);
    }
  });
}

function getSearchResults(term) {
  return new Promise(resolve => {
    let timeout = 100 + Math.floor(Math.random() * 1900);
    setTimeout(() => resolve([term.toLowerCase(), term.toUpperCase()]), timeout);
  });
}
Search: <input id="input">

在这里,我们通过注入一个undefined的结果并测试它来“取消”以前的搜索,但我们可以轻松想象改为使用"CancelledError"进行拒绝。

当然,这实际上并没有取消网络搜索,但这是fetch的限制。如果fetch接受一个取消的Promise作为参数,那么它就可以取消网络活动。

我在es-discuss中提出了这个“取消Promise模式”,正是为了建议fetch这样做。


@jib 为什么拒绝我的修改?我只是想澄清一下。 - allenyllee

11

我查看了 Mozilla JS 参考文档并找到了这个链接:

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Promise/race

我们来看一下:

var p1 = new Promise(function(resolve, reject) { 
    setTimeout(resolve, 500, "one"); 
});
var p2 = new Promise(function(resolve, reject) { 
    setTimeout(resolve, 100, "two"); 
});

Promise.race([p1, p2]).then(function(value) {
  console.log(value); // "two"
  // Both resolve, but p2 is faster
});

我们这里有p1和p2作为Promise.race(...)的参数,实际上这将创建一个新的resolve promise,这正是你所需的。


不错 - 这可能正是我需要的。我会试一试。 - Moonwalker
如果您遇到问题,可以在此处粘贴代码,以便我协助您 :) - nikola-miljkovic
14
试过了,但效果不完美。这个解决方案可以让 Promise 最快地被解决,但我需要始终解决最新提交的 Promise,也就是无条件取消任何较旧的 Promises。 - Moonwalker
1
这样,所有其他的承诺都不再被处理,你实际上无法取消一个承诺。 - nikola-miljkovic
我尝试了一下,第二个 Promise(在这个例子中)不让进程退出 :( - morteza ataiy

3
对于Node.js和Electron,我强烈推荐使用JavaScript扩展的Promise Extensions for JavaScript(Prex)。它的作者Ron Buckton是TypeScript工程师中的关键人物,也是当前TC39的ECMAScript Cancellation提案的幕后推手。这个库有很好的文档,有可能其中的一些内容会被纳入标准。
就个人而言,来自C#背景的我非常喜欢Prex基于现有的Cancellation in Managed Threads框架进行建模,即基于.NET API的CancellationTokenSource/CancellationToken方法。在我的经验中,这些方法非常方便,在管理应用程序中实现健壮的取消逻辑。
我还通过使用Browserify将Prex绑定到浏览器中进行验证。
这是一个取消延迟的示例(GistRunKit,使用Prex作为其CancellationTokenDeferred)。
// by @noseratio
// https://gist.github.com/noseratio/141a2df292b108ec4c147db4530379d2
// https://runkit.com/noseratio/cancellablepromise

const prex = require('prex');

/**
 * A cancellable promise.
 * @extends Promise
 */
class CancellablePromise extends Promise {
  static get [Symbol.species]() { 
    // tinyurl.com/promise-constructor
    return Promise; 
  }

  constructor(executor, token) {
    const withCancellation = async () => {
      // create a new linked token source 
      const linkedSource = new prex.CancellationTokenSource(token? [token]: []);
      try {
        const linkedToken = linkedSource.token;
        const deferred = new prex.Deferred();
  
        linkedToken.register(() => deferred.reject(new prex.CancelError()));
  
        executor({ 
          resolve: value => deferred.resolve(value),
          reject: error => deferred.reject(error),
          token: linkedToken
        });

        await deferred.promise;
      } 
      finally {
        // this will also free all linkedToken registrations,
        // so the executor doesn't have to worry about it
        linkedSource.close();
      }
    };

    super((resolve, reject) => withCancellation().then(resolve, reject));
  }
}

/**
 * A cancellable delay.
 * @extends Promise
 */
class Delay extends CancellablePromise {
  static get [Symbol.species]() { return Promise; }

  constructor(delayMs, token) {
    super(r => {
      const id = setTimeout(r.resolve, delayMs);
      r.token.register(() => clearTimeout(id));
    }, token);
  }
}

// main
async function main() {
  const tokenSource = new prex.CancellationTokenSource();
  const token = tokenSource.token;
  setTimeout(() => tokenSource.cancel(), 2000); // cancel after 2000ms

  let delay = 1000;
  console.log(`delaying by ${delay}ms`); 
  await new Delay(delay, token);
  console.log("successfully delayed."); // we should reach here

  delay = 2000;
  console.log(`delaying by ${delay}ms`); 
  await new Delay(delay, token);
  console.log("successfully delayed."); // we should not reach here
}

main().catch(error => console.error(`Error caught, ${error}`));

请注意,取消操作是一场竞赛。也就是说,一个承诺可能已经成功解决,但在您观察它(使用awaitthen)的时候,取消操作也可能已经被触发了。如何处理这场竞赛取决于您,但多次调用token.throwIfCancellationRequested()并不会有坏处,就像我上面所做的那样。

1

最近我遇到了类似的问题。

我有一个基于Promise的客户端(不是网络客户端),我希望始终将最新请求的数据提供给用户,以保持UI的流畅性。

在苦苦挣扎了取消想法、Promise.race(...)Promise.all(..)之后,我开始记住我的最后一个请求ID,并且当Promise实现时,只有在它匹配最后一个请求的ID时才呈现我的数据。

希望对某些人有所帮助。


Slomski,问题不在于在UI上显示什么,而在于取消Promise。 - CyberAbhay

0

您可以在完成之前让承诺拒绝:

// Our function to cancel promises receives a promise and return the same one and a cancel function
const cancellablePromise = (promiseToCancel) => {
  let cancel
  const promise = new Promise((resolve, reject) => {
    cancel = reject
    promiseToCancel
      .then(resolve)
      .catch(reject)
  })
  return {promise, cancel}
}

// A simple promise to exeute a function with a delay
const waitAndExecute = (time, functionToExecute) => new Promise((resolve, reject) => {
  timeInMs = time * 1000
  setTimeout(()=>{
    console.log(`Waited ${time} secs`)
    resolve(functionToExecute())
  }, timeInMs)
})

// The promise that we will cancel
const fetchURL = () => fetch('https://pokeapi.co/api/v2/pokemon/ditto/')

// Create a function that resolve in 1 seconds. (We will cancel it in 0.5 secs)
const {promise, cancel} = cancellablePromise(waitAndExecute(1, fetchURL))

promise
  .then((res) => {
    console.log('then', res) // This will executed in 1 second
  })
  .catch(() => {
    console.log('catch') // We will force the promise reject in 0.5 seconds
  })

waitAndExecute(0.5, cancel) // Cancel previous promise in 0.5 seconds, so it will be rejected before finishing. Commenting this line will make the promise resolve

不幸的是,fetch调用已经完成了,所以您将在网络选项卡中看到调用正在解决。 您的代码将忽略它。


0

0

我有一个异步函数,需要在用户输入时取消它,但它是一个涉及鼠标控制的长时间运行的函数。

我使用了p-queue,并将函数中的每一行添加到其中,并有一个可观察对象,我将取消信号提供给它。队列开始处理的任何内容都将无论如何运行,但您应该能够通过清除队列来取消之后的任何内容。您可以懒惰地将整个代码块抛入队列中,而不是像示例中的单行代码。

p-queue releases版本6与commonjs一起使用,7+切换到ESM可能会破坏您的应用程序。这破坏了我的electron/typescript/webpack应用。

const cancellable_function = async () => {
 const queue = new PQueue({concurrency:1});
 queue.pause();

 queue.addAll([
   async () => await move_mouse({...}),
   async () => await mouse_click({...}),
 ])

 for await (const item of items) {
  queue.addAll([
    async () => await do_something({...}),
    async () => await do_something_else({...}),
  ])
 } 

 const {information} = await get_information();

 queue.addAll([
   async () => await move_mouse({...}),
   async () => await mouse_click({...}),
 ])
 
 cancel_signal$.pipe(take(1)).subscribe(() => {
   queue.clear();
 });

 queue.start();
 await queue.onEmpty()
}

0

使用AbortController

我已经研究了几天,仍然觉得在中止事件处理程序内拒绝承诺只是方法的一部分。

问题在于,正如您所知,仅拒绝承诺会使代码等待它恢复执行,但如果有任何代码在拒绝或解决承诺之后运行,或者在其执行范围之外运行,例如在事件侦听器或异步调用内部,它将继续运行,浪费周期甚至可能浪费内存,这些都是不必要的。

缺乏方法

执行下面的片段时,2秒后,控制台将包含来自承诺拒绝执行和挂起工作的任何输出。 承诺将被拒绝,等待它的工作可以继续,但工作将不会继续进行,这是我认为这个练习的主要点。

let abortController = new AbortController();
new Promise( ( resolve, reject ) => {
  if ( abortController.signal.aborted ) return;

  let abortHandler = () => {
    reject( 'Aborted' );
  };
  abortController.signal.addEventListener( 'abort',  abortHandler );

  setTimeout( () => {
    console.log( 'Work' );
    console.log( 'More work' );
    resolve( 'Work result' );
    abortController.signal.removeEventListener( 'abort', abortHandler );
  }, 2000 );
} )
  .then( result => console.log( 'then:', result ) )
  .catch( reason => console.error( 'catch:', reason ) );
setTimeout( () => abortController.abort(), 1000 );

这让我想到,在定义了中止事件处理程序之后,必须进行调用。
if ( abortController.signal.aborted ) return;

在执行工作的代码中添加明智的代码点,以便工作不会被执行,并且如果必要,可以优雅地停止(在上面的if块中返回之前添加更多语句)。

建议

这种方法让我想起了几年前的可取消令牌提案,但它实际上可以防止无用的工作。现在控制台输出应该只是终止错误,甚至当工作正在进行时,然后在中间被取消时,它也可以在处理的明智步骤中停止,例如在循环体的开头。

let abortController = new AbortController();
new Promise( ( resolve, reject ) => {
  if ( abortController.signal.aborted ) return;

  let abortHandler = () => {
    reject( 'Aborted' );
  };
  abortController.signal.addEventListener( 'abort',  abortHandler );

  setTimeout( () => {
    if ( abortController.signal.aborted ) return;
    console.log( 'Work' );

    if ( abortController.signal.aborted ) return;
    console.log( 'More work' );
    resolve( 'Work result' );
    abortController.signal.removeEventListener( 'abort', abortHandler );
  }, 2000 );
} )
  .then( result => console.log( 'then:', result ) )
  .catch( reason => console.error( 'catch:', reason ) );
setTimeout( () => abortController.abort(), 1000 );


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