一、你遇到过的“小文件变卡、大文件崩了”的坑

你有没有过这种经历:做桌面端工具时,用户选了个几百兆甚至几G的大文件,比如要批量改文件名、提取里面的日志、或者把CSV转成JSON,结果软件直接卡成PPT,过一会还弹个“内存不足”的错误?

我之前做过一个给设计师用的PSD批量处理工具,用户传了个2.1G的PSD文件,我当时图省事,直接用Node.js的fs.readFile把整个文件读进内存,结果软件直接崩了——因为那时候内存占用飙到了3.2G,远超桌面端工具的常规内存限制。后来才明白,问题出在“一次性把整个大文件塞进内存”的蠢操作上。

那怎么解决?核心就是“异步流式处理”:不是把整个文件一口吞进肚子,而是像喝奶茶一样,一口一口(分块)喝,喝一口处理一口,处理完就吐掉(释放内存),全程只占很少的内存。

二、为什么选Electron的双流组合:Node.js流+Web Stream API

Electron的优势是能同时用Node.js和浏览器的能力,处理大文件时刚好能用上两套流:

  • Node.js流:负责和本地磁盘交互,比如读文件、写文件,速度快、稳定,适合处理本地大文件;
  • Web Stream API:负责把Node.js读出来的文件块,传给浏览器端处理(比如显示处理进度、让用户中途暂停),同时支持异步操作,不会卡界面。

这俩搭配起来,既能高效读写本地文件,又能保证界面流畅,还能把内存占用控制在几兆甚至几十兆,不管多大的文件都能处理。

三、完整实现:大文件异步流式处理的全流程

我拿一个“把大CSV转成JSON并统计行数”的例子来演示,这个例子覆盖了读文件、处理、写文件、显示进度的全流程,你可以直接套用到自己的需求里。

3.1 准备工作:初始化Electron项目

先搭个最简单的Electron项目,确保能跑起来。首先建个文件夹,然后在终端里执行:

# 初始化npm项目
npm init -y
# 安装electron
npm install electron --save-dev

然后在项目根目录建两个文件:

  • main.js:Electron的主进程(用Node.js流读写文件);
  • preload.js:预加载脚本(主进程和渲染进程的通信桥梁);
  • index.html:渲染进程(显示界面、进度)。

先写最简单的启动配置,在package.json里加个启动脚本:

{
  "name": "electron-stream-demo",
  "version": "1.0.0",
  "description": "",
  "main": "main.js",
  "scripts": {
    "start": "electron ."
  },
  "devDependencies": {
    "electron": "^28.0.0"
  }
}

3.2 核心实现:主进程的Node.js流读写

主进程的核心是用Node.js的fs.createReadStream读文件,fs.createWriteStream写文件,同时把处理进度传给渲染进程。

先写main.js

const { app, BrowserWindow, ipcMain } = require('electron');
const fs = require('fs');
const path = require('path');

// 创建窗口
function createWindow() {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'), // 加载预加载脚本
      contextIsolation: true, // 安全配置,隔离主进程和渲染进程
      nodeIntegration: false // 不允许渲染进程直接用Node.js,更安全
    }
  });
  win.loadFile('index.html');
}

app.whenReady().then(createWindow);

// 监听渲染进程的“开始转换”请求
ipcMain.handle('start-convert', async (event, inputPath, outputPath) => {
  // 1. 先获取文件总大小,用来算进度
  const fileStats = fs.statSync(inputPath);
  const totalSize = fileStats.size;
  let processedSize = 0; // 已处理的字节数
  let lineCount = 0; // 统计CSV的行数

  // 2. 创建读流:每次读1MB的块(可以自己调大小,比如2MB、4MB)
  const readStream = fs.createReadStream(inputPath, {
    highWaterMark: 1024 * 1024 // 1MB = 1024*1024字节,每次读这么多
  });

  // 3. 创建写流:用来写转换后的JSON
  const writeStream = fs.createWriteStream(outputPath);

  // 4. 监听读流的“数据”事件:每拿到一块数据就处理
  readStream.on('data', (chunk) => {
    // 把Buffer转成字符串(CSV是文本文件,用utf8编码)
    const csvChunk = chunk.toString('utf8');
    // 统计行数:每出现一个换行符就算一行
    lineCount += csvChunk.split('\n').length - 1;

    // 这里可以加自己的处理逻辑:比如把CSV转成JSON
    // 简单模拟:把CSV的每一行转成JSON对象
    const jsonChunk = csvChunk.split('\n').map(line => {
      const [name, age, city] = line.split(',');
      return { name, age: Number(age), city };
    }).filter(item => item.name); // 过滤空行

    // 把处理后的JSON块写入写流
    writeStream.write(JSON.stringify(jsonChunk) + '\n');

    // 更新已处理的字节数
    processedSize += chunk.length;
    // 计算进度(百分比)
    const progress = Math.round((processedSize / totalSize) * 100);
    // 把进度传给渲染进程,更新界面
    event.sender.send('update-progress', progress, lineCount);
  });

  // 5. 监听读流的“结束”事件:处理完所有数据
  readStream.on('end', () => {
    writeStream.end(); // 关闭写流
    // 告诉渲染进程处理完成
    event.sender.send('convert-complete', lineCount);
  });

  // 6. 监听错误事件:处理异常
  readStream.on('error', (err) => {
    event.sender.send('convert-error', err.message);
  });
  writeStream.on('error', (err) => {
    event.sender.send('convert-error', err.message);
  });
});

3.3 通信桥梁:预加载脚本

预加载脚本的作用是给渲染进程暴露主进程的通信方法,不用让渲染进程直接接触主进程,保证安全。写preload.js

const { contextBridge, ipcRenderer } = require('electron');

// 给渲染进程暴露安全的API
contextBridge.exposeInMainWorld('electronAPI', {
  // 开始转换的方法
  startConvert: (inputPath, outputPath) => ipcRenderer.invoke('start-convert', inputPath, outputPath),
  // 监听进度更新
  onUpdateProgress: (callback) => ipcRenderer.on('update-progress', (event, progress, lineCount) => callback(progress, lineCount)),
  // 监听转换完成
  onConvertComplete: (callback) => ipcRenderer.on('convert-complete', (event, lineCount) => callback(lineCount)),
  // 监听错误
  onConvertError: (callback) => ipcRenderer.on('convert-error', (event, errMsg) => callback(errMsg))
});

3.4 界面实现:渲染进程的Web Stream应用

渲染进程用Web Stream API来处理用户交互,比如选文件、显示进度,同时保证界面不会卡。写index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>大文件CSV转JSON工具</title>
  <style>
    .container { width: 600px; margin: 50px auto; }
    .progress-bar { width: 100%; height: 20px; background: #eee; border-radius: 10px; overflow: hidden; margin: 20px 0; }
    .progress-fill { height: 100%; background: #4CAF50; width: 0%; transition: width 0.1s; }
    .status { font-size: 16px; margin: 10px 0; }
  </style>
</head>
<body>
  <div class="container">
    <h1>大文件CSV转JSON工具</h1>
    <button id="selectFile">选择CSV文件</button>
    <div class="progress-bar">
      <div class="progress-fill" id="progressFill"></div>
    </div>
    <div class="status" id="status">等待选择文件...</div>
  </div>

  <script>
    // 绑定按钮点击事件
    document.getElementById('selectFile').addEventListener('click', async () => {
      // 1. 让用户选择输入文件(CSV)
      const [inputHandle] = await window.showOpenFilePicker({
        types: [
          {
            description: 'CSV文件',
            accept: { 'text/csv': ['.csv'] }
          }
        ]
      });
      const inputPath = await inputHandle.getFile().then(file => file.path);

      // 2. 让用户选择输出文件(JSON)
      const outputHandle = await window.showSaveFilePicker({
        types: [
          {
            description: 'JSON文件',
            accept: { 'application/json': ['.json'] }
          }
        ]
      });
      const outputPath = await outputHandle.getFile().then(file => file.path);

      // 3. 调用主进程开始转换
      window.electronAPI.startConvert(inputPath, outputPath);
    });

    // 监听进度更新
    window.electronAPI.onUpdateProgress((progress, lineCount) => {
      document.getElementById('progressFill').style.width = `${progress}%`;
      document.getElementById('status').textContent = `处理中:${progress}%,已处理行数:${lineCount}`;
    });

    // 监听转换完成
    window.electronAPI.onConvertComplete((lineCount) => {
      document.getElementById('status').textContent = `处理完成!总共有${lineCount}行`;
      alert('转换完成!');
    });

    // 监听错误
    window.electronAPI.onConvertError((errMsg) => {
      document.getElementById('status').textContent = `出错:${errMsg}`;
      alert(`处理出错:${errMsg}`);
    });
  </script>
</body>
</html>

现在你可以在终端执行npm start启动项目,选一个大的CSV文件(比如几百兆甚至几G的),会发现界面不会卡,进度条会慢慢涨,内存占用一直控制在几兆到几十兆之间,不会崩。

四、应用场景:什么时候该用这种流式处理?

不是所有情况都需要用流式处理,只有遇到下面这些场景时,才值得用:

  1. 大文件处理:比如几百兆以上的文本、日志、CSV、JSON、图片(比如批量压缩图片)、视频(比如批量转码);
  2. 低内存设备:比如给老旧电脑做工具,内存本来就小,不能一次性加载大文件;
  3. 需要实时反馈:比如处理文件时要显示进度、让用户中途暂停、取消,或者实时预览处理结果;
  4. 批量处理大量小文件:比如一次处理几千个小文件,每个文件都读进内存的话,总内存也会很高,流式处理可以逐个处理,处理完就释放。

五、技术优缺点:流式处理的得与失

5.1 优点

  • 内存占用极低:不管多大的文件,只占几兆到几十兆的内存,不会出现内存不足的问题;
  • 界面流畅:因为是异步处理,不会阻塞主进程和渲染进程,用户操作界面不会卡;
  • 实时反馈:可以随时获取处理进度、中间结果,支持中途暂停、取消;
  • 支持断点续传:如果处理中途出错,可以从上次处理的位置继续,不用从头开始。

5.2 缺点

  • 实现复杂:比一次性读文件麻烦,要处理流的各种事件(data、end、error),还要处理边界情况(比如文件块刚好在换行符中间);
  • 处理逻辑受限:有些处理逻辑必须要整个文件的内容才能做,比如排序、全文搜索(要找某个关键词的位置),这种情况流式处理就不太方便;
  • 调试困难:因为是分块处理,很难跟踪整个文件的处理流程,出错时不好定位问题。

六、注意事项:踩过的坑和避坑指南

  1. 分块大小要合适highWaterMark(每次读的块大小)不能太小,比如1KB的话,读文件的次数会太多,速度会很慢;也不能太大,比如1G的话,又会占太多内存。一般设成1MB到4MB之间最合适。
  2. 处理边界情况:比如CSV的一行刚好被分成两个块,比如第一块的结尾是“张三,20,北”,第二块的开头是“京”,这时候直接把两个块的字符串拼接起来就会出错。解决方法是:每处理完一个块,检查结尾是不是完整的行,如果不是,就把不完整的部分留到下一个块再拼接。
  3. 错误处理要完善:流的每个环节都可能出错,比如读文件时磁盘坏了、写文件时磁盘满了、处理时逻辑出错,都要监听error事件,及时告诉用户,还要释放资源(比如关闭读流、写流)。
  4. 安全配置要注意:Electron的contextIsolationnodeIntegration要设对,不要让渲染进程直接接触Node.js,避免安全漏洞。
  5. 异步逻辑要理清:因为流是异步的,不能用同步的思维来写代码,比如不能等流处理完再执行后面的代码,要通过事件来触发后续操作。

七、文章总结

处理大文件时,一次性把整个文件塞进内存是最蠢的做法,会导致内存不足、界面卡顿甚至软件崩溃。用Electron的Node.js流+Web Stream API的组合,异步分块处理大文件,既能高效读写本地文件,又能保证界面流畅,还能把内存占用控制在极低的水平。

这种方法适合大文件处理、低内存设备、实时反馈等场景,虽然实现起来比一次性读文件麻烦,但能解决大文件处理的核心问题。只要注意分块大小、边界情况、错误处理等细节,就能写出稳定、高效的大文件处理工具。