前端自动化脚本:用 Node.js 写批量处理工具(图片压缩、文件重命名)
前端项目中的静态资源常因多人协作与历史遗留而失控:体积过大、命名不规范、目录结构混乱、重复文件难以识别。将这些重复劳动工程化到一个稳定的 Node.js CLI,可以显著降低回归成本并提高交付质量。
目标与设计原则
- 可配置:命令行参数与配置文件双通道,适配不同项目目录结构与规范。
- 可追溯:生成操作映射与统计报告,支持回滚与审计。
- 可控并发:根据 CPU 和磁盘压力限制并发,避免阻塞与崩溃。
- 干跑优先:先演练再落地,降低误操作风险。
- 跨平台:路径、权限与编码在 macOS、Linux、Windows 下行为一致。
依赖与安装
-
fast-glob目录扫描 -
sharp图片处理 -
yargs命令解析 -
p-limit并发控制 -
slugify规范化重命名 -
chokidar监听改动 -
cli-progress进度条npm i fast-glob sharp yargs p-limit slugify chokidar cli-progress
CLI 架构与配置
- 子命令:
compress、rename、hash-report、watch - 配置文件:支持
tool.config.json - 输出物:生成操作映射
ops.json与统计report.json
json
{
"src": "./assets",
"out": "./dist/assets",
"quality": 82,
"concurrency": 4,
"rename": {"prefix": "", "suffix": "", "lowercase": true}
}
核心实现示例
js
const path = require('path')
const fs = require('fs/promises')
const fg = require('fast-glob')
const sharp = require('sharp')
const yargs = require('yargs')
const { hideBin } = require('yargs/helpers')
const pLimit = require('p-limit')
const slugify = require('slugify')
const chokidar = require('chokidar')
const cliProgress = require('cli-progress')
async function ensureDir(dir){
await fs.mkdir(dir, { recursive: true })
}
function normalize(name, {prefix = '', suffix = '', lowercase = true}){
const ext = path.extname(name)
let base = path.basename(name, ext)
base = lowercase ? base.toLowerCase() : base
base = slugify(base, { lower: lowercase, strict: true })
return `${prefix}${base}${suffix}${ext}`
}
async function writeJson(file, data){
await ensureDir(path.dirname(file))
await fs.writeFile(file, JSON.stringify(data, null, 2))
}
async function compressImages(src, out, {quality = 82, concurrency = 4}){
await ensureDir(out)
const files = await fg(['**/*.{png,jpg,jpeg}'], { cwd: src, absolute: true })
const bar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic)
bar.start(files.length, 0)
const limit = pLimit(concurrency)
const ops = []
await Promise.all(files.map(file => limit(async () => {
const rel = path.relative(src, file)
const targetJpg = path.join(out, rel.replace(path.extname(rel), '.jpg'))
const targetWebp = path.join(out, rel.replace(path.extname(rel), '.webp'))
await fs.mkdir(path.dirname(targetJpg), { recursive: true })
await sharp(file).rotate().jpeg({ quality, mozjpeg: true }).toFile(targetJpg)
await sharp(file).rotate().webp({ quality }).toFile(targetWebp)
ops.push({ from: file, to: [targetJpg, targetWebp] })
bar.increment()
})))
bar.stop()
await writeJson(path.join(out, 'ops.json'), ops)
}
async function renameFiles(src, {prefix = '', suffix = '', lowercase = true, dry = true}){
const files = await fg(['**/*'], { cwd: src, absolute: true, dot: false, onlyFiles: true })
const ops = []
for (const file of files){
const dir = path.dirname(file)
const target = path.join(dir, normalize(path.basename(file), {prefix, suffix, lowercase}))
if (file !== target){
ops.push({ from: file, to: target })
if (!dry) await fs.rename(file, target)
}
}
await writeJson(path.join(src, 'ops.json'), ops)
return ops
}
async function hashReport(src){
const crypto = require('crypto')
const files = await fg(['**/*.{png,jpg,jpeg,webp}'], { cwd: src, absolute: true })
const rows = []
for (const f of files){
const buf = await fs.readFile(f)
const hash = crypto.createHash('md5').update(buf).digest('hex')
rows.push({ file: f, bytes: buf.length, md5: hash })
}
await writeJson(path.join(src, 'report.json'), rows)
}
async function watchDir(src, out, options){
const watcher = chokidar.watch(['**/*.{png,jpg,jpeg}'], { cwd: src, ignoreInitial: true })
watcher.on('add', async rel => {
const abs = path.join(src, rel)
await compressImages(src, out, options)
})
}
async function main(){
const argv = yargs(hideBin(process.argv))
.command('compress', '压缩图片')
.command('rename', '文件重命名')
.command('hash-report', '生成哈希体积报告')
.command('watch', '监听目录变化')
.option('src', { type: 'string' })
.option('out', { type: 'string' })
.option('quality', { type: 'number', default: 82 })
.option('concurrency', { type: 'number', default: 4 })
.option('prefix', { type: 'string', default: '' })
.option('suffix', { type: 'string', default: '' })
.option('lowercase', { type: 'boolean', default: true })
.option('dry', { type: 'boolean', default: true })
.demandCommand(1)
.help()
.argv
const cmd = argv._[0]
if (cmd === 'compress'){
if (!argv.src || !argv.out) throw new Error('src 与 out 必填')
await compressImages(argv.src, argv.out, {quality: argv.quality, concurrency: argv.concurrency})
}
if (cmd === 'rename'){
if (!argv.src) throw new Error('src 必填')
await renameFiles(argv.src, {prefix: argv.prefix, suffix: argv.suffix, lowercase: argv.lowercase, dry: argv.dry})
}
if (cmd === 'hash-report'){
if (!argv.src) throw new Error('src 必填')
await hashReport(argv.src)
}
if (cmd === 'watch'){
if (!argv.src || !argv.out) throw new Error('src 与 out 必填')
await watchDir(argv.src, argv.out, {quality: argv.quality, concurrency: argv.concurrency})
}
}
main()
使用与演示
-
压缩图片并生成双格式与操作映射
node cli.js compress --src ./assets/images --out ./dist/images --quality 82 --concurrency 4
-
干跑重命名并写入映射文件
node cli.js rename --src ./docs --prefix p_ --suffix _v1 --lowercase true --dry true
-
生成哈希体积报告用于查重与排查大文件
node cli.js hash-report --src ./dist/images
-
监听目录新增图片并增量压缩
node cli.js watch --src ./assets/images --out ./dist/images
边界与兼容问题
- EXIF 方向:使用
rotate()使输出按拍摄方向渲染。 - 颜色与透明度:PNG 透明通道保留,JPEG 不支持透明需按业务转 WebP。
- 混合命名与编码:统一用
slugify严格模式消除空格与特殊字符。 - 路径大小写敏感:在大小写敏感的系统中保持规范化与一致性。
性能与稳定性
- 并发上限:根据核数和磁盘能力设置,避免上下文切换开销过大。
- 进度与统计:进度条与报告文件帮助团队可视化处理规模与耗时。
- 失败处理:记录失败项并二次重试,必要时拆分批次执行。
与前端构建的协同
- 在提交前执行资源处理,保证进入构建链的资源已优化。
- Vite 与 webpack 的图片插件适合增量优化,离线批处理适合清理历史资产。
- 将
ops.json与report.json纳入 PR 附件,提升评审透明度。
可演进方向
- 配置化规则文件支持 JSON 或 YAML。
- 子命令扩展到
sprite、thumbnail、pdf-extract。 - 结合任务队列实现失败重试与断点续跑。
结论:将批量处理抽象为可配置、可追溯的 CLI,配合并发控制、干跑与报告机制,能在多人协作的前端项目中长期稳定地管理静态资源质量与规模。
架构分层建议
- 目录结构:
bin/cli.js作为入口,lib/存放能力模块,config/存放默认与环境配置。 - 能力模块:将图片压缩、重命名、报告生成、监听各自封装,入口仅负责参数解析与路由。
- 插件机制:定义简单接口
apply(files, options),通过配置动态加载本地插件以扩展能力。
text
tool/
bin/cli.js
lib/compress.js
lib/rename.js
lib/report.js
lib/watch.js
config/defaults.json
配置校验与 Schema
- 使用 JSON Schema 校验配置有效性,避免运行期才暴露错误。
js
const Ajv = require('ajv')
const ajv = new Ajv()
const schema = {
type: 'object',
properties: {
src: { type: 'string' },
out: { type: 'string' },
quality: { type: 'integer', minimum: 1, maximum: 100 },
concurrency: { type: 'integer', minimum: 1, maximum: 32 }
},
required: ['src']
}
function validateConfig(cfg){
const valid = ajv.validate(schema, cfg)
if (!valid) throw new Error('Invalid config')
}
回滚与指纹化命名
- 根据
ops.json生成回滚映射,支持重命名撤销与压缩产物清理。 - 引入内容哈希以实现指纹化命名,从而保障缓存与去重。
js
const crypto = require('crypto')
function contentHash(buffer){
return crypto.createHash('md5').update(buffer).digest('hex').slice(0,8)
}
async function revertOps(mapFile){
const data = JSON.parse(await fs.readFile(mapFile, 'utf8'))
for (const row of data){
if (row.from && row.to) await fs.rename(row.to, row.from)
}
}
大规模处理策略
- 分批分层:按目录或按文件数分批执行,避免单次 Promise 过大导致内存抖动。
- 队列持久化:将待处理队列写入
queue.json,异常终止后可断点续跑。 - 增量基线:对已有
report.json做差分,仅处理新增或变更文件。
js
async function batch(files, size){
for (let i = 0; i < files.length; i += size){
const slice = files.slice(i, i + size)
await runSlice(slice)
}
}
生成多规格与占位图
- 多规格输出满足不同屏幕与网络条件:如
1x/2x/3x或sm/md/lg。 - 占位图生成用于渐进加载与骨架屏。
js
async function variants(file, out){
const sizes = [480, 960, 1440]
for (const w of sizes){
const url = path.join(out, `${path.basename(file, path.extname(file))}_${w}.webp`)
await sharp(file).resize({ width: w }).webp({ quality: 80 }).toFile(url)
}
}
async function placeholder(file, out){
const url = path.join(out, `${path.basename(file, path.extname(file))}_blur.webp`)
await sharp(file).resize({ width: 32 }).webp({ quality: 40 }).toFile(url)
}
去重与清理
- 利用哈希报告识别重复文件并输出候选清单,人工确认后批量移除。
js
async function dedupe(files){
const map = new Map()
const dup = []
for (const f of files){
const buf = await fs.readFile(f)
const h = crypto.createHash('md5').update(buf).digest('hex')
if (map.has(h)) dup.push([map.get(h), f])
else map.set(h, f)
}
await writeJson('duplicates.json', dup)
}
测试与质量保障
- 使用 Vitest 或 Jest 对关键函数进行单测与快照,保障重命名与哈希报告的确定性。
js
import { describe, it, expect } from 'vitest'
import { normalize } from './lib/rename'
describe('normalize', () => {
it('lowercase and slug', () => {
expect(normalize('A b.png', { lowercase: true })).toMatch(/a-b\.png$/)
})
})
CI 集成与守护流程
- GitHub Actions 在 PR 上运行
compress与hash-report,将report.json作为构件上传,便于评审。
yaml
name: assets-pipeline
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: node cli.js compress --src ./assets/images --out ./dist/images
- run: node cli.js hash-report --src ./dist/images
- uses: actions/upload-artifact@v4
with:
name: asset-report
path: dist/images/report.json
平台差异与注意事项
- Windows 路径分隔与大小写规则差异较大,统一用
path接口生成、解析路径。 - 网络盘与符号链接可能导致读取缓慢与循环遍历,扫描时关闭
followSymbolicLinks或增加目录白名单。 - 文件名编码:历史文件可能包含非 UTF-8 字符,规范化时保留扩展名并做最小改动。