LOADING...
利用 Cloudinary 与 Cloudflare Worker 结合,为前端站点(如 Next.js)提供低成本、高性能图片自动优化与缓存的完整解决方案。 文章详细说明了如何通过编写 Cloudflare Worker 脚本接管图片加载请求,利用边缘缓存大幅降低 Cloudinary 的带宽消耗。此外,该方案还内置了防盗链、恶意参数校验以及基于 HTTP Accept 头的图片格式(如 AVIF/WebP)按需智能分发功能,有效突破了常规免费图片优化服务(如 Vercel/Cloudflare 默认配额)的瓶颈。
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 算的话,单张照片的成本是:
也就是说,理论上每月可以处理张图片。虽然 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 中添加你的域名。
image.png
然后,前往 dash.cloudflare.com ,新建一个 Worker,选择”从 Hello World“开始即可。
image.png
然后点击编辑代码:
image.png
把 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;
},
};这个是我写的比较完善的一版,目前的图片分发就是用的这个。这能做到:
这还能绕过两个付费功能:Cloudflare 自带的缓存清理是收费的;Cloudinary 的免费版的 f_auto 并不会自动返回 avif 格式的图片。因此,这里在 Worker 里我手动处理了。
image.png
就是主页左上角的这个
有时你传入的url网址是相对路径的,比如/p/xxxxxxxxxxxx,这个字段在传入相对路径时,将其拼接为完整url。填你的站点地址即可。
w参数允许的值,一般是 next.config.js里面 image 的 imageSizes+ deviceSizes。
next/image 里是默认75,此时会让 Cloudinary 自行决定。
url 如果是个完整路径,其允许的域名列表。
允许的 referers 来源,用于防盗链
是否允许空 referers 请求,这不仅会拦截从其他网站发来的请求,还会拦截直接打开和下载等操作。不建议开启,因为实际没什么防护效果,referer 改一下就行。
image.png
不出意外的话,你的 Bandwidth 和 Storage 应该差别不大。这就说明 Cloudflare Worker 正确的起到了缓存的作用。
评论