{T}

异步编程实践

本讲系统讲解异步编程在工程实践中的常见模式,包括并发控制、重试机制、超时处理、缓存去重、异步队列、事件驱动、轮询、取消操作与错误处理,帮助你写出健壮、可维护的异步代码。

一、并发控制

限制并发数量

当需要同时执行大量异步任务(如批量下载图片、批量请求接口)时,应限制并发数量,避免资源耗尽或触发服务端限流。

javascript
async function mapLimit(tasks, limit, fn) {
  const results = [];
  let index = 0;

  async function worker() {
    while (index < tasks.length) {
      const current = index++;
      results[current] = await fn(tasks[current]);
    }
  }

  const workers = Array.from({ length: limit }, () => worker());
  await Promise.all(workers);
  return results;
}

// 使用:最多同时 3 个并发请求
const results = await mapLimit(urls, 3, (url) => fetch(url).then((r) => r.json()));

批量处理(分批执行)

将大量任务拆分为多批,逐批执行,避免单次触发过多异步操作:

javascript
async function batchProcess(items, batchSize, fn) {
  const results = [];
  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    const batchResults = await Promise.all(batch.map((item) => fn(item)));
    results.push(...batchResults);
  }
  return results;
}

并发控制对比

方式特点适用场景
Promise.all一次性全部并发并发数可控
mapLimit固定并发上限并发数较多
分批执行按批次串行需要分批处理

二、重试机制

基本重试

对不稳定接口增加重试,提升成功率:

javascript
async function fetchWithRetry(fn, { retries = 3, delay = 1000 } = {}) {
  for (let i = 0; i < retries; i++) {
    try {
      return await fn();
    } catch (error) {
      if (i === retries - 1) throw error;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

指数退避重试

逐次递增重试间隔,避免对服务端造成冲击:

javascript
async function fetchWithBackoff(fn, { retries = 3, baseDelay = 500, factor = 2 } = {}) {
  let delay = baseDelay;
  for (let i = 0; i < retries; i++) {
    try {
      return await fn();
    } catch (error) {
      if (i === retries - 1) throw error;
      await new Promise((resolve) => setTimeout(resolve, delay));
      delay *= factor; // 指数递增:500 → 1000 → 2000
    }
  }
}

重试策略对比

策略间隔适用场景
固定重试恒定临时抖动
指数退避递增服务过载
抖动退避递增 + 随机避免同时重试

三、超时处理

基本超时处理

使用 Promise.race 实现请求超时控制:

javascript
function withTimeout(promise, ms, message = '请求超时') {
  return Promise.race([
    promise,
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error(message)), ms)
    ),
  ]);
}

// 使用
try {
  const data = await withTimeout(fetchData(), 5000);
} catch (error) {
  console.error(error.message); // 请求超时 或 真正的请求错误
}

超时配置最佳实践

  • 超时后清理:超时后应尝试取消底层请求(见下文 AbortController),避免资源泄漏。
  • 区分错误类型:超时错误与请求错误应能区分,便于分类处理。
  • 合理设置超时:依据接口耗时特性设置,不宜过短或过长。

四、缓存与请求去重

简单缓存

缓存已完成的 Promise,避免重复请求:

javascript
const cache = new Map();

function cachedFetch(url) {
  if (cache.has(url)) {
    return cache.get(url); // 命中缓存,直接返回
  }
  const promise = fetch(url)
    .then((res) => res.json())
    .finally(() => {
      // 缓存失败时删除,便于下次重试
      cache.delete(url);
    });
  cache.set(url, promise);
  return promise;
}

请求去重

对于相同参数的并发请求,合并为一次真实请求:

javascript
const inFlight = new Map();

function dedupe(fn, getKey) {
  return function (...args) {
    const key = getKey(...args);
    if (inFlight.has(key)) {
      return inFlight.get(key); // 复用正在进行的请求
    }
    const promise = fn(...args).finally(() => inFlight.delete(key));
    inFlight.set(key, promise);
    return promise;
  };
}

const fetchUser = dedupe((id) => api.getUser(id), (id) => id);

缓存策略对比

策略说明适用场景
永久缓存结果不变化静态数据
过期缓存一段时间后失效频繁读取
请求去重合并并发请求热点请求

五、异步队列

基本任务队列

实现一个带并发控制的任务队列:

javascript
class TaskQueue {
  constructor(concurrency = 1) {
    this.concurrency = concurrency;
    this.running = 0;
    this.queue = [];
  }

  add(task) {
    return new Promise((resolve, reject) => {
      this.queue.push({ task, resolve, reject });
      this._run();
    });
  }

  _run() {
    while (this.running < this.concurrency && this.queue.length) {
      const { task, resolve, reject } = this.queue.shift();
      this.running++;
      task()
        .then(resolve, reject)
        .finally(() => {
          this.running--;
          this._run();
        });
    }
  }
}

// 使用
const queue = new TaskQueue(2);
[1, 2, 3, 4].forEach((i) => {
  queue.add(() => fetchTask(i)).then(console.log);
});

六、事件驱动

异步事件发射器

基于发布-订阅模式,解耦异步流程中的多个模块:

javascript
class AsyncEventEmitter {
  constructor() {
    this.handlers = new Map();
  }

  on(event, handler) {
    if (!this.handlers.has(event)) this.handlers.set(event, []);
    this.handlers.get(event).push(handler);
  }

  emit(event, ...args) {
    const handlers = this.handlers.get(event) || [];
    handlers.forEach((handler) => handler(...args));
  }

  off(event, handler) {
    const handlers = this.handlers.get(event) || [];
    const index = handlers.indexOf(handler);
    if (index > -1) handlers.splice(index, 1);
  }
}

// 使用
const emitter = new AsyncEventEmitter();
emitter.on('dataLoaded', (data) => console.log('数据加载完成:', data));
loadData().then((data) => emitter.emit('dataLoaded', data));

七、轮询

基本轮询

定期查询服务器状态,直到满足条件:

javascript
function poll(fn, { interval = 1000, timeout = 30000 } = {}) {
  return new Promise((resolve, reject) => {
    const start = Date.now();

    async function check() {
      try {
        const result = await fn();
        if (result.done) {
          resolve(result);
          return;
        }
        if (Date.now() - start > timeout) {
          reject(new Error('轮询超时'));
          return;
        }
        setTimeout(check, interval);
      } catch (error) {
        reject(error);
      }
    }

    check();
  });
}

// 使用:轮询任务状态
poll(() => getTaskStatus(taskId), { interval: 2000, timeout: 60000 })
  .then((result) => console.log('任务完成:', result));

八、取消异步操作

AbortController API

使用 AbortController 取消 fetch 请求:

javascript
const controller = new AbortController();
const signal = controller.signal;

// 发起请求
const promise = fetch('/api/large-data', { signal });

// 5 秒后取消
setTimeout(() => controller.abort(), 5000);

try {
  const data = await promise;
} catch (error) {
  if (error.name === 'AbortError') {
    console.log('请求已取消');
  } else {
    console.error('请求失败:', error);
  }
}

自定义取消机制

通过标志位实现手动取消:

javascript
function createCancellable(fn) {
  let cancelled = false;

  async function run(...args) {
    const result = await fn(...args);
    if (cancelled) throw new Error('Operation cancelled');
    return result;
  }

  run.cancel = () => {
    cancelled = true;
  };

  return run;
}

const task = createCancellable(loadData);
const promise = task();
// task.cancel(); // 取消

九、错误处理最佳实践

统一错误处理

封装统一的错误处理函数,避免每个异步调用处重复 try/catch:

javascript
async function safeCall(fn, fallback = null) {
  try {
    return await fn();
  } catch (error) {
    console.error('调用失败:', error);
    return fallback;
  }
}

// 使用
const user = await safeCall(() => fetchUser(id), { id: -1 });

错误分类处理

根据错误类型采取不同策略(重试、降级、提示用户等):

javascript
class ApiError extends Error {
  constructor(message, code) {
    super(message);
    this.code = code;
  }
}

async function handleRequest(fn) {
  try {
    return await fn();
  } catch (error) {
    if (error.code === 401) {
      // 未授权:跳转登录
      redirectToLogin();
    } else if (error.code === 429) {
      // 限流:退避重试
      await delay(2000);
      return handleRequest(fn);
    } else {
      // 其他:提示用户
      showError(error.message);
    }
    throw error;
  }
}

十、性能优化建议

避免常见性能陷阱

  • 避免串行等待不依赖的请求:使用 Promise.all 并行发起。
  • 避免在 forEach 中 await:改用 for...ofPromise.all
  • 避免无限递归微任务:会饿死宏任务,导致页面卡死。
  • 及时清理定时器与监听器:防止内存泄漏。

内存优化

javascript
// 及时移除事件监听器
const handler = () => { /* ... */ };
element.addEventListener('scroll', handler);
// 组件卸载时移除
element.removeEventListener('scroll', handler);

// 清理定时器
const timer = setInterval(() => { /* ... */ }, 1000);
// 不再需要时
clearInterval(timer);

十一、常见问题

Q1: 如何处理多个异步操作的错误?

  • 互不依赖的并行操作:Promise.all(任一失败即失败)或 Promise.allSettled(统计全部结果)。
  • 相互依赖的串行操作:try/catch 包裹,配合重试机制。
  • 需要区分错误:对错误分类处理。

Q2: 如何实现请求的串行执行?

使用 for...of 逐个 await,或通过 reduce 串联 Promise:

javascript
async function sequential(tasks) {
  const results = [];
  for (const task of tasks) {
    results.push(await task());
  }
  return results;
}

Q3: 如何检测 Promise 是否已 settled?

没有直接 API,可通过 Promise.race 包装或自行维护状态标志:

javascript
function createSettledFlag() {
  let settled = false;
  const promise = new Promise((resolve, reject) => {
    // ...
  });
  promise.finally(() => {
    settled = true;
  });
  return { isSettled: () => settled, promise };
}

Q4: 如何优雅地处理异步初始化?

在模块加载时初始化,使用顶层 await(ES2022)或将初始化结果缓存:

javascript
let configPromise;

export function getConfig() {
  if (!configPromise) {
    configPromise = loadConfig(); // 只初始化一次
  }
  return configPromise;
}

十二、总结

异步编程的工程实践核心原则:

  1. 控制并发:使用 mapLimit、批量处理避免资源耗尽。
  2. 提升可靠性:为不稳定操作增加重试(指数退避)与超时处理。
  3. 避免重复:使用缓存与请求去重。
  4. 可管理性:使用异步队列、事件驱动组织复杂流程。
  5. 可取消:使用 AbortController 或标志位实现取消。
  6. 健壮性:统一错误处理,分类应对不同错误。

选择合适的异步模式

场景推荐方案
多个互不依赖的请求Promise.all
大量任务需限制并发mapLimit / 任务队列
不稳定接口重试 + 指数退避
需要控制耗时withTimeout 超时处理
避免重复请求缓存 + 请求去重
需要中途取消AbortController