← 中文资源

简体中文版本

支持链式源的取消令牌

协作式取消原语,携带取消原因、可退订回调、throwIfCancelled 与级联取消的链接源。

阅读英文原文

功能说明

该示例提供不依赖 AbortController 的取消令牌模型。CancellationTokenSource 持有唯一令牌,cancel 可以传入 Error 或任意原因;令牌暴露 isCancelled、reason、throwIfCancelled 与 onCancel 订阅。静态 link 把多个父级令牌合并为一个子令牌,任一父级取消都会级联取消子令牌,适合把请求级取消、超时取消与用户主动取消合并到同一条信号上。

关键代码

取消状态与监听器集合封装在私有字段中,_cancel 只在首次调用时生效,先记录原因再对监听器快照逐个通知,最后清空集合,避免重复取消或通知过程中增删导致遗漏。onCancel 在令牌已取消时通过 queueMicrotask 异步回调,保证订阅方总能拿到通知而不会同步重入。link 为每个父令牌注册回调,并在子令牌取消时统一退订,防止父令牌长期存活造成监听器泄漏。

使用注意

_cancel 使用下划线表示内部方法,业务代码应只通过 source.cancel 触发取消。取消原因若不是 Error,throwIfCancelled 会包装成 CancellationError;若是 Error 则原样抛出,调用方可据此区分超时与主动取消。onCancel 对已取消令牌的通知是异步的,因此不要假设订阅后立刻同步执行。该实现不自动接入 fetch,需要把令牌状态桥接到 AbortSignal。

验证方式

创建源并订阅 onCancel,取消两次,断言回调只执行一次且第二次 cancel 返回 false;用 Error 作为原因,验证 throwIfCancelled 抛出同一对象;链接两个父源,分别取消其中一个,确认子令牌被取消且原因一致;在取消后注册回调,用微任务等待并断言仍会收到通知。

代码示例

export class CancellationError extends Error {
  constructor(message = "Operation cancelled") {
    super(message);
    this.name = "CancellationError";
  }
}

export class CancellationToken {
  #cancelled = false;
  #reason;
  #listeners = new Set();

  get isCancelled() {
    return this.#cancelled;
  }

  get reason() {
    return this.#reason;
  }

  throwIfCancelled() {
    if (!this.#cancelled) return;
    if (this.#reason instanceof Error) throw this.#reason;
    throw new CancellationError(String(this.#reason));
  }

  onCancel(listener) {
    if (typeof listener !== "function") {
      throw new TypeError("listener must be a function");
    }
    if (this.#cancelled) {
      queueMicrotask(() => listener(this.#reason));
      return () => {};
    }
    this.#listeners.add(listener);
    return () => this.#listeners.delete(listener);
  }

  _cancel(reason) {
    if (this.#cancelled) return false;
    this.#cancelled = true;
    this.#reason = reason ?? new CancellationError();
    for (const listener of [...this.#listeners]) listener(this.#reason);
    this.#listeners.clear();
    return true;
  }
}

export class CancellationTokenSource {
  #token = new CancellationToken();

  get token() {
    return this.#token;
  }

  cancel(reason) {
    return this.#token._cancel(reason);
  }

  static link(...sources) {
    const controller = new CancellationTokenSource();
    const unsubscribers = sources.map((source) =>
      source.token.onCancel((reason) => controller.cancel(reason)),
    );
    controller.token.onCancel(() => {
      for (const unsubscribe of unsubscribers) unsubscribe();
    });
    return controller;
  }
}