项目目标
本项目构建一个批量图片缩放工具,把一批图片缩放到最大宽度,保持宽高比与格式,跳过已足够小的图片并报告节省的空间。规划器与执行器分离,使核心逻辑可以只凭元数据测试,无需解码真实图片。
核心特性
- 递归发现受支持的图片并先报告数量
- 按最大宽度等比缩放且不放大小图
- 输出到独立目录并保留相对结构
- 记录每文件与总体的字节节省
架构与分层
发现模块找出候选文件;规划器依据尺寸决定缩放或跳过;执行器完成缩放与写入;报告器格式化摘要。图片库被薄适配器包裹,使规划器与报告器可在不解码像素的前提下测试。
实施阶段
六个阶段为:文件发现、缩放规划、图片适配器、执行器与输出布局、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,
};
}