跳到正文
跟我做
自媒体自动化6 分钟Sharp

批量压缩图片:100 张一次压完,原图不动

用 Sharp 把文件夹里的 jpg/png/webp 批量缩到最长边 1600px、质量 80,默认只预览,确认后写入 out/。原图保留,不覆盖。

约 6 分钟
本页目录
  1. 这篇教程解决什么问题
  2. 准备图片
  3. 写脚本
  4. 先预览
  5. 写入
  6. 怎么调参数
  7. 压完怎么检查
  8. 常见坑
  9. 1. 反复压缩同一批图
  10. 2. 透明 PNG 变白底
  11. 3. GIF 和 HEIC 不在列表里
  12. 4. 目录里混了其他文件
  13. 下一步读什么

30 秒速查

  • 图片放 input/,支持 jpg/png/webp
  • 预览:node resources/compress-images/compress.mjs input
  • 确认后加 --write,输出到 input/out/
  • 最长边 1600、质量 80,改脚本顶部

这篇教程解决什么问题

原图一张 5MB,上传慢、平台还会再压一次;手动用修图软件一张张导出,一百张要一下午。

这篇用一个脚本批量处理:最长边缩到 1600px,JPEG 质量 80,输出到单独的 out/,原图不动。先预览每张的体积变化,确认后再写盘。

文件列表 6 张图片,IMG_2001.jpg 到 IMG_2006.jpg,体积从 55 KB 到 142 KB,共 472 KB
① 文件名 ② 每张的真实体积(6 张,共 472 KB)

准备图片

新建 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。

预览会真的编码一遍,但只放在内存里,不写文件。

  1. 预览

    node resources/compress-images/compress.mjs input:真的编码一遍,但只放内存。

  2. 看体积对比

    三张测试图共 6.2 MB → 1.3 MB;只小了 10% 的图,没必要再压。

  3. 确认后 --write

    输出到 input/out/,文件名和原图对应,原图还在。

图 1:先预览,确认体积能接受,再写盘。

写入

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)。原图还在。

同一批 6 张图片压缩后,文件名不变,体积从 38 KB 到 59 KB,共 295 KB
① 名字没动 ② 体积变小(同一批图,共 295 KB)
终端输出 6 张已压缩,最后汇总共 6 张 472 KB 到 295 KB,省了 37%
① 原体积 → 新体积 ② 省了 37%(472 KB 的 37% 约 175 KB,472 − 175 = 297,和 295 对得上)

怎么调参数

打开脚本顶部:

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. 目录里混了其他文件

脚本只挑图片扩展名,其他文件会跳过;不会删除任何东西。

下一步读什么

压好的图,下一步是排版:小红书批量封面 用 SVG 模板批量出图;需要统一标识时,看图片批量加水印。

这条路线收在自媒体自动化入门,工具说明见 Sharp。

常见问题

会覆盖原图吗?

不会。脚本只读输入目录,结果写到 input/out/;加 --write 才写盘,之前只打印每张图压缩前后的体积。

PNG 的透明背景怎么变白了?

脚本统一转成 JPEG,并铺了白底,避免透明区域变黑。需要透明背景时,改脚本里的输出格式,或单独处理 PNG。

为什么有的图压得很少?

细节多、噪点多的图本来就难压。1600px 和 quality 80 是清晰度和体积的平衡;想更小,把 quality 调到 70,或把最长边改成 1280。

支持 GIF 和 HEIC 吗?

不支持。先把它们转成 jpg、png 或 webp,再放进输入目录。脚本只处理这三种格式。

每周一张复习表

每周分享一张实用复习表,不收钱不打扰,想走随时走

邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。

同方向继续

全部教程