基于Cloudflare R2与Workers构建免费个人图床:从原理到实践
2026/9/5 14:46:59 网站建设 项目流程

1. 背景与核心概念

在个人博客、技术文档或社区发帖时,图片的存储与访问一直是个令人头疼的问题。将图片直接上传到博客平台,不仅受限于空间大小,还可能面临迁移困难;使用第三方图床,又常常担忧其稳定性、隐私安全以及随时可能失效的风险。有没有一种方案,既能保证图片的长期稳定访问,又完全免费且能由自己掌控呢?

答案是肯定的,Cloudflare 提供的服务组合就能完美解决这个痛点。本文将手把手教你,如何利用 Cloudflare 的 R2 对象存储、Workers 无服务器函数以及 Pages 静态站点托管服务,搭建一个功能完整、永久免费且支持自定义域名的个人专属公开图床。这个方案的核心优势在于:数据完全由你掌控,存储在 Cloudflare 全球网络上,访问速度快,并且拥有极高的免费额度,足以满足绝大多数个人乃至中小型项目的需求。

简单来说,我们将构建这样一个系统:

  1. 存储层:使用 Cloudflare R2 来存放图片文件。R2 兼容 S3 API,存储成本极低,并且提供了每月 10GB 的免费存储空间和 100 万次 A 类操作(如下载)的免费额度。
  2. 逻辑层:使用 Cloudflare Workers 作为中间处理层。它负责接收上传请求、处理图片(如压缩、添加水印等,可选)、将图片存入 R2,并返回一个可访问的 URL。Workers 同样提供每天 10 万次免费请求的额度。
  3. 展示与上传层:使用 Cloudflare Pages 部署一个简洁的上传页面。这是一个纯前端的静态页面,通过 JavaScript 调用我们编写的 Worker 来上传图片。Pages 提供无限次数的构建和托管,完全免费。
  4. 访问层:通过自定义域名(如img.yourdomain.com)来访问 Worker 和 Pages,使整个图床服务更加专业和易于记忆。

接下来,我们将从零开始,一步步完成这个图床的搭建。

2. 环境准备与版本说明

在开始之前,你需要准备以下环境和账号:

  1. 一个 Cloudflare 账户:访问 Cloudflare 官网 注册即可。这是所有服务的基础。
  2. 一个自定义域名:你需要拥有一个自己的域名(例如yourdomain.com),并将其添加到 Cloudflare 进行托管(即使用 Cloudflare 的 DNS 服务器)。这是实现自定义域名访问图床的前提。本文假设你的域名已在 Cloudflare 管理。
  3. 本地开发环境
    • Node.js: 版本 16 或以上。用于运行 Wrangler CLI 工具。你可以从 Node.js 官网 下载安装。
    • npm 或 yarn: Node.js 的包管理器,通常随 Node.js 一起安装。
    • 代码编辑器:如 VS Code。
  4. 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的免费域名,也可以绑定自己的自定义域名。

工作流程总结

  1. 用户访问 Pages 部署的上传页面。
  2. 用户选择图片并点击上传。
  3. 前端页面通过 JavaScript 将图片数据POST到我们部署的 Worker URL。
  4. Worker 函数执行,验证请求,将图片存入指定的 R2 存储桶,并生成一个访问 URL。
  5. Worker 将 URL 返回给前端页面。
  6. 前端页面展示上传成功的图片和它的 Markdown/HTML 链接。

4. 完整实战搭建步骤

4.1 第一步:创建 Cloudflare R2 存储桶

存储桶(Bucket)是 R2 中存放对象的容器。我们首先需要创建一个。

  1. 登录 Cloudflare 仪表板。
  2. 在左侧菜单栏,找到“R2”并点击。
  3. 点击“创建存储桶”按钮。
  4. 输入一个存储桶名称,例如my-image-bed。名称需全局唯一。
  5. 点击“创建存储桶”完成创建。

创建成功后,记住你的存储桶名称,后续配置 Worker 时会用到。

4.2 第二步:创建并配置 Cloudflare Worker

我们将使用 Wrangler CLI 在本地创建和初始化 Worker 项目。

  1. 初始化 Worker 项目: 打开终端,创建一个新目录并进入,然后运行以下命令:

    # 创建一个新目录 mkdir cf-image-bed-worker && cd cf-image-bed-worker # 使用 Wrangler 初始化一个 Worker 项目,选择 “Hello World” 模板即可 wrangler init

    在交互式提示中,项目名称可以输入image-bed-worker,类型选择“Hello World”

  2. 绑定 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

  3. 编写 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-age=31536000'); // 缓存一年 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 中的图片,并设置了长期缓存。
  4. 部署 Worker: 在项目根目录下运行:

    wrangler deploy

    首次部署会让你确认,输入y即可。部署成功后,命令行会输出你的 Worker 域名,格式如image-bed-worker.<你的子域名>.workers.dev。记下这个域名,我们稍后会用到。

4.3 第三步:创建并部署前端上传页面 (Cloudflare Pages)

现在我们来创建一个简单但实用的前端页面。

  1. 创建前端项目: 在本地另一个位置(或同一目录下新建文件夹)创建前端文件。例如,创建一个名为cf-image-bed-frontend的文件夹,并在其中创建以下文件:

    cf-image-bed-frontend/ ├── index.html ├── style.css └── upload.js
  2. 编写前端代码

    • index.html(主页面)
    <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>我的免费图床</title> <link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> </head> <body> <div class="container"> <header> <h1><i class="fas fa-cloud-upload-alt"></i> 个人专属图床</h1> <p class="subtitle">基于 Cloudflare R2 + Workers 构建 | 永久免费</p> </header> <main> <div class="upload-area" id="dropArea"> <i class="fas fa-cloud-upload-alt fa-3x"></i> <p>将图片拖拽到此处,或 <label for="fileInput" class="browse-btn">点击选择</label></p> <input type="file" id="fileInput" accept="image/*" multiple style="display: none;"> <p class="hint">支持 JPG, PNG, GIF, WebP 等格式</p> </div> <div class="preview-container"> <h3><i class="fas fa-images"></i> 上传预览</h3> <div id="previewList"></div> </div> <div class="url-container"> <h3><i class="fas fa-link"></i> 链接格式</h3> <div class="format-buttons"> <button class="format-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 += `![${img.name}](${img.url})\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`; });
  3. 部署到 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 都绑定到自己的域名下。

  1. 为 Worker 绑定自定义域名

    • 在 Workers & Pages 仪表板,找到你部署的image-bed-worker
    • 进入“设置”->“触发器”
    • “自定义域”部分,点击“添加自定义域”
    • 输入你想要的子域名,例如img.yourdomain.com。Cloudflare 会自动为你配置 DNS 记录。
    • 绑定成功后,你就可以通过https://img.yourdomain.com/upload来上传,通过https://img.yourdomain.com/年/月/文件名.jpg来访问图片了。记得将前端页面upload.jsworkerUrlInput的默认值或提示用户修改的地址,更新为这个新域名。
  2. 为 Pages 绑定自定义域名

    • 在你的 Pages 项目 (my-image-bed-frontend) 设置中,找到“自定义域”
    • 点击“设置自定义域”,输入另一个子域名,例如upload.yourdomain.compic.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_BUCKETMY_BUCKET完全一致(包括大小写)。
图片上传成功,但无法通过返回的 URL 访问1. Worker 的GET请求处理逻辑有误。
2. R2 存储桶中对象的key与 URL 路径不匹配。
3. 自定义域名 DNS 解析未生效。
1. 检查 Worker 代码中处理GET请求的部分,确保路径拼接正确。
2. 直接通过 Worker 的*.workers.dev原始域名测试访问,排除自定义域名问题。
3. 去 Cloudflare DNS 设置检查自定义域名的CNAMEA记录是否已激活(通常状态应为“已代理”)。
前端页面样式错乱或 JS 不执行1. Pages 部署时文件路径错误。
2. 浏览器缓存了旧版本。
1. 确保index.html中引用的style.cssupload.js路径正确,且已成功上传。
2. 尝试强制刷新浏览器 (Ctrl+F5Cmd+Shift+R),或清除 Cloudflare Pages 的缓存(在 Pages 项目的设置中)。
Wrangler 部署失败,提示认证错误Wrangler CLI 登录状态失效。在终端重新运行wrangler login,按照提示完成浏览器授权。

6. 最佳实践与工程建议

搭建完成只是第一步,要让图床稳定、安全、好用,还需要注意以下几点:

  1. 安全性强化

    • 上传鉴权:目前的 Worker 是公开可访问的,任何人都可以上传。强烈建议添加上传认证。最简单的方式是在 Worker 中检查一个固定的密钥(Token),前端在上传时通过请求头(如Authorization: Bearer <your_token>)携带。生产环境应考虑更安全的机制。
    • 限制文件类型和大小:在 Worker 代码中,除了检查Content-Type,还应验证文件扩展名和大小,防止上传非图片文件或过大的文件消耗额度。
    • CORS 精细化:将‘Access-Control-Allow-Origin’: ‘*’改为你的前端 Pages 域名,例如‘https://upload.yourdomain.com’,避免被恶意网站滥用。
  2. 功能扩展

    • 图片处理:利用 Cloudflare 的 Images 产品或在 Worker 中集成 Sharp 库(需作为 Wasm 引入),实现图片压缩、缩略图生成、格式转换(如统一转为 WebP)等功能。
    • 管理功能:可以扩展前端和 Worker,增加图片列表查看、删除(需谨慎处理 R2 删除权限)等功能。
    • 批量操作:前端支持多文件选择、拖拽排序,Worker 支持批量处理。
  3. 运维与监控

    • 查看用量:定期在 Cloudflare 仪表板的“用量”部分查看 R2 的存储量和读取次数,以及 Workers 的请求次数,确保在免费额度内。
    • 设置告警:在 Cloudflare 中可以为用量设置告警,当接近免费额度时收到通知。
    • 日志排查:充分利用 Workers 的实时日志功能,它是调试和排查问题的利器。
  4. 成本控制

    • Cloudflare 的免费额度非常慷慨,但对于超高流量站点,仍需关注 定价细则 。R2 的 A 类操作(如listBuckets,putObject)和 B 类操作(如getObject)超出免费套餐后会产生费用。

通过以上步骤,你已经成功搭建了一个完全由自己控制、免费且功能完整的图床系统。它不仅解决了图片存储的痛点,更是一次对现代云原生边缘计算服务的实践。你可以在此基础上,根据个人需求不断迭代和优化,打造出最适合自己的工具。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询