背景:博客项目搬家后首页封面加载慢

博客从国外 Netlify 迁回国内一台轻量云服务器。迁移完打开首页,最直观的感受就是慢:头像、文章封面要等好几秒才出来,体验很不好。

优化效果:体积与加载时间对比

项目压缩前压缩后
单张封面3.0~4.45MB24~78KB
全部 13 张封面合计约 40MB约 0.5MB

平均压缩率 99%,省了 34.6MB。加载时间按服务器 3Mbps 出口带宽粗算,一张 4.45MB 的图要下 11 秒多,压完之后只要 0.08 秒,差了两个数量级。

根因分析:瓶颈在封面原图

排查思路其实很简单:先对比,再测量。

首页有两个图片区,一个是「人生瞬间」相册,一个是文章封面。相册页秒开,封面卡。相册图走 Cloudflare R2 的签名 URL,封面存在服务器本地。CDN 快、源站慢,问题基本锁定在源站出去的资源上。

然后 ls -lh dist/client/covers/ 一看封面大小:全是 3~4MB 的 webp。个人轻量服务器的出口带宽就那么大,一张 4MB 的图光传输就要好几秒,首页十几张图一起拉,白屏几秒是必然的。

再细想:这些封面是 AI 生成、模型导出时直接拿出来的,没做过体积管理。出图默认高分辨率、全质量保真,丢进项目时也没设任何上限。之前部署在 Netlify 上有 CDN 兜底,图再大也感觉不明显;一迁回自己的服务器,问题就暴露了。

实现方案:基于 sharp 的批量压缩

sharp 是 Node.js 生态里的图片处理库,底层是 C 写的 libvips,做缩放、格式转换、压缩这类操作又快又省内存。项目 package.json 的 dependencies 里本来就有,估计是建站时某个依赖带进来的,正好拿来用,不用再引入新东西。

写脚本前先考虑清楚三个问题:

  1. 封面需要多宽:封面在文章卡片、页面顶部横幅这些位置,实际渲染宽度不会超过 1200px,所以「宽最多 1200」。
  2. 用哪种格式:同样的内容 webp 比 jpeg 更小、jpeg 又比 png 小,封面图应该优先 webp,按扩展名分流处理。
  3. 质量压到多少:给每个格式一个「看不出糊」的保守值,webp 72、jpeg 75、png 走最优压缩。

概括起来,三步走:定尺寸上限 → 定格式策略 → 定质量档位。

捋清楚后脚本如下。完整脚本在 scripts/compress-covers.mjs,核心逻辑不长:

const meta = await sharp(p).metadata();
const width = Math.min(meta.width || 1200, 1200);
if (f.toLowerCase().endsWith('.webp')) {
  buf = await sharp(p).resize({ width, withoutEnlargement: true }).webp({ quality: 72 }).toBuffer();
} else if (f.toLowerCase().endsWith('.png')) {
  buf = await sharp(p)
    .resize({ width, withoutEnlargement: true })
    .png({ compressionLevel: 9 })
    .toBuffer();
} else {
  buf = await sharp(p)
    .resize({ width, withoutEnlargement: true })
    .jpeg({ quality: 75, mozjpeg: true })
    .toBuffer();
}

几处代码的含义:

  • sharp(p).metadata():读取原图元信息,拿到原始宽度
  • Math.min(meta.width || 1200, 1200):取「原图宽和 1200 的较小值」,原图本来就小于 1200 就不动尺寸;resize 里的 withoutEnlargement: true 再兜一层,保证小图不会被放大
  • 三个分支按扩展名分流,各自走对应格式的压缩参数
  • 压完 fs.writeFileSync(p, buf) 直接覆盖原文件,循环里把每张图的前后体积打印出来,最后汇总总共省了多少

跑一遍看输出,确认每张都正常缩小,然后 astro build 验证构建产物,部署后复测首页,加载肉眼可见地从「等几秒」变成「秒开」。

压完之后的图和之前比,除了体积,肉眼上几乎没有区别,不用担心没法浏览。原因有两点:

  1. 宽度上限够用:文章列表卡片、页面顶部横幅实际渲染宽度只有几百到一千像素出头,压之前那份两三千像素的原图,浏览器根本用不到那么多细节。
  2. 质量档位合适:质量 72 是试过的甜点位,纯色、渐变这些大面积区域几乎无损,只有文字边缘这类高频细节理论上会有一点点差异,但在封面那么小的展示尺寸下看不出来。

说白了,压缩只是把「用不到的分辨率」和「肉眼看不出的冗余像素」去掉了,留下的刚好是展示要用的部分。

权衡取舍:压缩边界与维护成本

  1. 为什么不压到更狠:质量 72 是「看不太出来损失」和「体积」之间的平衡点。压到 50 体积还能小一截,但文字边缘、渐变会开始糊,封面这种大图放首页一眼就能看出问题。
  2. 原地覆盖,原图没了:脚本直接覆盖源文件,压缩后原高清图不可恢复。以后要做更高清的版本(比如 2x 屏),得重新找原图。稳妥做法是先备份再覆盖,或者输出到另一个目录。我当时图省事直接覆盖了,只能接受这个代价。
  3. png 压不动:jpg/webp 能到 99% 的压缩率,是因为原图是设计输出,有大量平滑区域可以丢掉冗余;png 那张只从 301KB 压到 78KB。封面图尽量别用 png,同样的内容 png 天生比 webp 大。
  4. 压图是补救,不是根治:这个脚本是给已有的 13 张图擦屁股。真正该做的是以后生成封面时就按「宽 1200、webp」出图,别让 4MB 的原图进仓库。

经验总结:可复用的优化流程

这次优化的核心不是「用了 sharp」,而是三步:

  • 先测量,用快慢页面对比锁定资源,不瞎猜
  • 脚本化,一次写好,以后新增封面一条命令跑完
  • 用数字收尾,压缩率、节省的字节、加载时间前后对比一目了然

「测量 → 定位 → 脚本化 → 复测」这套路径,对网站这类「慢」的问题基本都能套用:先证明慢在哪,再对症下药。