跳到正文

· 6 分钟 · 前端 / Cloudflare R2 / 图片处理

照片从代码仓库搬进后台:在浏览器里做完图片处理

以前加一张照片要在电脑上跑脚本、改代码、重新部署;现在站长在后台拖进去就行。讲清楚:原来的图片处理流程怎么整个搬进浏览器,排序接口怎么防止「拿着旧列表把新照片挤掉」,以及一个让照片「隐身」的动画 bug。

这是 Seabloom 建站记录的第九篇。这次只做一件事:摄影页的照片改成在站长后台上传、排序、删除。

一、以前是怎么加照片的

第一篇讲过:照片放在 photos/originals/ 里,运行 pnpm photos,脚本用 sharp 把每张照片:

  1. 按拍摄方向摆正;
  2. 缩成 1600 宽和 800 宽两份 WebP;
  3. 生成一张 16 像素宽的模糊小图(加载前先显示大致颜色);
  4. 读出相机参数,去掉 GPS 位置;

处理好的文件放进 public/photos/,信息写进一个 JSON 文件,然后提交代码、重新部署。

对写代码的人来说这很方便,但对站长来说,加一张照片要会用命令行、会用 Git。而且照片随代码一起部署,照片越多,每次部署要上传的东西就越多。

二、现在:站长在后台拖进去

后台多了一个「摄影」标签页:

  • 把照片拖进去(或者点击选择),可以一次选多张;
  • 每张照片可以填标题、地点、拍摄日期;
  • 用 ↑ ↓ 调整顺序,排在最前面的 3 张会出现在首页;
  • 删除前要再确认一次。

照片存在 R2 里,信息存在数据库里,和社区图片、站长文件是同一套做法。

三、把图片处理流程搬进浏览器

最关键的问题是:原来脚本做的那四件事,现在谁来做?

Workers 里没有 sharp(它依赖原生的 C++ 库),CPU 时间也有限制,所以不能在服务器上处理。答案和第五篇社区图片一样:在站长的浏览器里做完,再上传结果。

原来脚本做的现在浏览器怎么做
按拍摄方向摆正createImageBitmap(file, { imageOrientation: "from-image" })
缩成 1600 / 800 两份画到 canvas 上,toBlob("image/webp") 各导出一次
16 像素的模糊小图同上,再缩到 16 宽,用 toDataURL 变成一小段文本存进数据库
读相机参数、去掉 GPS用 exifr 读出参数(只读需要的字段,不读位置);canvas 重新编码后,原图里的所有 EXIF 都没了

几个细节:

exifr 按需加载。 读 EXIF 的库只有站长上传时才用得到,所以用动态 import():

const { default: exifr } = await import("exifr");

打包工具会把它拆成一个单独的文件,只有真的上传照片时才下载,其他人打开网站不会多下载这几十 KB。

一张一张地处理。 一次选了 20 张照片,如果同时处理,浏览器要同时在内存里解码 20 张几千万像素的图片,手机上很可能直接崩掉。所以是串行的:处理完一张、上传完,再处理下一张,界面上显示「正在处理 3 / 20」。

用一个上传请求带三样东西。 元数据(JSON)、大图、小图用 multipart/form-data 一起发,后端用 c.req.formData() 读出来。这次没有像站长文件那样把请求体直接「流」进 R2,因为两个文件都要先检查文件头(确认真的是图片),而且压缩后都很小。

验证「位置真的被去掉了」

这是隐私问题,不能只靠「理论上 canvas 会去掉」。测试时专门造了一张带完整 EXIF 的 JPEG:相机 FUJIFILM X100V、光圈 f/2、拍摄时间,再加上一组 GPS 坐标。上传后检查:

  • 后台读出了相机、焦距、光圈、快门、ISO 和拍摄日期 ✅
  • 把上传到 R2 的文件下载回来,里面找不到 Exif,也找不到 GPS ✅

涉及隐私的承诺,一定要用真实的数据验证一遍。

四、排序:别拿着旧列表覆盖新数据

排序接口的设计是:前端把排好的整个 ID 列表发过来,后端按顺序写回每张照片的位置。

PUT /api/admin/photos/order
{ "ids": ["c", "a", "b"] }

这样做简单,但有一个隐患。假设两个站长同时在后台:

  1. 站长 A 打开页面,看到照片 a、b、c;
  2. 站长 B 上传了一张新照片 d;
  3. 站长 A 调整了顺序,发送 ["c", "a", "b"]。

如果后端照单全收,d 就没有被安排位置;如果是「按列表重建顺序、删掉不在列表里的」,d 甚至会被删掉。

所以后端会先检查:发来的列表必须正好是现在的全部照片,一张不多、一张不少、没有重复。对不上就拒绝,返回 409(Conflict,冲突),前端提示「照片列表有变化,请刷新后再排序」。

if (
  ids.length !== current.size ||
  new Set(ids).size !== ids.length ||
  !ids.every((id) => current.has(id))
) {
  return fail(c, 409, "stale", "照片列表有变化,请刷新后再排序");
}

这是一种很朴素的乐观并发控制:不加锁,假设大部分时候没有冲突,提交时再检查数据是否还是当初看到的样子。更通用的做法是给数据加一个版本号,提交时带上,版本对不上就拒绝。

五、一个让照片「隐身」的 bug

功能写完,打开摄影页:标题在,照片的位置也占好了,但照片全是透明的。

原因在网站的滚动动画上。很多元素带着 data-reveal 属性,滚动到它们时会淡入。实现方式是:页面加载完时,找出所有 data-reveal 元素,交给 IntersectionObserver 盯着,进入视野就加上 is-revealed。在这之前,CSS 让它们保持透明。

以前照片是在构建时就写进页面的,加载完就在。现在照片是从接口加载的,等它们出现在页面上时,动画的「找元素」那一步早就结束了。没人盯着它们,它们就一直透明。

修法是让动画也能发现「后来加进来的元素」,用 MutationObserver 监听页面的变化:

const mutations = new MutationObserver((records) => {
  for (const record of records) {
    for (const node of record.addedNodes) {
      if (!(node instanceof HTMLElement)) continue;
      if (node.matches("[data-reveal]")) track([node]);
      track(node.querySelectorAll("[data-reveal]"));
    }
  }
});
mutations.observe(document.body, { childList: true, subtree: true });
  • IntersectionObserver:盯着元素「有没有进入视野」;
  • MutationObserver:盯着页面「有没有加进新元素」。

两个配合,不管元素是什么时候出现的,都能正常淡入。这个修复是全站通用的,以后任何从接口加载的内容加上 data-reveal,都不会再遇到这个问题。

这类 bug 的共同点是:一个功能默认了「内容在页面加载时就全部存在」,而这个假设被后来的改动打破了。 网站从静态逐渐变成动态的过程中,这种隐含的假设特别容易出问题。

六、清理

照片搬进后台后,这些东西都不需要了,一起删掉:

  • scripts/photos.mjs 处理脚本;
  • public/photos/ 里的 5 张示例照片;
  • photos.generated.json 照片数据文件;
  • sharp 这个依赖(它带着几十 MB 的原生库,只有这个脚本在用)。

R2 的空间也重新分了一下:站长文件 5 GB + 帖子图片 3 GB + 摄影 2 GB,正好是 R2 免费的 10 GB。

七、面试怎么讲

原来照片在本地用 sharp 脚本处理后随代码部署。我把它改成了后台上传:处理流程整个搬进浏览器,用 createImageBitmap 按 EXIF 方向摆正,canvas 导出两种尺寸的 WebP 和模糊占位图,exifr 按需加载只读相机参数;canvas 重新编码会丢掉所有 EXIF,我用一张带 GPS 的测试图验证过上传后的文件里没有位置信息。排序接口接收完整的 ID 列表,提交时校验列表和当前数据完全一致,否则返回 409,避免并发时旧列表覆盖新数据。还修了一个滚动动画的 bug:异步加载的元素没被观察到,用 MutationObserver 补上了。

Q:图片处理放在客户端和服务端各有什么利弊? A:客户端处理不占服务器资源,上传的文件更小,也能在上传前就去掉隐私信息;缺点是依赖用户设备的性能和浏览器支持,比如旧浏览器导不出 WebP,要有降级;而且客户端的结果不可信,服务端仍然要校验类型和大小。服务端处理结果可控、格式统一,但要消耗计算资源,Serverless 环境还有 CPU 时间和原生依赖的限制。

Q:什么是乐观并发控制? A:不加锁,允许多人同时编辑,提交时检查数据是否被别人改过:常见做法是带上版本号或最后修改时间,和数据库里的对不上就拒绝,让用户刷新后重试。适合冲突少的场景。悲观并发控制是先加锁再修改,适合冲突频繁、代价高的场景。

八、下一步

  • 照片多了以后,可以加「相册」分组;
  • 后台排序可以改成拖拽,现在的 ↑ ↓ 按钮在照片很多时会比较累;
  • 关于页、项目、吉他曲目这些占位内容,也可以用类似的方式搬进后台。