
1. 背景与核心概念在个人博客、技术文档或社区发帖时图片的存储与访问一直是个令人头疼的问题。将图片直接上传到博客平台不仅受限于空间大小还可能面临迁移困难使用第三方图床又常常担忧其稳定性、隐私安全以及随时可能失效的风险。有没有一种方案既能保证图片的长期稳定访问又完全免费且能由自己掌控呢答案是肯定的Cloudflare 提供的服务组合就能完美解决这个痛点。本文将手把手教你如何利用 Cloudflare 的 R2 对象存储、Workers 无服务器函数以及 Pages 静态站点托管服务搭建一个功能完整、永久免费且支持自定义域名的个人专属公开图床。这个方案的核心优势在于数据完全由你掌控存储在 Cloudflare 全球网络上访问速度快并且拥有极高的免费额度足以满足绝大多数个人乃至中小型项目的需求。简单来说我们将构建这样一个系统存储层使用 Cloudflare R2 来存放图片文件。R2 兼容 S3 API存储成本极低并且提供了每月 10GB 的免费存储空间和 100 万次 A 类操作如下载的免费额度。逻辑层使用 Cloudflare Workers 作为中间处理层。它负责接收上传请求、处理图片如压缩、添加水印等可选、将图片存入 R2并返回一个可访问的 URL。Workers 同样提供每天 10 万次免费请求的额度。展示与上传层使用 Cloudflare Pages 部署一个简洁的上传页面。这是一个纯前端的静态页面通过 JavaScript 调用我们编写的 Worker 来上传图片。Pages 提供无限次数的构建和托管完全免费。访问层通过自定义域名如img.yourdomain.com来访问 Worker 和 Pages使整个图床服务更加专业和易于记忆。接下来我们将从零开始一步步完成这个图床的搭建。2. 环境准备与版本说明在开始之前你需要准备以下环境和账号一个 Cloudflare 账户访问 Cloudflare 官网 注册即可。这是所有服务的基础。一个自定义域名你需要拥有一个自己的域名例如yourdomain.com并将其添加到 Cloudflare 进行托管即使用 Cloudflare 的 DNS 服务器。这是实现自定义域名访问图床的前提。本文假设你的域名已在 Cloudflare 管理。本地开发环境Node.js: 版本 16 或以上。用于运行 Wrangler CLI 工具。你可以从 Node.js 官网 下载安装。npm 或 yarn: Node.js 的包管理器通常随 Node.js 一起安装。代码编辑器如 VS Code。Wrangler CLI: 这是 Cloudflare 官方提供的命令行工具用于管理 Workers、R2 和 Pages 项目。我们将使用它进行开发和部署。安装命令npm install -g wrangler安装后运行wrangler login登录你的 Cloudflare 账户进行授权。版本说明本文的操作和代码基于 2024 年初的 Cloudflare 服务界面和 Wrangler 3.x 版本。Cloudflare 控制台和 CLI 工具可能会更新但核心概念和步骤基本一致如有细微差别请以官方文档为准。3. 核心组件原理拆解在动手之前理解三个核心组件如何协同工作至关重要。3.1 Cloudflare R2你的免费图片仓库R2 是 Cloudflare 的对象存储服务你可以把它想象成一个无限扩展的网络硬盘专门用于存储图片、视频等文件。它与亚马逊的 S3 服务高度兼容。在我们的图床中所有上传的图片最终都存放在这里。它的免费额度10GB存储 每月100万次读取对个人图床来说绰绰有余。3.2 Cloudflare Workers智能的图片处理管家Workers 是一个在全球边缘网络运行的无服务器函数平台。它就像在你和 R2 仓库之间安排了一个“智能管家”。这个管家负责接收指令接收从前端上传页面发来的图片数据。处理图片可选可以编写代码对图片进行压缩、格式转换、添加水印等。存入仓库将处理好的图片数据安全地存入 R2 存储桶。返回地址生成一个指向该图片的永久 URL 并返回给前端。因为 Workers 运行在边缘节点所以处理速度极快并且能有效隐藏 R2 存储桶的直接访问端点提升安全性。3.3 Cloudflare Pages美观便捷的上传门户Pages 是一个 JAMstack 平台的静态网站托管服务。我们将用它来托管一个纯 HTML/JS/CSS 的上传页面。这个页面提供图形化界面允许你通过拖拽或选择文件的方式上传图片并通过 AJAX 调用后端的 Worker。部署后你会获得一个*.pages.dev的免费域名也可以绑定自己的自定义域名。工作流程总结用户访问 Pages 部署的上传页面。用户选择图片并点击上传。前端页面通过 JavaScript 将图片数据POST到我们部署的 Worker URL。Worker 函数执行验证请求将图片存入指定的 R2 存储桶并生成一个访问 URL。Worker 将 URL 返回给前端页面。前端页面展示上传成功的图片和它的 Markdown/HTML 链接。4. 完整实战搭建步骤4.1 第一步创建 Cloudflare R2 存储桶存储桶Bucket是 R2 中存放对象的容器。我们首先需要创建一个。登录 Cloudflare 仪表板。在左侧菜单栏找到“R2”并点击。点击“创建存储桶”按钮。输入一个存储桶名称例如my-image-bed。名称需全局唯一。点击“创建存储桶”完成创建。创建成功后记住你的存储桶名称后续配置 Worker 时会用到。4.2 第二步创建并配置 Cloudflare Worker我们将使用 Wrangler CLI 在本地创建和初始化 Worker 项目。初始化 Worker 项目 打开终端创建一个新目录并进入然后运行以下命令# 创建一个新目录 mkdir cf-image-bed-worker cd cf-image-bed-worker # 使用 Wrangler 初始化一个 Worker 项目选择 “Hello World” 模板即可 wrangler init在交互式提示中项目名称可以输入image-bed-worker类型选择“Hello World”。绑定 R2 存储桶 我们需要在 Worker 配置中声明它要操作的 R2 存储桶。编辑项目根目录下的wrangler.toml文件。# wrangler.toml name image-bed-worker main src/index.js compatibility_date 2024-01-01 # 添加 R2 存储桶绑定 [[r2_buckets]] binding MY_BUCKET # 在 Worker 代码中使用的变量名 bucket_name my-image-bed # 你在第一步中创建的 R2 存储桶的实际名称这里binding是你将在 Worker JavaScript 代码中访问该存储桶的变量名我们定义为MY_BUCKET。编写 Worker 核心代码 编辑src/index.js文件用以下代码替换原有内容。这段代码处理POST请求上传图片和GET请求获取或列出图片此处简化为直接代理到 R2。// src/index.js export default { async fetch(request, env) { const url new URL(request.url); const path url.pathname; // 处理 POST 请求 - 上传图片 if (request.method POST path /upload) { return handleUpload(request, env); } // 处理 GET 请求 - 访问图片 (简单代理到 R2) // 例如访问 https://worker.yourdomain.com/2024/05/image.jpg if (request.method GET) { // 这里简单地将路径作为 R2 中的 key 来处理 // 注意实际生产环境需要更严格的路径处理和错误处理 const objectKey path.slice(1); // 移除开头的 / if (objectKey) { const object await env.MY_BUCKET.get(objectKey); if (object null) { return new Response(Object Not Found, { status: 404 }); } const headers new Headers(); object.writeHttpMetadata(headers); headers.set(etag, object.httpEtag); // 设置缓存图片类资源可以缓存较长时间 headers.set(Cache-Control, public, max-age31536000); // 缓存一年 return new Response(object.body, { headers, }); } } // 其他请求返回 404 或一个简单的上传页面提示 return new Response(Not Found, { status: 404 }); }, }; async function handleUpload(request, env) { // 1. 检查 Content-Type const contentType request.headers.get(content-type); if (!contentType || !contentType.includes(multipart/form-data)) { return new Response(Expected multipart/form-data, { status: 400 }); } // 2. 解析 FormData const formData await request.formData(); const file formData.get(file); // 前端上传字段名是 ‘file’ if (!file || typeof file string) { return new Response(No file uploaded or invalid file, { status: 400 }); } // 3. 生成唯一文件名和路径 // 使用日期和随机字符串避免重名 const now new Date(); const year now.getUTCFullYear(); const month String(now.getUTCMonth() 1).padStart(2, 0); const day String(now.getUTCDate()).padStart(2, 0); const randomStr Math.random().toString(36).substring(2, 8); const fileExt file.name.split(.).pop() || bin; const sanitizedFileName file.name.replace(/[^a-zA-Z0-9-_.]/g, _).replace(/\.[^/.]$/, ); const objectKey ${year}/${month}/${sanitizedFileName}_${randomStr}.${fileExt}; // 4. 将文件存入 R2 try { await env.MY_BUCKET.put(objectKey, file.stream(), { httpMetadata: { contentType: file.type, }, }); } catch (error) { console.error(R2 upload error:, error); return new Response(Failed to upload file, { status: 500 }); } // 5. 构建返回的图片访问 URL // 注意这里假设 Worker 最终绑定的自定义域名是 img.yourdomain.com // 在实际部署后你需要将其替换为你的真实 Worker 域名或自定义域名。 const imageUrl https://${request.headers.get(host)}/${objectKey}; // 6. 返回 JSON 格式的成功响应 return new Response( JSON.stringify({ success: true, message: Upload successful, data: { url: imageUrl, key: objectKey, }, }), { headers: { Content-Type: application/json, Access-Control-Allow-Origin: *, // 允许前端跨域访问生产环境应限制为具体域名 }, } ); }代码关键点解释POST /upload接口处理文件上传从multipart/form-data中提取文件。文件名进行了简单处理并按照年/月/原文件名_随机串.扩展名的格式组织便于管理。文件通过env.MY_BUCKET.put()方法存入 R2。成功后会返回一个包含图片 URL 的 JSON。GET请求处理逻辑允许通过 Worker 的 URL 直接访问存储在 R2 中的图片并设置了长期缓存。部署 Worker 在项目根目录下运行wrangler deploy首次部署会让你确认输入y即可。部署成功后命令行会输出你的 Worker 域名格式如image-bed-worker.你的子域名.workers.dev。记下这个域名我们稍后会用到。4.3 第三步创建并部署前端上传页面 (Cloudflare Pages)现在我们来创建一个简单但实用的前端页面。创建前端项目 在本地另一个位置或同一目录下新建文件夹创建前端文件。例如创建一个名为cf-image-bed-frontend的文件夹并在其中创建以下文件cf-image-bed-frontend/ ├── index.html ├── style.css └── upload.js编写前端代码index.html(主页面)!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title我的免费图床/title link relstylesheet hrefstyle.css link relstylesheet hrefhttps://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css /head body div classcontainer header h1i classfas fa-cloud-upload-alt/i 个人专属图床/h1 p classsubtitle基于 Cloudflare R2 Workers 构建 | 永久免费/p /header main div classupload-area iddropArea i classfas fa-cloud-upload-alt fa-3x/i p将图片拖拽到此处或 label forfileInput classbrowse-btn点击选择/label/p input typefile idfileInput acceptimage/* multiple styledisplay: none; p classhint支持 JPG, PNG, GIF, WebP 等格式/p /div div classpreview-container h3i classfas fa-images/i 上传预览/h3 div idpreviewList/div /div div classurl-container h3i classfas fa-link/i 链接格式/h3 div classformat-buttons button classformat-btn active>/* style.css - 核心样式 */ body { font-family: Segoe UI, system-ui, sans-serif; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); min-height: 100vh; margin: 0; padding: 20px; color: #333; } .container { max-width: 900px; margin: 0 auto; background: white; border-radius: 20px; box-shadow: 0 15px 35px rgba(50, 50, 93, 0.1), 0 5px 15px rgba(0, 0, 0, 0.07); padding: 30px; } .upload-area { border: 3px dashed #4a6cf7; border-radius: 15px; padding: 60px 20px; text-align: center; margin: 30px 0; cursor: pointer; transition: all 0.3s ease; background-color: #f8faff; } .upload-area:hover, .upload-area.dragover { background-color: #eef2ff; border-color: #2b4df5; } .browse-btn { color: #4a6cf7; text-decoration: underline; cursor: pointer; font-weight: bold; } /* ... 其他样式预览区、按钮、响应式等请自行补充完整 ... */upload.js(核心交互逻辑)// upload.js document.addEventListener(DOMContentLoaded, function() { const dropArea document.getElementById(dropArea); const fileInput document.getElementById(fileInput); const previewList document.getElementById(previewList); const urlOutput document.getElementById(urlOutput); const copyBtn document.getElementById(copyBtn); const workerUrlInput document.getElementById(workerUrl); const formatButtons document.querySelectorAll(.format-btn); const toast document.getElementById(toast); let currentFormat markdown; let uploadedImages []; // 存储上传成功的图片信息 // 1. 点击选择文件 dropArea.addEventListener(click, () fileInput.click()); fileInput.addEventListener(change, handleFiles); // 2. 拖拽上传 [dragenter, dragover, dragleave, drop].forEach(eventName { dropArea.addEventListener(eventName, preventDefaults, false); }); function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); } [dragenter, dragover].forEach(eventName { dropArea.addEventListener(eventName, highlight, false); }); [dragleave, drop].forEach(eventName { dropArea.addEventListener(eventName, unhighlight, false); }); function highlight() { dropArea.classList.add(dragover); } function unhighlight() { dropArea.classList.remove(dragover); } dropArea.addEventListener(drop, handleDrop, false); function handleDrop(e) { const dt e.dataTransfer; const files dt.files; handleFiles({ target: { files } }); } // 3. 处理文件选择 function handleFiles(e) { const files Array.from(e.target.files); if (files.length 0) return; files.forEach(file { if (!file.type.startsWith(image/)) { showToast(文件 ${file.name} 不是图片类型已跳过。, warning); return; } uploadFile(file); }); fileInput.value ; // 重置 input } // 4. 上传文件到 Worker async function uploadFile(file) { const workerUrl workerUrlInput.value.trim(); if (!workerUrl) { showToast(请先配置正确的 Worker 上传地址, error); return; } const formData new FormData(); formData.append(file, file); // 显示上传中预览 const previewItem createPreviewItem(file, uploading); previewList.prepend(previewItem); try { const response await fetch(workerUrl, { method: POST, body: formData, // 注意如果 Worker 设置了 CORS这里不需要 mode: cors }); const result await response.json(); if (result.success) { // 上传成功 updatePreviewItem(previewItem, success, result.data.url, file); uploadedImages.unshift({ name: file.name, url: result.data.url }); updateUrlOutput(); showToast(${file.name} 上传成功, success); } else { // 上传失败业务逻辑失败 updatePreviewItem(previewItem, error, null, file); showToast(上传失败: ${result.message || 未知错误}, error); } } catch (error) { // 网络错误或请求失败 console.error(Upload error:, error); updatePreviewItem(previewItem, error, null, file); showToast(网络错误: ${error.message}, error); } } // 5. 创建和更新预览项 (DOM 操作函数需与 CSS 配合) function createPreviewItem(file, status) { /* ... 创建预览元素 ... */ } function updatePreviewItem(item, status, imageUrl, file) { /* ... 更新预览元素状态 ... */ } // 6. 更新输出框的链接 function updateUrlOutput() { if (uploadedImages.length 0) { urlOutput.value ; return; } let outputText ; uploadedImages.forEach(img { switch (currentFormat) { case markdown: outputText \n; break; case html: outputText img src${img.url} alt${img.name} /\n; break; case url: outputText ${img.url}\n; break; } }); urlOutput.value outputText.trim(); } // 7. 复制链接按钮 copyBtn.addEventListener(click, () { if (!urlOutput.value) { showToast(没有内容可复制, warning); return; } navigator.clipboard.writeText(urlOutput.value).then(() { showToast(链接已复制到剪贴板, success); }).catch(err { console.error(Copy failed:, err); showToast(复制失败请手动选择复制, error); }); }); // 8. 切换链接格式 formatButtons.forEach(btn { btn.addEventListener(click, () { formatButtons.forEach(b b.classList.remove(active)); btn.classList.add(active); currentFormat btn.dataset.format; updateUrlOutput(); }); }); // 9. 提示消息函数 function showToast(message, type info) { toast.textContent message; toast.className toast show type; setTimeout(() { toast.classList.remove(show); }, 3000); } // 初始化将部署后的 Worker 地址填入输入框示例 // workerUrlInput.value https://image-bed-worker.YOUR_SUBDOMAIN.workers.dev/upload; });部署到 Cloudflare Pages在 Cloudflare 仪表板选择“Pages”。点击“创建项目”-“直接上传”。给你的项目起个名字例如my-image-bed-frontend。将本地的cf-image-bed-frontend文件夹下的所有文件index.html,style.css,upload.js拖拽到上传区域或者打包成 ZIP 上传。点击“部署站点”。部署完成后你会获得一个*.pages.dev的域名例如my-image-bed-frontend.pages.dev。访问这个地址你就看到了自己的图床上传页面4.4 第四步绑定自定义域名可选但推荐为了让服务更专业我们将 Worker 和 Pages 都绑定到自己的域名下。为 Worker 绑定自定义域名在 Workers Pages 仪表板找到你部署的image-bed-worker。进入“设置”-“触发器”。在“自定义域”部分点击“添加自定义域”。输入你想要的子域名例如img.yourdomain.com。Cloudflare 会自动为你配置 DNS 记录。绑定成功后你就可以通过https://img.yourdomain.com/upload来上传通过https://img.yourdomain.com/年/月/文件名.jpg来访问图片了。记得将前端页面upload.js中workerUrlInput的默认值或提示用户修改的地址更新为这个新域名。为 Pages 绑定自定义域名在你的 Pages 项目 (my-image-bed-frontend) 设置中找到“自定义域”。点击“设置自定义域”输入另一个子域名例如upload.yourdomain.com或pic.yourdomain.com。绑定后你就可以通过这个更友好的地址访问上传页面了。5. 常见问题与排查思路在搭建和使用过程中你可能会遇到以下问题问题现象可能原因排查思路与解决方案前端页面无法上传控制台报跨域 (CORS) 错误Worker 没有正确设置 CORS 响应头。检查 Worker 代码在返回的Response中确保包含‘Access-Control-Allow-Origin’: ‘*’或你的前端页面域名。确保OPTIONS预检请求也被正确处理上述示例代码未包含复杂请求需要处理。上传返回 400 Bad Request1. 前端未以multipart/form-data格式发送数据。2. Worker 代码中解析FormData的字段名与前端不一致。1. 检查前端FormData的构建和fetch请求的Content-Type浏览器为multipart/form-data时会自动设置。2. 确保前端formData.append(‘file’, file)与 Worker 中formData.get(‘file’)的字段名’file’一致。上传返回 5xx 错误 (如 500 Internal Server Error)1. Worker 代码运行时错误如 R2 操作失败。2. R2 存储桶绑定 (binding) 名称错误或未配置。1. 登录 Cloudflare 仪表板进入你的 Worker查看“日志”面板这里有详细的运行时错误信息。2. 检查wrangler.toml中的binding名称是否与代码中env.MY_BUCKET的MY_BUCKET完全一致包括大小写。图片上传成功但无法通过返回的 URL 访问1. Worker 的GET请求处理逻辑有误。2. R2 存储桶中对象的key与 URL 路径不匹配。3. 自定义域名 DNS 解析未生效。1. 检查 Worker 代码中处理GET请求的部分确保路径拼接正确。2. 直接通过 Worker 的*.workers.dev原始域名测试访问排除自定义域名问题。3. 去 Cloudflare DNS 设置检查自定义域名的CNAME或A记录是否已激活通常状态应为“已代理”。前端页面样式错乱或 JS 不执行1. Pages 部署时文件路径错误。2. 浏览器缓存了旧版本。1. 确保index.html中引用的style.css和upload.js路径正确且已成功上传。2. 尝试强制刷新浏览器 (CtrlF5或CmdShiftR)或清除 Cloudflare Pages 的缓存在 Pages 项目的设置中。Wrangler 部署失败提示认证错误Wrangler CLI 登录状态失效。在终端重新运行wrangler login按照提示完成浏览器授权。6. 最佳实践与工程建议搭建完成只是第一步要让图床稳定、安全、好用还需要注意以下几点安全性强化上传鉴权目前的 Worker 是公开可访问的任何人都可以上传。强烈建议添加上传认证。最简单的方式是在 Worker 中检查一个固定的密钥Token前端在上传时通过请求头如Authorization: Bearer your_token携带。生产环境应考虑更安全的机制。限制文件类型和大小在 Worker 代码中除了检查Content-Type还应验证文件扩展名和大小防止上传非图片文件或过大的文件消耗额度。CORS 精细化将‘Access-Control-Allow-Origin’: ‘*’改为你的前端 Pages 域名例如‘https://upload.yourdomain.com’避免被恶意网站滥用。功能扩展图片处理利用 Cloudflare 的 Images 产品或在 Worker 中集成 Sharp 库需作为 Wasm 引入实现图片压缩、缩略图生成、格式转换如统一转为 WebP等功能。管理功能可以扩展前端和 Worker增加图片列表查看、删除需谨慎处理 R2 删除权限等功能。批量操作前端支持多文件选择、拖拽排序Worker 支持批量处理。运维与监控查看用量定期在 Cloudflare 仪表板的“用量”部分查看 R2 的存储量和读取次数以及 Workers 的请求次数确保在免费额度内。设置告警在 Cloudflare 中可以为用量设置告警当接近免费额度时收到通知。日志排查充分利用 Workers 的实时日志功能它是调试和排查问题的利器。成本控制Cloudflare 的免费额度非常慷慨但对于超高流量站点仍需关注 定价细则 。R2 的 A 类操作如listBuckets,putObject和 B 类操作如getObject超出免费套餐后会产生费用。通过以上步骤你已经成功搭建了一个完全由自己控制、免费且功能完整的图床系统。它不仅解决了图片存储的痛点更是一次对现代云原生边缘计算服务的实践。你可以在此基础上根据个人需求不断迭代和优化打造出最适合自己的工具。