在 Node.js 中做文件复制,早期常用 fs.copyFile 处理单个文件,遇到目录则需要自己遍历子目录并逐层创建。从 Node.js 16.7.0 开始,fs 模块新增了 fs.cp 方法,专门用于复制文件或目录,配合 recursive 选项可以一次完成目录树的递归复制,并用 force 选项统一控制目标已存在时的覆盖行为。这个方法同时提供回调风格与 Promise 风格,能简化备份脚本、构建产物复制和项目模板生成等任务。

下面从参数语义、覆盖策略、过滤机制以及与其他方案对比几个角度具体说明。
一、fs.cp 的基本用法与参数语义
fs.cp 的签名是 fs.cp(src, dest[, options], callback),其中 src 是源路径,dest 是目标路径,options 是可选配置对象,callback 是完成后的回调函数。Node.js 在 Promise 版本中对应 fs.promises.cp,不再需要 callback,而是返回一个 Promise 对象。最基本的文件复制只需要两个路径参数,默认情况下如果目标文件已存在,会直接覆盖。
要复制目录,必须把 options.recursive 设置为 true。否则当 src 是目录时,Node.js 会抛出 ERR_FS_EISDIR 错误。recursive 为 true 后,fs.cp 会递归遍历源目录,创建不存在的子目录,并把文件逐个复制过去。目标目录本身也可以不存在,fs.cp 会自动创建;如果目标目录已经存在,fs.cp 会把源目录中的内容合并进去,但不会删除目标目录里原本就有的其他文件。
const fs = require('fs');
// 复制单个文件,目标存在时默认覆盖
fs.cp('./config.json', './backup/config.json', (err) => {
if (err) {
console.error('复制失败:', err.message);
return;
}
console.log('单个文件复制完成');
});
// 递归复制整个目录
fs.cp('./project', './dist', { recursive: true }, (err) => {
if (err) {
console.error('目录复制失败:', err.message);
return;
}
console.log('目录递归复制完成');
});
上面第二段代码演示了目录递归复制。调用时没有设置 force,因此 force 取默认值 true,同名文件会被直接覆盖。这种方式适合构建目录输出,比如每次构建前不需要手动清理 dist 目录,但要注意 dist 中残留的旧文件不会被自动清除,只能保证源目录中的同名文件被新版本覆盖。
二、覆盖策略:force 与 errorOnExist 的区别
fs.cp 的 options.force 默认值是 true,表示允许覆盖目标位置已有的文件。如果把 force 设置为 false,而目标文件已经存在,方法会报错,阻止复制继续。errorOnExist 选项只有在 force 为 false 时才有意义,它可以让报错行为更明确。force 为 false 且 errorOnExist 为 true 时,一旦检测到目标存在,就会抛出错误;如果 force 为 true,即使 errorOnExist 为 true 也会被忽略。
这种设计适合安全备份场景。比如你想把当前版本备份到 backup 目录,但不想覆盖上一次备份中的同名文件,就可以使用 force: false 和 errorOnExist: true。不过需要区分文件和目录:当源是目录且目标目录已经存在时,fs.cp 并不会因为目标目录存在而报错,它仍然会进入目录并逐个检查文件,只有发现同名文件且 force 为 false 时才会报错。也就是说,目录本身的合并并不会被 force 阻止。
const fs = require('fs');
async function safeBackup() {
try {
await fs.promises.cp('./data', './snapshot', {
recursive: true,
force: false,
errorOnExist: true,
preserveTimestamps: true
});
console.log('安全备份完成,未覆盖任何已有文件');
} catch (err) {
console.error('备份中止:', err.message);
}
}
safeBackup();
上面的 Promise 版本使用 async/await,当 data 目录下的某个文件在 snapshot 中已存在时,复制会中止并抛出错误。这种方式能防止误覆盖,但有一个副作用:如果已经复制了一部分文件后才遇到冲突,前面复制成功的文件会保留在目标目录中。因此如果要求整个备份是原子性的,还需要自己再包一层临时目录,复制成功后再重命名替换。
三、用 filter 控制递归范围
递归复制目录时,并不是所有文件都需要带到目标位置。比如复制 Node.js 项目时,node_modules 和 .git 目录往往应该跳过,因为它们体积大,而且目标环境通常会重新安装依赖。fs.cp 提供了 filter 选项,可以传入一个函数,函数接收源路径和目标路径两个参数,返回 true 表示复制该条目,返回 false 表示跳过。
filter 在递归过程中对每个文件和目录都会被调用。如果对某个目录返回 false,这个目录及其所有子内容都不会被复制。过滤逻辑可以基于路径字符串,也可以使用 path.basename 获取条目名称。需要注意的是,filter 得到的是完整路径,判断目录名时最好结合 path.basename,避免因为父目录名称恰好相同而误判。
const fs = require('fs');
const path = require('path');
async function copySourceOnly() {
await fs.promises.cp('./workspace', './export', {
recursive: true,
force: true,
filter: (src) => {
const name = path.basename(src);
return name !== 'node_modules' && name !== '.git';
}
});
console.log('已跳过 node_modules 和 .git');
}
copySourceOnly();
这个例子中,filter 回调只需要源路径即可判断。所有名为 node_modules 或 .git 的目录都会被跳过,无论它们位于哪一层。filter 也可以用来复制特定扩展名文件,例如只复制 .js 和 .json 文件,这时可以结合 fs.statSync 判断类型,但要注意统计信息会带来额外 I/O 开销,在大量小文件场景下可能拖慢复制速度。
四、符号链接、时间戳与复制行为
目录中经常包含符号链接,Node.js 的 fs.cp 默认不会解引用符号链接,而是尽量复制链接本身。options.dereference 可以改变这一行为,设置为 true 后,复制过程中会解析符号链接并复制链接指向的实际文件或目录。options.verbatimSymlinks 默认值是 false,如果设为 true,则复制符号链接时保持原样而不做路径调整。这两个选项通常不需要同时设置,dereference 的优先级更高。
另一个实用选项是 preserveTimestamps,默认 false。设置为 true 后,复制生成的文件和目录会保留源文件的时间戳,而不是使用复制操作发生的时间。对于备份、归档或需要依赖修改时间判断增量构建的场景,保留时间戳非常重要。比如前端构建工具利用文件 mtime 判断缓存是否失效,如果复制后时间戳全变了,可能会导致后续增量流程误判。
const fs = require('fs');
fs.promises.cp('./release', './archive', {
recursive: true,
force: true,
dereference: true,
preserveTimestamps: true
}).then(() => {
console.log('归档完成,已解析符号链接并保留时间戳');
}).catch((err) => {
console.error('归档失败:', err.message);
});
当源目录里存在循环符号链接时,如果同时开启递归复制和解引用,理论上可能出现无限循环。Node.js 内部对递归深度和符号链接处理有一定保护,但为了避免不可控行为,更稳妥的做法是先用 filter 排除已知循环链接,或者明确设置 dereference: false,让链接保持为链接。
五、与手动递归及 fs-extra 的对比
在 fs.cp 出现之前,手动递归复制目录需要先用 fs.readdir 读取目录,再对每个条目用 fs.stat 判断是文件还是目录,文件用 fs.copyFile,目录则递归调用自身。这套流程代码不少,还要处理目标目录创建、错误回滚、过滤和覆盖判断。fs.cp 把这些逻辑收进底层实现,调用更简洁,同时在 libuv 层做目录遍历,省去在 JavaScript 中多次回调的往返成本。
社区常用的 fs-extra 库也提供 copy 方法,能力比 fs.cp 更丰富,例如支持 glob 风格过滤、覆盖控制、错误回调等。但 fs-extra 是第三方依赖,如果项目不希望为了复制功能引入额外包,或者对启动体积有要求,Node.js 内置的 fs.cp 已经满足多数常见需求。对于需要符号链接复制、时间戳保留和递归过滤的场景,fs.cp 的参数也足够覆盖。
const fs = require('fs');
const fse = require('fs-extra');
// 内置 fs.promises.cp
async function builtinCopy() {
await fs.promises.cp('./src', './out', {
recursive: true,
force: true
});
}
// fs-extra 的 copy
async function extraCopy() {
await fse.copy('./src', './out', {
overwrite: true,
filter: (src) => !src.includes('node_modules')
});
}
builtinCopy().then(extraCopy);
两者在选择时可以先确认 Node.js 版本是否包含 fs.cp。对于维护旧版本运行时的项目,建议继续使用 fs-extra 或手动实现兼容层;对于新项目,直接使用内置方法可以减少依赖数量。需要注意的是,fs-extra.copy 的 filter 参数签名和 fs.cp 略有不同,迁移时不能直接照搬过滤函数。