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读写每块的大小。
相关推荐
乌兰麦朵4 分钟前
Vue吹的颅内高潮,全靠选择性失明和 .value 的PUA!
前端·vue.js
Code季风4 分钟前
Gin Web 层集成 Viper 配置文件和 Zap 日志文件指南(下)
前端·微服务·架构·go·gin
蓝倾4 分钟前
如何使用API接口实现淘宝商品上下架监控?
前端·后端·api
舂春儿6 分钟前
如何快速统计项目代码行数
前端·后端
毛茸茸6 分钟前
⚡ 从浏览器到编辑器只需1秒,这个React定位工具改变了我的开发方式
前端
Pedantic7 分钟前
我们什么时候应该使用协议继承?——Swift 协议继承的应用与思
前端·后端
Software攻城狮8 分钟前
vite打包的简单配置
前端
Codebee8 分钟前
如何利用OneCode注解驱动,快速训练一个私有的AI代码助手
前端·后端·面试
流星稍逝8 分钟前
用vue3的写法结合uniapp在微信小程序中实现图片压缩、调整分辨率、做缩略图功能
前端·vue.js
知了一笑11 分钟前
独立开发问题记录-margin塌陷
前端