跟着 MDN 学 HTML day_31:(AbortSignal 深入解析与高级中止模式)

AbortSignal 是 AbortController 体系中的核心组成部分。如果说 AbortController 是"发令枪",那么 AbortSignal 就是传递信号的"通信兵"。它负责携带中止状态,并与一个或多个异步操作进行通信。理解 AbortSignal 的各种特性和方法,可以帮助我们构建更加健壮和可控的异步程序。

一、AbortSignal 的基本概念与实例属性

AbortSignal 接口继承自 EventTarget,它表示一个信号对象。这个对象本身不包含主动中止的方法,而是被动地反映是否已经被中止的状态。异步操作通过监听这个信号来决定何时停止工作。

javascript 复制代码
// 演示 AbortSignal 的基本状态监听
function demonstrateSignalState() {
  // 创建一个控制器,获取关联的信号
  const controller = new AbortController();
  const signal = controller.signal;
  
  console.log('初始状态 - 是否已中止:', signal.aborted); // false
  console.log('初始状态 - 中止原因:', signal.reason); // undefined
  
  // 监听 abort 事件
  signal.addEventListener('abort', () => {
    console.log('abort 事件触发');
    console.log('中止后的 aborted 状态:', signal.aborted); // true
    console.log('中止原因:', signal.reason);
  });
  
  // 模拟用户点击取消
  setTimeout(() => {
    controller.abort('用户主动取消下载');
  }, 2000);
  
  // 可以通过 onabort 属性直接赋值监听器
  const controller2 = new AbortController();
  const signal2 = controller2.signal;
  signal2.onabort = () => {
    console.log('通过 onabort 监听到信号中止');
  };
  
  controller2.abort();
}

demonstrateSignalState();

signal.aborted 是一个布尔值属性,用于快速检查信号状态。signal.reason 属性则在信号中止后提供了一个具体的原因说明。这两个属性都是只读的,只能由关联的 abort 方法修改。

二、静态方法 abort 与预置中止信号

AbortSignal 提供了一些静态方法,可以创建特定行为的中止信号。其中最直接的是 AbortSignal.abort(),它返回一个已经处于中止状态的信号对象。这对于一些需要立即拒绝操作的场景非常有用。

javascript 复制代码
// 使用预置的中止信号
function createAlreadyAbortedOperation() {
  // 创建一个已经中止的信号
  const abortedSignal = AbortSignal.abort('服务已关闭,拒绝新请求');
  
  console.log('预置信号的中止状态:', abortedSignal.aborted); // true
  console.log('预置信号的原因:', abortedSignal.reason);
  
  // 立即中止的信号可以在初始化时传入
  async function fetchWithPreAbort(url) {
    const signal = AbortSignal.abort('组件已销毁');
    
    try {
      // 这个请求会立即失败,不会真正发出网络请求
      const response = await fetch(url, { signal });
      return response;
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log('请求被预置信号中止:', error.message);
        console.log('中止原因:', signal.reason);
      }
      throw error;
    }
  }
  
  return { abortedSignal, fetchWithPreAbort };
}

// 使用场景:组件卸载后禁止发起新请求
class ComponentWithCleanup {
  constructor() {
    this.isDestroyed = false;
  }
  
  async makeRequest(url) {
    if (this.isDestroyed) {
      // 如果组件已销毁,使用预置中止信号
      const signal = AbortSignal.abort('组件已销毁,请求被阻止');
      return fetch(url, { signal });
    }
    
    // 正常发起可中止的请求
    const controller = new AbortController();
    this.currentSignal = controller.signal;
    return fetch(url, { signal: this.currentSignal });
  }
  
  destroy() {
    this.isDestroyed = true;
    if (this.currentSignal?.controller) {
      this.currentSignal.controller.abort('组件销毁,取消进行中的请求');
    }
  }
}

预置中止信号的最大价值在于:它可以让我们在任何需要信号的地方,传入一个已经处于"已中止"状态的信号,而不需要创建一个 AbortController 再立即调用 abort 方法。

三、静态方法 timeout 与超时控制

AbortSignal.timeout() 是一个非常实用的静态方法。它接收一个毫秒数作为参数,返回一个信号对象,这个信号会在指定的时间后自动中止。这种方式让超时控制的代码变得极其简洁。

javascript 复制代码
// 使用 timeout 静态方法实现请求超时控制
async function fetchWithTimeout(url, timeoutMs = 5000) {
  // 创建一个在 timeoutMs 毫秒后自动中止的信号
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
  
  try {
    console.log(`发起请求,超时时间: ${timeoutMs}ms`);
    const response = await fetch(url, { signal: timeoutSignal });
    
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    
    const data = await response.json();
    console.log('请求成功完成');
    return data;
    
  } catch (error) {
    // 区分超时错误和其他错误
    if (error.name === 'TimeoutError') {
      console.error(`请求超时: 超过 ${timeoutMs}ms 未响应`);
      throw new Error(`REQUEST_TIMEOUT: ${timeoutMs}ms`);
    } else if (error.name === 'AbortError') {
      console.error('请求被中止:', error.message);
      throw new Error('REQUEST_ABORTED');
    } else {
      console.error('网络或其他错误:', error);
      throw error;
    }
  }
}

// 实际应用:图片加载超时控制
async function loadImageWithTimeout(url, timeoutMs = 3000) {
  const img = new Image();
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
  
  // 返回一个 Promise 包装图片加载
  return new Promise((resolve, reject) => {
    // 监听中止信号
    const abortHandler = () =&gt {
      img.src = '';
      reject(new Error(`图片加载超时: ${timeoutMs}ms`));
    };
    
    timeoutSignal.addEventListener('abort', abortHandler, { once: true });
    
    img.onload = () => {
      timeoutSignal.removeEventListener('abort', abortHandler);
      resolve(img);
    };
    
    img.onerror = () => {
      timeoutSignal.removeEventListener('abort', abortHandler);
      reject(new Error('图片加载失败'));
    };
    
    img.src = url;
    
    // 如果信号已经中止
    if (timeoutSignal.aborted) {
      abortHandler();
    }
  });
}

// 使用示例
async function demoTimeoutUsage() {
  try {
    // 请求一个慢速接口,设置2秒超时
    const data = await fetchWithTimeout('https://jsonplaceholder.typicode.com/posts/1', 2000);
    console.log('数据:', data);
  } catch (error) {
    if (error.message.startsWith('REQUEST_TIMEOUT')) {
      console.log('请检查网络或稍后重试');
    }
  }
  
  try {
    const image = await loadImageWithTimeout('https://example.com/slow-image.jpg', 5000);
    document.body.appendChild(image);
  } catch (error) {
    console.log('图片加载失败:', error.message);
  }
}

需要注意,由 AbortSignal.timeout() 产生的超时错误类型是 TimeoutError,而不是普通的 AbortError。这让我们可以精确区分用户主动取消和超时导致的中止。

四、静态方法 any 与多信号合并

AbortSignal.any() 是一个较新的静态方法,它接收一个 AbortSignal 数组,返回一个新的信号。当传入的任意一个信号被中止时,返回的信号也会被中止。这在需要监听多个中止源时非常有用。

javascript 复制代码
// 使用 any 方法合并多个中止信号源
function createMultiSourceCancellation() {
  // 创建多个中止控制源
  const userController = new AbortController();
  const timeoutController = new AbortController();
  const parentController = new AbortController();
  
  // 合并三个信号:用户取消、超时、父任务取消
  const combinedSignal = AbortSignal.any([
    userController.signal,
    timeoutController.signal,
    parentController.signal
  ]);
  
  // 监听合并信号
  combinedSignal.addEventListener('abort', () => {
    console.log('操作被中止,来源信号的 reason:', combinedSignal.reason);
    // 注意:无法直接区分是哪个源触发了中止
  });
  
  return {
    signals: {
      userCancel: () => userController.abort('用户点击取消'),
      timeout: () => timeoutController.abort('操作超时'),
      parentCancel: () => parentController.abort('父任务取消')
    },
    combinedSignal
  };
}

// 实际应用:同时支持父组件取消和超时
class SearchComponent {
  constructor(parentSignal) {
    this.parentSignal = parentSignal;
    this.currentController = null;
  }
  
  async search(keyword) {
    // 取消之前的搜索
    if (this.currentController) {
      this.currentController.abort('新搜索开始');
    }
    
    // 创建一个超时信号
    const timeoutSignal = AbortSignal.timeout(3000);
    
    // 合并父信号和超时信号
    const combinedSignal = AbortSignal.any([
      this.parentSignal,
      timeoutSignal
    ].filter(s => s)); // 过滤掉 undefined
    
    this.currentController = new AbortController();
    
    try {
      const response = await fetch(`/api/search?q=${keyword}`, {
        signal: combinedSignal
      });
      
      const results = await response.json();
      console.log('搜索结果:', results);
      return results;
      
    } catch (error) {
      if (error.name === 'TimeoutError') {
        console.log('搜索超时,请稍后重试');
      } else if (error.name === 'AbortError') {
        console.log('搜索被取消');
      }
      throw error;
    } finally {
      this.currentController = null;
    }
  }
  
  cancel() {
    if (this.currentController) {
      this.currentController.abort();
    }
  }
}

// 使用场景:支持全局离线信号
let offlineSignal = new AbortController().signal;

window.addEventListener('online', () => {
  // 恢复时创建新的信号
  offlineSignal = new AbortController().signal;
});

window.addEventListener('offline', () => {
  // 离线时中止所有进行中的请求
  const offlineController = new AbortController();
  offlineController.abort('网络已断开');
  offlineSignal = offlineController.signal;
});

async function robustFetch(url) {
  // 合并离线信号和请求特定信号
  const combinedSignal = AbortSignal.any([
    offlineSignal,
    AbortSignal.timeout(10000)
  ]);
  
  return fetch(url, { signal: combinedSignal });
}

使用 AbortSignal.any() 时有一个重要限制:无法直接判断是哪一个原始信号触发了最终的中止。如果业务逻辑需要区分不同的中止原因,建议在传递给 any 的信号中设置不同的 reason 以便后续识别。

五、实例方法 throwIfAborted 的使用

throwIfAborted 是一个简洁实用的实例方法。当信号已经被中止时,调用这个方法会抛出 signal.reason;如果信号未中止,则什么也不做。它特别适合在长时间运行的异步任务中进行多点检查。

javascript 复制代码
// 使用 throwIfAborted 实现可中止的长任务
function createCancellableLongTask(signal) {
  return new Promise((resolve, reject) => {
    // 初始检查:如果信号已中止,立即拒绝
    try {
      signal.throwIfAborted();
    } catch (error) {
      reject(error);
      return;
    }
    
    let cancelled = false;
    
    // 监听 abort 事件
    const abortHandler = () => {
      cancelled = true;
      reject(signal.reason);
    };
    
    signal.addEventListener('abort', abortHandler, { once: true });
    
    // 模拟长时间任务,分多个阶段执行
    async function runTask() {
      try {
        // 阶段1: 数据处理
        console.log('阶段1: 开始数据处理');
        await delay(1000);
        signal.throwIfAborted(); // 检查点1
        
        // 阶段2: 网络请求
        console.log('阶段2: 发起网络请求');
        await delay(1000);
        signal.throwIfAborted(); // 检查点2
        
        // 阶段3: 结果整理
        console.log('阶段3: 整理最终结果');
        await delay(1000);
        
        if (!cancelled) {
          resolve({ success: true, data: '任务完成' });
        }
      } catch (error) {
        if (error !== signal.reason) {
          reject(error);
        }
        // 否则已经在 abort 处理中 reject 过了
      }
    }
    
    runTask();
  });
}

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// 演示多个检查点
async function demonstrateThrowIfAborted() {
  const controller = new AbortController();
  const signal = controller.signal;
  
  // 2秒后中止任务
  setTimeout(() => {
    console.log('发送中止信号');
    controller.abort('用户取消了任务');
  }, 2500);
  
  try {
    const result = await createCancellableLongTask(signal);
    console.log('任务结果:', result);
  } catch (error) {
    console.log('任务被中止:', error);
  }
}

// 封装常见的异步操作模式
function makeCancellable(asyncFn, signal) {
  return new Promise((resolve, reject) => {
    signal.throwIfAborted();
    
    const abortHandler = () => reject(signal.reason);
    signal.addEventListener('abort', abortHandler, { once: true });
    
    asyncFn()
      .then(result => {
        signal.removeEventListener('abort', abortHandler);
        resolve(result);
      })
      .catch(error => {
        signal.removeEventListener('abort', abortHandler);
        reject(error);
      });
  });
}

// 使用示例
async function useCancellableWrapper() {
  const controller = new AbortController();
  
  const cancellableTask = makeCancellable(
    () => new Promise(resolve => setTimeout(() => resolve('完成'), 3000)),
    controller.signal
  );
  
  setTimeout(() => controller.abort('超时取消'), 1000);
  
  try {
    const result = await cancellableTask;
    console.log(result);
  } catch (error) {
    console.log('任务被取消:', error);
  }
}

throwIfAborted 方法让代码更加简洁,避免了反复的 if (signal.aborted) 判断。在长任务的关键节点调用这个方法,可以快速响应中止请求。

六、实现可中止的自定义 API

对于库和框架开发者来说,让自定义的异步 API 支持 AbortSignal 是一种良好的实践。这可以让用户获得一致的中止体验。

javascript 复制代码
// 创建一个支持中止的大文件分块读取器
class CancellableFileReader {
  constructor(file, chunkSize = 1024 * 1024) {
    this.file = file;
    this.chunkSize = chunkSize;
    this.position = 0;
  }
  
  // 支持中止的读取方法
  async readChunks(signal, onChunk) {
    // 初始检查
    if (signal.aborted) {
      throw signal.reason;
    }
    
    const totalChunks = Math.ceil(this.file.size / this.chunkSize);
    
    // 监听中止事件
    const abortPromise = new Promise((_, reject) => {
      signal.addEventListener('abort', () => {
        reject(signal.reason);
      }, { once: true });
    });
    
    while (this.position < this.file.size) {
      // 每个循环检查是否应该中止
      if (signal.aborted) {
        throw signal.reason;
      }
      
      // 计算当前块的大小
      const end = Math.min(this.position + this.chunkSize, this.file.size);
      const blob = this.file.slice(this.position, end);
      
      // 读取当前块
      const chunk = await this.readBlobAsArrayBuffer(blob);
      const chunkIndex = Math.floor(this.position / this.chunkSize);
      
      // 回调处理块数据
      const shouldContinue = await Promise.race([
        Promise.resolve(onChunk(chunk, chunkIndex, totalChunks)),
        abortPromise
      ]);
      
      this.position = end;
      
      if (shouldContinue === false) {
        break;
      }
    }
    
    return { completed: this.position >= this.file.size, position: this.position };
  }
  
  readBlobAsArrayBuffer(blob) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = () => resolve(reader.result);
      reader.onerror = () => reject(reader.error);
      reader.readAsArrayBuffer(blob);
    });
  }
}

// 使用示例:带进度和取消功能的文件上传
async function uploadFileWithCancel(file) {
  const controller = new AbortController();
  const signal = controller.signal;
  const reader = new CancellableFileReader(file);
  
  let uploadedSize = 0;
  
  // 显示取消按钮
  const cancelButton = document.getElementById('cancelUpload');
  cancelButton.disabled = false;
  
  const cancelHandler = () => {
    controller.abort('用户取消了上传');
    cancelButton.disabled = true;
  };
  
  cancelButton.onclick = cancelHandler;
  
  try {
    const result = await reader.readChunks(signal, async (chunk, index, total) => {
      console.log(`上传块 ${index + 1}/${total}, 大小: ${chunk.byteLength} 字节`);
      uploadedSize += chunk.byteLength;
      
      const progress = (uploadedSize / file.size) * 100;
      console.log(`上传进度: ${progress.toFixed(2)}%`);
      
      // 模拟上传请求
      await fetch('/api/upload-chunk', {
        method: 'POST',
        body: chunk,
        headers: { 'X-Chunk-Index': index },
        signal // 每个子请求也支持中止
      });
      
      // 返回 true 继续,返回 false 停止读取更多块
      return true;
    });
    
    console.log('上传完成:', result);
    cancelButton.disabled = true;
    return result;
    
  } catch (error) {
    if (error.name === 'AbortError' || signal.reason) {
      console.log('上传被取消:', signal.reason || error);
    } else {
      console.error('上传出错:', error);
    }
    throw error;
  }
}

实现可中止的自定义 API 时,需要遵循几个核心原则:在函数入口检查 signal.aborted;监听 abort 事件并正确清理资源;将 signal 继续传递给内部的异步操作;使用 signal.reason 作为拒绝的理由。

七、浏览器兼容性与注意事项

AbortSignal 在现代浏览器中有着良好的支持。静态方法 abort 从 Chrome 93、Firefox 88 开始支持;timeout 和 any 方法相对较新,需要检查兼容性;throwIfAborted 从 Chrome 100、Firefox 97 开始支持。

javascript 复制代码
// 兼容性检测与降级方案
class AbortSignalPolyfill {
  static isSupported() {
    return typeof AbortSignal !== 'undefined';
  }
  
  static timeout(ms) {
    if (typeof AbortSignal?.timeout === 'function') {
      return AbortSignal.timeout(ms);
    }
    
    // 降级实现:手动创建控制器和定时器
    const controller = new AbortController();
    const timeoutId = setTimeout(() => {
      controller.abort(new DOMException('操作超时', 'TimeoutError'));
    }, ms);
    
    // 清理定时器
    const signal = controller.signal;
    const originalRemoveEventListener = signal.removeEventListener.bind(signal);
    signal.removeEventListener = (type, listener, options) => {
      if (type === 'abort') {
        clearTimeout(timeoutId);
      }
      return originalRemoveEventListener(type, listener, options);
    };
    
    return signal;
  }
  
  static any(signals) {
    if (typeof AbortSignal?.any === 'function') {
      return AbortSignal.any(signals);
    }
    
    // 降级实现
    const controller = new AbortController();
    
    for (const signal of signals) {
      if (signal.aborted) {
        controller.abort(signal.reason);
        return controller.signal;
      }
      
      signal.addEventListener('abort', () => {
        controller.abort(signal.reason);
      }, { once: true });
    }
    
    return controller.signal;
  }
}

// 安全使用 timeout
async function safeFetchWithTimeout(url, timeoutMs) {
  const signal = AbortSignalPolyfill.timeout(timeoutMs);
  
  try {
    const response = await fetch(url, { signal });
    return response.json();
  } catch (error) {
    if (error.name === 'TimeoutError' || error.message === '操作超时') {
      console.log('请求超时');
    }
    throw error;
  }
}

在实际项目中使用 AbortSignal 时,需要注意信号只能被中止一次;避免在信号上添加过多监听器造成内存泄漏;对于复杂场景,可以结合 AbortController 和 signal 的多种静态方法构建灵活的中止控制逻辑。


想要解锁更多HTML 核心标签实战、前端零基础入门干货、开发避坑全指南吗?
持续关注,后续将更新CSS 布局实战、JavaScript 交互基础、全站导航开发等硬核内容,带你从新手快速进阶,轻松搞定前端开发!

相关推荐
UXbot1 小时前
2026年文字转原型AI工具推荐:输入一句需求描述,自动生成多页面可交互界面
前端·低代码·ui·交互·ai编程·原型模式
im_AMBER1 小时前
Browser Agent 开发:从浏览器插件到Electron CDP
前端·javascript·架构·electron·agent
ZC跨境爬虫1 小时前
跟着 MDN 学 HTML day_30:(AbortController 实现可取消的异步请求)
前端·ui·html·edge浏览器·媒体
前端若水1 小时前
选择器的威力 —— :has()、@layer、原生嵌套
前端·css·css3
nashane1 小时前
HarmonyOS 6学习:Web组件本地资源跨域访问全解析与实战
前端·学习·harmonyos·harmonyos 5
小陈同学,,1 小时前
地图第一次进来慢的问题二
前端
阿赛工作室2 小时前
基于Vue3和TensorFlow.js的数字图像识别应用HTML单文件
javascript·html·tensorflow
feifeigo1232 小时前
音频重采样(Audio Resampling)实现指南
音视频
万少2 小时前
公测期 0 元/月!商汤 SenseNova 免费 Token 再不领就没了
前端·javascript·后端