功能说明
该示例提供不依赖 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;
}
}