JS随笔:异步编程与事件循环

JS随笔:异步编程与事件循环

本篇是「JS随笔」系列中的异步与事件循环篇,聚焦 JavaScript 的单线程与事件循环(Event Loop)机制,以及 Promise 与 async/await 的核心用法与实践,帮助在不阻塞主线程的同时完成高并发异步任务。


原文地址

墨渊书肆/JS随笔:异步编程与事件循环


单线程模型

JavaScript 采用单线程模型,意味着任一时刻仅有一个主线程执行代码。

  • 优势:简化并发与同步问题;避免共享数据的竞态
  • 挑战:密集计算可能阻塞 UI;需借助异步与任务分解避免卡顿

Event Loop 工作流

事件循环允许 JS 在单线程环境中处理异步:

  1. 调用栈(Call Stack):同步任务在栈中执行
  2. 事件队列(Event Queue):异步任务的回调入队待执行
  3. 轮询(Loop):栈空时从队列取出任务入栈执行
  4. 宏/微任务队列
    • 宏任务:scriptsetTimeoutsetIntervalI/OUI 渲染
    • 微任务:Promise 回调、MutationObserver
  5. 执行顺序:优先清空微任务队列,再处理下一个宏任务
  6. 浏览器 vs Node.js:两者队列实现细节存在差异

示例

javascript 复制代码
console.log('Script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('Script end');

执行顺序:

javascript 复制代码
Script start
Script end
promise1
promise2
setTimeout

Promise

Promise 表示一个尚未完成但预期未来会完成的异步操作。

javascript 复制代码
const myPromise = new Promise((resolve, reject) => {
  // 异步操作
  resolve('value');
});
myPromise
  .then(value => {
    // 处理返回值
  })
  .catch(error => {
    // 处理错误
  });

链式调用

then() 返回新的 Promise,便于链式组织多个异步步骤。

需要特别注意:

  • then/catch 中抛出的异常会进入后续的 catch,不要在每一层都捕获并"吃掉"错误
  • 若链尾缺少 catch,在部分环境中会出现"未处理的 Promise 拒绝",影响错误监控
javascript 复制代码
fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => {
    // 使用数据
  })
  .catch(error => {
    // 错误处理
  });

静态方法

javascript 复制代码
Promise.all([p1, p2, p3]).then(results => { /* 所有成功 */ });
Promise.race([p1, p2]).then(result => { /* 最先完成的决定结果 */ });

在实践中可以遵循:

  • 需要"全部成功才继续"时使用 Promise.all,并注意其中任一失败都会导致整体失败
  • 需要"收集所有结果"时使用 Promise.allSettled,避免因为单个错误中断整体流程
  • 与 UI 交互搭配时,应为长链路 Promise 增加超时与取消能力

async/await

async 将函数声明为异步,await 等待 Promise 解决,以同步风格书写异步逻辑。

javascript 复制代码
async function fetchData() {
  try {
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();
    // 使用数据
  } catch (error) {
    // 错误处理
  }
}

实战策略

  • 优先以 await 顺序化关键依赖链,减少嵌套
  • 并行独立任务:await Promise.all([...])
  • 控制并发:构建限流器或使用队列,避免过度并发打爆资源
  • 兜底与重试:try/catch + 指数退避重试
  • 可取消任务:封装 AbortController,避免悬空回调占用资源
  • 微任务 vs 宏任务:关注 UI 时机与绘制顺序,避免掉帧

与模块的协作

动态 import() 可在需求时加载模块,配合路由与组件懒加载提升首屏性能;事件循环与网络调度共同决定资源拉取时机。

宏任务与微任务细节

  • 宏任务示例scriptsetTimeoutsetIntervalI/OUI 渲染
  • 微任务示例Promise.then/catch/finallyMutationObserver、部分平台的 queueMicrotask
  • 调度顺序:每个宏任务结束后清空微任务队列,再进入下一个宏任务
  • 实践建议:将轻量、依赖当前帧状态的逻辑放入微任务;将耗时逻辑放入宏任务或 Worker
javascript 复制代码
queueMicrotask(() => {
  // 在当前宏任务末尾、绘制前执行
});
setTimeout(() => {
  // 下一个宏任务
}, 0);

浏览器 vs Node.js 差异

  • 浏览器:事件循环与渲染管线协作;微任务在绘制前执行,影响 UI 时机
  • Node.js :存在更细的阶段划分(timers、pending callbacks、poll、check、close callbacks),process.nextTickPromise 微任务的执行顺序有差异
javascript 复制代码
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));

并发控制模式

  • 限流器(Semaphore):限制同时进行的任务数量
  • 批处理(Batching):将零散任务聚合成批次执行
  • 背压(Backpressure):在生产者-消费者模型中控制生产速度
javascript 复制代码
function pLimit(max) {
  const queue = [];
  let active = 0;
  const next = () => {
    if (active >= max || queue.length === 0) return;
    active++;
    const { fn, resolve, reject } = queue.shift();
    Promise.resolve()
      .then(fn)
      .then(v => resolve(v))
      .catch(reject)
      .finally(() => { active--; next(); });
  };
  return (fn) => new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); next(); });
}

取消与超时

  • AbortController :为 fetch 与自定义异步任务提供取消能力
  • 超时封装:为任务设置上限时间,避免长时间挂起
javascript 复制代码
function withTimeout(promise, ms) {
  return new Promise((resolve, reject) => {
    const id = setTimeout(() => reject(new Error('Timeout')), ms);
    promise.then(v => { clearTimeout(id); resolve(v); },
                 e => { clearTimeout(id); reject(e); });
  });
}
const controller = new AbortController();
fetch('/api', { signal: controller.signal }).catch(() => {});
controller.abort();

重试与指数退避

  • 退避策略:每次失败后增加等待时间(如乘以系数和随机抖动)
  • 最大次数:设定最大重试次数,避免无限重试
javascript 复制代码
async function retry(fn, { retries = 3, base = 200 } = {}) {
  for (let i = 0; i < retries; i++) {
    try { return await fn(); } catch (e) {
      const wait = base * Math.pow(2, i) + Math.random() * 100;
      await new Promise(r => setTimeout(r, wait));
    }
  }
  throw new Error('Retry failed');
}

Worker 与主线程分工

  • Web Worker:将 CPU 密集计算下放到后台线程,避免阻塞 UI
  • Comlink 等封装:简化主线程与 Worker 的消息通信
  • 实践:将长循环、解析与压缩、图像处理等移入 Worker

任务调度与空闲时间

  • requestIdleCallback:在浏览器空闲时执行非关键任务(需考虑兼容性)
  • 优先级调度:将关键交互相关任务放在更靠前的时机执行
javascript 复制代码
requestIdleCallback(() => {
  // 执行统计与轻量缓存清理等非关键工作
});

流与异步迭代

  • ReadableStream:逐步消费响应体,降低内存峰值
  • for await...of:遍历异步可迭代对象,按到达顺序处理数据块
javascript 复制代码
async function readStream(stream) {
  const reader = stream.getReader();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    // 处理 value
  }
}

小结

  • 充分理解宏/微任务队列与渲染时机
  • 针对场景选择顺序化、并行与限流策略
  • 为任务提供取消与超时,提升稳健性
  • 使用 Worker 与流式处理降低卡顿与内存占用

Promise 组合与模式

  • 并行组合:Promise.all 聚合多个独立任务
  • 容错组合:Promise.allSettled 收集成功与失败结果
  • 最快结果:Promise.race 获取最先完成者
  • 顺序执行:通过 reduce 链式串联
javascript 复制代码
const tasks = [t1, t2, t3];
const results = await Promise.allSettled(tasks.map(t => t()));
const seq = tasks.reduce((p, t) => p.then(() => t()), Promise.resolve());
await seq;

异步迭代器管道

  • 生成器组合:构造可读的异步数据处理流水线
  • 背压协作:在拉取下一块数据前完成当前处理
javascript 复制代码
async function* mapAsync(iterable, fn) {
  for await (const x of iterable) yield fn(x);
}
async function* filterAsync(iterable, pred) {
  for await (const x of iterable) if (pred(x)) yield x;
}

简单任务队列示例

javascript 复制代码
class TaskQueue {
  constructor(concurrency = 4) {
    this.concurrency = concurrency;
    this.running = 0;
    this.queue = [];
  }
  push(task) {
    return new Promise((resolve, reject) => {
      this.queue.push({ task, resolve, reject });
      this.next();
    });
  }
  next() {
    if (this.running >= this.concurrency || this.queue.length === 0) return;
    const { task, resolve, reject } = this.queue.shift();
    this.running++;
    Promise.resolve()
      .then(task)
      .then(resolve, reject)
      .finally(() => { this.running--; this.next(); });
  }
}

UI 与空闲调度策略

  • 分片渲染:将大列表渲染拆分为多个宏任务,避免长任务阻塞
  • 优先关键路径:输入响应与动画优先,其次网络与非关键计算
javascript 复制代码
function chunkedRender(items, chunk = 100) {
  let i = 0;
  function run() {
    const end = Math.min(i + chunk, items.length);
    for (; i < end; i++) {
      // 渲染 items[i]
    }
    if (i < items.length) setTimeout(run, 0);
  }
  run();
}

Node.js 阶段简述

  • timers:处理 setTimeout/setInterval
  • pending callbacks:上一轮循环的 I/O 回调
  • poll:检索新的 I/O 事件
  • check:处理 setImmediate
  • close callbacks:关闭事件的回调

实战踩坑清单

  • 长任务阻塞:在主线程执行密集计算导致掉帧 → 使用 Worker 或分片
  • 遗漏微任务:链式 then 未正确处理异常 → 在链尾使用 catch
  • 无取消能力:长时间请求无法中断 → 引入 AbortController
  • 竞态条件:多个请求互相覆盖状态 → 使用令牌验证或版本戳
  • 资源打爆:过度并发压垮服务 → 使用限流与退避策略

参考实践

  • 在 UI 场景优先保证交互与动画时序
  • 在数据拉取场景优先引入取消与超时
  • 在高并发场景优先实现限流与背压
  • 在长耗时任务优先下放到 Worker

Service Worker 与缓存策略(概览)

  • Service Worker:拦截网络请求,实现离线与缓存
  • Cache Storage:以键值存储响应体,用于静态资源加速
javascript 复制代码
self.addEventListener('install', e => {
  e.waitUntil(caches.open('v1').then(cache => cache.addAll(['/index.html','/app.js'])));
});
self.addEventListener('fetch', e => {
  e.respondWith(caches.match(e.request).then(res => res || fetch(e.request)));
});

WebSocket 心跳与重连

  • 心跳:定期发送 ping 保持连接
  • 重连:在断线后指数退避重连,避免雪崩
javascript 复制代码
function connect() {
  const ws = new WebSocket('wss://example.com/socket');
  let timer;
  ws.onopen = () => { timer = setInterval(() => ws.send('ping'), 30000); };
  ws.onclose = () => { clearInterval(timer); setTimeout(connect, 2000); };
}
connect();

关键性能指标与监测

  • FCP/LCP/CLS:首绘、最大内容绘制、累计布局偏移
  • TTI/TBT:可交互时间与阻塞总时长
  • Performance API:采样与标记
javascript 复制代码
performance.mark('start');
// ... 任务
performance.mark('end');
performance.measure('task', 'start', 'end');

rAF vs setTimeout

  • requestAnimationFrame:与浏览器刷新同步,适合动画
  • setTimeout:时间驱动,可能与帧率不同步,导致抖动
javascript 复制代码
function animate(el) {
  let x = 0;
  function step() {
    x += 1;
    el.style.transform = `translateX(${x}px)`;
    requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

2025/2026 语言演进与异步

  • Temporal(ES2026 预计)
    • 高精度时间与时区处理,替代历史 Date 的缺陷
    • 时间段/持续时间(Temporal.Duration)便于表达延迟与节奏
    • 时区化时间(Temporal.ZonedDateTime)简化跨区调度
javascript 复制代码
// 以本地时区安排下一次任务(示例)
const now = Temporal.Now.zonedDateTimeISO();
const next = now.add({ minutes: 5 });
const delay = next.epochMilliseconds - now.epochMilliseconds;
setTimeout(() => {
  // 执行定时任务
}, delay);
  • Iterator Helpers(ES2025)
    • 惰性管道:不构建中间数组即可过滤/映射数据流
    • 与异步场景协作:在主线程压力较小时批量推进迭代
javascript 复制代码
const it = [1,2,3,4,5].values();
const out = it.filter(x => x % 2).map(x => x * 2).take(2);
for (const v of out) { /* 1->2, 3->6 */ }
  • import defer(ES2026 预计)
    • 延迟部分导入的初始化,优化首屏与关键路径
javascript 复制代码
// 假设:对大型非关键模块进行延迟求值
import defer heavy from './heavy-module.js';
// 业务关键路径运行完毕后再触发
queueMicrotask(() => heavy.init());

实战建议

  • 使用 Temporal 统一时间计算,避免 Date 的时区陷阱
  • 在大数据迭代管道中采用 Iterator Helpers,降低内存峰值
  • 配合 import defer 与空闲调度,将非关键初始化迁移到次时机
相关推荐
牛奶1 小时前
JS随笔:数据结构与集合
前端·javascript·面试
小陆猿1 小时前
股票实时行情Echarts动态图表
前端·javascript
Dilettante2582 小时前
React Server Components 全链路解析:Next.js 构建产物、导航流程与 Payload 格式
前端·next.js
前端付豪2 小时前
Nest 项目小实践之注册登陆
前端·node.js·nestjs
用户9121917620612 小时前
日本股票K线图生成实战:基于API的完整对接方案
前端
牛奶2 小时前
JS随笔:ES6+特性与模块化实践
前端·javascript
牛奶2 小时前
JS随笔:基础语法与控制结构
前端·javascript
天蓝色的鱼鱼2 小时前
Node.js 中间层退潮:从“前端救星”到“成本噩梦”
前端·架构·node.js
货拉拉技术2 小时前
如何用 AI 做业务级 Code Review
前端·agent·前端工程化