图片 WebP 与缩略图如何实现

【这是一篇写在此项目开发期的文章】 博客上传的原图动辄几 MB,列表页却只用得上小图。这篇文章将记录我怎么在后端用 sharp 生成 WebP 主图与缩略图、前端靠命名约定推导 URL,以及依赖锁文件不同步、异步生成竞态这几个踩到坑。

图片 WebP 与缩略图如何实现

图片 WebP 与缩略图如何实现

主要问题:原图直出太浪费

我的博客图片存在后端本地的 uploads 目录里,上传时 multer 存成什么格式,前端就原样请求什么格式。这就带来了两个问题:

一是体积。我平时上传的图片都是几 MB 的 JPEG,WebP 在同等观感下能小不少,但我一直没做转换。

二是尺寸浪费。首页文章列表的封面在卡片里只有两三百像素宽,但浏览器却会去下载一张原始大图,再缩到那个尺寸显示。

所以我想做的是两件事:上传时顺手转出 WebP,以及给列表单独准备一张小图。

解决方案:先定命名,再各自实现

命名约定先行

在写任何代码之前,我先定下产物命名,因为它决定了前后端怎么对接:

xxx.webp        主图,最长边 ≤ 1200px
xxx_thumb.webp  缩略图,最长边 ≤ 400px

这两个文件都生成在原图旁边。这样前端只要拿到原图 URL,把扩展名换掉就能推出 WebP 地址,不需要后端多返回任何字段,也不需要额外查一次数据库。

后端生成:sharpConverter

后端抽了一个 utils/sharpConverter.js,核心是 convertToWebP

// 主图 WebP 转换 (宽度最大 1200px)
await sharp(filePath)
  .resize(1200, null, { withoutEnlargement: true })
  .webp({ quality: 80 })
  .toFile(webpPath);

// 缩略图 (宽度 400px)
await sharp(filePath)
  .resize(400, null, { withoutEnlargement: true })
  .webp({ quality: 70 })
  .toFile(thumbPath);

几个当时特意处理的地方:

  • withoutEnlargement: true。如果原图本来就比 1200px 窄,就不要把它放大,没有意义。
  • 跳过已是 WebP 的文件。原图本来就是 .webp.avif 时直接返回,不再转一遍。
  • sharp 是可选依赖。用 try/catch 包住 require("sharp"),拿不到就让 convertToWebP 直接返回空值,不影响正常上传。

上传接口里,这个转换是不 await 的

// S-02: 异步生成 WebP + 缩略图(不阻塞上传响应)
convertToWebP(req.file.path).catch(() => {});

我当时想的是:转图是 CPU 密集活儿,让用户在那儿等几百毫秒没必要,先返回上传成功、让它在后台跑完就行。

前端推导 URL

前端 utils/image.ts 提供两个函数,就是按前面那套命名做字符串替换:

export const getThumbWebpUrl = (url?: string) => {
  if (!url) return "";
  // 已是 webp,无法再生成缩略图
  if (/\.webp($|\?)/i.test(url)) return url;
  // 去掉扩展名(保留 query),追加 _thumb.webp
  return url.replace(/\.([a-zA-Z0-9]+)(\?.*)?$/, "_thumb.webp$2");
};

getWebpUrl 同理,只是把扩展名换成 .webp

哪里用哪个

定完工具函数,我按"这张图实际会被渲染多大"来分配:

  • 文章列表卡片封面getThumbWebpUrl,400px 缩略图;
  • 文章详情页封面getWebpUrl,1200px 主图;
  • 站点 Logo、博客头像、背景图 → 暂时还用原图,只做了 normalizeAssetUrl 归一化。

加载失败要能退回去

getThumbWebpUrl 是纯字符串推导,它并不知道那个文件到底存不存在。如果 sharp 没装上、或者转换失败,xxx_thumb.webp 就是个 404。

所以卡片上配了 @error 回退:

const coverFailed = ref(false);
const coverSrc = computed(() => {
  const raw = normalizeAssetUrl(props.article.coverImage);
  if (coverFailed.value) {
    return raw;
  }
  return getThumbWebpUrl(raw);
});

一旦缩略图加载失败,就切回原图,至少不会显示成一片空白。

移动端调试时遇到的一个问题:localhost

开发环境后端返回的是 http://localhost:3000/uploads/...。我自己在电脑上看没问题,但用手机连局域网访问时,localhost 会被解析成手机自己,图片全挂。

所以加了个 normalizeAssetUrl,把本机地址前缀统一削成相对路径 /uploads/...,再交给 Nuxt 的 /uploads/** 代理转发到后端。

另一套后端也要对得上

我的项目有两个后端(Express 和 Spring Boot),前端只会按命名约定去推 URL,所以两边的产物必须完全一致

Spring Boot 那边用的是 webp-imageio(ImageIO 的 WebP 编码器),尺寸和质量参数跟 Node 端对齐:

writeWebP(resize(source, 1200), dir.resolve(baseName + ".webp"), 0.80f);
writeWebP(resize(source, 400), dir.resolve(baseName + "_thumb.webp"), 0.70f);

编码器不可用时只打一条 warn 日志,同样不影响原图上传。

踩到的几个坑

sharp 写进了 package.json,Docker 构建却挂了

这是最花时间的一个。我在 package.jsondependencies 里加了 "sharp": "^0.33.0",本地 npm install 一切正常,但 CI 的 docker-build 直接失败。

原因是 package-lock.json 没有跟着更新。Dockerfile 里用的是:

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

npm ci 会严格校验 lock 文件和 package.json 是否一致,不一致就直接报错退出,而不是像 npm install 那样顺手补上。所以哪怕 package.json 写对了,lock 没同步照样构建不了。

修法是重新生成 lock 文件(那一次提交只动了 package-lock.json,多了几百行)。这件事之后我记住了:改完 package.json 一定要把 lock 一起提交。

Dockerfile 不能用 --ignore-scripts

装依赖时我一度想用 --ignore-scripts 加速,后来发现不行。sharp 需要靠安装脚本去下载对应平台的预编译二进制,跳过脚本就拿不到,require("sharp") 会失败。

而因为我把 sharp 做成了可选依赖、失败静默跳过,构建不会报错,图片却全部悄悄没转换——这种"看起来正常"的失效最难查。所以 Dockerfile 里专门留了注释:

# 注意:不能使用 --ignore-scripts,否则 sharp 无法下载预编译二进制,
# 导致 WebP 图片转换静默失效。

异步生成带来的竞态

前面提到转换是不 await 的。这带来一个我一开始没料到的情况:

上传接口返回成功时,WebP 文件可能还没写完。如果前端在拿到响应后立刻渲染封面,请求 xxx_thumb.webp 就会 404,触发 @error 回退到原图——然后 coverFailed 变成 true,只要 coverImage 不变,它就不会再回头试缩略图了。

表现就是:第一眼看是大图,刷新一下才变成缩略图

这个我还没想好怎么修。可选的做法有几种——转换完再返回(牺牲响应速度)、前端失败后延迟重试一次、或者干脆前端多请求一次。暂时先记着。

原图本身是 WebP 时要原样返回

后端遇到已经是 .webp 的图会跳过转换,所以不会有 _thumb.webp。如果前端这时候还硬去推 xxx_thumb.webp,必然 404。

所以 getThumbWebpUrl 里加了这一句:

if (/\.webp($|\?)/i.test(url)) return url;

后端留了两个没人用的函数

sharpConverter.js 里还导出了 getWebPUrlisSharpAvailable,本意是给后端自己拼 URL 用的,但实际 URL 推导全在前端做了,这两个函数至今没有调用方。属于我写早了的代码,先留着,哪天顺手清掉。

还没想清楚的地方

尺寸只有两档。 现在只有 1200px 和 400px。像平板这种中间宽度,要么用主图偏大,要么用缩略图偏糊,没有合适的档位。更细的按需取图我还没做。

存量图片没补。 新上传的图有 WebP 变体,但之前已经存进 uploads 的老图还是原样。要给历史图片批量补生成,我还没想好是写个一次性脚本,还是干脆手动挑几张重新传。

封面用的还是 NuxtImg 我感觉它跟 /uploads 的代理配合可能有问题(它好像会把路径改写成自己的图片处理前缀,那样就绕过代理了),但我还没实际去查。这个得找时间确认一下。

浙ICP备2026077668号

欢迎来到我的网站 这个网站主要是作技术和生活两个维度的记录