Next.js 全生命周期图片优化与架构实践指南。梳理了现代化 Web 应用中“上传优化”(服务端利用 Sharp 进行格式校验、EXIF 转向、WebP 转码与尺寸限制)与“访问优化”(结合 <Image> 组件与 Next.js Image Optimizer 实现按需裁剪、响应式分发)的双层优化体系;同时提供了基于 App Router 的 Route Handler 最小实现示例,并深入分析了元信息存储设计、HTTP 长期缓存策略、大文件直传流程与软删除安全机制。
前言
图片上传可以被理解成一个很简单的功能:用户选择一张图片,服务器接收文件,然后把它放到对象存储里。
但没有这么简单,图片上传时要考虑文件大小、真实格式、尺寸、EXIF、压缩方式和重复文件处理方式;上传完成以后,还要考虑图片应该存在哪里、数据库里保存哪些信息、页面如何引用它(关系),以及最后浏览器拿到的是不是原图。
如果项目使用了 Next.js,next/image 通常还不会直接把原图直接发给浏览器,而是先经过 Next.js Image Optimizer,根据当前客户端请求的宽度、质量和格式生成一个新的响应。
在我的项目 NeutralPress 中,一张图片的生命周期大致是这样的:
选择图片
↓
上传到 Route Handler
↓
校验文件
↓
Sharp 处理图片
↓
保存文件和元数据
↓
返回图片 ID
↓
<Image> 生成图片请求
↓
Next.js Image Optimizer
↓
读取源图
↓
返回适合当前设备的图片下面只使用一个最小化的 Next.js 示例,说明图片生命周期中重要的几个环节。
上传优化和访问优化
首先要知道图片优化分为两部分。大部分旧架构中,通常只存在上传优化,即图片上传的时候进行裁剪 / 压缩等。近几年,在 Next.js 这种现代化框架中,通常除了上传优化,还会有访问优化。前者负责降低存储压力,后者则分别给出适合每个客户端的图像。
上传时可以做这些事:
- 检查文件是否真的是图片;
- 限制文件大小和像素数量;
- 统一 EXIF 方向;
- 删除不需要的元数据;
- 限制最长边;
- 转成 WebP 或 AVIF;
- 生成模糊占位图;
- 计算 hash,方便去重。
访问时则查看:
- 当前页面需要多宽的图片;
- 当前浏览器支持什么格式;
- 当前请求是否应该使用缓存;
- 是否已经有相同尺寸和格式的结果;
- 源图应该从哪里读取。
可以认为,上传阶段:把图片整理成适合保存的版本,访问阶段:把图片转换成适合当前请求的版本。举例,上传时把图片统一缩小到 1600px,并不代表每个用户都应该下载 1600px 的图片。文章列表可能只需要 384px,文章正文则可能需要 1200px,移动端甚至只需要几百像素。在传统架构中,这些客户端获取的都是完整大小的图片,而现代架构则会为其自动裁剪 / 压缩,在保证视觉观感不变的同时,优化加载速度。
初始化项目
先创建一个普通的 Next.js App Router 项目并安装 Sharp:
npx create-next-app@latest next-image-lifecycle
cd next-image-lifecycle
npm install sharp示例使用 TypeScript 和 App Router。因为 Sharp 依赖 Node.js 的能力,所以处理图片的 Route Handler 需要运行在 Node.js Runtime 中。目前应该可以用 wasm 来在 Edge 环境中使用 sharp 了,本文暂不考虑这些。
为了把重点放在流程上,下面仅使用一个内存 Map 作为临时存储。它只适合本地演示,进程重启以后图片会消失,多个实例之间不能共享图片,没有真正处理对象存储的权限和失败重试,不能在生产环境中用,但足够用来说明 Next.js 中各个环节之间是怎么连接的了。生产环境中,只需要把这个 Map 换成数据库和 S3 / R2 / Vercel Blob 等持久化存储即可,上传和访问的整体思路是一样的。
存储层
这里先定义图片记录和几个最基本的操作:
import { randomUUID } from "node:crypto";
/**
* 存储在内存中的图片元数据与二进制数据对象
*/
export type StoredImage = {
/** 图片的原始二进制数据(Node.js Buffer 对象) */
buffer: Buffer;
/** 图片的 MIME 类型(例如:"image/png"、"image/jpeg"、"image/webp") */
contentType: string;
/** 图片宽度(单位:像素 px) */
width: number;
/** 图片高度(单位:像素 px) */
height: number;
};
/**
* 扩展全局 globalThis 的类型定义
*
* 在 Next.js、Vite 或 Node.js 开发环境中,代码热更新(HMR)会重新执行模块文件。
* 如果直接声明普通的模块级变量 `const images = new Map()`,每次保存文件热重载后,
* 变量都会被重新初始化,导致之前上传/保存的数据全部丢失。
* 挂载在全局对象 `globalThis` 上可以保证开发阶段数据持久存在。
*/
type GlobalStore = typeof globalThis & {
demoImages?: Map<string, StoredImage>;
};
// 将 globalThis 断言为包含 demoImages 属性的类型
const globalStore = globalThis as GlobalStore;
/**
* 获取全局唯一的图片存储容器
*
* 初始化,使用空值赋值运算符(Nullish coalescing assignment `??=`):
* - 如果 `globalStore.demoImages` 已经是 Map,则保持原引用不变
* - 如果是 `undefined` 或 `null`,则创建一个新的 `Map<string, StoredImage>()` 并赋值
*/
const images = (globalStore.demoImages ??= new Map());
/**
* 保存一张图片到内存中
*
* @param image 要保存的图片对象(包含二进制与元信息)
* @returns 自动生成的唯一图片标识符(UUID v4)
*
* @example
* ```ts
* const id = saveImage({
* buffer: fileBuffer,
* contentType: "image/png",
* width: 800,
* height: 600,
* });
* console.log(`图片已保存,ID: ${id}`);
* ```
*/
export function saveImage(image: StoredImage): string {
// 使用 Node.js 内置 crypto 生成标准的 RFC 4122 v4 UUID
const id = randomUUID();
images.set(id, image);
return id;
}
/**
* 根据图片 ID 查询并获取图片信息
*
* @param id 保存图片时返回的 UUID 字符串
* @returns 对应的 StoredImage 对象;如果找不到则返回 undefined
*
* @example
* ```ts
* const image = findImage("some-uuid-string");
* if (!image) {
* throw new Error("图片不存在 (404)");
* }
* // 可直接用于 HTTP 响应:
* // res.setHeader("Content-Type", image.contentType);
* // res.send(image.buffer);
* ```
*/
export function findImage(id: string): StoredImage | undefined {
return images.get(id);
}这里使用 globalThis,主要是为了在开发模式下减少模块热更新造成的 Map 丢失。数据库里通常还应该保存类似下面的信息:
id
originalName
contentType
size
width
height
hash
storageKey
blurDataURL
createdAt文件本身和存储在对象存储里,数据库里一般只会用来存元信息。不过,的确有某些神人 App 在 SQLite 里存二进制的,好孩子不要这样做。
此外,如果以后要实现“这张图片被哪些文章使用”,还需要额外保存图片和内容之间的引用关系,最好使用关系型数据库。
上传接口
接下来实现一个最小的上传 Route Handler。它接收 multipart/form-data,从字段中取出名为 file 的图片。
import sharp from "sharp";
/**
* Next.js 路由段配置
*
* Sharp 底层依赖预编译的 C/C++ 原生二进制库(libvips),不能在 V8 隔离沙盒或
* 边缘运行时(Edge Runtime)中执行。
* 显式声明 `export const runtime = "nodejs"` 可确保该 API 运行在完整的 Node.js 环境中。
*/
export const runtime = "nodejs";
/**
* 处理图片上传的 POST 接口
*
* 流程:
* 1. 基础校验:参数存在性、基础 MIME 类型校验、请求体大小限制
* 2. 深度探针:利用 Sharp 解码文件头,防范伪造 MIME 扩展名的恶意文件
* 3. 图像处理:EXIF 自动转向校正、等比限制最大尺寸缩放、WebP 现代化格式转换
* 4. 数据落地:持久化存储并返回客户端渲染所需的几何元数据
*/
export async function POST(request: Request) {
// 1. 解析 multipart/form-data 请求体
const formData = await request.formData();
const file = formData.get("file");
// 基础类型校验:确保表单中包含名为 "file" 的文件对象
if (!(file instanceof File)) {
return Response.json(
{ error: "没有找到图片文件" },
{ status: 400 } // 400 Bad Request
);
}
// 初步 MIME 校验:检查客户端请求头声称的文件类型
// 注意:客户端声明的 Content-Type 极易伪造,此步骤仅作为低成本的第一道过滤
if (!file.type.startsWith("image/")) {
return Response.json(
{ error: "只允许上传图片" },
{ status: 415 } // 415 Unsupported Media Type
);
}
// 请求体积限制:限制最大为 10MB,你也可以根据业务情况自行更改
// 大型生产环境通常会在 Nginx、Caddy 或云厂商 API 网关层做硬截断,
// 避免大文件打爆 Node.js 内存;应用层做校验是双重兜底。
if (file.size > 10 * 1024 * 1024) {
return Response.json(
{ error: "图片不能超过 10MB" },
{ status: 413 } // 413 Payload Too Large
);
}
// 将 Web 标准的 ArrayBuffer 转换为 Node.js 高效的 Buffer 二进制数据
const input = Buffer.from(await file.arrayBuffer());
let metadata: sharp.Metadata;
// 2. 深度完整性校验
try {
// 仅解析图片元数据(Header),不进行完整解压像素矩阵,执行开销极低。
// 如果用户上传了一个名为 "virus.png" 但实际是可执行文件或文本的文件,Sharp 在此步会抛出异常。
metadata = await sharp(input).metadata();
} catch {
return Response.json(
{ error: "文件不是有效的图片" },
{ status: 415 }
);
}
// 语义校验:确保解析出了可用的宽和高(防止损坏的不完整破损图)
if (!metadata.width || !metadata.height) {
return Response.json(
{ error: "无法读取图片尺寸" },
{ status: 422 } // 422 Unprocessable Content
);
}
// 3. 图像处理管道
const { data, info } = await sharp(input)
/**
* .rotate() 解决手机拍摄照片的「方向颠倒」问题:
* iPhone 或现代相机拍摄竖屏照片时,实际传感器像素往往是横向保存的,方向信息记录在 EXIF 的 Orientation 标签中。
* 无参调用 `rotate()` 会自动读取 EXIF 标记将像素矩阵翻转到肉眼直立的正确方向,并自动剥离多余的 EXIF 旋转标记。
*/
.rotate()
/**
* 等比限制最大宽度缩放
* - withoutEnlargement: true:如果原图宽度小于 1600px(比如 800px),则禁止放大,防止图像模糊并白白膨胀体积。
*/
.resize({
width: 1600,
height: 1600,
fit: "inside",
withoutEnlargement: true,
})
/**
* 转码为现代 WebP 格式
* - quality: 82 是业界的「黄金平衡点」,在肉眼无法分辨的失真范围内提供极佳的压缩率(体积通常比同画质 JPEG 小 30%~50%)。
*/
.webp({ quality: 82 })
/**
* 输出为 Buffer 并附带处理后的元数据
* resolveWithObject: true 会让 Promise 返回包含 `{ data: Buffer, info: OutputInfo }` 的复合对象,
* info 中包含了经缩放与旋转后的最终像素尺寸(info.width, info.height)与最终字节大小(info.size)。
*/
.toBuffer({ resolveWithObject: true });
// 4. 数据落库/存储
// 将压缩优化后的 WebP 数据写入内存或对象存储
const id = saveImage({
buffer: data,
contentType: "image/webp",
width: info.width,
height: info.height,
});
// 返回 200 响应及前端需要的尺寸参数
return Response.json({
id,
width: info.width,
height: info.height,
});
}首先,先检查表单里是否真的有文件,然后检查文件声明的 MIME 类型和文件大小。这里的 MIME 类型只能作为第一层检查,因为用户可以修改文件扩展名和请求头。真正的图片格式检查由 Sharp 完成。如果把一段普通文本改名成 .jpg,它不会因为文件名看起来像图片就通过解析。
接下来,Sharp 会先读取图片元数据,再把图片旋转到正确方向,限制最长边,最后转换成 WebP。
这里的 rotate() 是必要的,很多手机拍出来的照片并不是直接把像素旋转好,而是把方向记录在 EXIF 里。如果不调用它,服务器处理之后可能会出现图片方向错误。
示例中把最长边限制为 1600px,具体还要看图片用途。如果是摄影作品或者原图存档,则不应该直接覆盖原始文件等。
保留策略
通常有三种选择:
只保存处理后的图片
适合:
- 文章截图;
- 普通博客配图;
- 不需要下载原图的头像;
- 只面向网页展示的图片。
优点是存储成本低,读取链路也简单。缺点是原图无法恢复,后面如果想生成更大的版本,只能让用户重新上传。
同时保存原图和处理图
适合:
- 摄影作品;
- 用户头像原图;
- 需要下载原文件的媒体库;
- 以后可能生成多个衍生尺寸的图片。
这时数据库里最好区分原始对象和衍生对象,而不是只用一个 storageKey 表示所有文件。
只保存原图,访问时再优化
适合已经有图片处理服务或 CDN 的场景。
优点是上传逻辑比较简单,后面可以根据请求动态生成不同尺寸。缺点是第一次访问时需要承担处理开销,而且每个请求都可能触发额外的回源和转换。
我通常不会把这三种方式说成谁一定更好。没钱的话只存修改之后的图即可,有钱或者是有特殊的保留原图的需求则可以再存一份,超级有钱可以运行时再压缩,毕竟挺消耗计算资源的。不过你也可以托管给其他云服务商,例如 使用 Cloudinary + CF Worker 实现图片自动优化 | RavelloH's Blog 。
图片源接口
上传成功后,前端拿到一个图片 ID。接下来需要一个 GET 接口,根据 ID 返回图片内容。下面是一个最小版本:
/**
* 路由匹配路径示例: /api/images/[id]/route.ts
*
* @param _request Web 标准 Request 对象。参数名前加下划线 `_` 是 TypeScript/ESLint 惯用法,
* 表示由于函数签名需要声明了该参数,但函数体内暂未使用。
* @param context.params 动态路由参数对象
*
* 自 Next.js 15 起,为了优化服务端渲染架构与支持流式传输(Streaming),
* 路由参数 `params` 与 `searchParams` 被设计为异步 Promise。
* 必须使用 `await params` 显式解包后才能安全访问具体的参数字段(如 id)。
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
// 1. 异步解包路由参数,提取图片唯一 ID
const { id } = await params;
// 2. 从存储容器(内存/数据库/对象存储)中按 ID 检索图片记录
const image = findImage(id);
// 3. 资源不存在时的防御响应
// 遵循 RESTful 规范,未命中的资源直接返回 404 Not Found
if (!image) {
return new Response("Not Found", { status: 404 });
}
// 4. 返回二进制媒体流与关键 HTTP 标头
// Node.js 的 Buffer 实现了 Uint8Array 接口,可直接作为 Web 标准 Response 的 BodyInit 传入
return new Response(image.buffer, {
headers: {
/**
* 告知客户端该二进制流的真实 MIME 格式(例如 "image/webp")
* 缺少该 Header 浏览器会退化为文件下载或无法直接在 <img> 标签中正确渲染。
*/
"Content-Type": image.contentType,
/**
* 缓存控制策略
* - public: 允许客户端浏览器以及所有中间代理(如 CDN 边缘节点、反向代理缓存)缓存该响应。
* - max-age=31536000: 缓存最大有效期为 1 年(365 × 24 × 60 × 60 秒),属于 HTTP 规范推荐的长期静态资源上限。
* - immutable: 声明资源内容「永久不可变」。
*
* 这里的图片是通过随机 UUID(或内容 Hash)命名的。一旦保存,该 ID 对应的内容就永远不会发生改变;
* 即使内容更新,也会生成一个全新的图片 ID。
* 加上 `immutable` 后,用户在浏览器中点击刷新按钮时,浏览器甚至不会向服务器发送任何
* 条件验证请求(If-None-Match / 304),而是直接命中本地磁盘/内存缓存,极大减轻服务器并发压力。
*/
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}不同版本的 Next.js 对 Route Handler 中 params 的类型处理可能略有区别。如果你的项目使用的版本把 params 作为同步对象传入,就不需要 Promise 和 await。这里的重点不是类型写法,而是这个接口的职责:
根据 ID 找到图片
↓
读取存储中的对象
↓
设置 Content-Type
↓
返回源图内容这个接口本身不是 CDN,也不是完整的图片优化器。
如果图片放在 S3 或 R2 里,它可以读取对象并返回,也可以重定向到一个受保护的对象 URL。实际项目中还应该检查 ID 格式、权限、签名和存储请求超时。
上传表单
下面是一个足够简单的客户端组件。它把文件放进 FormData,然后提交给刚才的上传接口。
"use client";
import { useState } from "react";
import Image from "next/image";
/**
* 上传成功后服务端返回的数据模型
* 包含图片唯一标识及图像原始几何尺寸,专供前端 <Image /> 渲染使用
*/
type UploadedImage = {
id: string;
width: number;
height: number;
};
export default function UploadForm() {
// 保存上传成功后的图片元数据;为 null 时表示尚未上传或重置
const [image, setImage] = useState<UploadedImage | null>(null);
// 交互状态指示器:用于显示加载态、禁止用户重复点击上传
const [loading, setLoading] = useState(false);
/**
* 文件选择变更处理函数
* 当用户在文件选择框中挑中文件时触发
*/
async function handleChange(
event: React.ChangeEvent<HTMLInputElement>
) {
// 从文件列表中提取首个文件(支持可选链以防止用户打开选择框后又点击取消)
const file = event.target.files?.[0];
// 如果用户取消了选择,直接中断逻辑
if (!file) {
return;
}
/**
* 构建标准 multipart/form-data 请求体
* 字段名称 "file" 必须与服务端后端代码中 `formData.get("file")` 的键名完全对应。
*/
const formData = new FormData();
formData.append("file", file);
setLoading(true);
try {
/**
* 发起异步上传请求
*
* 使用 fetch 发送 FormData 时,不用手动在 headers 中设置 "Content-Type": "multipart/form-data"!
* 如果手动设置,会丢失浏览器自动生成的随机分界符(boundary),导致服务端无法解析;
* 直接将 formData 传给 body,浏览器会自动附加带正确 boundary 的请求头。
*/
const response = await fetch("/api/images", {
method: "POST",
body: formData,
});
// HTTP 状态码非 2xx 时(如 400、413、415、500)判定为请求失败
if (!response.ok) {
throw new Error("上传失败");
}
// 解析服务端返回的 JSON 数据:{ id, width, height }
const data: UploadedImage = await response.json();
setImage(data);
} catch (error) {
console.error("图片上传异常:", error);
// 实际项目中可在此处触发 Toast 弹窗通知用户失败原因
} finally {
// 无论成功还是失败抛错,都在 finally 块中复位加载状态
setLoading(false);
}
}
return (
<div className="upload-container">
{/*
文件选择输入框
- accept="image/*": 调起系统原生文件选择器时过滤非图片格式
- disabled={loading}: 上传中禁用控件,防止网络未响应时并发重复提交
*/}
<input
type="file"
accept="image/*"
onChange={handleChange}
disabled={loading}
/>
{/* 条件渲染:只有在图片上传成功并拿到元数据后才展示预览 */}
{image && (
/**
* Next.js 核心优化图片组件
*
* 1. 为什么必须传 width 和 height?
* 服务端在上传阶段就提前把图片的真实宽高返回给了前端。
* 传入这两个值并非强制图片在页面上显示该固定尺寸,而是让浏览器在图片下载完成前,
* 就能精确计算其纵横比(Aspect Ratio)并提前撑开占位空间,防止页面布局累积偏移。
*
* 2. sizes 响应式断点:
* 告知浏览器该图片在不同视口下的视觉预期渲染宽度:
* - 手机端屏幕(视口宽 <= 768px)时:占满整个视口宽度 (100vw)
* - 桌面端宽屏时:最大占用 800px 宽度
* Next.js 会据此自动生成对应档位的 srcset,避免手机端加载过大的原图。
*/
<Image
src={`/api/images/${image.id}`}
width={image.width}
height={image.height}
sizes="(max-width: 768px) 100vw, 800px"
alt="上传的图片"
/>
)}
</div>
);
}这里的 src 是图片源接口的地址。它不是浏览器最终一定会请求的地址,因为我们使用了 Next.js 的 Image 组件。
假设页面里写的是:
<Image
src="/api/images/9b2c..."
width={1200}
height={800}
sizes="(max-width: 768px) 100vw, 1200px"
alt="示例图片"
/>在默认配置下,浏览器通常不会直接请求这个源图地址,而是请求类似下面的 URL:
/_next/image?url=%2Fapi%2Fimages%2F9b2c...&w=1200&q=75完整过程大致如下:
浏览器
↓
/_next/image
↓
Next.js Image Optimizer
↓
/api/images/9b2c...
↓
源图接口返回 WebP
↓
Next.js 根据请求重新生成结果
↓
浏览器得到最终图片所以页面里的源图 URL 和用户最后看到的请求 URL 不是同一个东西,Image 组件负责生成合适的图片请求,Image Optimizer 负责读取源图、选择输出格式、调整尺寸并缓存结果。源图接口只需要稳定地返回图片内容即可。
如果这样写:
<Image
src="/api/images/9b2c..."
width={1200}
height={800}
unoptimized
alt="示例图片"
/>Next.js 会绕过 Image Optimizer,浏览器更接近于直接请求源图地址。
这在某些外部图片、原图下载或自定义图片服务场景中有用。
间接上传
当文件较大时,可以让浏览器直接把文件上传到 S3、R2 或其他对象存储,然后服务器再下载下来进行处理。常见流程是:
浏览器请求初始化
↓
服务端检查权限和文件信息
↓
服务端返回临时上传凭证
↓
浏览器直传对象存储
↓
浏览器通知服务端上传完成
↓
服务端读取临时对象并处理图片
↓
保存最终对象和数据库记录这解决了“原始文件经过谁的网络”。它不代表服务端不需要处理图片,如果上传完成后仍然需要检查真实格式 / 统一方向 / 生成 WebP 或 AVIF / 计算宽高 / 生成 blur / 去重 / 写入数据库,那么 complete 阶段依旧需要由服务端完成这些工作。
另外,临时对象不能只依赖正常流程删除。浏览器关闭、网络断开、处理异常或数据库写入失败,都可能留下垃圾文件,因此生产环境还需要定期清理过期临时对象。
缓存
源图接口可以设置长期缓存,例如:
Cache-Control: public, max-age=31536000, immutable但这有一个前提,相同的图片 URL 不应该被原地替换。
如果图片 ID 是不可变的,每次内容变化都生成新的 ID,长期缓存比较安全。如果同一个 URL 对应的对象可以被覆盖,浏览器和 CDN 里可能长期保留旧内容,此时应当设置缓存失效机制(stale),或者减小缓存时间。
Next.js Image 的缓存还会受到多个参数影响,如源图 URL、输出宽度、quality、输出格式等。允许的尺寸和质量组合越多,缓存变体也越多。所以 deviceSizes、imageSizes、qualities 和 formats 不只是图片显示配置,也会影响缓存规模。
缓存键设计得不合理时,可能出现同一张图片生成了大量几乎一样的缓存,或不同请求错误地共享了不应该共享的结果的情况。图片 ID、尺寸、质量和输出格式最好都经过明确的规范化,不要把用户传入的任意参数直接拿来构造缓存键。
图片删除
删除时需要同时处理多个地方:
检查权限
↓
检查图片是否仍被内容引用
↓
删除或标记数据库记录
↓
删除对象存储中的文件
↓
清理缓存和临时对象这里最好不要一上来就删除对象存储里的文件,然后再尝试删除数据库记录。如果数据库发现图片仍然被文章引用,或者事务中途失败,就可能出现文件已经删除,但数据库记录还在、文章仍然引用这张图片的情况。
更稳妥的方式是删除前先检查引用关系、使用软删除,延迟清理对象、在数据库事务和对象存储任务之间增加可重试状态,然后由后台任务异步清理已经确认不再使用的对象。文件和数据库不在同一个事务里,因此需要考虑一方失败的情况。
检验
示例跑起来以后,可以按下面的顺序观察浏览器 Network 面板:
- 选择一张图片,确认上传请求使用的是
multipart/form-data; - 查看上传接口返回的图片 ID 和尺寸;
- 查看页面里的
Image组件最终生成了什么请求; - 对比
/_next/image和源图接口的响应头; - 修改
width或sizes,观察优化器请求的宽度是否变化; - 添加
unoptimized,确认请求是否绕过了优化器;
后言
Next 的优化理念还是太先进了,部分时候并没有那么多计算资源给每个客户端的图片优化用,Vercel 和 Cloudflare 的图片优化又比较贵,此时你可以试试 使用 Cloudinary + CF Worker 实现图片自动优化 | RavelloH's Blog 。
评论