前端 ↔ Worker ↔ D1:完整 CRUD 串接
做一個真正能跑的待辦事項 App:用一個 HTML 頁面,透過一個 Worker 對雲端 SQL 資料庫做新增、讀取、更新、刪除。
我們要串什麼?
CRUD 是 Create、Read、Update、Delete(新增、讀取、更新、刪除)的縮寫,幾乎每個 App 對資料都在做這四件事。這篇我們要做一個小小的待辦事項 App:瀏覽器跟 Worker(跑在 Cloudflare 邊緣上的小程式)講話,Worker 再去讀寫 D1 資料庫(一個以 SQLite 為底的無伺服器 SQL 資料庫)。
三層、一條資料流。前端永遠不會直接碰資料庫——它一律透過 Worker,而唯一拿著資料庫綁定(binding)的也只有 Worker。先看一下全貌:
把它想成一間餐廳
瀏覽器是點餐的客人,Worker 是把點單送進廩房、再把菜端出來的服務生,而 D1 就是存放所有食材(你的資料)的廩房。客人不會自己走進廩房——一律跟服務生點。
哪一層負責什麼?
前端(瀏覽器)
負責畫面並用 fetch() 呼叫 API。它只知道網址和 JSON 長什麼樣——完全不碰 SQL。
Worker(API)
接收 HTTP 請求、驗證輸入、依方法分流,透過 env.DB 綁定跑 SQL,再回傳 JSON。
D1(資料庫)
存放 todos 資料表,安全地執行真正的 SELECT / INSERT / UPDATE / DELETE 語句。
綁定(binding)
wrangler.toml 把 Worker 和 D1 以 env.DB 這個名字接起來——不用連線字串、也不用密碼。
四個 CRUD 動作都打到同一條 /api/todos 路徑;Worker 依 HTTP 方法(GET、POST、PUT、DELETE)決定要做什麼。一張圖看懂路由邏輯:
資料模型
我們的 App 只需要一張資料表:todos。每一列就是一件待辦事項。id 是主鍵(PK,用來唯一識別一列的號碼),title 是待辦內容,done 是 0 或 1(SQLite 沒有真正的布林型別,所以用整數代替),created_at 則是自動填上的時間戳記。
轉成 SQL,這張表長這樣。把它存成 schema.sql——下面的動手步驟會把它套用到 D1。
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);一次完整的往返
我們跟蹤一次 Create 從頭到尾:使用者打完待辦事項、送出表單。瀏覽器發出 POST /api/todos(帶著 JSON 資料),Worker 驗證後跑 INSERT,D1 透過 RETURNING * 回傳剛建好的那一列,Worker 再把那筆 JSON 送回來,畫面就能馬上顯示。
為什麼用 RETURNING *?
沒有 RETURNING 的話,你得再跑一次 SELECT 才能拿到新的 id 跟 created_at。RETURNING * 讓你一個查詢就拿到整列資料——往返更少、程式更簡單。
一步一步串起來
跟著這七個步驟走,你就會有一個部署上線的全端待辦事項 App。你只需要裝好 Node.js;其他全都透過 npx wrangler(Cloudflare 的命令列工具)來跑。
建立 D1 資料庫
Wrangler 會印出一組 database_id——複製起來,下一步要貼進 wrangler.toml。
npx wrangler d1 create todo-db把 D1 綁定到 Worker
[[d1_databases]] 這段讓資料庫以 env.DB 的名字出現在 Worker 裡。把步驟 1 的 id 貼進來。
name = "todo-api" main = "src/index.js" compatibility_date = "2024-09-23" [[d1_databases]] binding = "DB" database_name = "todo-db" database_id = "<paste-your-id-here>"套用資料表結構
把 schema.sql 跑在雲端真正的資料庫上。想先在本機測試的話,把 --remote 換成 --local。
npx wrangler d1 execute todo-db --remote --file=./schema.sql # quick check it worked npx wrangler d1 execute todo-db --remote --command "SELECT name FROM sqlite_master WHERE type='table';"寫 Worker(src/index.js)
一個 fetch 處理函式用 prepare().bind() 加上 .all() / .first() / .run(),把四個 CRUD 動詞全都路由到 D1。CORS 標頭則讓另一個來源(origin)的瀏覽器能呼叫這個 API。
export default { async fetch(request, env) { const url = new URL(request.url); const id = url.pathname.match(/^\/api\/todos\/(\d+)$/); const cors = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS", "Access-Control-Allow-Headers": "Content-Type" }; if (request.method === "OPTIONS") return new Response(null, { headers: cors }); try { // READ: list every todo if (url.pathname === "/api/todos" && request.method === "GET") { const { results } = await env.DB .prepare("SELECT * FROM todos ORDER BY created_at DESC") .all(); return json(results, 200, cors); } // CREATE: insert one todo if (url.pathname === "/api/todos" && request.method === "POST") { const { title } = await request.json(); if (!title) return json({ error: "title required" }, 400, cors); const row = await env.DB .prepare("INSERT INTO todos (title) VALUES (?) RETURNING *") .bind(title) .first(); return json(row, 201, cors); } // UPDATE: toggle done or edit title if (id && request.method === "PUT") { const { title, done } = await request.json(); const row = await env.DB .prepare("UPDATE todos SET title = COALESCE(?, title), done = COALESCE(?, done) WHERE id = ? RETURNING *") .bind(title ?? null, done ?? null, Number(id[1])) .first(); return row ? json(row, 200, cors) : json({ error: "not found" }, 404, cors); } // DELETE: remove one todo if (id && request.method === "DELETE") { const info = await env.DB .prepare("DELETE FROM todos WHERE id = ?") .bind(Number(id[1])) .run(); return json({ deleted: info.meta.changes }, 200, cors); } return json({ error: "not found" }, 404, cors); } catch (err) { return json({ error: err.message }, 500, cors); } } }; function json(data, status, cors) { return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json", ...cors } }); }前端:四個 fetch 呼叫
這幾個小函式跟 CRUD 一對一:GET 讀、POST 新增、PUT 更新、DELETE 刪除。把 API 指向你部署好的 Worker 網址。
const API = "https://todo-api.<your-subdomain>.workers.dev/api/todos"; // READ — GET all todos export async function listTodos() { const res = await fetch(API); return res.json(); } // CREATE — POST a new todo export async function addTodo(title) { const res = await fetch(API, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title }) }); return res.json(); } // UPDATE — PUT to toggle done (or edit title) export async function toggleTodo(id, done) { const res = await fetch(`${API}/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ done }) }); return res.json(); } // DELETE — remove a todo export async function removeTodo(id) { const res = await fetch(`${API}/${id}`, { method: "DELETE" }); return res.json(); }接進網頁(index.html)
一個完整、能跑的頁面:表單負責 POST 新增、清單負責 GET 讀取、勾選框負責 PUT 切換完成、按鈕負責 DELETE。用任何靜態伺服器打開即可。
<!DOCTYPE html> <html lang="zh-Hant"> <head> <meta charset="UTF-8" /> <title>Todo</title> </head> <body> <h1>My Todos</h1> <form id="new"> <input id="title" placeholder="What needs doing?" required /> <button type="submit">Add</button> </form> <ul id="list"></ul> <script type="module"> const API = "https://todo-api.<your-subdomain>.workers.dev/api/todos"; async function load() { const todos = await (await fetch(API)).json(); const ul = document.getElementById("list"); ul.innerHTML = ""; for (const t of todos) { const li = document.createElement("li"); const box = document.createElement("input"); box.type = "checkbox"; box.checked = !!t.done; box.onchange = () => update(t.id, box.checked ? 1 : 0); const span = document.createElement("span"); span.textContent = " " + t.title + " "; const del = document.createElement("button"); del.textContent = "x"; del.onclick = () => remove(t.id); li.append(box, span, del); ul.appendChild(li); } } async function update(id, done) { await fetch(`${API}/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ done }) }); load(); } async function remove(id) { await fetch(`${API}/${id}`, { method: "DELETE" }); load(); } document.getElementById("new").addEventListener("submit", async (e) => { e.preventDefault(); const title = document.getElementById("title").value.trim(); if (!title) return; await fetch(API, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title }) }); e.target.reset(); load(); }); load(); </script> </body> </html>本機跑跑看,再部署
wrangler dev 在本機跑 Worker;wrangler deploy 把它發布到全世界,並印出你上線的 workers.dev 網址。
# develop with live reload npx wrangler dev # ship it npx wrangler deploy
重點概念
預備語句
prepare("... ?").bind(value) 把使用者輸入和 SQL 文字分開,擋掉 SQL 注入攻擊。千萬不要用字串拼接來組 SQL。
.all()、.first()、.run()
all() 回傳多列,first() 回傳一列(或 null),run() 用在只需要知道改了幾列的寫入操作。
HTTP 方法就是意圖
GET 讀、POST 新增、PUT/PATCH 更新、DELETE 刪除。同一網址、不同動詞——這就是 REST 的精髓。
CORS(跨來源存取)
除非伺服器回以 Access-Control-* 標頭,否則瀏覽器會擋掉跨來源呼叫。這就是為什麼 Worker 每個回應都要加上它們。
本機與遠端 D1
--local 打的是你磁碟上的 SQLite 檔、測試很快;--remote 打的是雲端真資料庫。兩邊是分開的,記得都要填資料。
RETURNING *
SQLite 的功能,讓 INSERT/UPDATE 在同一查詢裡就把受影響的那列送回來——不用再 SELECT 一次。
陷阱與計費
不要把 D1 直接露給瀏覽器
env.DB 綁定只存在於 Worker 內部。千萬不要想從前端 JavaScript 查 D1——一律走 Worker,讓驗證和權限判斷都留在伺服器端。
依列數計費,不看時間
D1 算的是讀取列數、寫入列數,再加上儲存量。免費額度每天給 500 萬列讀取、10 萬列寫入、加上 5 GB 儲存——做個待辦 App 綽綽有餘。
- 在 Worker 裡先驗證輸入(例如拒絕空白的 title),再去碰資料庫。
- 在常用來篩選或排序的欄位上建索引(index),可以少讀很多列、同時省錢。
- 狀態碼要用得有意義:201 代表建立成功、404 找不到、400 輸入有誤。
- 記得 --local 與 --remote 的 D1 是兩個不同的資料庫;你在測哪個就要填哪個。
- 把前端放到 Cloudflare Pages,讓 HTML 和 API 共用同一個網域(連 CORS 都可以省)。