承诺 - 是否可以强制取消承诺

Promise - is it possible to force cancel a promise

我使用 ES6 Promises 来管理我所有的网络数据检索,在某些情况下我需要强制取消它们。

基本上情况是这样的,我在 UI 上进行了预输入搜索,其中请求委托给后端必须根据部分输入执行搜索。虽然此网络请求 (#1) 可能需要一点时间,但用户继续键入最终触发另一个后端调用 (#2)

这里 #2 自然优先于 #1,所以我想取消 Promise 包装请求 #1。我已经在数据层中缓存了所有 Promise,因此理论上我可以在尝试为 #2 提交 Promise 时检索它。

但是,一旦从缓存中检索到 Promise #1,我该如何取消它呢?

谁能推荐一种方法?

我查看了 Mozilla JS 参考并发现了这个:

https://developer.mozilla.org/en-US/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,这正是您所需要的。

在现代 JavaScript - 没有

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

相反,有一个跨平台(节点、浏览器等)取消原语作为 WHATWG(也构建 HTML 的标准机构)的一部分,称为 AbortController。您可以使用它来取消 functions return promises 而不是 promises 本身:

// 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 承诺不支持取消。它正在路上,它的设计是很多人非常努力的事情。 声音 取消语义很难正确处理,这项工作正在进行中。关于“fetch”回购协议、esdiscuss 和 GH 上的其他几个回购协议存在有趣的争论,但如果我是你,我会耐心等待。

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

事实上,取消确实客户端编程中的一个重要场景。您描述的像中止 Web 请求这样的案例很重要,而且无处不在。

所以...语言把我搞砸了!

是的,很抱歉。 Promises 必须在进一步的事情被指定之前首先进入 - 所以他们进入时没有一些有用的东西,比如 .finally.cancel - 虽然它正在通过 DOM 达到规范。取消 不是 事后的想法,它只是一个时间限制和一种更迭代的 API 设计方法。

那我该怎么办?

您有多种选择:

  • 使用像 bluebird 这样的第三方库,它可以比规范移动得快得多,因此可以取消以及其他一些好东西 - 这就是像 WhatsApp 这样的大公司所做的。
  • 传递一个注销token.

使用第三方库是很明显的。至于令牌,你可以让你的方法接受一个函数然后调用它,这样:

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 这样的库在这里并不“闪耀”,因为它们是可观察的库,它们只是具有与用户级承诺库相同的优势,即不受规范约束。我想我们会等着看在 ES2016 中 Observables 成为原生的。不过,它们 很适合提前输入。

可取消承诺的标准提案失败。

承诺不是实现它的异步操作的控制面;将所有者与消费者混淆。相反,创建异步 函数 可以通过一些传入的令牌取消。

另一个 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 结果并对其进行测试来 "cancelling" 之前的搜索,但我们可以很容易地想象用 "CancelledError" 来拒绝。

当然这实际上并没有取消网络搜索,但这是 fetch 的限制。如果 fetch 将取消承诺作为参数,那么它可以取消网络 activity。

我在 es-discuss 上 proposed 这个 "Cancel promise pattern",正是为了建议 fetch 这样做。

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

我有一个基于承诺的客户端(不是网络客户端),我想始终向用户提供最新请求的数据以保持 UI 流畅。

在与取消想法作斗争后,Promise.race(...)Promise.all(..) 我刚刚开始记住我的最后一个请求 ID,当 promise 实现时,我只在它与最后一个请求的 ID 匹配时才呈现我的数据.

希望对大家有所帮助。

对于 Node.js 和 Electron,我强烈建议使用 Promise Extensions for JavaScript (Prex). Its author Ron Buckton is one of the key TypeScript engineers and also is the guy behind the current TC39's ECMAScript Cancellation 提案。这个库有很好的文档记录,Prex 中的一些可能会达到标准。

就我个人而言,来自 C# 背景,我非常喜欢 Prex 以现有 Cancellation in Managed Threads 框架为模型的事实,即基于 CancellationTokenSource/[= 所采用的方法12=] .NET API。根据我的经验,这些对于在托管应用程序中实现强大的取消逻辑非常方便。

我还通过使用 Browserify.

捆绑 Prex 验证了它可以在浏览器中工作

这里是一个延迟取消的例子(Gist and RunKit, using 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() 也没什么坏处。

https://www.npmjs.com/package/promise-abortable

$ npm install promise-abortable

因为@jib拒绝了我的修改,所以我post在这里回答。它只是 的修改,带有一些注释并使用更易于理解的变量名称。

下面我仅展示两种不同方法的示例:一种是 resolve() 另一种是 reject()

let cancelCallback = () => {};

input.oninput = function(ev) {
  let term = ev.target.value;
  console.log(`searching for "${term}"`);
  cancelCallback(); //cancel previous promise by calling cancelCallback()

  let setCancelCallbackPromise = () => {
    return new Promise((resolve, reject) => {
      // set cancelCallback when running this promise
      cancelCallback = () => {
        // pass cancel messages by resolve()
        return resolve('Canceled');
      };
    })
  }

  Promise.race([setCancelCallbackPromise(), getSearchResults(term)]).then(results => {
    // check if the calling of resolve() is from cancelCallback() or getSearchResults()
    if (results == 'Canceled') {
      console.log("error(by resolve): ", results);
    } else {
      console.log(`results for "${term}"`, results);
    }
  });
}


input2.oninput = function(ev) {
  let term = ev.target.value;
  console.log(`searching for "${term}"`);
  cancelCallback(); //cancel previous promise by calling cancelCallback()

  let setCancelCallbackPromise = () => {
    return new Promise((resolve, reject) => {
      // set cancelCallback when running this promise
      cancelCallback = () => {
        // pass cancel messages by reject()
        return reject('Canceled');
      };
    })
  }

  Promise.race([setCancelCallbackPromise(), getSearchResults(term)]).then(results => {
    // check if the calling of resolve() is from cancelCallback() or getSearchResults()
    if (results !== 'Canceled') {
      console.log(`results for "${term}"`, results);
    }
  }).catch(error => {
    console.log("error(by reject): ", error);
  })
}

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

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

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

很遗憾,提取调用已经完成,因此您将在“网络”选项卡中看到调用正在解析。您的代码将忽略它。

使用外部包提供的Promise子类,可以这样实现:Live demo

import CPromise from "c-promise2";

function fetchWithTimeout(url, {timeout, ...fetchOptions}= {}) {
    return new CPromise((resolve, reject, {signal}) => {
        fetch(url, {...fetchOptions, signal}).then(resolve, reject)
    }, timeout)
}

const chain= fetchWithTimeout('http://localhost/')
    .then(response => response.json())
    .then(console.log, console.warn);

//chain.cancel(); call this to abort the promise and releated request

使用 AbortController

可以使用中止控制器拒绝承诺或根据您的要求解决:

let controller = new AbortController();

let task = new Promise((resolve, reject) => {
  // some logic ...
  controller.signal.addEventListener('abort', () => {
    reject('oops'));
  }
});

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

此外,最好在中止时删除事件侦听器以防止内存泄漏

同样适用于取消获取:

let controller = new AbortController();
fetch(url, {
  signal: controller.signal
});

或者只传递控制器:

let controller = new AbortController();
fetch(url, controller);

并调用 abort 方法来取消您传递给该控制器的一次或无限次提取 controller.abort();

使用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 块中的 return 之前添加更多语句)。

提案

这种做法让我想起了几年前的可取消代币提案,但实际上它可以防止工作白费。控制台输出现在应该只是中止错误,仅此而已,甚至,当工作正在进行时,然后在中间取消,它可以停止,如前所述,在处理的明智步骤中,就像在开始时一样循环体

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