啊!!!Stream API 居然这么好用!

174 阅读7分钟

啊!!!Stream API 居然这么好用!

在前端开发中,我们总会遇到 “大文件”“实时数据” 的场景:比如上传 1GB 的视频、加载 10 万条数据的 API 响应、实时接收后端日志…… 此时,一次性加载全量数据的方案(比如 Blob)往往会导致内存溢出、页面卡顿。

然而,有一个 API 却能轻松解决这些问题 —— 它就是Stream API。它像 “流水处理器” 一样,将数据拆分成小块逐个处理,既节省内存,又能实现实时交互,却常常被开发者忽略。

今天,就让我们揭开 Stream API 的面纱,看看它到底有多实用。

一、Stream API 是什么?

从字面意思看,Stream 是 “流” 的意思。在 JavaScript 中,Stream API 是一套处理 “流数据” 的标准接口,它将连续的数据拆分为可逐个处理的 “块(Chunk)”,而非一次性加载全部数据。

核心优势

  • 低内存占用:无需加载全量数据,仅处理当前块,避免大文件导致的内存溢出。
  • 实时性:数据到达后可立即处理,无需等待全部传输完成(如实时渲染日志、边下载边播放)。
  • 灵活性:支持数据转换(压缩、加密)、分流、合并等复杂操作。

核心对象

Stream API 的核心由三个对象组成,覆盖 “数据来源→数据处理→数据目的地” 全流程:

  • ReadableStream:可读流,数据的 “来源”(如文件、API 响应、WebSocket 消息)。
  • WritableStream:可写流,数据的 “目的地”(如 DOM、Blob、服务器请求)。
  • TransformStream:转换流,数据的 “处理器”(如压缩、加密、格式转换),连接可读流和可写流。

简单示例:创建一个基础可读流

// 创建一个简单的可读流,每秒产生一个数字
const readableStream = new ReadableStream({
  start(controller) {
    let count = 1;
    const timer = setInterval(() => {
      controller.enqueue(count); // 向流中添加数据块
      if (count >= 5) {
        clearInterval(timer);
        controller.close(); // 数据生成完成,关闭流
      }
      count++;
    }, 1000);
  }
});
// 读取流中的数据
const reader = readableStream.getReader();
async function readStream() {
  while (true) {
    const { done, value } = await reader.read();
    if (done) break; // 流已关闭,停止读取
    console.log("读取到数据:", value); // 依次输出 1、2、3、4、5
  }
}
readStream();

二、Stream API 的 “超能力” 有哪些?

Stream API 的真正价值,在于解决传统方案(如 Blob、全量 JSON)无法应对的场景,每个能力都附带可直接复用的代码示例。

1. 大文件分片上传:避免超时与内存溢出

传统大文件上传需用Blob.slice()手动分片,而 Stream API 可自动分块,且支持暂停 / 恢复,更适合 1GB + 的视频、压缩包等文件。

// 大文件分片上传实现(5MB/块)
const fileInput = document.getElementById("file-input");
fileInput.addEventListener("change", async (e) => {
  const file = e.target.files[0];
  if (!file) return;
  const chunkSize = 5 * 1024 * 1024; // 5MB每块
  const totalChunks = Math.ceil(file.size / chunkSize);
  let currentChunk = 0;
  const fileName = file.name;
  // 1. 创建可读流,分块读取文件
  const fileStream = file.stream(); // 文件自带的ReadableStream
  const chunkReader = fileStream.getReader();
  // 2. 循环读取并上传每一块
  while (true) {
    const { done, value: chunkBuffer } = await chunkReader.read();
    if (done) break;
    // 3. 构建分片请求参数
    const formData = new FormData();
    formData.append("fileChunk", new Blob([chunkBuffer]), `${fileName}.chunk`);
    formData.append("fileName", fileName);
    formData.append("chunkIndex", currentChunk);
    formData.append("totalChunks", totalChunks);
    // 4. 上传当前分片(需后端配合接收分片)
    await fetch("/api/upload-chunk", {
      method: "POST",
      body: formData,
    });
    // 5. 更新上传进度
    currentChunk++;
    const progress = (currentChunk / totalChunks) * 100;
    document.getElementById("progress").textContent = `上传进度:${progress.toFixed(2)}%`;
  }
  // 6. 通知后端合并所有分片
  await fetch("/api/merge-chunks", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ fileName, totalChunks }),
  });
  alert("大文件上传完成!");
});

2. 实时解析大 API 响应:边加载边渲染

如果 API 返回 10 万条数据的 JSON 列表,传统方案需等待全部数据加载完才能渲染,导致页面 “卡死”。Stream API 可边接收数据边解析,实现 “数据到了就渲染”。

// 实时解析大JSON响应,边接收边渲染表格
async function loadLargeTableData() {
  const tableBody = document.getElementById("data-table-body");
  try {
    const response = await fetch("/api/large-user-list", {
      headers: { "Accept": "application/json" },
    });
    if (!response.ok) throw new Error("请求失败");
    if (!response.body) throw new Error("浏览器不支持流响应");
    // 1. 获取流读取器和解码器(处理UTF-8文本)
    const reader = response.body.getReader();
    const decoder = new TextDecoder("utf-8");
    let partialData = ""; // 存储未解析的部分数据(防止JSON被截断)
    // 2. 循环读取流数据
    while (true) {
      const { done, value: chunk } = await reader.read();
      if (done) break;
      // 3. 解码当前块并拼接未解析部分
      const text = partialData + decoder.decode(chunk, { stream: true });
      // 分割JSON数组(假设响应格式为 [{id:1}, {id:2}, ...])
      const items = text.split(/,(?={)/); // 按逗号分割,确保分割在对象开头前
      // 4. 渲染已完整的JSON对象
      for (let i = 0; i < items.length - 1; i++) {
        const itemStr = items[i].replace(/^[[{]+|[]}]+$/g, ""); // 去除前后括号
        if (!itemStr) continue;
        const user = JSON.parse(itemStr);
        // 实时添加表格行
        const row = document.createElement("tr");
        row.innerHTML = `<td>${user.id}</td><td>${user.name}</td><td>${user.age}</td>`;
        tableBody.appendChild(row);
      }
      // 5. 保存最后一个可能不完整的项,供下一次拼接
      partialData = items[items.length - 1];
    }
    // 6. 处理最后剩余的部分数据
    if (partialData) {
      const itemStr = partialData.replace(/^[[{]+|[]}]+$/g, "");
      if (itemStr) {
        const user = JSON.parse(itemStr);
        const row = document.createElement("tr");
        row.innerHTML = `<td>${user.id}</td><td>${user.name}</td><td>${user.age}</td>`;
        tableBody.appendChild(row);
      }
    }
    console.log("所有数据渲染完成");
  } catch (err) {
    console.error("处理失败:", err);
  }
}
// 调用函数加载数据
loadLargeTableData();

3. 前端实时压缩文本:减少传输体积

无需等待全量文本输入,可通过TransformStream实时压缩数据(如日志、富文本),减少后续上传的流量。

// 用TransformStream实时压缩文本(依赖pako库:https://github.com/nodeca/pako)
import pako from "pako";
async function compressTextStream(text) {
  // 1. 创建可读流(文本来源)
  const readable = new ReadableStream({
    start(controller) {
      controller.enqueue(text);
      controller.close();
    },
  });
  // 2. 创建转换流(压缩文本)
  const transform = new TransformStream({
    transform(chunk, controller) {
      // 压缩当前文本块(gzip格式)
      const compressed = pako.gzip(chunk, { to: "Uint8Array" });
      controller.enqueue(compressed);
    },
  });
  // 3. 创建可写流(接收压缩后的数据)
  const writable = new WritableStream({
    start() {
      this.compressedData = [];
    },
    write(chunk) {
      this.compressedData.push(chunk);
    },
    close() {
      // 合并压缩块,生成Blob(可用于下载或上传)
      const compressedBlob = new Blob(this.compressedData);
      console.log("压缩前大小:", text.length, "字节");
      console.log("压缩后大小:", compressedBlob.size, "字节");
      // 下载压缩文件
      const url = URL.createObjectURL(compressedBlob);
      const a = document.createElement("a");
      a.href = url;
      a.download = "compressed.txt.gz";
      a.click();
      URL.revokeObjectURL(url);
    },
  });
  // 4. 连接流:可读流 → 转换流 → 可写流
  await readable.pipeThrough(transform).pipeTo(writable);
}
// 测试:压缩一段长文本
const longText = "这是一段很长很长的文本...".repeat(1000);
compressTextStream(longText);

4. 实时接收后端日志:SSE + Stream 组合

后端通过 SSE(Server-Sent Events)推送实时日志时,Stream API 可逐行处理日志,避免日志堆积导致的页面卡顿。

// 实时接收并展示后端SSE日志
function loadRealTimeLogs() {
  const logContainer = document.getElementById("log-container");
  const sse = new EventSource("/api/realtime-logs"); // 建立SSE连接
  // 监听SSE消息(后端每次推送一条日志)
  sse.onmessage = (event) => {
    // 创建可读流处理单条日志(可扩展为批量处理)
    const logStream = new ReadableStream({
      start(controller) {
        controller.enqueue(event.data);
        controller.close();
      },
    });
    // 读取流并渲染日志
    const reader = logStream.getReader();
    reader.read().then(({ done, value: log }) => {
      if (done) return;
      // 给不同级别日志添加颜色(假设日志格式为 "INFO: 内容")
      const [level, content] = log.split(": ", 2);
      const logElement = document.createElement("div");
      logElement.className = `log log-${level.toLowerCase()}`;
      logElement.textContent = `[${new Date().toLocaleTimeString()}] ${log}`;
      logContainer.appendChild(logElement);
      // 滚动到最新日志
      logContainer.scrollTop = logContainer.scrollHeight;
    });
  };
  // 处理SSE错误
  sse.onerror = (err) => {
    console.error("SSE连接错误:", err);
    sse.close();
  };
}
// 启动实时日志接收
loadRealTimeLogs();

三、Stream + 其他 API:强强联合

Stream API 很少单独使用,与 Fetch、Blob、Web Worker 等组合后,能发挥更强大的作用。

1. Stream + Fetch + Blob:大文件边下载边保存

下载 GB 级文件时,传统方案会将全量数据存入内存,导致页面崩溃。通过 Stream 可边下载边生成 Blob,最终通过a标签触发下载。

// 大文件边下载边保存,避免内存溢出
async function downloadLargeFile(fileUrl, fileName) {
  try {
    const response = await fetch(fileUrl);
    if (!response.ok) throw new Error("下载失败");
    if (!response.body) throw new Error("浏览器不支持流下载");
    // 1. 创建可写流,将数据写入Blob
    const blobWriter = new WritableStream({
      start() {
        this.blobParts = [];
      },
      write(chunk) {
        this.blobParts.push(chunk); // 逐块添加到Blob数组
      },
      close() {
        // 合并所有块为Blob,触发下载
        const blob = new Blob(this.blobParts);
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        a.download = fileName;
        a.click();
        URL.revokeObjectURL(url);
      },
    });
    // 2. 连接流:Fetch响应流 → 可写流(Blob)
    await response.body.pipeTo(blobWriter);
    console.log("文件下载完成");
  } catch (err) {
    console.error("下载错误:", err);
  }
}
// 调用:下载1GB的视频文件
downloadLargeFile("https://example.com/large-video.mp4", "my-video.mp4");

2. Stream + Web Worker:流处理不阻塞主线程

处理大文件流(如加密、解析)时,会占用大量 CPU,导致页面卡顿。将流处理逻辑放入 Web Worker,可避免阻塞主线程。

// 主线程:将文件流传给Worker处理
const fileInput = document.getElementById("file-input");
fileInput.addEventListener("change", async (e) => {
  const file = e.target.files[0];
  if (!file) return;
  const worker = new Worker("stream-processor.js");
  // 将文件流传给Worker(Stream可通过结构化克隆传递)
  worker.postMessage({ type: "PROCESS_STREAM", stream: file.stream() });
  // 接收Worker处理后的结果
  worker.onmessage = (e) => {
    if (e.data.type === "PROCESS_DONE") {
      const { resultBlob } = e.data;
      // 下载处理后的文件
      const url = URL.createObjectURL(resultBlob);
      const a = document.createElement("a");
      a.href = url;
      a.download = `processed-${file.name}`;
      a.click();
      URL.revokeObjectURL(url);
      worker.terminate();
    }
  };
});
// stream-processor.js(Web Worker)
self.onmessage = async (e) => {
  if (e.data.type !== "PROCESS_STREAM") return;
  const { stream } = e.data;
  const blobParts = [];
  // 1. 创建转换流(示例:给文本文件添加水印)
  const watermarkTransform = new TransformStream({
    transform(chunk, controller) {
      const decoder = new TextDecoder("utf-8");
      const encoder = new TextEncoder();
      // 给每块文本添加水印
      const text = decoder.decode(chunk) + "\n[已处理]";
      controller.enqueue(encoder.encode(text));
    },
  });
  // 2. 创建可写流,收集处理后的数据
  const writable = new WritableStream({
    write(chunk) {
      blobParts.push(chunk);
    },
    close() {
      // 生成处理后的Blob,发送给主线程
      const resultBlob = new Blob(blobParts, { type: "text/plain" });
      self.postMessage({ type: "PROCESS_DONE", resultBlob });
    },
  });
  // 3. 处理流:可读流 → 转换流 → 可写流
  await stream.pipeThrough(watermarkTransform).pipeTo(writable);
};

四、Stream API 的局限与注意事项

虽然 Stream API 很强大,但在使用时仍需注意以下问题:

  1. 兼容性:现代浏览器(Chrome 63+、Firefox 65+、Safari 14.1+)支持良好,但IE 完全不支持,旧版 Edge(≤18)也不支持。如需兼容,可使用@streamparser/json(JSON 流解析)、web-streams-polyfill(全量 polyfill)等库。
  1. 学习成本:相比 Blob 的 “创建→使用” 简单流程,Stream 涉及Readable/Writable/Transform三个对象,且需理解 “流状态(暂停 / 继续 / 关闭)”“背压(Backpressure,防止写入过快)” 等概念,逻辑更复杂。
  1. 错误处理:流处理过程中可能出现断流(如网络断开、文件损坏),需在start/transform/write方法中监听error事件,并手动处理重试逻辑(如重新连接流、跳过损坏块)。
  1. 服务器端配合:部分场景(如分片上传、SSE 流)需要后端支持对应的协议(如接收分片并合并、返回text/event-stream类型响应),前端单独使用 Stream 无法实现完整功能。

五、结语:Stream API 是前端的 “流处理专家”

Stream API 不是最炫酷的前端技术,没有 Vue/React 那样的生态,也没有 Tailwind 那样的易用性,但它却是处理大文件、实时数据的 “刚需工具”

它解决了 Blob、全量 JSON 等方案的核心痛点 —— 内存占用过高、实时性差,让前端能够从容应对 “大文件上传下载”“实时日志”“大 API 响应” 等复杂场景。

在现代 Web 开发中,理解并熟练使用 Stream API,不仅能提升项目的性能和用户体验,还能让你在处理 “海量数据” 时更有底气。

所以,下次遇到大文件或实时数据场景时,别忘了:Stream API 居然这么好用!