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读写每块的大小。
相关推荐
小约翰仓鼠5 分钟前
vue3子组件获取并修改父组件的值
前端·javascript·vue.js
Lin Hsüeh-ch'in7 分钟前
Vue 学习路线图(从零到实战)
前端·vue.js·学习
烛阴23 分钟前
bignumber.js深度解析:驾驭任意精度计算的终极武器
前端·javascript·后端
计蒙不吃鱼30 分钟前
一篇文章实现Android图片拼接并保存至相册
android·java·前端
全职计算机毕业设计1 小时前
基于Java Web的校园失物招领平台设计与实现
java·开发语言·前端
你的人类朋友1 小时前
✍️Node.js CMS框架概述:Directus与Strapi详解
javascript·后端·node.js
啊~哈1 小时前
vue3+elementplus表格表头加图标及文字提示
前端·javascript·vue.js
小小小小宇2 小时前
前端小tips
前端
小小小小宇2 小时前
二维数组按顺时针螺旋顺序
前端
安木夕2 小时前
C#-Visual Studio宇宙第一IDE使用实践
前端·c#·.net