返回文章列表

Next.js 对接 R2 对象存储

1 次阅读

起因

博客上线之后,封面图字段是没有的,后来加了个url字段只能粘贴链接,太麻烦了,就对接了 Cloudflare 的 R2 存储,因为可以白嫖,给我一个博客平台使用足够了。

为什么不用服务端中转

最开始的直觉方案是:表单里放个文件输入,提交到 Server Action,由服务端上传到对象存储,但在 Next.js 里这条路有两个硬限制:

Server Action 的请求体默认上限是 1MB (Next.js 16 文档),超了直接报错;虽然可以用 experimental.serverActions.bodySizeLimit 调大,但是 Vercel 的 Serverless 函数本身还有请求体上线(4.5MB),大图依旧会踩线。

所以选了另一条路:浏览器直接传到对象存储,服务端只负责签发一次性上传凭证,图片字节完全不过服务器。

整体链路

浏览器选图
  → canvas 压缩(长边 1600 / WebP)
  → Server Action:校验管理员身份 + 签发预签名 PUT
  → 浏览器直接 PUT 到 R2
  → 拿到公开 URL,随表单一并入库

一、Cloudflare 侧的四步配置

  1. 建桶:R2 → Create bucket(存储类选"标准",位置选"自动")
  2. 绑自定义域名:bucket → Settings → Custom Domains,绑一个 cdn.example.com不要用 r2.dev——官方明确说它只适合测试,有速率限制。公网读取只走这个域名,顺便白得一层 Cloudflare 边缘缓存。
  3. 建 API Token:R2 → Manage API Tokens,权限选 "Object Read & Write" 并限定到该桶。会拿到 Access Key ID / Secret Access Key(Secret 只显示一次),端点形如 https://<account-id>.r2.cloudflarestorage.com
  4. 配 CORS(最容易漏,下面第二个坑就跟它有关):
[
  {
    "AllowedOrigins": ["https://www.example.com", "http://localhost:3000"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["etag"],
    "MaxAgeSeconds": 3600
  }
]

二、服务端:只做签发,不碰字节

依赖就两个(只在服务端用,不会进客户端包):

pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
// src/lib/storage.ts
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
 
export const CACHE_CONTROL = "public, max-age=31536000, immutable";
 
function config() {
  const accountId = process.env.R2_ACCOUNT_ID;
  // 故意不在模块顶层抛错:next build 会求值这个模块,而 CI 里没有这些变量
  if (!accountId) throw new Error("缺少 R2 环境变量");
 
  return {
    bucket: process.env.R2_BUCKET!,
    publicBaseUrl: process.env.R2_PUBLIC_BASE_URL!.replace(/\/+$/, ""),
    client: new S3Client({
      region: "auto", // R2 固定 auto
      endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
      credentials: {
        accessKeyId: process.env.R2_ACCESS_KEY_ID!,
        secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
      },
    }),
  };
}
 
export async function createUploadTarget(key: string, contentType: string) {
  const { client, bucket, publicBaseUrl } = config();
 
  const uploadUrl = await getSignedUrl(
    client,
    new PutObjectCommand({
      Bucket: bucket,
      Key: key,
      ContentType: contentType,
      CacheControl: CACHE_CONTROL,
    }),
    { expiresIn: 300 }, // 5 分钟有效
  );
 
  return {
    uploadUrl,
    publicUrl: `${publicBaseUrl}/${key}`,
    // 客户端必须原样带上这两个头,原因见「坑 1」
    headers: { "Content-Type": contentType, "Cache-Control": CACHE_CONTROL },
  };
}

对象键用 UUID 而不是原文件名,有三个理由:不会撞名、不受中文与特殊字符的 URL 编码折磨、内容不可变所以可以放心声明一年缓存。

三、浏览器端:先压缩,再直传

const STEPS = [
  { maxEdge: 1600, quality: 0.82 },
  { maxEdge: 1600, quality: 0.68 },
  { maxEdge: 1280, quality: 0.75 },
];
 
async function compressImage(file: File): Promise<File> {
  const bitmap = await createImageBitmap(file);
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d")!;
 
  let best: Blob | null = null;
  for (const step of STEPS) {
    const scale = Math.min(1, step.maxEdge / Math.max(bitmap.width, bitmap.height));
    canvas.width = Math.round(bitmap.width * scale);
    canvas.height = Math.round(bitmap.height * scale);
    ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
 
    const blob = await new Promise<Blob | null>((resolve) =>
      canvas.toBlob(resolve, "image/webp", step.quality),
    );
    if (blob && (!best || blob.size < best.size)) best = blob;
    if (best && best.size <= 450 * 1024) break; // 体积达标就停手
  }
 
  bitmap.close();
  // 压不动就老老实实用原图,不阻断上传
  return best && best.size < file.size ? new File([best], "cover.webp", { type: "image/webp" }) : file;
}

实测效果:一张 1920×1288 的 PNG 截图,3184KB → 261KB(-92%)。

上传本身就是一次普通的 fetch

const target = await createCoverUploadAction({ type: upload.type, size: upload.size });
await fetch(target.uploadUrl, {
  method: "PUT",
  headers: target.headers,
  body: upload,
});

三个真实的坑

坑 1:预签名 URL 只签了 host,Cache-Control 不会自己带上

我在 PutObjectCommand 里明明写了 CacheControl: "public, max-age=31536000, immutable",上传也成功,但线上取图返回的是:

cache-control: max-age=14400

这是 Cloudflare 的默认值,不是我要的一年。用 S3 API 查对象元数据,CacheControlundefined

翻了签名 URL 的查询串才明白:

X-Amz-SignedHeaders=host

它只签了 host 这一个头。 ContentTypeCacheControl 这类"请求头参数"不在签名范围内——客户端不发,服务端就不写元数据,而且不会报签名错误,非常安静

修法:把需要携带的头由服务端统一返回,客户端原样带上。改完再查:

HEAD  object → CacheControl: "public, max-age=31536000, immutable" ✅
GET   public → cache-control: public, max-age=31536000, immutable ✅

坑 2:CORS 的 AllowedHeaders 要跟着代码一起改

上面那个修复之后,浏览器直接给你看 net::ERR_FAILED

Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

先别怀疑签发逻辑——手动打一次预检最快:

curl -i -X OPTIONS \
  -H "Origin: https://www.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: content-type,cache-control" \
  "https://<bucket>.<account>.r2.cloudflarestorage.com/covers/probe.webp"

结果很干脆:只请求 content-type 时返回 204 正常,一旦加上 cache-control 就是 403——因为加头的同时,CORS 策略里的 AllowedHeaders 还是老的 content-type

结论:每次给上传请求加自定义头,都要同步检查 CORS 的 AllowedHeaders(写成 ["*"] 一劳永逸,把 AllowedOrigins 限制好就行)。

坑 3:客户端压缩"看起来没生效"

第一次实测,上传的仍是那个 3.2MB 的 PNG,文件名还是 .png。可能是三种情况:

  1. 页面还在跑旧 bundle(开发环境的 HMR 有时很慢),压缩代码根本没执行
  2. 浏览器不支持 canvas 的 WebP 编码,toBlob 悄悄回退成 PNG
  3. 压缩结果反而更大,代码按设计回退了原图

三种表现一模一样,没法猜。于是在压缩函数里加了几行开发日志:

log("原图", { sizeKB, width, height });
log("候选", { maxEdge, quality, type: blob.type, sizeKB: Math.round(blob.size / 1024) });

再传一次,日志直接说明是第 1 种:硬刷新之后,261KB 的 WebP 就出来了。这几行日志我特意留在代码里——下次出问题不用再猜。

一点取舍:前台为什么用原生 <img>

图片已经在浏览器端压到 200–400KB 的 WebP,又由 Cloudflare 边缘缓存一年,所以前台渲染用的是原生 <img>

  • next/image 会多一跳 Vercel 图片优化(对国内访客反而更慢),还占优化额度
  • 原生标签配上显式的 width/height(防布局抖动)和 loading="lazy"(列表缩略图)就够了
  • 列表缩略图、文章头图、og:image 全部复用同一个 URL

小结

  • 图片这类"大而静态"的数据,浏览器直传 + 服务端只签发是最省心的架构
  • 预签名 URL 的签名范围要实测(看 X-Amz-SignedHeaders),别假设 SDK 里的参数都会生效
  • CORS 和请求头是连体婴,改一个必须检查另一个
  • 排查"没生效"时,先把可能性用日志收窄,比反复改代码快得多

参考资料

  1. Next.js 官方文档 · Server ActionsbodySizeLimit 默认 1MB,及如何调大)
  2. Vercel 官方文档 · Functions 限制(Serverless 函数请求体上限,文中"4.5MB 量级"的依据)
  3. Cloudflare R2 文档 · Public buckets(自定义域名与 r2.dev 的定位)
  4. Cloudflare R2 文档 · Bucket CORS(CORS 策略字段与写法,对应"坑 2")
  5. Cloudflare R2 文档 · API Tokens(S3 凭证与端点格式)
  6. Cloudflare R2 文档 · S3 API 兼容性(支持哪些 S3 操作与参数)
  7. Cloudflare R2 文档 · Presigned URLs(R2 侧的预签名上传说明,对应"坑 1"讨论的签名范围)
  8. AWS 文档 · Sharing objects with presigned URLs(SigV4 签名覆盖范围、X-Amz-SignedHeaders 的语义)
  9. AWS SDK for JavaScript v3 · @aws-sdk/s3-request-presignergetSignedUrl 的 API 参考)
  10. MDN · createImageBitmap()(浏览器端解码图片,压缩的第一步)
  11. MDN · HTMLCanvasElement.toBlob()image/webp 编码与不支持时的回退行为,对应"坑 3")