← Back to Blog
EN中文

给相册瘦个身:构建时缩略图预处理实战

我的个人网站 yuxu.ge 有一个我很喜欢的相册页面,用来记录和分享生活中的一些摄影作品。随着照片越攒越多,我渐渐发现一个尴尬的问题:相册页面变得越来越慢,尤其是对于网络环境不好的用户,简直是一场灾难。

最近,我终于下定决心解决这个性能瓶颈。通过一套简单的构建时预处理流水线,我成功将相册首屏的加载量从约 8.5MB 剧降到 0.7MB,性能提升了惊人的 91%。这篇文章,我想和你分享一下整个"瘦身"过程。

问题在哪?病根是"大图小用"

诊断性能问题,我通常会打开 Chrome DevTools 的 Network 面板。不看不知道,一看吓一跳。在我的相册页面,虽然用户看到的是一个由许多小方格组成的图片墙,但每个方格加载的,竟然都是一张原始尺寸的图片。

我们来算一笔账:

  • 我的照片经过初步压缩后,平均每张大小约 469KB
  • 在桌面端,相册首屏通常会加载 18 张图片。
  • 总加载量 = 469KB × 18 ≈ 8.5MB

这是一个非常恐怖的数字。用户在屏幕上看到的,可能只是一个宽度 300px 左右的缩略图格子,浏览器却在背后吭哧吭哧地下载一张 2000px 宽的"高清大图"。这完全是"杀鸡用牛刀",浪费了用户的流量和时间。

更糟糕的是,我的首页有一个"生活瞬间"的胶片滚动条,里面的图片尺寸更小,大约只有 120×80px,但它们同样在加载原始大图。这让我的首页也背上了沉重的负担。

解决方案:构建时生成缩略图

问题很明确,解决方案也呼之欲出:为相册里的每张图片生成一个专门用于网格预览的缩略图。

我的目标很简单:

  1. 网格视图(Grid View):加载并显示小尺寸、低文件大小的缩略图。
  2. 灯箱视图(Lightbox View):当用户点击缩略图时,再加载并显示完整尺寸的原始图片。

整个实现的核心思路是 "构建时处理" (Build-time Processing)。我的网站是静态生成的,这意味着我可以在网站构建的环节,提前把所有需要的缩略图都生成好。这样做的好处是:

  • 零运行时成本:服务器不需要在用户请求时动态生成图片,浏览器也不需要做任何处理。一切都在部署前完成。
  • 极致的加载性能:用户访问时,请求的就是一个已经优化好的、小巧的静态图片文件。

我选择了强大的命令行工具 ImageMagick 来完成这个任务。具体的优化参数设定如下:

  • 缩略图宽度:600px(对于高分屏下的 300px 格子,2x 分辨率足够清晰)
  • JPEG 压缩质量:75%(在视觉质量和文件大小之间的一个经典平衡点)

经过这番处理,效果立竿见影:

  • 原始图片平均大小:~469KB
  • 缩略图平均大小:~25KB
  • 单张图片体积缩减:~94.7%

现在,让我们看看具体是怎么实现的。

实现第一步:升级 compress-photos.sh 脚本

我的项目里原本就有一个 compress-photos.sh 脚本,用于将相机直出的原始图片(通常很大)压缩到 2000px 宽、85% 质量,作为网站上使用的"原始大图"。我需要做的,就是在这个脚本里增加一个生成缩略图的步骤。

这是新增的核心代码:

# Thumbnail settings
THUMB_WIDTH=600
THUMB_QUALITY=75

# ... a loop over each image file `img` ...

# Naming: insert -thumb before extension
# e.g., A7C00748.JPG → A7C00748-thumb.JPG
ext="${img##*.}"
base="${img%.*}"
thumb_file="${base}-thumb.${ext}"

magick "$img" \
    -auto-orient \
    -resize "${THUMB_WIDTH}x>" \
    -quality "$THUMB_QUALITY" \
    -strip \
    -interlace Plane \
    "$thumb_file"

我们来逐行解析一下 magick 这个命令:

  • -auto-orient: 自动旋转图片。有些手机或相机拍摄的照片带有方向信息(EXIF),这个参数能确保图片方向正确。
  • -resize "${THUMB_WIDTH}x>": 这是调整尺寸的核心。600x 表示宽度调整为 600px,高度按比例缩放。末尾的 > 符号是关键,它表示"仅当图片宽度大于 600px 时才缩小",避免了将小图放大的情况。
  • -quality "$THUMB_QUALITY": 设置 JPEG 压缩质量为 75。
  • -strip: 移除所有 EXIF 元数据(如相机型号、拍摄参数等)。对于缩略图来说,这些信息是多余的,去掉可以节省一些空间。
  • -interlace Plane: 生成渐进式 JPEG (Progressive JPEG)。这种格式的图片在加载时会先显示一个模糊的轮廓,然后逐渐变清晰,可以提升用户感知到的加载速度。

为了让这个脚本在日常使用中更友好,我还加入了几个辅助功能:

  • 缓存机制:我创建了一个 .thumb-cache 文件来记录已经生成过缩略图的图片。每次运行时,脚本会先检查缓存,跳过已经处理过的文件,大大加快了后续执行速度。
  • 命令行标志:支持 --force 强制重新生成所有缩略图,以及 --dry-run 只打印将要执行的操作而不实际生成文件,方便调试。
  • 避免递归处理:脚本会过滤掉文件名中已经包含 -thumb 的图片,防止对已经生成的缩略图再次处理。

实现第二步:更新 build-photos-json.js 数据源

我的相册页面不是直接读取文件目录,而是通过一个 photos.json 文件来获取图片列表。这个 JSON 文件由一个 Node.js 脚本 build-photos-json.js 在构建时生成。

我需要修改这个脚本,让它在生成 JSON 的时候,不仅包含原始图片的路径,也包含对应缩略图的路径。

修改后的逻辑大致如下:

// Thumbnail path construction
groupThumbs = groupFiles.map(img => {
    const dot = img.lastIndexOf('.');
    // Manually construct the thumb path to preserve extension case
    return `/_content/photos/${year}/${event}/${img.substring(0, dot)}-thumb.${img.substring(dot + 1)}`;
});

最终生成的 photos.json 结构从这样:

{
  "images": ["/_content/photos/2026/.../A7C00748.JPG"]
}

变成了这样:

{
  "images": ["/_content/photos/2026/.../A7C00748.JPG"],
  "thumbs": ["/_content/photos/2026/.../A7C00748-thumb.JPG"]
}

这样,前端组件就能同时拿到原始图和缩略图的地址了。

实现第三步:改造前端代码

万事俱备,只欠东风。最后一步就是修改前端代码,让它真正用上我们新生成的缩略图。

相册页面 (gallery/index.html)

在渲染图片网格时,我修改了 <img> 标签的 src 属性,让它指向缩略图。同时,为了增加健壮性,我添加了一个 onerror 的 fallback 机制。

<img src="${thumb}" alt="${group.location}" loading="lazy"
     onerror="this.onerror=null;this.src='${encodedImg}'">

这里的 onerror 属性是一个很棒的保险措施。它的意思是:"如果 src 指定的缩略图加载失败(比如因为某些原因没有生成成功),那么就不要再尝试加载了(this.onerror=null),并且将 src 切换到原始大图的地址。"

这样一来,即使缩略图缺失,页面也不会显示一个破碎的图片图标,而是会优雅地降级去加载原始图片,保证了功能的完整性。

首页胶片滚动条

首页的"生活瞬间"组件也同样得到了优化。它的数据处理逻辑被修改为优先使用缩略图:

let allPairs = photos.flatMap(p =>
    p.images.map((img, i) => ({
        thumb: p.thumbs && p.thumbs[i] ? p.thumbs[i] : img,
        original: img
    }))
);

这段代码为每张图片创建了一个 {thumb, original} 对象。如果 p.thumbs[i] 存在,就用它;如果不存在,就用原始图片 img 作为 thumb 的备选。对于只有 120px 宽的胶片格子,600px 的缩略图绰绰有余,加载速度却快了几十倍。

意外之喜:头像优化

在优化完相册后,我突然想到,我的个人头像图片是不是也可以用同样的方式处理?

网站导航栏和关于页面的头像其实显示尺寸很小,但原始文件 avatar.jpg 却有 352KB。我立刻用 ImageMagick 为它生成了一个缩略图版本 avatar-thumb.jpg,大小只有 32KB,体积减少了 91%

成果展示:数据不会说谎

经过这一系列操作,我的网站性能得到了质的飞跃。让我们用数据说话:

场景 优化前 优化后 性能提升
相册首屏 (约18张图) ~8.5MB ~0.7MB ~91%
单张图片平均大小 ~469KB ~25KB ~94.7%
首页胶片条 (30张图) ~14MB ~0.75MB ~94.6%
个人头像 352KB 32KB 91%

加载时间从肉眼可见的"等待"变成了"瞬间打开",尤其是在移动设备上,体验提升极为明显。

踩坑记录:一个关于文件扩展名大小写的 Bug

build-photos-json.js 脚本中,我最初使用了 Node.js 的 path 模块来构建缩略图路径,代码类似这样:

// The buggy version
const path = require('path');
const ext = path.extname(img);   // .JPG -> .jpg (!)
const base = path.basename(img, ext);
const thumbFile = `${base}-thumb${ext}`;

这段代码在我的 macOS(默认文件系统不区分大小写)上运行得很好。但当我把网站部署到 Linux 服务器(区分大小写)上时,问题就来了。我的原始文件名是 A7C00748.JPG(大写),但 path.extname 会返回小写的 .jpg。这导致脚本生成的缩略图路径是 ...-thumb.jpg,而实际文件是 ...-thumb.JPG,路径不匹配,导致所有缩略图 404 Not Found。

最终,我放弃了 path 模块,改用简单的字符串操作来解决:

const dot = img.lastIndexOf('.');
return `.../${img.substring(0, dot)}-thumb.${img.substring(dot + 1)}`;

这个方法能完美保留原始文件扩展名的大小写,从而解决了跨平台部署的问题。这是一个小细节,但足以让整个功能失效,值得警惕。

总结

为图片库实现构建时缩略图预处理,是一项投入产出比极高的性能优化。它完美地诠释了"把工作尽可能往前做"的理念。

  • 简单有效:核心逻辑只是一个脚本和几行前端代码的修改,却带来了近 10 倍的性能提升。
  • 零运行时成本:所有图片处理都在开发和构建阶段完成,对线上服务器和用户浏览器没有任何额外负担。
  • 体验与健壮性兼备:不仅大大提升了加载速度,onerror fallback 机制还保证了即使在异常情况下,网站功能依然可用。

这个方法不仅适用于我的个人网站,对于任何包含大量图片的静态网站(如博客、作品集、产品展示网站等)都具有很好的借鉴意义。如果你也遇到了类似的性能问题,不妨动手给你的相册也"瘦个身"吧!