前端自动化脚本:用 Node.js 写批量处理工具(图片压缩、文件重命名)

前端自动化脚本:用 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 架构与配置

  • 子命令:compressrenamehash-reportwatch
  • 配置文件:支持 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.jsonreport.json 纳入 PR 附件,提升评审透明度。

可演进方向

  • 配置化规则文件支持 JSON 或 YAML。
  • 子命令扩展到 spritethumbnailpdf-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/3xsm/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 上运行 compresshash-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 字符,规范化时保留扩展名并做最小改动。
相关推荐
Jolyne_39 分钟前
antd Image base64缓存 + loading 态优化方案
前端
BINGCHN1 小时前
NSSCTF每日一练 SWPUCTF2021 include--web
android·前端·android studio
O***p6041 小时前
JavaScript在Node.js中的集群负载均衡
javascript·node.js·负载均衡
Z***u6591 小时前
前端性能测试实践
前端
xhxxx1 小时前
prototype 是遗产,proto 是族谱:一文吃透 JS 原型链
前端·javascript
倾墨1 小时前
Bytebot源码学习
前端
用户93816912553601 小时前
VUE3项目--集成Sass
前端
S***H2832 小时前
Vue语音识别案例
前端·vue.js·语音识别
啦啦9118862 小时前
【版本更新】Edge 浏览器 v142.0.3595.94 绿色增强版+官方安装包
前端·edge