node大文件拷贝优化(显示进度)

前言

在electron项目中,拷贝大文件时,如果不考虑显示进度情况, 直接使用 fs.cp 函数通常会有更好的性能,原因如下:

  1. 底层优化 : fs.cp (Node.js v16.7.0+)是官方提供的高层API,底层实现会根据操作系统自动选择最优的复制方式(如 copy_file_range 、 sendfile 等),通常比手动用 fs.createReadStream 和 fs.createWriteStream 实现的流式复制更快。

  2. 更少的JavaScript开销 : fs.cp 大部分操作在C++层完成,减少了JavaScript层的数据调度和事件监听的开销。

  3. 简化代码 : fs.cp 用法简单,出错点更少。

小结: 绝大多数场景下, fs.cp 会比流式复制更快,尤其是大文件或大量小文件时。

但是我们的场景需要显示拷贝的进度,所以只能通过创建可读写、可写流来监听拷贝的进度。

初始版本

Typescript 复制代码
/**
 * 复制文件并显示进度
 *
 * @param src 源文件路径
 * @param dest 目标文件路径
 * @param callback 进度回调函数,参数为进度百分比(保留两位小数),可选
 * @returns 返回一个Promise,成功时resolve为true,失败时reject为错误对象
 */
export async function copyFileWithProgress(src: string, dest: string, callback?: (progress: number, total: number, loaded: number) => void): Promise<string> {
  const stat = await fs.promises.stat(src);
  const totalSize = stat.size;
  let copiedSize = 0;
  const isShowProgress = !!callback // 是否显示进度

  return new Promise((resolve, reject) => {
    const readStream = fs.createReadStream(src);
    const writeStream = fs.createWriteStream(dest);

    if (isShowProgress) {
      readStream.on('data', (chunk) => {
        copiedSize += chunk.length;
        const progress = (copiedSize / totalSize) * 100;
        callback?.(Math.floor(progress * 100) / 100, totalSize, copiedSize);

        writeStream.write(chunk)

        // 检查是否已经复制完成
        if(copiedSize === totalSize) {
          writeStream.close()
        }
      });
    }

    writeStream.on('close', () => resolve(dest));
    writeStream.on('error', reject);
    readStream.on('error', reject);
  })
}

优点:

  • 计算进度时,只需要累加长度(length),而不是直接存储chunk,减少内存的占用。

不足:

  • writeStream.write(chunk)直接写入会占用极大的内存,特别是拷贝大文件的时候,很容易内存爆表

所以可以对于内存进行优化。

第二版

typescript 复制代码
/**
 * 复制文件并报告进度
 *
 * @param src 源文件路径
 * @param dest 目标文件路径
 * @param callback 进度回调函数,参数为进度百分比(保留两位小数),可选
 * @returns 返回一个Promise,成功时resolve为true,失败时reject为错误对象
 */
export function copyFileWithProgress(src: string, dest: string, callback?: (progress: string) => void): Promise<boolean> {
	return new Promise((resolve, reject) => {
        const stat = fs.statSync(src);
        const totalSize = stat.size;
        let copiedSize = 0;

        const readStream = fs.createReadStream(src);
        const writeStream = fs.createWriteStream(dest);

        readStream.on('data', (chunk) => {
            copiedSize += chunk.length;
            const progress = (copiedSize / totalSize) * 100;
            callback?.(progress.toFixed(2));
            Logger.log(`复制文件进度: ${progress.toFixed(2)}%`);
        });

        readStream.pipe(writeStream);

        writeStream.on('finish', () => {
            Logger.log('【copyFileWithProgress】File copied successfully.');
        });

        writeStream.on('close', () => {
            resolve(true);
        })

        writeStream.on('error', (err) => {
            reject(err)
            Logger.error('【copyFileWithProgress】Error writing file:', err);
        });

        readStream.on('error', (err) => {
            reject(err)
            Logger.error('【copyFileWithProgress】Error reading file:', err);
        });
    })
}

优化点:

  • 使用到了pipe管道,以流的形式实现一边读一边写,占用内存更小。

不足:

  • pipe管道分片读写时,每次读写内容太小,会导致读写次数太多,造成时间耗费过长。

pipe其实就是将流文件拆分成为一小块一小块流动起来,而无需一次性全部读入。可以看下面图,应该很好理解。

一次性读入:

pipe管道:

最终版

typescript 复制代码
import Logger from "electron-log";
import fs from "fs";

/**
 * 复制文件并显示进度
 *
 * @param src 源文件路径
 * @param dest 目标文件路径
 * @param callback 进度回调函数,参数为进度百分比(保留两位小数),可选
 * @returns 返回一个Promise,成功时resolve为true,失败时reject为错误对象
 */
export async function copyFileWithProgress(src: string, dest: string, callback?: (progress: number) => void): Promise<string> {
  const stat = await fs.promises.stat(src);
  const totalSize = stat.size;
  let copiedSize = 0;
  const isShowProgress = !!callback // 是否显示进度
  const blockSize = await getHighWaterMarkBlockSize(src)

  return new Promise((resolve, reject) => {
    const readStream = fs.createReadStream(src, { highWaterMark: blockSize });
    const writeStream = fs.createWriteStream(dest);

    const startTime = process.hrtime.bigint();

    let isDone = false;

    const safeResolve = (result: string) => {
      if (!isDone) {
        isDone = true;
        resolve(result);
      }
    };

    const safeReject = (err: Error) => {
      if (!isDone) {
        isDone = true;
        reject(err);
        Logger.error('【copyFileWithProgress】文件拷贝错误:', err);
      }
    };

    // 【性能优化】显示进度时,监听数据事件以计算进度
    if (isShowProgress) {
      readStream.on('data', (chunk) => {
        copiedSize += chunk.length;
        const progress = (copiedSize / totalSize) * 100;
        callback?.(Math.floor(progress * 100) / 100);
        // Logger.log(`复制文件进度: ${progress.toFixed(2)}%`);
      });
    }

    writeStream.on('finish', () => {
      const durationMilliseconds = Number(process.hrtime.bigint() - startTime) / 1e6;
      Logger.info(`【copyFileWithProgress】⌛️文件复制完成!总耗时:${durationMilliseconds.toFixed(3)} 毫秒`);
    });

    writeStream.on('close', () => safeResolve(dest));
    writeStream.on('error', safeReject);
    readStream.on('error', safeReject);

    readStream.pipe(writeStream);
  })
}



/**
 * 获取文件合适的 highWaterMark 块大小
 * @param filePath 文件路径
 * @returns 返回一个 Promise,成功时resolve为true,失败时reject为错误对象
 */
export async function getHighWaterMarkBlockSize(filePath: string): Promise<number> {
	let blockSize = 64 * 1024; // 默认 64 KB

	try {
		if (!filePath || !fs.existsSync(filePath)) {
			Logger.error(`【getHighWaterMarkBlockSize】路径文件不存在:${filePath}`);
			return blockSize
		}

		const stat = await fs.promises.stat(filePath);
		// 自动决定 highWaterMark 块大小(更高性能)
		const sizeMB = stat.size / (1024 * 1024);

		if (sizeMB > 500) {
			blockSize = 2 * 1024 * 1024; // 2 MB
		} else if (sizeMB > 10) {
			blockSize = 512 * 1024; // 512 KB
		}
	} catch (error) {
		Logger.error(`【getHighWaterMarkBlockSize】获取文件大小失败:${error}`);
	}

	return blockSize;
}

优点:

  • createReadStream默认设定highWaterMark是64k,通过动态控制highWaterMark大小减少读写次数。
  • 针对不同文件大小可以按照下面这个规则进行设置
文件大小 建议 highWaterMark 说明
< 10 MB 默认即可(64 KB) 不明显影响性能,避免浪费内存
10MB -- 500MB 512 KB - 1 MB 明显减少系统调用次数,提升吞吐
> 500MB 2 MB 或更大(2048 * 1024) 大幅提升性能,但注意内存占用

测试

针对优化2版本和最终版本使用同一个文件各自进行8次测试。

优化前:

测试1: 文件大小:251M 耗时:423ms
测试2: 文件大小:251M 耗时:401ms
测试3: 文件大小:251M 耗时:543ms
测试4: 文件大小:251M 耗时:489ms
测试5: 文件大小:251M 耗时:411ms
测试6: 文件大小:251M 耗时:457ms
测试7: 文件大小:251M 耗时:477ms
测试8: 文件大小:251M 耗时:533ms

优化后:

测试1: 文件大小:251M 耗时:201ms
测试2: 文件大小:251M 耗时:299ms
测试3: 文件大小:251M 耗时:250ms
测试4: 文件大小:251M 耗时:197ms
测试5: 文件大小:251M 耗时:211ms
测试6: 文件大小:251M 耗时:244ms
测试7: 文件大小:251M 耗时:210ms
测试8: 文件大小:251M 耗时:199ms

对highWaterMark优化之后效果还是挺明显的。

总结

  1. 拷贝不需要考虑进度,则直接使用fs.cp
  2. 拷贝需要显示进度,使用pipe进行读写,动态控制highWaterMark读写每块的大小。
相关推荐
crary,记忆37 分钟前
Angular微前端架构:Module Federation + ngx-build-plus (Webpack)
前端·webpack·angular·angular.js
漂流瓶jz1 小时前
让数据"流动"起来!Node.js实现流式渲染/流式传输与背后的HTTP原理
前端·javascript·node.js
SamHou01 小时前
手把手 CSS 盒子模型——从零开始的奶奶级 Web 开发教程2
前端·css·web
我不吃饼干2 小时前
从 Vue3 源码中了解你所不知道的 never
前端·typescript
开航母的李大2 小时前
【中间件】Web服务、消息队列、缓存与微服务治理:Nginx、Kafka、Redis、Nacos 详解
前端·redis·nginx·缓存·微服务·kafka
Bruk.Liu2 小时前
《Minio 分片上传实现(基于Spring Boot)》
前端·spring boot·minio
星辰离彬2 小时前
Java 与 MySQL 性能优化:MySQL 慢 SQL 诊断与分析方法详解
java·spring boot·后端·sql·mysql·性能优化
鱼樱前端2 小时前
Vue3+d3-cloud+d3-scale+d3-scale-chromatic实现词云组件
前端·javascript·vue.js
zhangxingchao3 小时前
Flutter入门:Flutter开发必备Dart基础
前端