Stream(流)算是任何一个编程语言里最基础的API之一,但又是听起来简单又晦涩难懂的API之一;本文主要介绍JS里面关于流的所有你想要了解的基本知识,学习并能熟练使用或开发自定义流是每一个想要成为JS开发老司机的必备技能。
Stream基本介绍
- Stream相当于数据流,无需等待数据全部到达,可获取到一部分数据即可开始提前处理;
- 主要解决大数据量时的分块处理,以
chunk为单位处理,减少内存使用,提升效率;
- 几乎所有的地方间接或直接的使用Stream, e.g: 命令行管道链
a | b | c | d;
- 可以理解为I/O操作的标准或抽象,文件I/O、网络I/O等都基于stream来做处理;
Why Stream?
一个读取大文件(400+MB)不用与用Stream的区别
- 可以看到下图1直接内存使用达到400多MB,如果请求多的话,每一次都全部读到内存,Node进程可能会很快挂掉;
- 而下图2在使用Stream的情况下,内存会维持在很低的值,大大减少内存占用;
Why Web Stream?
🎉 Web Stream API在Node 18已可直接使用,也包括fetch等其他Web端的一些API 🎉
然而为什么Node已经有Stream的API了,还要搞一个Web Stream出来?且看下面国外大佬们的吐槽😂
Streams are Node’s best and most misunderstood idea.
— Dominic Tarr
Stream的流向过程
- 下图可比做一个可读流
- 上面的水龙头及再往前相当于流的数据源,来自于水厂(
underlying source),通过管道流过来
- 打开水龙头相当于流变为
readable,读取到tank中(类似水池),相当于流的内部缓冲区buffer
- 下面的水龙头(下水口)相当于消费上面的可读流
- 上下进出水的速率不一样,缓冲区会慢慢被放满,继续放会导致水溢出甚至丢失
- 水槽接近高水位时(
highWaterMark),需要通知上游关掉或调小上面水龙头开关以避免水流失(backpressure机制)
- 等下水道流的差不多时,水位低于
highWaterMark,再通知上游打开水龙头继续放水
- 重复上面流程直到结束Stream结束
接下来介绍Stream各个类型:
Readable Stream(可读流)
- 可读流从一个数据源(如文件)不断读取数据,放入buffer,供消费者读取
- Node里常用可读流如:
process.stdin、fs.createReadStream()、httpServer的IncomingMessage;
- Web端:fetch的
response.body
Writable Stream(可写流)
- 可写流对应一个目的地,通过程序不断的
writechunk, 进入buffer, 再把buffer里的chunk写到最终的目的地(如文件)
- Node里常用可写流如:
process.stdout、fs.createWriteStream()、httpServer的ServerResponse
Duplex Stream(双工流)
- 既可以往里写数据,又可以从里面读取数据
- 读写流buffer相互独立
- Node里常用双工流如:
net.Socket
http.createServer()流程:
import http from 'node:http'
const server = http.createServer((req, res) => {
// 此处
// req 为readable stream
// res 为 writable stream
// 如下图,相当于在Socket的基础上又封装了一层
})
server.listen(3000)
Transform Stream(转换流)
- 转换流也算双工流的特殊的一种
- 转换流输出端(readable)由输入端(writable)经过transform后得到
- Node里常用转换流如:
zlib、crypto模块、stream.PassThrough
- Web端:
TextEncoderStream、TextDecoderStream、CompressionStream、DecompressionStream
Backpressure(数据流积压)
- Stream默认会有
highWaterMark(可自定义大小)来作为一个临界参考值
- 当消费数据与生产数据速率不一致时(e.g: 读取本地文件速度大于把文件内容上传到服务器的速度),内部buffer大小会达到或超过该值时,此时会触发backpressure机制
- 该机制作为一个信号来通知上游需要停止写入(进入
pause mode),来给下游更多的时间消费掉buffer里的数据
- 当buffer数据消耗完后,通知上游可以继续生产数据(重新进入
flowing mode)
- 管道相关方法(如
pipe)会自动处理backpressure机制
消费Streams
我们与Stream打交道的大多数情况下是消费已经存在的Stream(系统自带或第三方库),消费流的方式可以简单分为自动消费与手动消费:
自动消费(通过管道)
pipe、pipeline(node)
pipe、pipeline属于Node Stream的方法:
import { pipeline } from 'node:stream'
// pipe
readableSrc
.pipe(transformStream1)
.pipe(transformStream2)
.pipe(finalWrtitableDest);
// pipeline替换pipe
pipeline(
fs.createReadStream('archive.tar'),
zlib.createGzip(),
fs.createWriteStream('archive.tar.gz'),
(err) => {
if (err) {
console.error('Pipeline failed.', err);
} else {
console.log('Pipeline succeeded.');
}
}
);
pipeThrough、pipeTo(web)
此处2个为Web Stream里的方法:
const res = await fetch('https://www.baidu.com')
await res.body
.pipeThrough(transformStream)
.pipeTo(writableStream);
消费多次: pipe(node)
如果一个流想要被多个消费者同时消费数据,在node stream里可以pipe给多个Transform流或Writable流,
下图相当于把一个文件copy了2份:
消费多次: Teeing一分为二(web)
在web stream中,一个流同时只能被一个reader消费,如果想要2个消费者同时消费,可通过const [r1, r2] = readableStream.tee()一分为二:
Async Iteration
通过管道形式我们可以方便的消费stream中的数据,在js中,也可以通过异步迭代(for await ... of语法)来快速消费数据
const res = await fetch('https://www.baidu.com')
// nodejs only, 目前只在node端可以直接这样用
for await (const chunk of res.body) {
// Uint8Array(16384)[]
console.log(chunk)
}
// web前端可通过async generator包装一层
async function* getAsyncIterableFor(readableStream) {
const reader = readableStream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) return
yield value
}
} finally {
reader.releaseLock()
}
}
for await (const chunk of getAsyncIterableFor(res.body)) {
console.log(chunk)
}
手动读取
监听data等事件(node)
import { Readable } from 'node:stream'
const res = await fetch('https://www.baidu.com')
const nodeReadableStream = Readable.fromWeb(res.body)
// 自动进入flowing mode
nodeReadableStream.on('data', (chunk) => {
console.log(chunk);
})
nodeReadableStream.on('end', () => {
console.log('end.');
});
// paused mode情况下手动read
nodeReadableStream.on('readable', function() {
let data;
// 返回null,当前读取结束,等待下一次readable事件
while ((data = this.read()) !== null) {
console.log(data);
}
});
调用read等方法(web)
const webReadableStream = res.body
const reader = webReadableStream.getReader();
try {
while (true) {
const {done, value: chunk} = await reader.read();
// Use `chunk`
console.log(chunk);
}
} finally {
reader.releaseLock();
}
手动写入
使用WritableStream,通过writer.write(chunk)写入
import { Writable } from 'node:stream'
// web WritableStream
const writer = webWritableStream.getWriter();
try {
await writer.write('line1 \n');
await writer.write('line2 \n');
await writer.close();
} finally {
writer.releaseLock()
}
// node Writable stream
const nodeWritableStream = Writable.fromWeb(webWritableStream)
function write(data, cb) {
// 写入数据, 返回false说明buffer满了,等待清空
if (!nodeWritableStream.write(data)) {
// buffer已清空,可以继续写入
nodeWritableStream.once('drain', cb);
} else {
process.nextTick(cb);
}
}
write('hello', () => {
console.log('上一波写入完毕,可继续下一波写入');
write('more data', () => console.log('end'))
});
实现自定义Streams
上面主要讲了如何使用已存在的Stream,
下面主要介绍如何实现一些简单的Stream能让别人去使用(去消费你写的Stream)
自定义ReadableStream(web)
const readableStream = new ReadableStream({
start(controller) {
controller.enqueue('line1 \n');
controller.enqueue('line2 \n');
},
pull(controller) {
controller.enqueue('more data: ', Math.random())
},
cancel(reason) {
console.log('canceled:', reason)
}
});
// consume
for await (const chunk of readableStream) {
console.log(chunk);
}
自定义WritableStream(web)
const writableStream = new WritableStream({
start(controller) {
console.log('write start...')
this.ret = ''
},
write(chunk, controller) {
console.log('write chunk: ', chunk)
this.ret += chunk
},
close() {
console.log('writable stream close, ret:')
console.log(this.ret)
},
abort(err) {
console.log('writable stream about: ', err)
}
})
// use it
const writer = writableStream.getWriter()
writer.write('a')
await writer.ready
writer.write('b')
await writer.ready
writer.write('c')
await writer.close()
console.log('done write')
writer.releaseLock()
自定义TransformStream(web)
new TransformStream({
start(controller) {
console.log('transform start...')
controller.enqueue('start chunk...\n')
},
async transform(chunk, controller) {
console.log('transform chunk...\n')
// 会等待当前transform resolve,才会执行下一个transform
await sleep(1000)
// 转为大写
controller.enqueue(String(chunk).toUpperCase() + '\n')
},
flush(controller) {
controller.enqueue('final chunk...')
}
})
Pipe chain 合起来
const rs = createReadableStream()
const ts = createTransformStream()
const ws = createWritableStream()
// const ws = Writable.toWeb(process.stdout)
rs.pipeThrough(ts)
.pipeTo(ws)
// node
Readable.fromWeb(rs)
.pipe(Transform.fromWeb(ts))
.pipe(Writable.fromWeb(ws))
Node与Web Streams对比
下面简单罗列了2种Stream API的区别
| Node Stream | Web Stream | |
|---|---|---|
| 基本机制 | 基于事件,继承EventEmitter | 基于Promise |
| 内部buffer | - 默认string或Buffer; - 使用objectMode来push JS对象; | - 默认任意JS对象;- 使用Byte streams(type: 'bytes)来使用二进制流; |
| highWaterMark | - 默认16KB;- objectMode时为写入的对象数量; | - new CountQueuingStrategy({ highWaterMark: 1 });- 二进制时为0; |
| 处理backpresure | push(chunk): boolean、write(chunk): boolean返回值判断, false时pause;监听drain事件,触发时resume | controller.desiredSize <= 0时暂停写入;await writer.readyfullfill时,可以继续写入数据 |
| 相互转换(node only) | Readable.toWeb(),Readable.fromWeb(),Writable、Transform一样 |
Streams Demo
命令行pipe
ll -a | node node-stream.js
// node-stream.js
function upperCaseStream() {
let chunkCount = 0
let totalSize = 0
// process.stdin 即为命令行标准输入,是个可读流
const out = process.stdin
.pipe(
// node stream,
// 把stdin的输入数据转成大写,随便记录中间的chunk数量
new Transform({
transform(chunk, encoding, callback) {
console.log('chunk encoding: ', encoding)
chunkCount++
totalSize += chunk.length
// 写入转换后的数据
// 在web stream里是controler.enqueue(chunk)
callback(null, chunk.toString().toUpperCase())
},
flush(callback) {
console.log('flush chunk count: ', chunkCount, ', total size: ', totalSize)
callback(null, 'done...')
}
})
)
.pipe(process.stdout)
out.on('close', () => {
console.log('chunk count: ', chunkCount)
})
}
读写压缩文件
通过浏览器自带的一些流实现一个不依赖第三方库实现压缩能力,
选择本地文件压缩后再写回文件
<input type="file"> -> file.stream()-> comrepss -> download
let file
async function onFileChangeAndGzipFile(e) {
file = e.target.files[0]
console.log('file: ', file)
// File继承自Blob, 调用stream获得一个可读流
const fileStream = file.stream()
// pipe给压缩转换流
const gzippedStream = fileStream.pipeThrough(new CompressionStream('gzip'))
// 通过可读流创建一个Response
const res = new Response(gzippedStream)
const resBlob = await res.blob()
const url = URL.createObjectURL(resBlob)
const link = document.createElement('a')
link.download = 'browser_gzipped.txt.gz'
link.href = url
link.click()
}
// 上面的例子在gzippedStream之后,就没有利用流来写入文件了,还是得把压缩后的数据全部放到内存里
// 下面通过fs文件系统使用WritableStream写回文件, chrome>=86
async function gzipAndSave() {
if(!file) return
//
const newHandle = await window.showSaveFilePicker();
// 得到写文件流
const writableStream = await newHandle.createWritable();
await file.stream()
.pipeThrough(new CompressionStream('gzip'))
.pipeTo(writableStream)
console.log('write done')
}
fetch demo
fetch gzip file -> gunzip -> decode -> text/json
async function fetchAndGunzipFile() {
const url = 'https://abc.com/gzipped-file'
let ret = ''
const res = await fetch(url)
await res.body
// 解压缩
.pipeThrough(new DecompressionStream('gzip'))
// 解码
.pipeThrough(new TextDecoderStream())
// 得到实际文本
.pipeTo(
new WritableStream({
start() {
this.count = 0
},
write(chunk) {
this.count++
// console.log(chunk.length)
ret += chunk
},
close() {
console.log('count:', this.count)
}
})
)
return ret
}
总结
本文介绍了Stream的相关概念以及Node Stream与较新的Web Stream之间的对比,及在前端已经可以使用的实例,希望能对各位在Stream的学习之路上有所帮助🙌
参考文档
- 本文使用到的一些示例代码: stream-demo
- Node Stream API
- Web Stream on MDN
本文部分图片收集至网络