在 JavaScript 中通过取消管理复杂事件序列的实用/优雅方法是什么?

What is a practical / elegant way to manage complex event sequences with cancellation in JavaScript?

我有一个 JavaScript (EmberJS + Electron) 应用程序需要执行异步任务序列。这是一个简化的例子:

  1. 向远程设备发送消息
  2. 在不到 t1 秒后收到响应
  3. 再发一条消息
  4. 在不到 t2 秒后收到第二个响应
  5. 显示成功信息

对于简单的情况,这似乎相当容易实现 Promises: 1 then 2 then 3 ... It gets a little trickier when timeouts are incorporated, but Promise.race and Promise.all 似乎是合理的解决方案。

但是,我需要让用户能够优雅地取消序列,我正在努力想出明智的方法来做到这一点。想到的第一件事是在每个步骤中进行某种轮询,以查看某个位置是否设置了变量以指示应取消序列。当然,这有一些严重的问题:

我的另一个想法可能是将到目前为止的所有事情都与另一个只有在用户发送取消信号时才会解决的承诺进行比较。这里的一个主要问题是单个任务 运行(用户想要取消)不知道他们需要停止、回滚等,所以即使从race 工作正常,其他 promise 中的代码没有得到通知。

曾几何时 talk about cancel-able promises, but it looks to me like the proposal was withdrawn so won't be incorporated into ECMAScript any time soon though I think the BlueBird promise library supports this idea. The application I'm making already includes the RSVP promise library,所以我真的不想引入另一个,但我想这是一个潜在的选择。

这个问题还能怎么解决? 我应该使用承诺吗? pub/sub 事件系统或类似的东西会更好吗?

理想情况下,我想将被取消的关注点与每个任务分开(就像 Promise 对象如何处理异步关注点一样)。如果取消信号可以是 passed-in/injected.

也很好

尽管我不擅长绘图,但我试图通过制作下面的两张图来说明我正在尝试做的事情。如果您发现它们令人困惑,请随时忽略它们。


如果我对你的问题理解正确,以下可能是一个解决方案。

简单超时

假设您的主线代码如下所示:

send(msg1)
  .then(() => receive(t1))
  .then(() => send(msg2))
  .then(() => receive(t2))
  .catch(() => console.log("Didn't complete sequence"));

receive 类似于:

function receive(t) {
  return new Promise((resolve, reject) => {
    setTimeout(() => reject("timed out"), t);
    receiveMessage(resolve, reject);
  });
}

这假设存在一个底层 API receiveMessage,它接受两个回调作为参数,一个用于成功,一个用于失败。 receive 简单地包装 receiveMessage 并添加超时,如果时间 treceiveMessage 解决之前经过,则拒绝承诺。

用户注销

但是如何构造它以便外部用户可以取消序列?您有正确的想法使用承诺而不是轮询。让我们自己写 cancelablePromise:

function cancelablePromise(executor, canceler) {
  return new Promise((resolve, reject) => {
    canceler.then(e => reject(`cancelled for reason ${e}`));
    executor(resolve, reject);
  });
}

我们传递了一个 "executor" 和一个 "canceler"。 "Executor" 是传递给 Promise 构造函数的参数的技术术语,具有签名 (resolve, reject) 的函数。我们传入的取消器是一个承诺,当它实现时,取消(拒绝)我们正在创建的承诺。所以 cancelablePromise 的工作方式与 new Promise 完全相同,只是增加了第二个参数,用于取消的承诺。

现在您可以编写如下代码,具体取决于您希望何时能够取消:

var canceler1 = new Promise(resolve => 
  document.getElementById("cancel1", "click", resolve);
);

send(msg1)
  .then(() => cancelablePromise(receiveMessage, canceler1))
  .then(() => send(msg2))
  .then(() => cancelablePromise(receiveMessage, canceler2))
  .catch(() => console.log("Didn't complete sequence"));

如果您使用 ES6 编程并且喜欢使用 类,您可以编写

class CancelablePromise extends Promise {
  constructor(executor, canceler) {
    super((resolve, reject) => {
      canceler.then(reject);
      executor(resolve, reject);
    }
}

这显然会被用作

send(msg1)
  .then(() => new CancelablePromise(receiveMessage, canceler1))
  .then(() => send(msg2))
  .then(() => new CancelablePromise(receiveMessage, canceler2))
  .catch(() => console.log("Didn't complete sequence"));

如果使用 TypeScript 编程,使用上面的代码,您可能需要以 ES6 为目标,运行 ES6 友好环境中的结果代码可以处理 built-ins 的子类化,例如 Promise 正确。如果您以 ES5 为目标,TypeScript 发出的代码可能无法正常工作。

上述方法有一个小(?)缺陷。即使 canceler 之前 我们开始序列,或调用 cancelablePromise(receiveMessage, canceler1),尽管承诺仍将按预期取消(拒绝),执行者仍然会运行,开始接收逻辑——在最好的情况下,这可能会消耗我们不希望的网络资源。解决这个问题留作练习。

"True"取消

但是上面的 none 解决了真正的问题:取消 in-progress 异步计算。这种情况激发了可取消承诺的提议,包括最近从 TC39 流程中撤回的提议。假设是计算提供了一些接口来取消它,例如 xhr.abort().

假设我们有一个 web worker 来计算第 n 个素数,它在收到 go 消息时开始:

function findPrime(n) {
  return new Promise(resolve => {
    var worker = new Worker('./find-prime.js');
    worker.addEventListener('message', evt => resolve(evt.data));
    worker.postMessage({cmd: 'go', n});
  }
}

> findPrime(1000000).then(console.log)
< 15485863

我们可以取消它,假设工作人员响应 "stop" 消息以终止其工作,再次使用 canceler 承诺,方法是:

function findPrime(n, canceler) {
  return new Promise((resolve, reject) => {
    // Initialize worker.
    var worker = new Worker('./find-prime.js');

    // Listen for worker result.
    worker.addEventListener('message', evt => resolve(evt.data));

    // Kick off worker.
    worker.postMessage({cmd: 'go', n});

    // Handle canceler--stop worker and reject promise.
    canceler.then(e => {
      worker.postMessage({cmd: 'stop')});
      reject(`cancelled for reason ${e}`);
    });
  }
}

网络请求可以使用相同的方法,例如,取消将涉及调用 xhr.abort()

顺便说一句,处理这种情况的一个相当优雅(?)的建议,即知道如何取消自己的承诺,是让执行者,其 return 值通常被忽略,而不是return 一个可以用来取消自身的函数。在这种方法下,我们将 findPrime 执行器编写如下:

const findPrimeExecutor = n => resolve => {
  var worker = new Worker('./find-prime.js');
  worker.addEventListener('message', evt => resolve(evt.data));
  worker.postMessage({cmd: 'go', n});

  return e => worker.postMessage({cmd: 'stop'}));
}

换句话说,我们只需要对执行器进行一次更改:一条 return 语句,它提供了一种取消正在进行的计算的方法。

现在我们可以编写 cancelablePromise 的通用版本,我们将其称为 cancelablePromise2,它知道如何使用这些特殊的执行器,return 一个函数来取消进程:

function cancelablePromise2(executor, canceler) {
  return new Promise((resolve, reject) => {
    var cancelFunc = executor(resolve, reject);

    canceler.then(e => {
      if (typeof cancelFunc === 'function') cancelFunc(e);
      reject(`cancelled for reason ${e}`));
    });

  });
}

假设有一个取消器,你的代码现在可以写成这样

var canceler = new Promise(resolve => document.getElementById("cancel", "click", resolve);

function chain(msg1, msg2, canceler) {
  const send = n => () => cancelablePromise2(findPrimeExecutor(n), canceler);
  const receive =   () => cancelablePromise2(receiveMessage, canceler);

  return send(msg1)()
    .then(receive)
    .then(send(msg2))
    .then(receive)
    .catch(e => console.log(`Didn't complete sequence for reason ${e}`));
}

chain(msg1, msg2, canceler);

在用户点击 "Cancel" 按钮且 canceler 承诺得到履行的那一刻,任何待处理的发送都将被取消,工作人员将在中途停止,and/or 任何待处理的接收都将被取消,承诺将被拒绝,拒绝沿着链向下级联到最终的 catch.

针对可取消承诺提出的各种方法试图使上述内容更精简、更灵活、更实用。仅举一个例子,其中一些允许同步检查取消状态。为此,他们中的一些人使用可以传递的 "cancel tokens" 的概念,扮演着类似于我们的 canceler 承诺的角色。然而,在大多数情况下,可以在纯用户态代码中处理取消逻辑而不会太复杂,就像我们在这里所做的那样。