Web 异步编程

在现代 Web 开发中,理解异步编程的本质和运行机制对于编写高性能应用至关重要。本文将探讨事件循环机制、主线程与 Web Worker。

异步的本质

异步编程的核心在于解耦任务的发起和执行。当我们发起一个异步操作时,我们不是在等待它完成,而是告诉系统:"当这个任务完成时,请通知我"。异步的本质特征

  1. 非阻塞(Non-blocking):发起异步操作后,主线程继续执行后续代码,不会等待
  2. 回调通知(Callback Notification):异步操作完成后,通过回调函数通知调用者
  3. 时间解耦(Temporal Decoupling):任务的发起时间和执行时间是分离的

并发 vs 并行

理解异步的本质,还需要区分两个重要概念:

  • 并发(Concurrency):多个任务在同一时间段内交替执行,看起来像是同时进行

    • JavaScript 的事件循环就是并发模型
    • 通过快速切换任务,给人一种"同时执行"的错觉
  • 并行(Parallelism):多个任务真正同时在不同的物理核心上执行

    • Web Worker 提供了真正的并行执行能力
    • 需要多核 CPU 支持
javascript 复制代码
// 并发示例(主线程)
async function concurrent() {
  console.log('任务1开始');
  await sleep(1000);
  console.log('任务1结束');

  console.log('任务2开始');
  await sleep(1000);
  console.log('任务2结束');
}
// 总耗时:2秒(任务交替执行)

// 并行示例(Web Worker)
function parallel() {
  const worker1 = new Worker('task1.js');
  const worker2 = new Worker('task2.js');

  worker1.postMessage('start'); // 在独立线程1执行
  worker2.postMessage('start'); // 在独立线程2执行
}
// 总耗时:1秒(任务同时执行)

事件循环与任务队列

事件循环(Event Loop)是 JavaScript 异步编程的核心机制,它协调了同步代码、异步任务、浏览器渲染之间的执行顺序。

事件循环的基本结构

JavaScript 运行时包含以下几个关键组件:

javascript 复制代码
┌─────────────────────────────────────┐
│        JavaScript 运行时             │
│                                     │
│  ┌──────────────┐                  │
│  │  调用栈      │  ← 执行同步代码   │
│  │  (Call Stack)│                  │
│  └──────────────┘                  │
│                                     │
│  ┌──────────────┐                  │
│  │  微任务队列   │  ← Promise.then │
│  │ (Micro Queue)│     async/await  │
│  └──────────────┘                  │
│                                     │
│  ┌──────────────┐                  │
│  │  宏任务队列   │  ← setTimeout   │
│  │ (Macro Queue)│     setInterval  │
│  └──────────────┘     I/O 操作     │
│                                     │
└─────────────────────────────────────┘

事件循环的执行流程

事件循环的完整执行顺序可以概括为:

markdown 复制代码
1. 执行调用栈中的所有同步代码
   └─ 直到调用栈清空

2. 执行所有微任务
   └─ 如果微任务产生新的微任务,继续执行
   └─ 直到微任务队列完全清空

3. 执行一个宏任务
   └─ 从宏任务队列中取出最早的一个任务

4. 回到步骤 2(如果有渲染需求,则先执行渲染)

让我们通过代码示例理解这个流程:

javascript 复制代码
console.log('1 - 同步代码开始');

setTimeout(() => {
  console.log('2 - setTimeout 1');
  Promise.resolve().then(() => {
    console.log('3 - Promise in setTimeout');
  });
}, 0);

Promise.resolve().then(() => {
  console.log('4 - Promise 1');
  setTimeout(() => {
    console.log('5 - setTimeout in Promise');
  }, 0);
}).then(() => {
  console.log('6 - Promise 2');
});

setTimeout(() => {
  console.log('7 - setTimeout 2');
}, 0);

console.log('8 - 同步代码结束');

// 输出顺序:
// 1 - 同步代码开始
// 8 - 同步代码结束
// 4 - Promise 1
// 6 - Promise 2
// 2 - setTimeout 1
// 3 - Promise in setTimeout
// 7 - setTimeout 2
// 5 - setTimeout in Promise

常见的任务队列

常见的宏任务,每次事件循环只执行一个宏任务。

  • setTimeout / setInterval - 定时器回调
  • I/O 操作 - 文件读写、网络请求(XMLHttpRequest)
  • UI 事件 - 用户交互(click、scroll 等)
  • MessageChannel / postMessage

常见的微任务,每次事件循环会清空整个微任务队列。

  • Promise.then / catch / finally
  • async/await(本质是 Promise)
  • MutationObserver - DOM 变化监听
  • queueMicrotask - 显式添加微任务

主线程 vs Web Worker

理解主线程和 Web Worker 的区别,是掌握 Web 异步编程的重要一环。这两者的异步模型有着本质的不同。

主线程:并发执行模型

主线程通过事件循环实现并发,但所有代码都在同一个线程上执行。

主线程的特点

javascript 复制代码
// 主线程的单线程特性
console.time('主线程执行');

// 即使使用 Promise,也是在同一个线程上执行
Promise.resolve().then(() => {
  // 密集计算会阻塞主线程
  let sum = 0;
  for (let i = 0; i < 1000000000; i++) {
    sum += i;
  }
  console.log('计算完成');
});

// 这段代码要等上面的 Promise 回调执行完才能执行
setTimeout(() => {
  console.log('定时器触发');
}, 0);

console.timeEnd('主线程执行');

requestAnimationFrame (RAF)

requestAnimationFrame 是主线程中的特殊异步 API,它与浏览器的渲染循环紧密相关。

javascript 复制代码
// RAF 在浏览器重绘之前执行
let lastTime = 0;

function animate(currentTime) {
  const delta = currentTime - lastTime;
  lastTime = currentTime;

  console.log(`距上一帧: ${delta.toFixed(2)}ms`);

  // 更新动画状态
  updateAnimation();

  // 递归调用,持续动画
  requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

RAF 的特点:

  1. 执行时机:在浏览器重绘之前执行,通常每秒 60 次(60fps)
  2. 自动节流:页面不可见时会暂停执行,节省性能
  3. 高精度时间戳:回调接收 DOMHighResTimeStamp 参数
  4. 避免布局抖动:所有 DOM 修改在同一帧内批量处理
javascript 复制代码
// RAF 的执行顺序
console.log('1 - 同步代码');

Promise.resolve().then(() => {
  console.log('2 - 微任务');
});

requestAnimationFrame(() => {
  console.log('3 - RAF');
});

setTimeout(() => {
  console.log('4 - 宏任务');
}, 0);

// 输出顺序:
// 1 - 同步代码
// 2 - 微任务
// 3 - RAF
// 4 - 宏任务

requestIdleCallback (RIC)

requestIdleCallback 是另一个主线程专属的异步 API,用于在浏览器空闲时执行低优先级任务。

javascript 复制代码
// RIC 在浏览器空闲时执行
requestIdleCallback((deadline) => {
  console.log('空闲时间:', deadline.timeRemaining(), 'ms');
  console.log('是否超时:', deadline.didTimeout);

  // 在空闲时间内尽可能多地处理任务
  while (deadline.timeRemaining() > 0 && tasks.length > 0) {
    const task = tasks.shift();
    processTask(task);
  }

  // 如果还有任务,继续请求空闲回调
  if (tasks.length > 0) {
    requestIdleCallback(arguments.callee);
  }
}, { timeout: 2000 }); // 最多等待 2 秒

RIC 的特点:

  1. 执行时机:在浏览器完成一帧渲染后,如果还有剩余时间
  2. 优先级最低:不会影响关键渲染和用户交互
  3. 可设置超时:避免任务被无限延迟
  4. 时间预算 :通过 deadline.timeRemaining() 获取剩余时间
javascript 复制代码
// RIC 的典型应用:后台数据分析
const analyticsQueue = [];

function sendAnalytics(data) {
  analyticsQueue.push(data);

  // 使用 RIC 在空闲时发送
  requestIdleCallback(() => {
    if (analyticsQueue.length > 0) {
      const batch = analyticsQueue.splice(0, 10);
      fetch('/analytics', {
        method: 'POST',
        body: JSON.stringify(batch)
      });
    }
  });
}

// 用户操作时收集数据,不影响性能
button.addEventListener('click', () => {
  sendAnalytics({ event: 'click', timestamp: Date.now() });
});

主线程完整的执行顺序

综合以上所有异步 API,主线程的完整执行顺序如下:

javascript 复制代码
┌─────────────── 一轮事件循环 ───────────────┐
│                                            │
│  1. 执行同步代码(调用栈)                  │
│     └─ 清空调用栈                          │
│                                            │
│  2. 执行所有微任务                          │
│     ├─ Promise.then/catch/finally         │
│     ├─ async/await                        │
│     ├─ MutationObserver                   │
│     └─ queueMicrotask                     │
│                                            │
│  3. 渲染阶段(如果需要渲染)                │
│     ├─ 执行 requestAnimationFrame 回调     │
│     ├─ 计算样式(Style)                   │
│     ├─ 布局(Layout)                      │
│     └─ 绘制(Paint)                       │
│                                            │
│  4. 执行一个宏任务                          │
│     ├─ setTimeout/setInterval             │
│     ├─ I/O 操作                           │
│     ├─ UI 事件                            │
│     └─ MessageChannel/postMessage         │
│                                            │
│  5. 如果有空闲时间                          │
│     └─ 执行 requestIdleCallback 回调       │
│                                            │
└─────────────── 回到步骤 2 ─────────────────┘

完整示例:

javascript 复制代码
console.log('1 - 同步代码');

setTimeout(() => {
  console.log('2 - setTimeout');
}, 0);

Promise.resolve().then(() => {
  console.log('3 - Promise');
});

queueMicrotask(() => {
  console.log('4 - queueMicrotask');
});

requestAnimationFrame(() => {
  console.log('5 - rAF');
});

requestIdleCallback(() => {
  console.log('6 - rIC');
});

console.log('7 - 同步代码结束');

// 输出顺序:
// 1 - 同步代码
// 7 - 同步代码结束
// 3 - Promise
// 4 - queueMicrotask
// 5 - rAF
// 2 - setTimeout
// 6 - rIC (最后执行,且可能延迟较长时间)

微任务、宏任务、RAF 无限循环

javascript 复制代码
// ❌ 微任务无限循环 - 会卡死浏览器
function infiniteMicrotask() {
  Promise.resolve().then(() => {
    infiniteMicrotask(); // 微任务队列永远无法清空
  });
}
// 浏览器完全卡死,无法渲染,无法响应交互

// ⚠️ 宏任务无限循环 - 严重卡顿但不会完全卡死
function infiniteMacrotask() {
  setTimeout(() => {
    infiniteMacrotask(); // 每次执行后会清空微任务,可能渲染
  }, 0);
}
// 浏览器严重卡顿,但理论上还有渲染和响应的机会

// ✅ RAF 无限循环 - 不会卡死,每帧执行一次
function infiniteRAF() {
  requestAnimationFrame(() => {
    infiniteRAF(); // 跟随浏览器渲染周期,约 60fps
  });
}
// 浏览器不会卡死,每帧执行一次,其他任务仍能正常执行

原因: 事件循环必须清空所有微任务后才能进入渲染和下一个宏任务。如果微任务不断产生新的微任务,渲染永远无法执行。而宏任务每次只执行一个,执行间隙浏览器有机会渲染。RAF 则绑定在渲染周期上,每帧执行一次,不会阻塞其他任务。

Web Worker:并行执行模型

Web Worker 提供了真正的多线程并行执行能力,与主线程完全独立。

javascript 复制代码
// 主线程
const worker = new Worker('worker.js');

console.log('主线程继续执行');

// 向 Worker 发送消息(不阻塞主线程)
worker.postMessage({ type: 'calculate', data: largeDataset });

// 接收 Worker 的消息
worker.addEventListener('message', (event) => {
  console.log('Worker 返回结果:', event.data);
});

// worker.js(运行在独立线程)
self.addEventListener('message', (event) => {
  const { type, data } = event.data;

  if (type === 'calculate') {
    // 密集计算不会阻塞主线程
    const result = expensiveCalculation(data);
    self.postMessage(result);
  }
});

Web Worker 的限制

Web Worker 在技术实现上是线程(Thread) ,理论上是共享内存的,但它被设计成不共享内存的独立执行环境,无法访问主线程的许多 API。

❌ Web Worker 不支持的 API:

  1. DOM 操作
  2. 渲染相关: requestAnimationFrame/requestIdleCallback
  3. 存储相关: localStorage / sessionStorage
  4. window 对象的大部分属性

✅ Web Worker 支持的 API:

  1. 事件循环
  2. 网络请求
  3. IndexedDB

为什么 Web Worker 不允许操作 DOM?

  1. 线程安全问题:DOM 操作不是线程安全的,多线程同时修改 DOM 会导致竞态条件
  2. 复杂性控制:如果允许多线程访问 DOM,就需要引入锁机制,大大增加复杂性
  3. 性能考虑:频繁加锁会严重降低性能,违背了使用 Worker 的初衷
  4. JavaScript 哲学:JavaScript 从设计之初就是单线程模型,DOM 操作应该在主线程统一管理

因此,Web Worker 虽然是线程,但通过消息传递而非共享内存来通信,避免了传统多线程编程带来的问题。

Web Worker 的消息传递

主线程和 Worker 之间通过消息传递通信,有两种数据传递方式:

javascript 复制代码
// 1. 结构化克隆(默认)- 数据被复制
const data = { numbers: [1, 2, 3, 4, 5] };
worker.postMessage(data);
data.numbers.push(6); // 不会影响 Worker 收到的数据

// 2. 转移所有权(Transferable)- 零拷贝
const buffer = new ArrayBuffer(1024 * 1024); // 1MB
worker.postMessage(buffer, [buffer]); // 转移所有权
console.log(buffer.byteLength); // 0 - 主线程失去访问权

// Worker 中接收
self.addEventListener('message', (event) => {
  const buffer = event.data;
  console.log(buffer.byteLength); // 1048576 - Worker 获得所有权
});

使用 Transferable 的优势:

  • 避免大数据的复制开销
  • 适合处理大型 ArrayBuffer、ImageBitmap 等
  • 性能更好,但主线程会失去访问权

总结

特性 主线程 Web Worker
异步模型 并发(Concurrency) 并行(Parallelism)
线程 单线程 独立线程
事件循环 共享主线程的事件循环 拥有独立的事件循环
DOM 访问 ✅ 完全支持 ❌ 不支持
requestAnimationFrame ✅ 支持 ❌ 不支持
requestIdleCallback ✅ 支持 ❌ 不支持
localStorage ✅ 支持 ❌ 不支持
fetch / XMLHttpRequest ✅ 支持 ✅ 支持
setTimeout / Promise ✅ 支持 ✅ 支持
IndexedDB ✅ 支持 ✅ 支持
WebSocket ✅ 支持 ✅ 支持
通信方式 直接调用 postMessage(消息传递)
内存 共享 独立
适用场景 UI 交互、渲染、轻量异步 CPU 密集型计算

异步类型分类

1. 基于 Promise 的异步

任务队列:微任务(Micro Task)

Promise 是对异步操作的抽象,代表一个未来会完成的值。

javascript 复制代码
// fetch API
fetch('/api/data')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error(error))
  .finally(() => console.log('请求结束'));

// Promise 链式调用
Promise.resolve(1)
  .then(value => value + 1)
  .then(value => value * 2)
  .then(value => console.log(value)); // 4

特点:

  • 三种状态:pending、fulfilled、rejected
  • 支持链式调用,避免回调地狱
  • 统一的错误处理(catch)
  • 可以组合(Promise.all、Promise.race 等)
  • 微任务,优先级高于宏任务

2. async/await 异步

任务队列:微任务(Micro Task)

Promise 的语法糖,使异步代码看起来像同步代码。

javascript 复制代码
async function fetchData() {
  try {
    const response = await fetch('/api/data');
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('请求失败:', error);
  }
}

// 并行执行多个异步操作
async function fetchMultiple() {
  const [data1, data2, data3] = await Promise.all([
    fetch('/api/data1').then(r => r.json()),
    fetch('/api/data2').then(r => r.json()),
    fetch('/api/data3').then(r => r.json())
  ]);

  return { data1, data2, data3 };
}

特点:

  • 基于 Promise,本质是微任务
  • 代码可读性高,接近同步代码
  • 错误处理使用 try/catch
  • 可以使用 await 等待 Promise 完成
  • 函数必须声明为 async

3. 定时器类异步

任务队列:混合类型

  • setTimeout/setInterval:宏任务(Macro Task)
  • requestAnimationFrame:rAF 队列(渲染循环)
  • requestIdleCallback:rIC 队列(渲染循环)

基于时间的异步操作,包括 setTimeout、setInterval、requestAnimationFrame 等。

javascript 复制代码
// setTimeout - 延迟执行一次
const timeoutId = setTimeout(() => {
  console.log('1秒后执行');
}, 1000);
clearTimeout(timeoutId); // 取消

// setInterval - 重复执行
const intervalId = setInterval(() => {
  console.log('每秒执行');
}, 1000);
clearInterval(intervalId); // 取消

// requestAnimationFrame - 浏览器下一次重绘前执行
function animate() {
  // 更新动画
  updateAnimation();

  // 继续下一帧
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

// requestIdleCallback - 浏览器空闲时执行
requestIdleCallback((deadline) => {
  while (deadline.timeRemaining() > 0 && tasks.length > 0) {
    performTask(tasks.shift());
  }
});

特点:

  • setTimeout/setInterval:宏任务,在下一轮事件循环执行
  • requestAnimationFrame:在浏览器重绘前执行,约 60fps(16.67ms)
  • requestIdleCallback:在浏览器空闲时执行,优先级最低
  • 最小延迟限制(setTimeout 约 4ms)

4. 事件驱动的异步

任务队列:宏任务(Macro Task)

基于事件监听器的异步模式,广泛用于 DOM 交互和自定义事件。

javascript 复制代码
// DOM 事件
document.addEventListener('click', (event) => {
  console.log('页面被点击');
});

// 自定义事件
const eventTarget = new EventTarget();

eventTarget.addEventListener('custom', (event) => {
  console.log('自定义事件触发:', event.detail);
});

eventTarget.dispatchEvent(new CustomEvent('custom', {
  detail: { message: 'Hello' }
}));

// WebSocket 事件
const ws = new WebSocket('ws://example.com');

ws.addEventListener('open', () => {
  console.log('连接建立');
});

ws.addEventListener('message', (event) => {
  console.log('收到消息:', event.data);
});

ws.addEventListener('close', () => {
  console.log('连接关闭');
});

特点:

  • 基于观察者模式
  • 可以有多个监听器
  • 支持事件冒泡和捕获
  • 需要手动清理,防止内存泄漏

5. Observer API

任务队列:混合类型

  • MutationObserver:微任务(Micro Task)
  • IntersectionObserver/ResizeObserver/PerformanceObserver:宏任务(Macro Task)

现代浏览器提供的观察者 API,用于监听特定类型的变化。

javascript 复制代码
// MutationObserver - 监听 DOM 变化
const observer = new MutationObserver((mutations) => {
  mutations.forEach(mutation => {
    console.log('DOM 变化:', mutation.type);
  });
});

observer.observe(document.body, {
  childList: true,
  subtree: true,
  attributes: true
});

// IntersectionObserver - 监听元素可见性
const intersectionObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log('元素进入视口');
      lazyLoadImage(entry.target);
    }
  });
});

intersectionObserver.observe(document.querySelector('.lazy-image'));

// ResizeObserver - 监听元素尺寸变化
const resizeObserver = new ResizeObserver((entries) => {
  entries.forEach(entry => {
    console.log('元素尺寸变化:', entry.contentRect);
  });
});

resizeObserver.observe(document.querySelector('.resizable'));

特点:

  • 异步执行,批量处理变化
  • 性能优于轮询
  • 需要手动断开连接(disconnect)

6. Worker 类异步

任务队列:独立线程(不在事件循环中)

在独立线程中执行代码,真正的并行计算。Worker 的消息回调是宏任务。

javascript 复制代码
// Web Worker - 独立线程
const worker = new Worker('worker.js');

worker.postMessage({ type: 'start', data: largeData });

worker.addEventListener('message', (event) => {
  console.log('Worker 返回结果:', event.data);
});

worker.addEventListener('error', (event) => {
  console.error('Worker 错误:', event.message);
});

// 终止 worker
worker.terminate();

// worker.js
self.addEventListener('message', (event) => {
  const result = heavyComputation(event.data);
  self.postMessage(result);
});

特点:

  • 真正的多线程并行
  • 不能直接访问 DOM
  • 通过消息传递通信
  • 独立的全局作用域
  • 适合 CPU 密集型任务

7. 流式异步(Streams)

任务队列:微任务(Micro Task)

处理大量数据的流式接口。Stream 的读取操作基于 Promise,因此是微任务。

javascript 复制代码
// ReadableStream - 读取流
async function processStream(url) {
  const response = await fetch(url);
  const reader = response.body.getReader();

  while (true) {
    const { done, value } = await reader.read();

    if (done) {
      console.log('流读取完成');
      break;
    }

    console.log('收到数据块:', value.length, '字节');
    processChunk(value);
  }
}

// TransformStream - 转换流
const transformStream = new TransformStream({
  transform(chunk, controller) {
    // 转换数据
    const transformed = chunk.toUpperCase();
    controller.enqueue(transformed);
  }
});

// 管道连接
fetch('/data')
  .then(response => response.body)
  .then(body => body.pipeThrough(transformStream))
  .then(stream => stream.pipeTo(writableStream));

特点:

  • 逐块处理数据,内存效率高
  • 支持背压(backpressure)
  • 可以组合和转换
  • 适合处理大文件或实时数据

8. Generator 与 Async Generator

任务队列:取决于具体实现

  • Generator:同步执行(在调用栈中)
  • Async Generator:微任务(基于 Promise)

可以暂停和恢复执行的函数。

javascript 复制代码
// Generator - 同步迭代
function* numberGenerator() {
  yield 1;
  yield 2;
  yield 3;
}

const gen = numberGenerator();
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }

// Async Generator - 异步迭代
async function* asyncNumberGenerator() {
  yield await fetch('/api/1').then(r => r.json());
  yield await fetch('/api/2').then(r => r.json());
  yield await fetch('/api/3').then(r => r.json());
}

(async () => {
  for await (const data of asyncNumberGenerator()) {
    console.log(data);
  }
})();

特点:

  • 可以暂停和恢复执行
  • 懒加载,按需生成值
  • Async Generator 结合了 Promise 和 Generator
  • 适合处理序列数据

参考资料

相关推荐
CptW3 小时前
手撕 Promise 一文搞定
前端·面试
腹黑天蝎座3 小时前
浅谈React19的破坏性更新
前端·react.js
东华帝君3 小时前
react组件常见的性能优化
前端
第七种黄昏3 小时前
【前端高频面试题】深入浏览器渲染原理:从输入 URL 到页面绘制的完整流程解析
前端·面试·职场和发展
angelQ3 小时前
前端fetch手动解析SSE消息体,字符串双引号去除不掉的问题定位
前端·javascript
Huangyi3 小时前
第一节:Flow的基础知识
android·前端·kotlin
林希_Rachel_傻希希3 小时前
JavaScript 解构赋值详解,一文通其意。
前端·javascript
Yeats_Liao3 小时前
Go Web 编程快速入门 02 - 认识 net/http 与 Handler 接口
前端·http·golang
金梦人生3 小时前
🔥Knife4j vs Swagger:Node.js 开发者的API文档革命!
前端·node.js