前言
在electron项目中,拷贝大文件时,如果不考虑显示进度情况, 直接使用 fs.cp 函数通常会有更好的性能,原因如下:
-
底层优化 : fs.cp (Node.js v16.7.0+)是官方提供的高层API,底层实现会根据操作系统自动选择最优的复制方式(如 copy_file_range 、 sendfile 等),通常比手动用 fs.createReadStream 和 fs.createWriteStream 实现的流式复制更快。
-
更少的JavaScript开销 : fs.cp 大部分操作在C++层完成,减少了JavaScript层的数据调度和事件监听的开销。
-
简化代码 : 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优化之后效果还是挺明显的。
总结
- 拷贝不需要考虑进度,则直接使用fs.cp
- 拷贝需要显示进度,使用pipe进行读写,动态控制highWaterMark读写每块的大小。