用 Claude Code 构建图片优化流水线
用 Claude Code 自动完成 WebP/AVIF 转换、响应式图片和 CI 图片预算检查。
图片优化不能只靠发布前手动压缩一次。网站里有首屏大图、文章截图、商品缩略图、图表和社交分享图以后,任何一张没有处理的 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 中的 jpg、jpeg、png,输出到 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 顺序、srcset 和 sizes 选择最适合当前布局的文件。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 元素,所以需要固定 width 和 height,只对这一张图优先加载,其余图继续懒加载。
第三个场景是电商或作品集图库。同一张原图可能出现在卡片、详情页、相关推荐和 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:build 和 npm run images:check。确认输出质量后,再结合Claude Code 工作流自动化,把图片预算检查放进 Pull Request。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code Permission Receipt Pattern:记录权限、证据和回滚方式
Claude Code 权限 receipt:记录允许动作、需要批准的边界、验证命令、回滚说明,以及 Gumroad 和咨询 CTA 检查。
Claude Code/Codex 安全 Agent Harness 实战:权限、验证与回滚
用权限策略、执行计划、验证脚本和回滚日志,为 Claude Code 与 Codex 搭建更安全的 AI Agent 工作流。
Claude Code 子代理实战指南:安全委派并行文章与代码工作
用 Claude Code 子代理安全拆分文章和代码工作:委派规则、提示词模板、失败模式与检查清单。