批量压缩图片:100 张一次压完,原图不动
用 Sharp 把文件夹里的 jpg/png/webp 批量缩到最长边 1600px、质量 80,默认只预览,确认后写入 out/。原图保留,不覆盖。
本页目录
30 秒速查
- 图片放
input/,支持 jpg/png/webp- 预览:
node resources/compress-images/compress.mjs input- 确认后加
--write,输出到input/out/- 最长边 1600、质量 80,改脚本顶部
这篇教程解决什么问题
原图一张 5MB,上传慢、平台还会再压一次;手动用修图软件一张张导出,一百张要一下午。
这篇用一个脚本批量处理:最长边缩到 1600px,JPEG 质量 80,输出到单独的 out/,原图不动。先预览每张的体积变化,确认后再写盘。

准备图片
新建 input/,把要压的图片放进去。支持 .jpg、.jpeg、.png、.webp。
脚本用 Sharp,第一次在 resources/ 里装依赖:
cd resources
npm install
如果只是跟着站点仓库跑,根目录已经装过 Sharp,可以直接用。
写脚本
保存为 resources/compress-images/compress.mjs:
import sharp from "sharp";
import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
// 批量压缩图片:最长边缩到 1600px,JPEG 质量 80。
// 默认只预览,不写文件;加 --write 才输出到 input/out/。
export const MAX_WIDTH = 1600;
export const QUALITY = 80;
export async function collectImages(target) {
const info = await stat(target);
if (!info.isDirectory()) return [target];
const entries = await readdir(target, { withFileTypes: true });
return entries
.filter(
(entry) =>
entry.isFile() &&
/\.(jpe?g|png|webp)$/i.test(entry.name) &&
!entry.name.startsWith("."),
)
.sort((a, b) => a.name.localeCompare(b.name))
.map((entry) => path.join(target, entry.name));
}
export async function compressFile(file, { write = false, outDir } = {}) {
const before = (await stat(file)).size;
const image = sharp(file).rotate();
const metadata = await image.metadata();
// 按「最长边」缩:竖拍的图也不会被缩成 2133 高
const oriented = metadata.autoOrient ?? metadata;
const originalWidth = oriented.width ?? MAX_WIDTH;
const originalHeight = oriented.height ?? MAX_WIDTH;
const maxBefore = Math.max(originalWidth, originalHeight);
const scale = Math.min(1, MAX_WIDTH / maxBefore);
const width = Math.max(1, Math.round(originalWidth * scale));
const height = Math.max(1, Math.round(originalHeight * scale));
const buffer = await image
.resize({ width: MAX_WIDTH, height: MAX_WIDTH, fit: "inside", withoutEnlargement: true })
.flatten({ background: "#ffffff" })
.jpeg({ quality: QUALITY, mozjpeg: true })
.toBuffer();
const target = path.join(outDir, path.basename(file, path.extname(file)) + ".jpg");
if (write) {
await mkdir(outDir, { recursive: true });
await writeFile(target, buffer);
}
return {
file,
target,
before,
after: buffer.length,
width,
height,
originalWidth,
originalHeight,
maxBefore,
maxAfter: Math.max(width, height),
written: write,
};
}
export async function compressAll(target, options = {}) {
const files = await collectImages(target);
const outDir = path.join(
path.extname(target) ? path.dirname(target) : target,
"out",
);
const results = [];
for (const file of files) {
results.push(await compressFile(file, { ...options, outDir }));
}
return results;
}
function mb(bytes) {
return (bytes / 1024 / 1024).toFixed(1) + " MB";
}
function kb(bytes) {
return Math.max(1, Math.round(bytes / 1024)) + " KB";
}
const isDirectRun =
process.argv[1] &&
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
if (isDirectRun) {
const args = process.argv.slice(2);
const write = args.includes("--write");
const target = args.find((arg) => !arg.startsWith("--")) ?? "input";
try {
const results = await compressAll(target, { write });
if (results.length === 0) {
console.error("没有找到 jpg / png / webp 图片。");
process.exitCode = 1;
} else {
for (const item of results) {
const name = path.basename(item.file);
const flag = write ? "已压缩" : "预览";
console.log(
`[${flag}] ${name} ${mb(item.before)} → ${kb(item.after)}(最长边 ${item.maxBefore} → ${item.maxAfter} px)`,
);
}
const before = results.reduce((sum, item) => sum + item.before, 0);
const after = results.reduce((sum, item) => sum + item.after, 0);
console.log(`\n共 ${results.length} 张:${mb(before)} → ${mb(after)}`);
if (write) {
console.log(`输出目录:${path.join(path.dirname(results[0].target))}`);
} else {
console.log("这是预览,没有写文件。确认后加 --write。");
}
}
} catch (error) {
if (error.code === "ENOENT") {
console.error(`打不开路径:${target}`);
process.exitCode = 1;
} else {
throw error;
}
}
}
关键只有三行:
resize({ width: MAX_WIDTH, height: MAX_WIDTH, fit: "inside" }):按最长边缩,只缩小不放大;flatten({ background: "#ffffff" }):透明区域铺白底;jpeg({ quality: 80 }):统一输出 JPEG,体积更可控。
先预览
node resources/compress-images/compress.mjs input
真实输出(三张测试图):
[预览] big.jpg 5.0 MB → 578 KB(最长边 3000 → 1600 px)
[预览] medium.jpg 0.8 MB → 471 KB(最长边 1200 → 1200 px)
[预览] small.png 0.4 MB → 236 KB(最长边 800 → 800 px)
共 3 张:6.2 MB → 1.3 MB
这是预览,没有写文件。确认后加 --write。
预览会真的编码一遍,但只放在内存里,不写文件。
预览
node resources/compress-images/compress.mjs input:真的编码一遍,但只放内存。
看体积对比
三张测试图共 6.2 MB → 1.3 MB;只小了 10% 的图,没必要再压。
确认后 --write
输出到 input/out/,文件名和原图对应,原图还在。
写入
node resources/compress-images/compress.mjs input --write
[已压缩] big.jpg 5.0 MB → 578 KB(最长边 3000 → 1600 px)
[已压缩] medium.jpg 0.8 MB → 471 KB(最长边 1200 → 1200 px)
[已压缩] small.png 0.4 MB → 236 KB(最长边 800 → 800 px)
共 3 张:6.2 MB → 1.3 MB
输出目录:input/out
input/out/ 里是压缩后的 JPEG,文件名和原图对应(.png 会变成 .jpg)。原图还在。


怎么调参数
打开脚本顶部:
export const MAX_WIDTH = 1600;
export const QUALITY = 80;
- 公众号封面:1600px 够用;
- 小红书配图:最长边 1440px 也可以;
- 想更小:
QUALITY = 70,或MAX_WIDTH = 1280; - 还要更大:别超过原图宽度,
withoutEnlargement会拦住。
压完怎么检查
- 打开
out/里的第一张,放大到 100%,看文字边缘有没有明显锯齿。 - 对比原图体积;如果只小了 10%,说明这张图细节本来就少,没必要再压。
- 上传到平台后看一次预览;平台还会再压一次,最终体积以平台为准。
要处理几百张,先把 MAX_WIDTH 调到 1280 试跑 10 张,确认能接受再放开。
常见坑
1. 反复压缩同一批图
JPEG 是有损格式,压两次会继续掉画质。每次从原图目录重新跑,不要拿 out/ 里的图再压。
2. 透明 PNG 变白底
脚本统一输出 JPEG,透明区域铺白。需要透明背景,把输出改成 .png,并去掉 flatten()。
3. GIF 和 HEIC 不在列表里
先转成 jpg/png/webp。GIF 动图压成 JPEG 会只剩第一帧,不要直接跑。
4. 目录里混了其他文件
脚本只挑图片扩展名,其他文件会跳过;不会删除任何东西。
下一步读什么
常见问题
会覆盖原图吗?
不会。脚本只读输入目录,结果写到 input/out/;加 --write 才写盘,之前只打印每张图压缩前后的体积。
PNG 的透明背景怎么变白了?
脚本统一转成 JPEG,并铺了白底,避免透明区域变黑。需要透明背景时,改脚本里的输出格式,或单独处理 PNG。
为什么有的图压得很少?
细节多、噪点多的图本来就难压。1600px 和 quality 80 是清晰度和体积的平衡;想更小,把 quality 调到 70,或把最长边改成 1280。
支持 GIF 和 HEIC 吗?
不支持。先把它们转成 jpg、png 或 webp,再放进输入目录。脚本只处理这三种格式。
每周一张复习表
每周分享一张实用复习表,不收钱不打扰,想走随时走
邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。