从一段 JS 回调变为异步的代码来了解 JavaScript 异步编程

在开发对接 AI 大模型(如 OpenAI、DeepSeek)的聊天应用时,流式响应(Server-Sent Events, SSE)是标配。你可能见过这样一段代码:后端数据源源不断地"推"过来,而前端需要像打字机一样优雅地"拉"取显示。

如何把底层的"被动推模式(回调函数)"无缝转换为现代 JS 最喜欢的"主动拉模式(迭代器)"?

很多经验不足的程序员写出的转换逻辑要么导致内存泄露,要么直接让 CPU 飙到 100%。今天,我们用几十行纯原生 JS 代码,亲手搭建一个高性能、零漏洞、具备全链路熔断能力的"工业级流式适配器"。


一、 核心代码示例

我们设计了一个极其精巧的流式包装器 EventStreamAdapter。它的核心职责是:接受一个持续触发回调的异步工作流,将其完美封装为一个可以用 for await...of 轻松遍历的异步生成器。

typescript 复制代码
class EventStreamAdapter<T, R> {
  /**
   * 将基于回调的推模式,转换为基于异步生成器的拉模式
   * @param task 底层异步任务(例如请求大模型)
   * @param state 初始状态
   */
  async *stream(
    task: (options: { onEvent: (e: T) => void; signal: AbortSignal }) => Promise<R>,
  ): AsyncGenerator<T, R, undefined> {
    const queue: T[] = [];
    let isFinished = false;
    let taskError: unknown = null;
    let taskResult: R | undefined;

    // 🔑 核心防御 1:创建全链路终止控制器
    const controller = new AbortController();
    const { signal } = controller;

    // 🔓 叫醒服务的遥控器
    let resolveWait: () => void = () => {};
    const waitForEvent = () =>
      new Promise<void>((r) => {
        resolveWait = r; // 偷梁换柱:把 Promise 的 resolve 暴露给全局变量
      });

    // 底层数据到达时的回调函数
    const onEvent = (e: T) => {
      queue.push(e);
      resolveWait(); // 拍醒正在冬眠的生成器
    };

    // 执行底层任务,并妥善维护状态生命周期
    const taskPromise = task({ onEvent, signal })
      .then((res) => { taskResult = res; })
      .catch((e) => { taskError = e; })
      .finally(() => {
        isFinished = true;
        resolveWait(); // 任务结束或崩溃时,同样唤醒生成器收工
      });

    try {
      while (true) {
        // 🏎️ 优化:内层纯同步循环,一口气榨干当前队列里的所有货
        while (queue.length > 0) {
          const event = queue.shift()!;
          yield event; // 阻断阀:向外递交数据,等待外部拉取
        }

        // 队列搬空了,检查后台任务是否已经彻底收工
        if (isFinished) {
          if (taskError !== null) throw taskError; // 异常精准爆破
          await taskPromise; 
          return taskResult!; // 完美退出
        }

        // 🛑 核心防御 2:没有货且任务未结束,执行"无锁阻塞",让出 CPU 核心控制权
        await waitForEvent();
      }
    } finally {
      // 🛡️ 核心防御 3:外部无论因为 break、return 还是报错中断,必定触发此处的安全网
      controller.abort(); // 顺着网线过去,把失控的后台任务直接强行掐断!
      taskPromise.catch(() => {}); // 吞掉离开上下文后的垃圾报错,防止进程崩溃
    }
  }
}

二、 代码逐层剖析:它精巧在哪?

这段短短几十行的代码,蕴含了极其深厚的资深开发思维。我们挑出其中最惊艳的 4 个设计点进行微观剖析:

1. 为什么 while(true) 没有占满 CPU?

初学者听到 while(true) 都会冒冷汗,认为会让电脑卡死。但请注意循环体底部的 await waitForEvent()

当队列为空(queue.length === 0)时,代码执行到这一行。waitForEvent 返回一个处于等待状态(pending)的 Promise。

此时,V8 引擎会瞬间执行"现场截取",把这个循环的进度、变量打包扔进堆内存的"冷库"里,原地冻结。在没有新事件发生的期间,该循环对 CPU 的开销是纯粹的 0%。

2. 空函数 resolveWait 究竟是怎么变成"叫醒钥匙"的?

在代码最开始,resolveWait 只是一个空函数。但每当代码执行到 waitForEvent 时:

typescript 复制代码
const waitForEvent = () => new Promise<void>((r) => { resolveWait = r; });

它利用了闭包特性,把这个新 Promise 的 resolve 解锁按钮(参数 r) 赋给了全局变量 resolveWait。当后台数据到来触发 onEvent 时,别人调用 resolveWait(),调用的其实正是当前卡住代码的那个 Promise 的解锁开关。这种做法避开了繁重的第三方事件库,仅用几行原生 JS 就实现了轻量级的状态同步。

3. 双层 while 循环的"微任务极致优化"

代码没有傻傻地每处理一个事件就去走一遍复杂的异步 await 流程。

外层 while 负责在没货时睡觉,而一旦被唤醒,内层纯同步的 while (queue.length > 0) 就会瞬间爆发,像割韭菜一样一口气同步抽干当前缓冲区里的所有数据。这种"静如处子,动如脱兔"的设计,具备极高的高并发吞吐量。

4. finally 块里的"全链路熔断防火墙"

如果外部消费者使用 for await...of 遍历这个流,中途突然写了个 break; 跑路了,会发生什么?

如果没有 finally 块,后台的 task 网络请求还在源源不断地下数据、吃带宽,成为失控的"僵尸协程"。

在这段代码里,外部一旦 break,JS 引擎会强制触发内部的 finally。我们在这个安全网里顺理成章地执行 controller.abort(),顺着网线直接把后台正在跑的网络请求一并掐死,干净利落。


三、 体系化技术底座:必须掌握的知识版图

要真正看懂并写出这种工业级代码,你需要将以下知识点融会贯通,串联成一个立体的系统。

1. 计算机硬件与操作系统层(时间管理思维)

  • 非阻塞 I/O(Non-blocking I/O):现代操作系统在等待网络响应时,网卡和 CPU 是分离的。当网络数据没到时,CPU 应当被彻底释放去干别的事(比如渲染 UI、处理用户点击)。
  • 通知(Notification)战胜轮询(Polling):死循环卡死电脑是因为代码在毫无意义地疯狂主动询问;而高并发架构必须基于"通知机制",代码进入休眠,由硬件中断和事件循环在数据到达时精准震醒。

2. JavaScript 语言特性与运行时(V8 引擎层)

  • 异步生成器(Async Generator):带有 async * 的函数天生是拉模式(Pull-based)的。它是被动的,外面不拉(不调用 .next()),里面就物理封印,一步也不往前走。
  • 执行栈(Call Stack)与堆内存(Heap)的现场交接:深刻理解 await 并不是让线程在原地死等,而是将当前的执行上下文(Context)打包成快照存入堆内存,清空当前 CPU 执行栈,让出单线程的控制权。
  • 闭包与变量逃逸:通过作用域链,将 Promise 内部的私有控制权(resolve 触发器)"逃逸"暴露到外部,从而实现跨上下文的精准异步控制。

3. 高级架构与工程落地层(生产防御思维)

  • 设计模式(适配器模式变体):在实际业务中,由于大模型、WebSockets 等高频底层数据全都是"推模式(Push)",这种利用中间缓冲区(queue)和阻塞器将推转换为拉的架构,是现代前端数据流的核心适配技术。
  • 未捕获异常防火墙(Unhandled Rejection):在流式开发中,当迭代器上下文死掉后,后台任务若发生报错,会演变成游离在普通的 try...catch 之外的全局灾难性报错。必须利用 promise.catch(() => {}) 挂载全局垃圾桶,筑牢容错防火墙。
  • 背压与生命周期控制(AbortSignal):全链路引入 AbortController,确保生产者(底层任务)与消费者(外层组件)在全生命周期内的生存状态强绑定。外部消亡,内部自杀,实现内存和系统资源零泄露。
相关推荐
胡萝卜术1 小时前
声明式与命令式的边界:从鉴权路由守卫到 useRef 的引用哲学
前端·javascript·面试
默_笙1 小时前
🍉 我把《天龙八部》喂给了 AI,问它"段誉会什么武功?"答案让我惊呆了
前端·javascript
bonechips2 小时前
useRef + Web Worker:React 中的多线程计算
前端·javascript
渣波2 小时前
从“迷路”到“瞬移”:彻底搞懂 React Router 的 `useLocation` 与 `useNavigate`
前端·javascript
橘子星2 小时前
React Context + 自定义 Hooks:告别 Props 地狱,一行代码跨层级传数据
前端·javascript
触底反弹2 小时前
🔥 别再只用 useRef 拿 DOM 了!这 5 个实战场景让你彻底理解它(附源码解析 + 面试题)
前端·javascript·react.js
BreezeJiang2 小时前
Web Worker 不负责渲染:React 中的线程分工与消息闭环
javascript·react.js
做前端的娜娜子2 小时前
JavaScript 闭包
前端·javascript·掘金·金石计划
何时梦醒2 小时前
🎯 从零彻底搞懂 React Context API —— 一篇带你穿越"组件树"的状态共享方案
前端·javascript·react.js