图床完整方案:R2 上传接口与图片生命周期管理
上一篇配好了 Cloudflare R2 存储桶,这篇讲代码:上传接口怎么写,图片的 key 怎么起名,以及图片什么时候该被删掉。最后这一点最容易被忽略:文章里删掉一张图,或者整篇文章删掉,桶里的图片都不会自己消失。
上传接口
上传接口做三件事:确认是管理员,收下文件,写进 R2 并返回公开地址。
// server/api/upload/upload-image.post.ts(简化版)
export default defineEventHandler(async (event) => {
await serverAdminAuth(event) // 只有管理员能传
const formData = await readMultipartFormData(event)
const file = formData?.find(item => item.name === 'image')
if (!file?.data) throw createError({ statusCode: 400, message: 'No file provided' })
const ext = file.filename?.split('.').pop() || 'png'
const key = `blog/${nanoid()}.${ext}` // 前缀 + 随机名 + 扩展名
await createR2Client(event).put(key, file.data, file.type || 'application/octet-stream')
const base = useRuntimeConfig(event).public.cloudflareR2PublicUrl.replace(/\/+$/, '')
return { url: `${base}/${key}` }
})
文件名用 nanoid() 随机生成,原来的文件名只用来取扩展名。这样既不会重名,也躲开了中文和空格文件名带来的各种转义问题。
公开地址的环境变量要填域名根,比如 https://images.greatpi.dev,不要带 /blog。前缀已经在 key 里了,两边都带会拼成 /blog/blog/。
不要在 Worker 里用 AWS SDK
R2 兼容 S3 的接口,所以最自然的做法是装 @aws-sdk/client-s3,我一开始也是这么写的。在本地一切正常,部署到 Cloudflare Pages 之后,上传接口开始返回 500。
原因是 AWS SDK 从 3.7xx 版本起,在 Worker 环境里会尝试去读 ~/.aws 下的配置文件(fs.readFile),而 Worker 的运行时根本没有文件系统。这个问题在官方仓库里有 issue(#7446),官方的回复是不修,让你去改运行时。
最后我换成了 aws4fetch。它只做一件事:给 fetch 请求加上 S3 需要的签名。没有文件系统依赖,包也小一个数量级。我只需要上传和删除两个操作,自己包一层就够了:
// server/utils/r2.ts(简化版)
import { AwsClient } from 'aws4fetch'
export function createR2Client(event: H3Event) {
const { cloudflareAccountId, cloudflareR2BucketName, cloudflareAccessKeyId, cloudflareSecretAccessKey }
= useRuntimeConfig(event)
const aws = new AwsClient({
accessKeyId: cloudflareAccessKeyId,
secretAccessKey: cloudflareSecretAccessKey,
service: 's3',
region: 'auto'
})
const base = `https://${cloudflareAccountId}.r2.cloudflarestorage.com/${cloudflareR2BucketName}`
return {
put: async (key: string, body: Uint8Array, contentType: string) => {
const res = await aws.fetch(`${base}/${key}`, { method: 'PUT', body, headers: { 'content-type': contentType } })
if (!res.ok) throw new Error(`R2 PUT failed: ${res.status}`)
},
delete: async (key: string) => {
const res = await aws.fetch(`${base}/${key}`, { method: 'DELETE' })
if (!res.ok) throw new Error(`R2 DELETE failed: ${res.status}`)
}
}
}
实际的代码里还多了一步:四个环境变量缺任何一个,就直接报 500 并写明缺的是哪个。之前那次 500 排查了很久,一部分原因就是配置缺失时 SDK 不报错,而是悄悄去走默认的凭据查找流程,最后才撞上文件系统的问题。
key 用前缀加随机名,不按目录细分
R2 和 S3 其实没有目录,所谓前缀只是 key 字符串的开头。我所有编辑器上传的图片都放在 blog/ 前缀下,以后要按这一类图片做迁移、设缓存规则、统计用量,都有办法下手。
再往下按日期或者文章 id 细分,我觉得收益很小。图片地址是作为绝对 URL 写死在 markdown 正文里的,以后换了目录结构,旧图的地址不会跟着变,两套结构共存反而更乱。至于「怎么找到某张图」,靠的是编辑器和后台,不是去翻存储桶。
编辑时:删掉文章里的图,也要删掉桶里的图
在编辑器里删掉一张图,桶里那张图还在。所以保存时要做一次清理:对比打开编辑器时文章里的图片集合和现在的集合,少掉的那些就是要删的。
这里有一个很隐蔽的坑。S3 删除一个不存在的 key,返回的也是成功。如果你只拿文件名 abc.png 去删,而真实的 key 是 blog/abc.png,接口照样成功,实际什么也没删,日志里也看不出任何问题。所以从正文里提取 key 时,必须取完整路径:
// shared/images.ts
export function extractImageKeys(content: string, r2Base: string): Set<string> {
const keys = new Set<string>()
for (const match of content.matchAll(/!\[.*?\]\((.*?)\)/g)) {
const url = match[1]?.trim()
if (!url?.startsWith(`${r2Base}/`)) continue // 只收自己桶里的图
const key = url.slice(r2Base.length + 1).split(/[?#]/)[0]
if (key) keys.add(decodeURIComponent(key))
}
return keys
}
只收自己桶里的图也是同样的道理。文章里引用的外链图片,它的文件名不该被拿去 R2 删,删了也是「成功但什么都没发生」,还会让你以为清理生效了。
删除文章时:先取正文,再删行,最后删图
编辑时的清理只管编辑。整篇文章删掉的时候,文章里的图片也得一起清。删除接口的顺序是:
// server/api/blog/posts/[id]/index.delete.ts(简化版)
const { data: post } = await supabase.from('posts').select('content').eq('id', id).single()
await supabase.from('posts').delete().eq('id', id) // 删行
await cache?.delete(detailCacheKey) // 让详情页缓存失效
const keys = extractImageKeys(post.content, base) // 从正文里取出图片
await Promise.all([...keys].map(key => r2.delete(key)))
顺序不能乱:正文必须在删行之前取,行删了就取不到了。
图片删除要 await。在 Serverless 环境里,没等完的 promise 会随着请求结束被取消,这和缓存那篇里 cache.put 的坑是同一个原因。删文章是很少做的操作,多等几百毫秒无所谓,要保证图片真的删掉了。
图片删除失败只记日志,不影响文章本身的删除。清理图片是尽力而为,主操作已经完成了。
中间那一步删缓存也不能少。详情页的接口有 10 分钟的缓存,不删的话,读者在这 10 分钟里还能打开一篇已经删掉的文章。
上传前:在浏览器里转 WebP、缩尺寸
编辑器在上传之前,会先在浏览器里把图片处理一遍:最长边超过 2000px 的缩到 2000px,再转成质量 0.82 的 WebP。
这一步放在前端做,是因为服务端做不了。上传接口跑在 Cloudflare 的 Worker 上,常用的图片库 sharp 依赖原生模块,在 Worker 里根本装不上;想在服务端转,就只能另外付费用 Cloudflare Images。而这个编辑器只有我自己用,浏览器用 Canvas 就能转,不花钱,也不用加任何依赖:
const toWebp = async (file: File): Promise<File> => {
// GIF 画进 canvas 只剩第一帧,动图就没了;SVG 是矢量,转成位图是降级。都原样上传
if (file.type === 'image/gif' || file.type === 'image/svg+xml') return file
const bitmap = await createImageBitmap(file)
const scale = Math.min(1, 2000 / Math.max(bitmap.width, bitmap.height))
const canvas = document.createElement('canvas')
canvas.width = Math.round(bitmap.width * scale)
canvas.height = Math.round(bitmap.height * scale)
canvas.getContext('2d')!.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
const blob = await new Promise<Blob | null>(r => canvas.toBlob(r, 'image/webp', 0.82))
// 没缩尺寸、转完反而更大(小图、已经压过的图会这样),就传原图
if (!blob || (scale === 1 && blob.size >= file.size)) return file
return new File([blob], file.name.replace(/\.[^.]+$/, '.webp'), { type: 'image/webp' })
}
真正省空间的其实是缩尺寸,而不是换格式。我的正文宽度只有 672px,一张 3840px 宽的截图放进去,多出来的像素都是浪费。
这一步是后来才加的,之前传的图都是原样存的。后来我用同一套规则把早期的 22 张 PNG 也批量转了一遍:在浏览器里转好、传新图、替换正文里的链接,确认保存成功之后再删旧图。总共从 6.5MB 降到了 1.5MB,最大的那张从 1.5MB 降到 158KB。
这套方案里最花时间的两个问题,AWS SDK 在 Worker 上崩溃和「删除成功但什么都没删」,都有一个共同点:接口没有报错。所以我现在写这类接口,会让配置缺失时直接报错,删除之后也会去桶里确认一下图片真的没了。