命令行批量重命名这事我前前后后折腾过不下十次。每次从相机导出一堆照片或者接过一个遗留项目里乱糟糟的文件目录总得打开某个图形化工具或者临时网上搜一段脚本改改参数、跑一遍、报错、再改一遍。直到某次为了整理三百多个素材文件一个晚上把这些问题全踩了一遍才下定决心自己写一个顺手的工具。这个工具就是 rea——Rename Everything Automatically 的缩写一个特别朴素的批量文件重命名工具。它不依赖任何第三方库一个 JavaScript 文件就能跑支持正则匹配、预览、模拟执行、分组捕获、冲突检测。如果你平时经常跟文件打交道或者也想动手写一个适合自己的小 CLI 工具这篇内容应该能给你一些可复现的思路和完整代码。rea 的目标很单纯用一条命令完成批量重命名并且保证在真正动手之前你能清清楚楚看到每条改名规则会产生什么结果。核心原则是可预览、可回滚、可追溯。我不追求做成一个多么强大的瑞士军刀只求在需要的时候能立刻想起它、两秒内用起来而且不会因为手滑把文件改坏。1. 项目整体设计与思路拆解1.1 为什么我又造了一个轮子说实话市面上处理批量重命名的方案并不少。操作系统自带的批量重命名功能只能做简单的编号替换规则极其有限碰到把文件名里的日期从 20240101 改成 2024-01-01这种需求就直接歇菜。图形化的管理工具功能倒是强但每次都要打开界面、框选文件、找对应按钮遇到上百个文件时手指和鼠标都很累。更麻烦的是这些工具的规则是点出来的很难沉淀成可复用的命令。也有人选择每次临时写 Python 脚本。我一开始也是这么干的但用几次就发现问题了脚本散落在各种临时目录里下次用时要重新翻找不同脚本处理边界的方式不一致一个文件重名会报错另一个却会静默覆盖更重要的是脚本里的正则写错了要么批量改坏要么跑完才发现结果不对。rea 就是冲着这几个痛点去的把最常用的重命名逻辑收敛成一个固定命令所有规则显式写在命令行里结果先预览后执行。1.2 rea 的定位和设计目标设计 rea 之前我给自己定了几条硬性要求。第一零依赖一个文件拷贝到任何一台机器上都能跑不需要npm install不需要配环境变量。第二跨平台Windows、macOS、Linux 的行为必须一致不能出现同一个规则在不同系统上结果不同。第三安全优先默认只做模拟执行真正执行必须显式加参数确认。第四输出可读每一条重命名都要打出原名 - 新名一眼就能审计。基于这些约束我选择了 Node.js 而不是 Python。原因很实际Node.js 的单文件脚本在三大平台上的行为一致性更好路径处理模块足够稳而且#!/usr/bin/env node这种 shebang 写法在类 Unix 系统下可以直接当命令用Windows 下也可以配合批处理包装。Python 也很好但环境差异2/3 版本、编码默认值容易在分发时引入额外麻烦。这个选择不一定适合所有人但对我这种偶尔写脚本、不想维护环境的场景是最省心的。2. 核心细节解析与实操要点2.1 重命名的本质字符串变换加文件系统操作很多人把批量重命名想得很复杂其实拆开看就两件事把旧文件名通过某种规则变成新文件名再把磁盘上的文件真实改名。rea 的核心就是一条正则替换链路但这条链路有不少细节要注意。首先是扩展名问题。绝大多数重命名需求都希望保留扩展名不变比如report_001.pdf改成final_report_001.pdf。但如果直接用整条文件名做正则很容易误伤扩展名。我的做法是把文件名拆成base和ext两部分正则只作用在base上替换完成后再把扩展名拼回去。这里有个隐藏细节path.extname对.tar.gz这种复合扩展名只会识别成.gz所以 rea 里我额外处理了常见复合扩展名避免把archive.tar.gz的.tar部分当成文件名主体折腾。第二个细节是大小写和 Unicode 规范化。macOS 的文件系统默认对文件名做某种形式的 Unicode 规范化而 Linux 不做这就导致同一个中文文件在 Mac 上创建、拷贝到 Linux 上之后可能出现视觉上看一模一样但实际字节不同的名字。rea 在比较新旧名字时统一做了一次标准化能减少很多莫名奇妙的冲突误报。这个问题在跨平台处理素材时特别容易踩到。2.2 冲突检测与双阶段提交批量重命名最危险的操作是新名字互相撞车。比如你想把所有*.jpg改成photo_1.jpg如果原文件是a.jpg、b.jpg规则变成前面加 photo_那没问题但如果规则是全部改成 photo.jpg那第二个文件就会覆盖第一个。更隐蔽的是中间状态碰撞比如要把a.txt改成b.txt同时把b.txt改成a.txt这两条规则如果逐个执行第一步就把原来的b.txt覆盖了随后第二步又找不到源文件结果文件直接消失。rea 的解决方案分成两层。第一层是目标名冲突检测把所有新名字放进一个集合如果出现重复就报错并中止。第二层是双阶段提交先检查是否存在规则 A 的目标名恰好是规则 B 的源名这种情况如果存在就先把其中一个文件改成一个随机临时名再执行后续操作最后改回来。这就像搬家具大件挪不动的时候先在中间放一个缓冲位而不是直接往同一个位置推。代码层面实现也不复杂核心就是一个 Map 记录每个旧名字对应的新名字再判断目标名集合和源名集合的交集。2.3 跨平台文件名合法性校验不同操作系统对文件名的合法字符要求不一样这是 rea 里我花时间最多的地方。Windows 不允许文件名包含 : / \ | ? *这九个字符也不允许以点或者空格结尾还有一批保留设备名CON、PRN、AUX、NUL、COM1到COM9、LPT1到LPT9不能用。macOS 和 Linux 相对宽松但正斜杠和空字符是所有平台都不允许的。rea 的做法是统一按最严格的 Windows 规则做校验因为一个重命名工具如果生成的目录拷贝到 Windows 上打不开那这个改名就是失败的。校验不通过时我不会直接跳过而是明确报错并告诉你哪个字符有问题这样你能立刻调整规则。选项里也留了一个--force-windows-safe的开关默认开着关掉后只做基础的不允许空字符和斜杠的检查适合纯 Linux 环境下使用。3. 实操过程从头实现 rea 的核心功能3.1 项目结构与入口设计我选择了最朴素的单文件结构整个工具就一个rea.js。这样做的好处是分发极其简单scp rea.js userhost:/usr/local/bin/rea chmod x完事。Windows 用户也可以扔进任意目录用一个rea.bat包装一下node rea.js %*就能用。文件内部按功能拆成了三个模块CLI 参数解析、文件遍历、重命名引擎。虽然都在一个文件里但用注释做了明显的分区方便以后抽离成多文件。入口部分长这样#!/usr/bin/env node const fs require(fs); const path require(path); function main() { const args parseArgs(process.argv.slice(2)); if (args.help) { printHelp(); return; } if (args.version) { console.log(rea 0.3.0); return; } const items collectFiles(args.dir, args.recursive); const plan buildRenamePlan(items, args.pattern, args.replacement); if (plan.errors.length 0) { plan.errors.forEach((e) console.error([错误] ${e})); process.exit(1); } if (args[dry-run] || !args.execute) { printPlan(plan); console.log(模拟执行共 ${plan.changes.length} 条改动使用 --execute 真正执行。); return; } executePlan(plan); } main();这个入口的逻辑很直白解析参数、收集文件、生成改名计划、检查错误、模拟执行或者真实执行。我特意把生成计划和执行分开因为这是整个工具安全性的核心——所有决策都在计划阶段完成执行阶段不做任何判断只机械地照做。3.2 CLI 参数解析手写一个够用的解析器没有用任何参数解析库因为需求太简单了。支持的参数包括--dir指定目录、--recursive递归子目录、--pattern正则表达式、--replacement替换表达式、--dry-run模拟执行、--execute真实执行、--limit限制最多处理多少条、--help帮助。手写解析的好处是零依赖坏处是要注意各种边界function parseArgs(argv) { const args { dir: ., recursive: false, dry-run: true, execute: false, limit: Infinity }; for (let i 0; i argv.length; i) { const a argv[i]; if (a --dir) args.dir argv[i]; else if (a --recursive) args.recursive true; else if (a --pattern) args.pattern argv[i]; else if (a --replacement) args.replacement argv[i]; else if (a --execute) { args.execute true; args[dry-run] false; } else if (a --limit) args.limit parseInt(argv[i], 10); else if (a --help) args.help true; else if (a.startsWith(--)) { console.error(未知参数: ${a}); process.exit(1); } else if (!args.pattern) args.pattern a; else if (!args.replacement) args.replacement a; else console.error(多余参数: ${a}); } return args; }位置参数的设计也考虑过要不要最后决定支持省略--pattern和--replacement直接传两个位置参数的写法因为这种命令最顺手rea IMG_(\d) pic_$1 --execute。注意正则里的反斜杠在 shell 里需要转义所以实际命令里会写成IMG_(\\d)这在帮助信息里专门标注了算是新手最容易卡住的地方。3.3 重命名引擎正则替换与占位符展开核心引擎函数的实现其实很短但细节都在占位符展开上。用户写的 replacement 字符串里可能出现$1、$2这样的分组引用也可能出现{n}这种自增序号。我用一个函数统一处理function applyReplacement(base, pattern, replacement, seq) { const regex new RegExp(pattern, g); let result base.replace(regex, (match, ...groups) { let out replacement; // 支持 $1 $2 分组引用 groups.forEach((g, i) { out out.split($ (i 1)).join(g undefined ? : g); }); // 支持 {n} 自增序号{n:3} 表示三位零填充 out out.replace(/\{n(?::(\d))?\}/g, (_, width) { const num String(seq); if (width) return num.padStart(parseInt(width, 10), 0); return num; }); return out; }); return result; }这段代码着重处理了一个意外情况如果正则匹配到多次而 replacement 里有自增序号那么每次匹配都应该使用不同的序号。我通过闭包里的seq变量来实现每次匹配成功seq自增。这个需求来源于一个真实场景素材目录里的文件没有规律但导出后需要用素材_001、素材_002重新编号这时候{n:3}就派上大用场了。3.4 文件遍历与计划构建文件遍历我用的是fs.readdirSync配合递归没有用fs.walk之类的库。有一个细节很值得说默认跳过隐藏文件和符号链接。隐藏文件跳过的原因很简单你大概率不想批量重命名.git或者.DS_Store符号链接跳过是因为重命名链接和重命名目标在语义上完全不同贸然处理容易出事。这两个行为都做成了开关--include-hidden和--include-symlink需要的时候可以打开。构建计划时我会对每个文件做四步处理检查文件名合法性、应用替换规则、构建新旧映射、检测冲突。全部通过才认为这个文件可以进入执行列表function buildRenamePlan(files, pattern, replacement) { const plan { changes: [], errors: [] }; const seen new Set(); let seq 1; for (const file of files) { const oldName file.base; const newBase applyReplacement(oldName, pattern, replacement, seq); const newName newBase file.ext; if (!isValidName(newName)) { plan.errors.push(非法文件名: ${newName} (来自 ${oldName})); continue; } if (newName oldName) continue; if (seen.has(newName)) { plan.errors.push(冲突: ${newName} 将被多个文件使用); continue; } seen.add(newName); plan.changes.push({ from: file.fullPath, to: path.join(file.dir, newName), oldName, newName }); } return plan; }这里newName oldName直接跳过是因为没变化的文件没必要进入执行阶段也减少了后面冲突检测的干扰项。值得说明的是我用seen集合检测的是新名字之间的冲突而不是新名字与磁盘上已有文件名的冲突。后一种情况我也做了检查但出于谨慎默认遇到目标已存在时报错而不覆盖因为覆盖操作一旦出错很难恢复。4. 实测过程与经典场景4.1 真实场景整理几百个截图文件说一个我实际用过的场景。某次工作需要把手机和相机里导出的几百个图片整理归档文件名是IMG_20240513_093021.jpg、DSC_0001.JPG这种混杂格式。我想统一成游记_2024-05-13_编号.jpg的格式。第一步先把大小写统一规则是匹配.JPG结尾并改为小写——这一步也可以叫清洗阶段。第二步从文件名里提取日期rea IMG_(\d{4})(\d{2})(\d{2})_\d{6} 游记_$1-$2-$3_{n:3} --dry-run。模拟执行的结果会打印出前二十条和最后五条改动中间省略。我盯着结果确认了两件事日期分组有没有正确的捕获序号有没有按预期填充。确认没问题后才加--execute执行。整个操作一分钟内完成如果换成图形工具或者手写脚本至少要折腾十分钟。这里我特意提一下预览策略。rea 默认在改动多于二十条时只展示首尾不是因为它小气而是为了可读性。几百条改动全部刷屏你反而看不出来有没有问题。首尾加上中间的省略号,能让你快速扫一眼前面规则对不对、后面边界有没有异常。如果真的想全部看也有--verbose参数。4.2 常用命令速查我把几个常用的玩法整理成一个速查表方便复制改改就用需求命令示例说明去掉文件名前的前缀前缀rea IMG_ --execute把所有IMG_移除日期格式标准化rea (\d{4})(\d{2})(\d{2}) $1-$2-$3 --execute把20240513改成2024-05-13批量添加序号rea .* {n:3}_$0 --execute$0表示整个匹配文本仅预览不改动rea IMG_ photo_ --dir ./test默认就是模拟执行只处理当前目录一层的文件rea tmp temp --execute默认不递归子目录限制处理数量rea x y --execute --limit 50最多只改 50 个文件其中$0这个用法值得解释一下很多刚接触替换工具的人会只记得$1是分组忘了$0代表整个匹配结果。上面的批量添加序号就是靠$0保留原始文件名然后通过{n:3}生成三位编号前缀。这个组合命令是我日常用得最多的。5. 常见问题与排查技巧实录5.1 那些年我踩过的坑问题一文件显示改名成功但实际找不到有一次我改了三百多张照片命令执行完毕没有任何报错结果打开目录一看新名字都不见了。排查了半天发现是模式写得太宽把路径的一部分也匹配进去替换了。rea 的默认行为是只处理文件名不处理目录名但如果你是针对--recursive场景使用并且 pattern 里含有点号.它可能匹配到文件名的隐藏部分。这个问题后来我在构建计划阶段增加了一个断言改名前后的文件必须位于同一个目录否则直接报错。再遇到类问题第一步永远是看模拟执行的输出而不是直接执行。问题二批量改完发现多个文件被覆盖这是我最不愿回忆的一次事故。当时用了一个脚本把a.txt改成b.txt没意识到目录里本来就有b.txt结果脚本把它覆盖了。rea 里我做了双重防护目标名冲突检测 目标已存在检测。注意目标已存在检测默认是报错模式不提供覆盖开关。真需要覆盖的话你得手动先删掉目标文件这是有意为之的因为覆盖是一个非常危险的动作不应该被命令行的低摩擦操作轻易触发。问题三Windows 下提示文件被占用重命名node_modules里某个正被进程使用的文件时Windows 会报错。这个问题没法在代码层面完全规避rea 能做到的是在执行阶段自动跳过失败项并在最后统一汇总哪些成功了、哪些失败以及失败原因。你不希望因为一个文件占用导致后面几百个文件全部停摆。跳过比中止更合理但会打印出醒目的警告。问题四正则匹配不到任何文件新手常见问题是 pattern 写对了但忘了转义反斜杠。\d在 shell 双引号里可能被削弱在 PowerShell 里又有另一套转义逻辑。我的一般性建议是先在 ORF 类似的在线正则测试工具里验证你的 pattern再放进命令。rea 的帮助信息里也专门提醒了这一点。另外如果你的文件名里有中文字符建议 pattern 里用明确的字符范围避免在不同编码环境下出现意外。5.2 排查思路与诊断日志rea 里我加了一个--debug参数它会输出详细信息每个文件是否匹配、匹配的分组内容、替换后的中间结果、合法性检查的每一个判定原因。这个参数平时用不上但一旦出现诡异问题它给的信息比任何猜测都有用。调试逻辑遵循一个原则宁可在计划阶段多输出真实信息也不要在执行阶段吞掉错误。错误被吞掉用户就会在文件丢失很久之后才发现问题。有一次一个同事 A 跑 rea 后说有个文件怎么没改名。开 debug 一看原来是那个文件是隐藏文件.config.yml默认被跳过了。他当时有点不理解觉得隐藏文件也是文件应该一并改。我给他开了--include-hidden开关问题解决。这给我一个启发每个设计决策都可能和用户预期冲突与其争论哪个更合理不如把行为说清楚并留一个开关。6. 后续扩展与个人体会rea 目前的功能对我来说已经够用但如果你想把它扩展成更强大的工具有几个方向值得考虑。一是加入基于文件内容的过滤比如只重命名包含特定文本的文本文件这需要引入文件读取但很多素材归档场景很需要。二是支持撤销功能实现方式是在执行前自动生成一份rename_undo.log记录所有新旧路径出问题后可以通过一条rea --undo rename_undo.log恢复。我自己遇到过几次改完发现规则写错的情况这个功能其实挺值得做的。三是加一个分组改名模式比如把*.jpg全部变成folder.jpg或者在每个文件名前加父目录名这在整理音乐专辑和图片素材时很常用。说点个人体会。写 rea 之前我以为重命名工具就是正则加文件系统 API写完才发现真正的复杂度全在边界情况里跨平台命名规则、冲突检测、中间状态碰撞、编码差异、用户预期管理。每一类边界问题都对应一次真实的文件损失或者一次让人抓狂的排查。所以我给所有工具的通用建议是任何批量操作模拟执行和日志都不是可选项而是必需品。尤其是那些看起来只改一次、实际会反复运行的小工具第一次做的时候多花十分钟把安全和可审计性做进去后面会帮你省下远超十小时的懊恼。最后分享一个小技巧把 rea 和你的终端别名绑定比如在 shell 配置文件里加上alias rnmnode /path/to/rea.js之后无论在哪台机器上只要把rea.js复制过去配好路径就能用同一条别名跑起来。这个工具不复杂但真的能帮你从每月手动整理一次文件的重复劳动里彻底解放出来。如果你也动手写一个自己的版本我建议第一个版本不要加太多功能先把安全改名这四个字做扎实剩下的需求会在使用中自己浮现出来。