2.4 图片处理:批量压缩与 WebP 转换

本节是第二篇的收尾,也是整个构建链路里投入产出比最高的一环。

创业页第二个月,设计交付了首屏视觉:一张 4.2MB 的 PNG,移动端打开要 3.8 秒。运营同学问能不能“把图片压一下但别失真”,我打开在线压缩网站一张张传,传到第 6 张时意识到这事必须自动化。本节记录我们用 Node 加 sharp 搭的图片流水线:批量压缩、转 WebP、HTML 回退,顺带把 CDN 流量费也降了下来。

2.4.1 先量再优:图片占包体多少

打开 Chrome DevTools 的 Network 面板统计首屏请求:5.4MB 里 4.8MB 是图片,JS 压缩后只有 180KB。结论很明确——优化图片的 ROI 最高,远高于继续抠 JS 体积。又翻了 云监控 一周的流量数据:图片占 CDN 回源流量的八成。这组数字后来在第五篇的成本复盘里直接成为预算依据。

2.4.2 格式选择:为什么是 WebP

格式 相对体积 透明通道 浏览器支持 用途
PNG 100% 支持 全部 小图标、需要透明
JPEG 25% 到 35% 不支持 全部 照片兜底
WebP 15% 到 25% 支持 96% 以上 主力格式
AVIF 10% 到 18% 支持 85% 左右 面向未来

WebP 压缩率比 JPEG 再省三成,又支持透明与动画,2026 年兼容性已经不是问题。我们的策略:产物统一 WebP,AVIF 暂不做(编码慢、CI 时间翻倍不划算),PNG 只保留小尺寸图标。注意渐进式 JPEG 之类的细节不必折腾,目标平台已经统一现代浏览器。更多 CDN 侧的缓存与格式协商策略,参考 4.1 CDN 接入。

2.4.3 sharp 流水线

sharp 是 Node 生态最快的图像库,底层 libvips 是 C++ 实现,不依赖 GraphicsMagick 之类的外部运行时。安装一般一条命令:

npm i -D sharp

构建脚本 scripts/images.ts 的核心:

import sharp from "sharp";
import { readdir, mkdir } from "node:fs/promises";
import { join, extname } from "node:path";

const INPUT = "src/assets";
const OUTPUT = "dist/assets";

const files = (await readdir(INPUT)).filter((f) =>
  [".png", ".jpg", ".jpeg"].includes(extname(f).toLowerCase())
);

await mkdir(OUTPUT, { recursive: true });

for (const f of files) {
  const name = f.replace(/\.\w+$/, "");
  await sharp(join(INPUT, f))
    .rotate()                       // 修正手机拍照 EXIF 朝向
    .resize({ width: 1920, withoutEnlargement: true })
    .webp({ quality: 78 })
    .toFile(join(OUTPUT, `${name}.webp`));
  console.log(`${f} -> ${name}.webp`);
}

quality 78 是我们肉眼对比后的甜点值:78 以上体积直线上升但几乎看不出差别,70 以下天空出现色带。withoutEnlargement 防止小图被放大变糊,1920 封顶覆盖主流屏。批量处理 30 张图约 6 秒,已经集成进 2.2 的 npm run build 链路,一次构建全程无人工。

2.4.4 响应式:一张图管三种屏

再输出三档宽度,配合 srcset 让手机不下载 1920 宽的大图:

for (const w of [640, 1080, 1920]) {
  await sharp(join(INPUT, f))
    .resize({ width: w, withoutEnlargement: true })
    .webp({ quality: 78 })
    .toFile(join(OUTPUT, `${name}-${w}.webp`));
}

HTML 侧用 picture 标签做格式回退,写在 Vue 组件里:

<picture>
  <source type="image/webp" srcset="/assets/hero-1080.webp" />
  <img src="/assets/hero-1080.jpg" alt="首屏主视觉" loading="lazy" />
</picture>

除了格式回退,还要给浏览器喂 sizes 提示,避免它在小屏上仍然预取大图。我们的组件约定:首屏大图按视口 100vw 计,博客内文图按容器 680px 计。配错 sizes 的典型症状是 Lighthouse 显示图片明明转了 WebP,移动端流量却没降——浏览器按 sizes 下载了 1920 那一档。排查时用 DevTools 的 Network 面板按宽度筛选请求即可确认。其实整个响应式方案最终都要与 CDN 缓存策略配合,双方各管一段:构建侧出多档文件,CDN 侧做缓存与刷新,这在 CDN 接入与缓存策略 里有完整链路。

有了正确的 sizes 提示,loading=lazy 才能真正发挥作用:首屏外的图延迟加载,博客长页首屏再省 1.2MB。不支持 WebP 的老旧浏览器会自动 fallback 到 img 里的 JPEG 兜底。

2.4.5 踩坑实录

坑一:CI 上 sharp 安装失败。CI 容器架构与本地不同,sharp 预编译二进制偶有缺失。解法是 CI 缓存 node_modules 并固定 sharp 版本,避免它悄悄升级后二进制不匹配。

坑二:图名没变、内容变了,CDN 缓存里还是旧图。后来所有产物图名带内容哈希(如 hero-a3f2.webp),配合 CDN 刷新策略,彻底告别缓存不更新问题。

坑三:EXIF 朝向。手机竖拍照片不调 rotate() 时在页面上横躺,加上 .rotate() 后正常。这个属性在部分工具链里默认不开,sharp 需要显式写。

坑四:SVG 混进流水线。有次 logo 的 SVG 也被脚本扫进去,sharp 对 SVG 的处理需要额外的密度参数,直接报错中断构建。解法是在过滤扩展名时把 SVG 单独排除:SVG 本身是矢量,体积已经很小,需要优化时手工用工具压一遍即可,不值得进自动化链路。

2.4.6 压缩前后对照

把改造前后的关键页面记录下来,作为后续讲性能与成本的证据:

页面 改造前体积 改造后体积 降幅 备注
首页 5.4MB 0.9MB 83% 首屏图加响应式
产品页 3.1MB 0.6MB 81% 统一转 WebP
博客列表 4.7MB 1.1MB 77% 懒加载生效
关于我们 1.2MB 0.4MB 67% 本来图就少

这张表钉在 docs/ 里,每次设计交付新切图后跑一次脚本更新数据。有了量化指标,团队对“要不要花时间优化”再没有分歧,流量账单的变化也让老板愿意继续投入体验优化。

2.4.7 效果与小结

一周后复测:首屏图片从 4.2MB 降到 780KB,移动端打开 1.2 秒,CDN 月流量费预期降六成。图片流水线成了 npm run build 的一环,设计交付新图后跑一次命令即可,再也不用去在线压缩网站排队传图。要体验这套构建产物上线后的加载速度,可以直接试腾讯云网站托管的演示:https://cloud.tencent.com/act/cps/redirect?redirect=2497&cps_key=2886aaf7fd441c23ba682c6fda7a9aff&from=console

第二篇到这就把仓库、构建、模板、图片四件事理顺了,dist 目录随时可部署。第三篇我们正式把站传到云上:从 COS 存储桶开通 开始,接域名与 SSL 证书。

延伸阅读