檔案上傳:前端 → Worker → R2
三層、一條流程:瀏覽器選檔案、Worker 存進 R2、D1 記住所有細節。
我們要串什麼?
我們會做一個很小的網頁,上面有一個檔案輸入框(file input)。使用者選好檔案後送到 Worker,Worker 把原始檔案內容存進 R2(物件儲存,object storage,就是專門存放整份檔案如圖片、PDF 的服務),把檔案細節寫進 D1(一種 SQL 資料庫),之後再用一個公開網址把檔案提供回去。
把它想成寄物櫃
Worker 就像寄物櫃的櫃台。你把外套(檔案)交給它,它把外套放進後方倉庫(R2),並在登記簿(D1)寫下一個號碼牌。之後你出示號碼牌(也就是 key),它就能把外套拿回給你。
各層的職責
每一層只負責一件事。把「檔案內容」(放 R2)和「關於檔案的資料」(放 D1)分開,之後要查詢、列出清單、清理檔案時都會輕鬆很多。
前端網頁
顯示檔案選擇器,用 fetch 把選到的檔案以 multipart/form-data 格式 POST 出去。
Worker
接收上傳、把內容寫進 R2、在 D1 新增一筆資料,並在被請求時把檔案串流回去。
R2(檔案內容)
物件儲存,存放真正的檔案內容。把它讀出來給使用者完全不收流出(egress)費用。
D1(檔案資料)
一個 SQL 資料庫,記錄誰上傳了什麼、檔名、類型、大小與時間。
中繼資料表
R2 只會把檔案內容存在一個 key(key=物件的唯一名稱/路徑)底下。但要列出某位使用者的上傳清單、或顯示原始檔名,就得在 D1 裡為每個物件保留一小筆中繼資料。
CREATE TABLE IF NOT EXISTS files (
key TEXT PRIMARY KEY, -- R2 object key, e.g. uploads/172...-photo.jpg
filename TEXT NOT NULL, -- original name the user picked
content_type TEXT, -- MIME type, e.g. image/png
size_bytes INTEGER, -- file size, for quotas and display
uploaded_at TEXT NOT NULL -- ISO timestamp
);為什麼用 key 當主鍵?
R2 的 key 本來就是唯一的,而且正是日後取回物件所需的名稱,因此用它當主鍵(primary key)最乾淨,能把 D1 的資料列和 R2 的物件一對一綁在一起。
請求流程
1)上傳檔案
瀏覽器會把檔案打包成 multipart 內容(multipart/form-data=瀏覽器把一個或多個檔案連同表單欄位一起打包的標準格式)。Worker 讀取後用 put() 把內容寫進 R2,再把一筆資料寫進 D1,最後回傳一個網頁可以連結的網址。
2)把檔案提供回去
要看檔案時,網頁只要請求 /files/<key>。Worker 呼叫 get(),把儲存時記下的 Content-Type 複製到回應的標頭上,然後把位元組直接串流給瀏覽器——過程中不會把整份檔案一次塞進記憶體。
動手一步步串起來
Wrangler 是 Cloudflare 的命令列工具(CLI=你在終端機輸入指令操作的程式)。開始前請先執行過 npm install -g wrangler 與 wrangler login。
建立 R2 儲存桶與 D1 資料庫
兩個都建立。把 wrangler 為 D1 資料庫印出的 database_id 複製下來,下一步要貼上。
# Bucket that will hold the file bytes wrangler r2 bucket create user-uploads # Database that will hold the metadata (copy the printed database_id) wrangler d1 create uploads-meta在 wrangler.toml 加上兩個綁定
「綁定(binding)」會讓你的程式碼多一個變數來操作某個資源。這裡 [[r2_buckets]] 會給你 env.BUCKET,[[d1_databases]] 會給你 env.DB。
name = "file-uploader" main = "src/index.js" compatibility_date = "2025-09-23" # R2 binding -> reachable as env.BUCKET in your Worker [[r2_buckets]] binding = "BUCKET" bucket_name = "user-uploads" # D1 binding -> reachable as env.DB in your Worker [[d1_databases]] binding = "DB" database_name = "uploads-meta" database_id = "paste-the-id-from-wrangler-d1-create"建立中繼資料表
對真正(remote)的 D1 資料庫執行 CREATE TABLE。你也可以把它存成 schema.sql,改用 --file=./schema.sql 來執行。
wrangler d1 execute uploads-meta --remote --command "CREATE TABLE IF NOT EXISTS files (key TEXT PRIMARY KEY, filename TEXT NOT NULL, content_type TEXT, size_bytes INTEGER, uploaded_at TEXT NOT NULL);"前端:檔案輸入框 + fetch
FormData 會請瀏覽器幫我們組好 multipart/form-data 內容,所以我們完全不用自己手動拼裝上傳資料。
<!-- index.html (front-end) --> <input type="file" id="file" accept="image/*,.pdf" /> <button id="send">Upload</button> <p id="status"></p> <script> const fileInput = document.getElementById("file"); const statusEl = document.getElementById("status"); document.getElementById("send").addEventListener("click", async () => { const file = fileInput.files[0]; if (!file) { statusEl.textContent = "Pick a file first."; return; } // FormData makes the browser send a multipart/form-data body for us const form = new FormData(); form.append("file", file); statusEl.textContent = "Uploading..."; const res = await fetch("/upload", { method: "POST", body: form }); const data = await res.json(); statusEl.innerHTML = `Done! <a href="${data.url}">${data.filename}</a>`; }); </script>Worker:存進 R2 + 記到 D1 + 提供下載
同一個處理器負責兩件事:POST /upload 寫進 R2 和 D1;GET /files/<key> 從 R2 讀出來並串流回去。
export default { async fetch(request, env) { const url = new URL(request.url); // 1) UPLOAD: POST /upload (multipart/form-data) if (request.method === "POST" && url.pathname === "/upload") { const form = await request.formData(); const file = form.get("file"); if (!(file instanceof File)) { return Response.json({ error: "No file field" }, { status: 400 }); } // Build a unique key so two uploads never collide const key = `uploads/${Date.now()}-${crypto.randomUUID()}-${file.name}`; // Store the raw bytes in R2 (reading them back out is free) await env.BUCKET.put(key, file.stream(), { httpMetadata: { contentType: file.type }, }); // Remember the details in D1 (bound params = no SQL injection) await env.DB.prepare( "INSERT INTO files (key, filename, content_type, size_bytes, uploaded_at) VALUES (?, ?, ?, ?, ?)" ) .bind(key, file.name, file.type, file.size, new Date().toISOString()) .run(); return Response.json({ key, filename: file.name, url: `${url.origin}/files/${encodeURIComponent(key)}`, }); } // 2) SERVE: GET /files/<key> if (request.method === "GET" && url.pathname.startsWith("/files/")) { const key = decodeURIComponent(url.pathname.slice("/files/".length)); const object = await env.BUCKET.get(key); if (object === null) { return new Response("Not found", { status: 404 }); } // Copy the stored content-type etc. onto the response, then stream it const headers = new Headers(); object.writeHttpMetadata(headers); headers.set("etag", object.httpEtag); return new Response(object.body, { headers }); } return new Response("Not found", { status: 404 }); }, };部署上線
把 Worker 發布到 Cloudflare 的全球網路。你的上傳頁面與 /files/ 端點會在全球同時上線。
wrangler deploy
重點概念
物件儲存 (Object storage)
專門存放「整份檔案(物件)」的服務,而不是資料列或磁碟區塊。最適合圖片、PDF、影片。
鍵 (Key)
物件的唯一名稱/路徑,例如 uploads/2026/photo.jpg。之後就是用這個 key 把物件讀回來。
儲存桶 (Bucket)
裝著很多物件、有名字的容器,可以想成你 App 檔案的最上層資料夾。
multipart/form-data
瀏覽器傳送檔案時使用的標準請求格式。FormData 會自動幫你組好它。
Content-Type(MIME 類型)
像 image/png 這樣的標籤,告訴瀏覽器該如何顯示檔案。上傳時記下、提供時設回去。
預簽網址 (Presigned URL)
一個有時效、帶簽章的連結,讓瀏覽器直接對 R2 上傳或下載,遇到大檔案時就能繞過 Worker。
小提示、陷阱與計費
大檔案?改用預簽網址
讓檔案經過 Worker 上傳最簡單,但單一請求內容有大小上限(免費方案約 100 MB)。遇到大檔案時,請改由 Worker 產生一個「預簽網址(presigned URL)」,讓瀏覽器以分段(multipart)方式直接傳給 R2,之後再回頭把中繼資料存起來。
零流出費是最大賣點
不論使用者從 R2 下載一個檔案幾次,把它傳給網路上的使用者一律 0 元流出費——不像多數雲端,一個熱門檔案就可能讓帳單暴增。
- key 一定要唯一(時間戳 + 隨機 id),新的上傳才不會覆蓋舊檔。
- 在 Worker 端、put() 之前就驗證檔案大小與類型——絕不能只信任瀏覽器。
- 務必記下並重新套用 Content-Type,否則瀏覽器可能會下載檔案而不是直接顯示。
- 若網頁與 Worker 不在同一個來源(origin),記得在 /upload 加上 CORS 標頭。
- 別把使用者的原始檔名直接塞進 key,請先編碼或去掉特殊字元。