使用 Cloudinary + CF Worker 实现图片自动优化

前往 https://ravelloh.com/posts/auto-image-optimization-cloudinary-cloudflare-worker 查看以获得最佳体验。

前言

NeutralPress 自带图片优化，使用 Next.js 内置的图片优化来自带裁剪并压缩图片，在每个设备上都能给出适配其实际显示大小的图片。

这的确在一定程度上，让图片体积更小、加载更快，然而，图片优化极其消耗性能，所以各家给的限额都很少。强如 Vercel 和 Cloudflare，也都仅仅只提供每月 5000 次转换。如果短期之内上传大量图片，则很容易导致你的 Billing 不太好看。

不过，Cloudinary 则提供了很大方的额度：免费版（Free Plan）每月提供 25 个积分，每个积分可以用来提供 1000 次转换，或者 1 GB 存储，或者 1 GB 带宽。

其中消耗最快的一般是带宽，所以这里我们引入 Cloudflare 来对每个请求尽可能的缓存。理想情况，除了第一次请求、后续请求完全由 Cloudflare CDN 来处理。此外，Cloudflare 也能顺便用来解决 Cloudinary 默认域名访问异常、容易被恶意请求刷优化次数的问题。

NeutralPress 在上传图片的时候会将其转为 avif 并压缩，在我个人这里的话，截图一般能压到 30 KB 左右，普通图片大概 300 KB。按平均一张照片 200 KB 算的话，单张照片的成本是：

转换费： 1 次转换 = 0.001 积分（1/1000）。

带宽费： 200KB ≈ 0.00019GB = 0.00019 积分（传给 Cloudflare）。

存储费： 200KB ≈ 0.00019GB = 0.00019 积分（存在 Cloudinary）。

单张总计： 0.00138 积分

也就是说，理论上每月可以处理25 积分0.00138 积分/张≈18,115\frac{25 \text{ 积分}}{0.00138 \text{ 积分/张}} \approx 18,1150.00138 积分/张25 积分​≈18,115张图片。虽然 Next.js 的图片优化一般会生成很多个不同尺寸的图片，但在这个额度下，应该也没有任何额度焦虑了。

原理

下面以 NeutralPress 举例，实际上所有使用 next/image 组件的都可以用。或者，如果不是 Next.js ，自己写一个根据当前图片实际显示宽度获取对应尺寸的照片的 Image 组件也可以。

'use client'

import Image from 'next/image'

const imageLoader = ({ src, width, quality }) => {
return `https://example.com/${src}?w=${width}&q=${quality || 75}`
}

export default function Page() {
return (
<Image
loader={imageLoader}
src="me.png"
alt="Picture of the author"
width={500}
height={500}
/>
)
}

默认情况下，你可以认为 Next.js 内置的 Loader 就是 /_next/image?url={url}&w={width}&q={quality}，因此只需要修改它，改成先请求 Cloudflare Worker => Worker 再请求 Cloudinary => Cloudinary 从你的图片源中获取图片并优化 即可。

其中，Worker 负责转发请求的同时，还需要验证请求参数，防止有意外的断点大小，造成额外的 Cloudinary 调用。

步骤

首先需要一个 Cloudinary 账号，然后点击左下角 Settings - Security，在Allowed fetch domains 中添加你的域名。

然后，前往 dash.cloudflare.com ，新建一个 Worker，选择”从 Hello World“开始即可。

然后点击编辑代码：

把 hello word 修改成下列内容：

export default {
async fetch(request, env, ctx) {
const urlObj = new URL(request.url);
const params = urlObj.searchParams;

// ==========================================
// 1. 配置区域 (Configuration)
// ==========================================

// Cloudinary Cloud Name
const cloudName = 'your_cloud_id';
// 当输入是相对路径时，拼接的域名
const defaultOrigin = 'https://ravelloh.com';
// 可选尺寸参数
const allowedWidths = new Set([32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2560]);
// 可选质量参数，（75的时候，交由 Cloudinary 自动处理）
const allowedQualities = new Set([75]);
// 允许代理的域名
const allowedDomains = new Set(['ravelloh.com']);
// 防盗链白名单（留空则关闭防盗链）
const allowedReferers = new Set([
'ravelloh.com',
'localhost'
]);
// 是否允许空 Referer (建议 true，否则无法直接在浏览器/部分App打开图片)
const allowEmptyReferer = true;

// ==========================================
// 2. 基础请求处理 (CORS & Methods)
// ==========================================

// 处理 OPTIONS 预检请求
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,HEAD,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
}
});
}

// 只允许 GET 和 HEAD
if (request.method !== 'GET' && request.method !== 'HEAD') {
return new Response('Method Not Allowed', { status: 405 });
}

// ==========================================
// 3. 防盗链检查 (Hotlink Protection)
// ==========================================
const referer = request.headers.get('Referer');
if (referer) {
try {
const refererUrl = new URL(referer);
const hostname = refererUrl.hostname;

// 检查 Referer 是否在白名单中 (允许子域名)
const isAllowed = Array.from(allowedReferers).some(allowed =>
hostname === allowed || hostname.endsWith(`.${allowed}`)
);

if (!isAllowed) {
// 放行搜索引擎爬虫
const ua = (request.headers.get('User-Agent') || '').toLowerCase();
const isBot = ua.includes('googlebot') || ua.includes('bingbot') || ua.includes('twitterbot');

if (!isBot) {
return new Response('Hotlinking Not Allowed', { status: 403 });
}
}
} catch (e) {
// Referer 格式异常，视为不通过
console.log(`[Block] Invalid Referer: ${referer}`);
return new Response('Invalid Referer', { status: 403 });
}
} else {
// Referer 为空时的处理
if (!allowEmptyReferer) {
return new Response('Hotlinking Not Allowed (Empty Referer)', { status: 403 });
}
}

// ==========================================
// 4. 健康检查 & 参数验证
// ==========================================

let imageUrl = params.get('url');
// 如果没有 url 参数，返回服务状态 JSON
if (!imageUrl) {
return new Response(JSON.stringify({
status: "active",
service: "Image Optimization Proxy",
region: request.cf?.colo || "unknown",
time: new Date().toISOString()
}, null, 2), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store'
}
});
}

// URL 格式化与校验
try {
if (!imageUrl.startsWith('http')) {
if (imageUrl.startsWith('//')) {
imageUrl = 'https:' + imageUrl;
} else {
imageUrl = new URL(imageUrl, defaultOrigin).toString();
}
}
const targetUrlObj = new URL(imageUrl);
// 限制只能代理白名单域名的图片
if (!allowedDomains.has(targetUrlObj.hostname)) {
return new Response('Forbidden: Domain not allowed', { status: 403 });
}
} catch (e) {
return new Response('Invalid URL', { status: 400 });
}

const width = parseInt(params.get('w'), 10);
const quality = parseInt(params.get('q'), 10);

if (!width || !quality) {
return new Response('Missing required parameters: w, q', { status: 400 });
}
if (!allowedWidths.has(width)) return new Response('Invalid width.', { status: 400 });
if (!allowedQualities.has(quality)) return new Response('Invalid quality.', { status: 400 });

// ==========================================
// 5. 格式归一化 (Format Normalization)
// ==========================================

// 将复杂的 Accept 头坍缩为简单的 key，提高缓存命中率
const acceptHeader = request.headers.get('Accept') || '';
let format = 'auto';
if (acceptHeader.includes('image/avif')) {
format = 'avif';
} else if (acceptHeader.includes('image/webp')) {
format = 'webp';
}
// 其他情况 format = 'auto' (通常回退到 jpeg/png)

// ==========================================
// 6. 缓存键构建 (Cache Key Construction)
// ==========================================

// 使用固定的 URL 结构作为 Cache Key，确保参数顺序一致
const cacheKeyUrl = new URL('http://cache-key');
cacheKeyUrl.searchParams.set('url', imageUrl);
cacheKeyUrl.searchParams.set('w', width.toString());
cacheKeyUrl.searchParams.set('q', quality.toString());
cacheKeyUrl.searchParams.set('f', format);

// 强制使用 GET 方法的 Request 对象作为 Key
const cacheKeyReq = new Request(cacheKeyUrl.toString(), { method: 'GET' });
const cache = caches.default;

// ==========================================
// 7. 缓存查询与回源逻辑
// ==========================================

let response = await cache.match(cacheKeyReq);

if (!response) {
// --- Cache Miss: 回源 Cloudinary ---
console.log(`[Cache Miss] ${imageUrl}`);

const qualityParam = (quality === 75) ? 'q_auto' : `q_${quality}`;
const encodedImageUrl = encodeURIComponent(imageUrl);
const cloudinaryUrl =
`https://res.cloudinary.com/${cloudName}/image/fetch/` +
`f_${format},c_limit,w_${width},${qualityParam}/` +
`${encodedImageUrl}`;

try {
const originResponse = await fetch(cloudinaryUrl, {
headers: {
'User-Agent': 'Cloudflare Worker Image Proxy'
}
});

if (!originResponse.ok) {
// 返回源站错误信息，方便调试
return new Response(`Upstream Error: ${originResponse.status}`, { status: 502 });
}

// --- 构建优化的响应头 ---
const newHeaders = new Headers(originResponse.headers);

// 允许跨域
newHeaders.set('Access-Control-Allow-Origin', '*');

// 强缓存配置 (1年 + immutable)
newHeaders.set('Cache-Control', 'public, max-age=31536000, s-maxage=31536000, immutable');
newHeaders.delete('CDN-Cache-Control');
newHeaders.delete('Cloudflare-CDN-Cache-Control');
newHeaders.delete('Pragma');

// 标记实际返回的格式
newHeaders.set('X-Image-Format', format);

// 删除 Vary 头，防止缓存碎片化
newHeaders.delete('Vary');

// 清理不必要的头
newHeaders.delete('Content-Disposition');
newHeaders.delete('Set-Cookie');

// --- Stream 处理 ---
// 1. 创建返回给用户的 Response
response = new Response(originResponse.body, {
status: originResponse.status,
statusText: originResponse.statusText,
headers: newHeaders
});

// 2. 克隆 Response 用于写入缓存
const responseToCache = response.clone();

// 3. 异步写入缓存
ctx.waitUntil(cache.put(cacheKeyReq, responseToCache));

} catch (error) {
return new Response('Image Fetch Error', { status: 502 });
}
} else {
// --- Cache Hit ---
console.log(`[Cache Hit] ${imageUrl}`);
const newHeaders = new Headers(response.headers);
newHeaders.set('X-Cache-Status', 'HIT');

// 重建 Response 以便修改 headers
response = new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders
});
}

// ==========================================
// 8. HEAD 请求处理
// ==========================================
// 如果是 HEAD 请求，只返回 Headers，不返回 Body
if (request.method === 'HEAD') {
return new Response(null, {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
}

return response;
},
};

这个是我写的比较完善的一版，目前的图片分发就是用的这个。这能做到：

防盗链

限制参数（w/q），防止恶意构建的请求

限制代理 url 模式，防止被代理其他域名

内容协商（自动判断应该发放 avif / webp / 其他）

边缘缓存，使用 Cloudflare Cache 来进行缓存

缓存键清理，即便 Vary 头不同、参数顺序不同，仍能使用同一份缓存

这还能绕过两个付费功能：Cloudflare 自带的缓存清理是收费的；Cloudinary 的免费版的 f_auto 并不会自动返回 avif 格式的图片。因此，这里在 Worker 里我手动处理了。

配置

Cloudinary Cloud Name

就是主页左上角的这个

defaultOrigin

有时你传入的url网址是相对路径的，比如/p/xxxxxxxxxxxx，这个字段在传入相对路径时，将其拼接为完整url。填你的站点地址即可。

allowedWidths

w参数允许的值，一般是 next.config.js里面 image 的 imageSizes+ deviceSizes。

allowedQualities

next/image 里是默认75，此时会让 Cloudinary 自行决定。

allowedDomains

url 如果是个完整路径，其允许的域名列表。

allowedReferers

允许的 referers 来源，用于防盗链

allowEmptyReferer

是否允许空 referers 请求，这不仅会拦截从其他网站发来的请求，还会拦截直接打开和下载等操作。不建议开启，因为实际没什么防护效果，referer 改一下就行。

效果

不出意外的话，你的 Bandwidth 和 Storage 应该差别不大。这就说明 Cloudflare Worker 正确的起到了缓存的作用。