← 中文资源

简体中文版本

构建批量图片缩放命令行工具

遍历图片目录、按目标宽度等比缩放、跳过已符合尺寸的图片,并报告前后体积节省的命令行工具。

阅读英文原文

项目目标

本项目构建一个批量图片缩放工具,把一批图片缩放到最大宽度,保持宽高比与格式,跳过已足够小的图片并报告节省的空间。规划器与执行器分离,使核心逻辑可以只凭元数据测试,无需解码真实图片。

核心特性

  • 递归发现受支持的图片并先报告数量
  • 按最大宽度等比缩放且不放大小图
  • 输出到独立目录并保留相对结构
  • 记录每文件与总体的字节节省

架构与分层

发现模块找出候选文件;规划器依据尺寸决定缩放或跳过;执行器完成缩放与写入;报告器格式化摘要。图片库被薄适配器包裹,使规划器与报告器可在不解码像素的前提下测试。

实施阶段

六个阶段为:文件发现、缩放规划、图片适配器、执行器与输出布局、CLI 表面、报告与测试。边界处「等于目标宽度即跳过」被显式断言。

测试与验收

用 vitest 运行。规划器测试用伪造的尺寸记录断言跳过、缩放与不放大的判定;发现测试在临时目录树中核对扩展名筛选;一个集成测试缩放极小的真实图片并断言输出宽度。

延伸方向

格式转换、质量调节与水印都是规划器的新分支与适配器的新调用,不改动发现与报告。吞吐量需要时,并发池包裹执行器;清单文件让重跑变为增量。

本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。

代码示例

export function planResize(file, targetWidth) {
  const { width, height, sizeBytes } = file;
  if (width <= targetWidth) {
    return { action: 'skip', reason: 'already within target', width, height };
  }
  const ratio = targetWidth / width;
  return {
    action: 'resize',
    width: targetWidth,
    height: Math.round(height * ratio),
    estimatedBytes: Math.round(sizeBytes * ratio * ratio),
  };
}
mkdir image-resizer && cd image-resizer
npm init -y
npm install sharp@^0.33.0
npm install -D vitest@^2.1.0
{
  "name": "image-resizer",
  "type": "module",
  "scripts": {
    "resize": "node src/cli.js",
    "test": "vitest run"
  }
}
import { readdir, stat } from 'node:fs/promises';
import { join, extname, relative } from 'node:path';

const SUPPORTED = new Set(['.jpg', '.jpeg', '.png', '.webp']);

export async function discover(root) {
  const found = [];
  async function walk(dir) {
    for (const entry of await readdir(dir, { withFileTypes: true })) {
      const full = join(dir, entry.name);
      if (entry.isDirectory()) await walk(full);
      else if (SUPPORTED.has(extname(entry.name).toLowerCase())) {
        const info = await stat(full);
        found.push({ path: full, rel: relative(root, full), sizeBytes: info.size });
      }
    }
  }
  await walk(root);
  return found;
}
export function planResize(file, targetWidth) {
  if (file.width <= targetWidth) {
    return { action: 'skip', reason: 'within target', width: file.width, height: file.height };
  }
  const ratio = targetWidth / file.width;
  return {
    action: 'resize',
    width: targetWidth,
    height: Math.max(1, Math.round(file.height * ratio)),
  };
}

export function planAll(files, targetWidth) {
  return files.map((f) => ({ file: f, plan: planResize(f, targetWidth) }));
}
import sharp from 'sharp';

export function createAdapter() {
  return {
    async readMetadata(path) {
      const meta = await sharp(path).metadata();
      return { width: meta.width, height: meta.height, format: meta.format };
    },
    async resize(path, outPath, width, height) {
      await sharp(path).resize({ width, height, fit: 'fill' }).toFile(outPath);
      return outPath;
    },
  };
}
import { mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';

export async function execute(planned, { adapter, outDir }) {
  const results = [];
  for (const { file, plan } of planned) {
    if (plan.action === 'skip') { results.push({ rel: file.rel, action: 'skip' }); continue; }
    const meta = await adapter.readMetadata(file.path);
    const target = join(outDir, file.rel);
    await mkdir(dirname(target), { recursive: true });
    await adapter.resize(file.path, target, plan.width, plan.height);
    results.push({ rel: file.rel, action: 'resize', from: meta, to: plan });
  }
  return results;
}
import { discover } from './discover.js';
import { planAll } from './planner.js';
import { execute } from './executor.js';
import { createAdapter } from './adapter.js';

const args = parse(process.argv.slice(2));
if (!args.input || !args.width) {
  console.error('usage: resize --input <dir> --width <px> [--out out] [--dry-run]');
  process.exit(1);
}
const files = await discover(args.input);
const adapter = createAdapter();
const withMeta = [];
for (const f of files) withMeta.push({ ...f, ...(await adapter.readMetadata(f.path)) });
const planned = planAll(withMeta, Number(args.width));
console.log(files.length + ' images found, ' + planned.filter((p) => p.plan.action === 'resize').length + ' to resize');
if (!args.dryRun) {
  await execute(planned, { adapter, outDir: args.out ?? 'resized' });
}
export function summarize(results, before) {
  let bytesBefore = 0;
  let bytesAfter = 0;
  for (const r of results) {
    bytesBefore += r.sizeBytes ?? 0;
    bytesAfter += r.afterBytes ?? r.sizeBytes ?? 0;
  }
  const saved = bytesBefore - bytesAfter;
  return {
    files: results.length,
    resized: results.filter((r) => r.action === 'resize').length,
    skipped: results.filter((r) => r.action === 'skip').length,
    savedPercent: bytesBefore ? Math.round((saved / bytesBefore) * 100) : 0,
  };
}