Advanced (更新: 2026/6/2)

用 Claude Code 构建图片优化流水线

用 Claude Code 自动完成 WebP/AVIF 转换、响应式图片和 CI 图片预算检查。

用 Claude Code 构建图片优化流水线

图片优化不能只靠发布前手动压缩一次。网站里有首屏大图、文章截图、商品缩略图、图表和社交分享图以后,任何一张没有处理的 PNG 都可能拖慢页面,尤其当它成为 Largest Contentful Paint,也就是首屏里最大可见元素的加载目标时,影响会非常明显。

这篇文章把 Claude Code 当作实现搭档,搭一条可重复执行的图片优化流水线:用 sharp 生成 AVIF、WebP、JPEG 多种格式,用响应式 picture 组件交给浏览器选择,再用 CI 检查输出文件是否超过预算。目标不是盲目追求最小体积,而是在清晰度、兼容性、命名规则和可审查性之间取得稳定平衡。

Masa 在一个小型技术博客里第一次尝试时,只让 Claude Code 生成 AVIF。结果字节数确实下降了,但部分爬虫仍需要 JPEG,代码截图的文字在低质量 AVIF 下变糊,首屏大图还被错误地设置成了懒加载。后来把“转换、展示、验证”拆开,才变成可长期维护的流程。

如果还不熟悉 Claude Code 的基本用法,可以先看Claude Code 入门指南。如果想同时处理更多性能问题,也可以搭配阅读Claude Code 性能优化实践

流水线全貌

不要只对 Claude Code 说“帮我压缩图片”。更好的做法是给它一个明确的数据流,让每个文件只负责一件事。

flowchart LR
  A["original images"] --> B["sharp conversion"]
  B --> C["AVIF / WebP / JPEG variants"]
  C --> D["OptimizedImage component"]
  D --> E["browser chooses best source"]
  C --> F["manifest.json"]
  F --> G["CI size budget check"]

转换脚本负责从原图生成派生文件;组件负责把候选图片交给浏览器;检查脚本负责在部署前挡住过重的图片。这样拆分以后,Claude Code 生成的差异更小,代码审查也更容易判断问题出在哪里。

编码前先确定质量规则

图片优化最容易犯的错,是给所有图片套同一个质量值。照片、界面截图、线框图和 OGP 图的失败方式完全不同。开始写代码前,我通常会把下面的规则交给 Claude Code。

场景目标审查重点
首屏大图至少 1280px,优先 AVIF/WebP,保留 JPEG可能影响 LCP,需要优先加载
文章截图以 640px/960px 为主小字号 UI 文本必须清楚
图片列表多用 320px/640px首屏外图片使用懒加载
社交分享图保留 JPEG 或 PNG兼容不支持现代格式的爬虫

截至 2026 年 6 月,图片处理格式可以参考 sharp 官方文档。HTML 部分建议遵循 MDN 响应式图片指南srcset 必须和准确的 sizes 一起使用,否则浏览器可能下载过大的候选图。

实现1:用 sharp 生成多尺寸图片

下面的脚本读取 public/images/original 中的 jpgjpegpng,输出到 public/images/optimized,并生成后续 CI 会使用的 manifest.json

npm install -D sharp glob tsx
// scripts/optimize-images.ts
import path from "node:path";
import { mkdir, writeFile } from "node:fs/promises";
import { glob } from "glob";
import sharp from "sharp";

const inputDir = process.argv[2] ?? "public/images/original";
const outputDir = process.argv[3] ?? "public/images/optimized";
const widths = [320, 640, 960, 1280, 1920] as const;
const formats = ["avif", "webp", "jpeg"] as const;
const quality = { avif: 52, webp: 76, jpeg: 82 } as const;

type ImageFormat = (typeof formats)[number];
type ManifestEntry = {
  src: string;
  width: number;
  format: string;
  bytes: number;
};

const manifest: Record<string, ManifestEntry[]> = {};

function slugFromPath(filePath: string) {
  const relative = path.relative(inputDir, filePath);
  return relative
    .replace(path.extname(relative), "")
    .split(path.sep)
    .join("-")
    .replace(/[^a-zA-Z0-9_-]/g, "-")
    .toLowerCase();
}

function extension(format: ImageFormat) {
  return format === "jpeg" ? "jpg" : format;
}

async function buildVariant(filePath: string, slug: string, width: number, format: ImageFormat) {
  let image = sharp(filePath).rotate().resize({ width, withoutEnlargement: true });

  if (format === "avif") image = image.avif({ quality: quality.avif, effort: 4 });
  if (format === "webp") image = image.webp({ quality: quality.webp, effort: 4 });
  if (format === "jpeg") image = image.jpeg({ quality: quality.jpeg, mozjpeg: true });

  const fileName = `${slug}-${width}w.${extension(format)}`;
  const target = path.join(outputDir, fileName);
  const info = await image.toFile(target);

  return {
    src: `/images/optimized/${fileName}`,
    width: info.width,
    format: extension(format),
    bytes: info.size,
  };
}

async function optimizeOne(filePath: string) {
  const metadata = await sharp(filePath).metadata();
  const sourceWidth = metadata.width ?? widths[widths.length - 1];
  const targetWidths: number[] = widths.filter((width) => width <= sourceWidth);

  if (!targetWidths.includes(sourceWidth)) targetWidths.push(sourceWidth);
  targetWidths.sort((a, b) => a - b);

  const slug = slugFromPath(filePath);
  manifest[slug] = [];

  for (const width of targetWidths) {
    for (const format of formats) {
      manifest[slug].push(await buildVariant(filePath, slug, width, format));
    }
  }

  console.log(`optimized ${slug}: ${manifest[slug].length} files`);
}

async function main() {
  await mkdir(outputDir, { recursive: true });

  const pattern = `${inputDir.replace(/\\/g, "/")}/**/*.{jpg,jpeg,png}`;
  const files = await glob(pattern, { nodir: true });

  for (const filePath of files) {
    await optimizeOne(filePath);
  }

  await writeFile(
    path.join(outputDir, "manifest.json"),
    JSON.stringify(manifest, null, 2),
  );

  console.log(`done: ${files.length} source images`);
}

void main().catch((error) => {
  console.error(error);
  process.exit(1);
});

这里最重要的是不要放大原图。如果一张 900px 的截图被写成 1280w 文件名,之后排查页面为什么不清晰时会非常混乱。脚本把真实宽度和字节数写进 manifest,审查者可以直接看到输出结果。

实现2:响应式图片组件

生成文件以后,需要一个组件把候选图交给浏览器。浏览器会根据 source 顺序、srcsetsizes 选择最适合当前布局的文件。sizes 写错时,浏览器可能在小卡片里也下载 1280px 图片。

// src/components/OptimizedImage.tsx
import type { ImgHTMLAttributes } from "react";

type OptimizedImageProps = Omit<
  ImgHTMLAttributes<HTMLImageElement>,
  "src" | "srcSet" | "sizes" | "width" | "height" | "loading"
> & {
  slug: string;
  alt: string;
  width: number;
  height: number;
  widths?: number[];
  sizes?: string;
  priority?: boolean;
};

function srcSet(slug: string, widths: number[], extension: "avif" | "webp" | "jpg") {
  return widths
    .map((width) => `/images/optimized/${slug}-${width}w.${extension} ${width}w`)
    .join(", ");
}

export function OptimizedImage({
  slug,
  alt,
  width,
  height,
  widths = [320, 640, 960, 1280],
  sizes = "(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 960px",
  priority = false,
  className,
  ...imgProps
}: OptimizedImageProps) {
  const fallbackWidth = widths.includes(960) ? 960 : widths[Math.floor(widths.length / 2)];
  const priorityProps = priority
    ? ({ fetchPriority: "high" } as ImgHTMLAttributes<HTMLImageElement>)
    : {};

  return (
    <picture className={className}>
      <source type="image/avif" srcSet={srcSet(slug, widths, "avif")} sizes={sizes} />
      <source type="image/webp" srcSet={srcSet(slug, widths, "webp")} sizes={sizes} />
      <img
        src={`/images/optimized/${slug}-${fallbackWidth}w.jpg`}
        srcSet={srcSet(slug, widths, "jpg")}
        sizes={sizes}
        width={width}
        height={height}
        alt={alt}
        loading={priority ? "eager" : "lazy"}
        decoding={priority ? "sync" : "async"}
        {...priorityProps}
        {...imgProps}
      />
    </picture>
  );
}

只有首屏主图才应该设置 priority。如果所有图片都急切加载,CSS、字体、JavaScript 和真正重要的 LCP 图片会互相抢网络资源。判断优先级时,可以参考 web.dev 的 LCP 说明

实现3:用 CI 限制图片预算

流水线需要能自动失败。审查者通常会看布局,但不会逐个打开所有派生图片。下面的脚本会读取 manifest,并在大尺寸候选图超过预算时退出。

// scripts/check-image-budget.mjs
import { readFile } from "node:fs/promises";

const manifestUrl = new URL("../public/images/optimized/manifest.json", import.meta.url);
const manifest = JSON.parse(await readFile(manifestUrl, "utf8"));
const maxBytes = Number(process.env.IMAGE_BUDGET_BYTES ?? 240_000);
const failures = [];

for (const [slug, entries] of Object.entries(manifest)) {
  for (const entry of entries) {
    const isLargeCandidate = entry.width >= 1280 && ["avif", "webp", "jpg"].includes(entry.format);
    if (isLargeCandidate && entry.bytes > maxBytes) {
      failures.push(`${slug} ${entry.width}w.${entry.format}: ${entry.bytes} bytes`);
    }
  }
}

if (failures.length > 0) {
  console.error(`Image budget exceeded. Limit: ${maxBytes} bytes`);
  for (const failure of failures) console.error(`- ${failure}`);
  process.exit(1);
}

console.log("Image budget check passed.");
{
  "scripts": {
    "images:build": "tsx scripts/optimize-images.ts",
    "images:check": "node scripts/check-image-budget.mjs"
  }
}

一开始可以用一个简单的 240KB 上限。等收集到真实输出以后,再按用途拆成首屏图、截图、缩略图不同预算。这样不会一开始就把规则做得太复杂。

三个实用场景

第一个场景是技术博客。截图通常来自高分辨率屏幕,直接上传 PNG 会很重。把正文最大宽度、移动端宽度和文字可读性告诉 Claude Code,它就更容易生成正确的 sizes 和质量设置。

第二个场景是 SaaS 落地页。首屏产品截图往往就是 LCP 元素,所以需要固定 widthheight,只对这一张图优先加载,其余图继续懒加载。

第三个场景是电商或作品集图库。同一张原图可能出现在卡片、详情页、相关推荐和 OGP 中。manifest 可以让测试和后台知道每张原图已经生成了哪些尺寸。

常见陷阱

不要只看 AVIF 文件大小。质量压得太低时,照片可能还可以接受,但界面截图里的文字会变软。至少要分别检查照片、截图和图解。

不要省略 sizes。没有 sizes 时,浏览器可能假设图片占满整个视口,结果在小卡片中也下载大图。

不要把首屏主图设为懒加载。懒加载适合首屏以外的内容,但会让真正可见的主图更晚开始下载。

不要一次让 Claude Code 完成 CDN 上传、后台管理、框架迁移和图片转换。先做转换脚本,再做组件,最后做 CI 检查,每一步都能独立审查。

可直接使用的 Claude Code 提示词

Create an image optimization script for jpg/png files in public/images/original.
Output files to public/images/optimized.
Generate 320, 640, 960, 1280, and 1920px widths in avif, webp, and jpg.
Do not generate a width larger than the original image.
Write manifest.json with src, width, format, and bytes.
Add package scripts named images:build and images:check.
Keep the diff minimal and do not touch unrelated files.

这个提示词明确了输入、输出、格式、验证方式和不要触碰的范围。Claude Code 越清楚边界,生成的差异就越容易合并。

实测结果与下一步

Masa 的验证中,把 1920px 原始 PNG 截图换成这条流水线后,文章图片传输量降到原来的一半以下。失败的一次是把代码截图的 AVIF 质量降到 45,虽然更小,但文字明显发糊。最后稳定的做法是:照片用 50 左右的 AVIF,UI 截图同时检查 WebP/JPEG,只有首屏图使用优先加载。

下一步可以先选择一个图片目录运行 npm run images:buildnpm run images:check。确认输出质量后,再结合Claude Code 工作流自动化,把图片预算检查放进 Pull Request。

#Claude Code #图片优化 #WebP #AVIF #性能
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。