【Nodejs】Http异步编程从EventEmitter到AsyncIterator和Stream

场景:现有一个事件模型 EventEmitter,负责生产数据。通过http请求来消费生产的数据。

下面定义了DataEmitter(继承EventEmitter),通过定时器来产生数据。emitter每秒产生1个时间戳,产生5个后结束。

data-emitter.ts

ts 复制代码
import { EventEmitter } from 'node:events';

export class DataEmitter extends EventEmitter {
  private timer: NodeJS.Timeout | null = null;
  private count = 0;

  constructor(private maxCount: number, private intervalMs: number) {
    super();
  }

  start() {
    if (this.timer) return;

    this.timer = setInterval(() => {
      this.count += 1;
      const chunk = `${Date.now()}
`;
      this.emit('data', chunk);

      if (this.count >= this.maxCount) {
        this.stop();
        this.emit('end');
      }
    }, this.intervalMs);
  }

  stop() {
    console.log('stop...');
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  }
}

export const emitter = new DataEmitter(5, 1000);

1.on方法来订阅消费

使用node原生的http模块,创建一个web服务。然后eventEmitter.on(eventName, data)方法来消费,响应请求。

server-demo.ts

ts 复制代码
import http from 'node:http';
import { emitter } from './data-emitter';

const server = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {
  if (req.url === '/test') {
    res.statusCode = 200;
    res.setHeader('Content-Type', 'text/plain; charset=utf-8');
    // res.setHeader('Transfer-Encoding', 'chunked'); //可省略

    const onData = (chunk: string) => {
      res.write(chunk);
    };
    const onEnd = () => {
      res.end();
      cleanup();
    };
    function cleanup() {
      emitter.off('data', onData);
      emitter.off('end', onEnd);
    }
    emitter.on('data', onData);
    emitter.on('end', onEnd);

    emitter.start();

    // 清理计时器和事件监听器
    req.on('close', () => {
      cleanup();
    });
  } else {
    res.statusCode = 404;
    res.setHeader('Content-Type', 'text/plain; charset=utf-8');
    res.end('Not Found');
  }
});

const PORT = 3000;

server.listen(PORT, () => {
  console.log(`Server is running at http://localhost:${PORT}`);
});

当我curl http://localhost:3000/test 请求时,每隔1s会打印一行:

复制代码
1766984830578
1766984831578
1766984832580
1766984833580
1766984834582

2.async-await消费

有些特殊场景,需要await来消费,即需要将eventEmitter转换为一个线性的逻辑。 比如在hono.js中,提供stream/streamText都需在一个async方法中完成响应。

hono-demo.ts

ts 复制代码
import { Hono } from 'hono'
import { streamText } from 'hono/streaming'
import { serve } from '@hono/node-server'
import { emitter } from './data-emitter';

const app = new Hono()

app.get('/test', (c) => {
  return streamText(c, async (s) => {
	const onData = (chunk: string) => {
      s.write(chunk);
    };
    const onEnd = () => {
      s.close();
      cleanup();
    };

    function cleanup() {
      emitter.off('data', onData);
      emitter.off('close', onEnd);
    }

    emitter.start();
    
    
    // 相当于node的req.on('close', () => {}),清理计时器和事件监听器
    s.onAbort(() => {
      cleanup();
    })
  })
})

// export default app
serve(app, (info) => {
  console.log(`Server is running at http://localhost:${info.port}`)
})

当你这样写,不会生效。因为streamText的第二个参数函数一旦结束,响应就结束了(相当于res.end()),因此必须在async/await的生命周期内消费数据,一旦结束这个周期便无法消费数据了。

那么,此时有两种方式来完成EventEmitter模型到async-await模型的转换。

方式一:异步迭代器

官方在 events 模块中提供了一个方法 on(),它能将某个EventEmitter转换为一个「异步迭代器」。

ts 复制代码
import {on} from 'node:events';

const asyncIterator = on(emitter,'data')
for await (const [chunk] of asyncIterator) {
  s.write(chunk);
}

「异步迭代器」能被for await... of语法遍历,是因为它实现了Symbol.asyncIterator 协议。此外,须知Stream也实现了Symbol.asyncIterator,所以也能这样被遍历。

将 「回调」 转换为 「异步迭代器」,从此不再是回调的方式来消费,而是线性("同步")方式消费。

问题:如何停止/结束?

1.通过error事件,这样循环「异步迭代器」时会抛出错误。

ts 复制代码
this.emit('error', new Error('max count reached'));
arduino 复制代码
Error: max count reached
    at Timeout.<anonymous>

2.通过事件的值

ts 复制代码
for await (const [data] of on(ee, 'data')) {
  console.log(data);
  if (data === 'quit') {
    break; // 此时循环结束,底层监听器被移除
  }
}

EventEmitter的emit方法,可以传递多个参数作为数据,所以,我们可以增加一个参数作为是否迭代结束的判断 在data-emitter.ts中增加:

ts 复制代码
this.emit('data', '', true); // 第三个参数shouldStop

hono-demo.ts中修改为:

ts 复制代码
// 只要 emitter 一直发 data 且没有触发 error,循环就不会结束 ,相当于一个"无限流"。
    for await (const [chunk, shouldStop] of asyncIterator) {
      if(shouldStop) break;
      s.write(chunk);
    }

3.通过AbortController 信号量终止 (官方推荐)

data-emitter.ts中增加:

ts 复制代码
export const ac = new AbortController();
...
//当你要终止
ac.abort()

hono-demo.ts中修改为:

ts 复制代码
import { on } from 'node:events';
import { ac } from './data-emitter';

app.get('/test', (c) => {
  return streamText(c, async (s) => {

    const asyncIterator = on(emitter,'data', { signal: ac.signal })
    emitter.start();
    // 只要 emitter 一直发 data 且没有触发 error,循环就不会结束 ,相当于一个"无限流"。
    // for await (const [chunk, shouldStop] of asyncIterator) {
    //   if(shouldStop) break;
    //   s.write(chunk);
    // }

    try {
      for await (const [chunk] of asyncIterator) {
        s.write(chunk);
      }
    } catch (err:any) {
      if (err.name === 'AbortError') {
        console.log('监听已停止');
      } else {
        throw err;
      }
    }
    
    // 连接断开,事件监听器
    s.onAbort(() => {
      console.log('abort...');
      // cleanup();
    })

  })
})

方式二:转换成流

1. Readable

直接一开始使用 Readable 而非 EventEmitter。

ts 复制代码
import { Readable } from 'node:stream'

export function createDataStream(maxCount: number, intervalMs: number) {
  let count = 0
  let timer: NodeJS.Timeout | null = null

  const stream = new Readable({
    encoding: 'utf8',
    read() {
      if (timer) return

      this.push('history1')
      this.push('history2')

      timer = setInterval(() => {
        count += 1
        const chunk = `${Date.now()}\n`
        const canContinue = this.push(chunk)

        if (!canContinue || count >= maxCount) {
          if (timer) {
            clearInterval(timer)
            timer = null
          }
          this.push(null) //流结束
        }
      }, intervalMs)
    }
  })

  return stream
}
ts 复制代码
app.get('/test', (c) => {
  return streamText(c, async (s) => {
    const stream = createDataStream(5, 1000)

    s.onAbort(() => {
      stream.destroy()
    })

    for await (const chunk of stream) {
      s.write(chunk.toString())
    }
  })
})

这种方式有一个局限性,就是stream无法「外部写」,而是又内部生成数据。当然也可以强行暴露一个write方法

ts 复制代码
stream.write = function(chunk: any) {
    return this.push(chunk)
  }

但这样不太优雅,看起来更像一个Readable+Writable双工流。 那不如直接使用双工流。

2. PassThrough

Node.js 中的 PassThrough 流是 流(Stream)模块 提供的一种特殊类型的 双工流(Duplex Stream) ,其核心特点是:它不会对流经的数据做任何修改,仅起到"透明中转"的作用 。换句话说,写入 PassThrough 的数据会原封不动地从可读端流出,因此常被用作中间层或"观察点"来串联、分发、监控数据流。

双工流PassThrough示例:

ts 复制代码
const stream = new PassThrough()

// 任何地方都可以写
stream.write('xxx')

// 其他地方可以消费
for await (const chunk of stream){
	console.log(chunk)
}

现在写一个方法将最初的EventEmitter转为Stream. data-emitter中, 在原来的class DataEmitter 中增加一个方法createStream创建一个PassThrough

ts 复制代码
createStream() {
    const stream = new PassThrough();

    const onData = (chunk: string) => {
      stream.write(chunk);
    };

    const onEnd = () => {
      stream.end();
    };

    const cleanup = () => {
      this.off('data', onData);
      this.off('end', onEnd);
      stream.removeListener('close', cleanup);
    };

    this.on('data', onData);
    this.once('end', onEnd);

    stream.on('close', cleanup);

    return stream;
  }

hono-demo.ts中修改为:

ts 复制代码
// 使用流
    const stream = emitter.createStream();
    for await (const chunk of stream) {
      s.write(chunk);
    }
    
    // 连接断开,事件监听器
    s.onAbort(() => {
      console.log('abort...');
      // cleanup();
    })

思考题:

为什么AsyncIterator 无法像 Stream 那样会自动结束?

  • Stream(流):设计上就有"边界"。文件读完了、网络请求传完了,就会发 end 事件,异步迭代器看到 end 就会标记为 done: true。
  • EventEmitter:设计上是"消息中心"。比如一个 process 对象的 SIGINT 信号监听,或者一个聊天室的 message 事件。只要程序没死,这些事件就可能一直发生,底层没有一个通用的"我以后再也不会发消息了"的协议。

3.消费历史数据

到目前为止,存在一个问题:eventEmitter产生的历史数据如何消费?

data-emitter中提前生成了两条数据history1history2

ts 复制代码
export class DataEmitter extends EventEmitter {...}

export const emitter = new DataEmitter(5, 1000);
// ++++++++++ 增加 START ++++++++++++
emitter.emit('data', 'history1\n');
emitter.emit('data', 'history2\n');
// ++++++++++ 增加 END ++++++++++++

使用纯EventEmitter、或者结合异步迭代器的方式,都无法消费历史数据,因为本质是消费同一个生成过的历史数据是过去式,现在无法消费了。

而结合Stream的方式创建一个流,则是产生了一个新,我们是从头到尾消费这个,故能消费到历史数据。 唯一需要处理的就是在PassThrough创建时写入历史数据:

  • 对于DataEmitter 需要记录历史数据(到数组)。
  • 对于新建的PassThrough,需要写入历史数据。

最终data-emitter.ts长这样:

ts 复制代码
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';


export class DataEmitter extends EventEmitter {
  private timer: NodeJS.Timeout | null = null;
  private count = 0;
  private history: string[] = [];

  constructor(private maxCount: number, private intervalMs: number) {
    super();
    this.on('data', (chunk) => {
      this.history.push(chunk);  //记录历史数据
    })
  }

  start() {
    if (this.timer) return;

    this.timer = setInterval(() => {
      this.count += 1;
      const chunk = `${Date.now()}
`;
      
      this.emit('data', chunk);

      if (this.count >= this.maxCount) {
        this.stop();
        this.emit('end');  

      }
    }, this.intervalMs);
  }

  stop() {
    console.log('stop...');
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  }

  createStream() {
    const stream = new PassThrough();

    this.history.forEach(chunk => stream.write(chunk)); // 历史数据写入流

    const onData = (chunk: string) => {
      stream.write(chunk);
    };

    const onEnd = () => {
      stream.end();
    };

    const cleanup = () => {
      this.off('data', onData);
      this.off('end', onEnd);
      stream.removeListener('close', cleanup);
    };

    this.on('data', onData);
    this.once('end', onEnd);

    stream.on('close', cleanup);

    return stream;
  }

}


export const emitter = new DataEmitter(5, 1000);
emitter.emit('data', 'history1\n');
emitter.emit('data', 'history2\n');

打印结果

复制代码
history1
history2
1766993518537
1766993519538
1766993520538
1766993521539
1766993522541

使用EventEmitter作为生产者,结合Stream,给每一个消费者提供一个Stream。这种方案还带来另外两个好处:

  1. 针对每个HTTP实例(消费者),都单独提供一个Stream供消费,互不干扰,并且能消费到历史数据。
  2. 如果历史数据很多,例如:history1...historyn。你可以结合流的特性,方便处理「背压」。

4.PassThrough 双工流使用场景

1) 日志多路分发(Multiplexing)

当需要将同一份日志同时写入文件和控制台时,可以使用 PassThrough 作为统一入口:

javascript 复制代码
import fs from 'node:fs'
import { PassThrough } from 'node:stream'

const logStream = new PassThrough();
const fileStream = fs.createWriteStream('app.log');

logStream.pipe(fileStream); // 写入文件
logStream.on('data', (chunk) => {
  console.log('Log:', chunk.toString()); // 输出到控制台
});

logStream.write('This is a log message.\n');
logStream.end();

这种方式避免了重复写入逻辑,且保持数据一致性

2) 合并多个输出流(如 stdout + stderr)

在执行子进程(如 shell 脚本)时,常需同时捕获 stdoutstderr 并合并为一个输出流返回给 HTTP 客户端。此时 PassThrough 可作为内存中的"汇聚点":

javascript 复制代码
import spawn from 'node:child_process'
import { PassThrough } from 'node:stream'

const child = spawn('sh', ['script.sh']);
const memoryStream = new PassThrough();

child.stdout.pipe(memoryStream, { end: false });
child.stderr.pipe(memoryStream, { end: false });

child.on('close', () => {
  memoryStream.end(); // 所有输出完成后关闭流
});

// 在 Egg.js 或 Express 中可直接设为 ctx.body 或 res
ctx.body = memoryStream;

这里 PassThrough 充当了"内存缓冲流",将两个独立流合并为一个可读流

注意事项

  • 背压(Backpressure)自动处理PassThrough 遵循标准流的背压机制。当下游消费变慢时,上游会自动暂停,防止内存溢出
  • 不要同时使用 pipe() 和手动 data 监听 :一旦注册 data 事件,流会进入"流动模式",此时不能再安全地使用 pipe(),反之亦然
  • 错误传播 :通过 pipe() 连接的流链中,任一环节抛出 error 事件,通常需手动监听并处理,否则可能导致进程崩溃
相关推荐
烧冻鸡翅QAQ2 小时前
从0开始的游戏编程——开发前的编程语言准备(JAVAScript)
开发语言·javascript·游戏
软弹2 小时前
Vue2 - Dep到底是什么?如何简单快速理解Dep组件
前端·javascript·vue.js
晴虹2 小时前
lecen:一个更好的开源可视化系统搭建项目--介绍、搭建、访问与基本配置--全低代码|所见即所得|利用可视化设计器构建你的应用系统-做一个懂你的人
前端·后端·低代码
WangHappy2 小时前
面试官:如何优化批量图片上传?队列机制+分片处理+断点续传三连击!
前端·node.js
借个火er2 小时前
Qiankun vs Wujie:微前端框架深度对比
前端
小笔学长2 小时前
事件委托:优化事件处理性能
javascript·性能优化·项目实战·前端开发·事件委托
freeWayWalker2 小时前
【前端工程化】前端代码规范与静态检查
前端·代码规范
C2X2 小时前
关于Git Graph展示图的理解
前端·git
昊茜Claire2 小时前
鸿蒙开发之:性能优化与调试技巧
前端