このチュートリアルでは、HTML、CSS、JavaScript で To-Do リストアプリを作ります。アプリのデータは Workers KV に保存します。
このプロジェクトを始める前に、HTML、CSS、JavaScript の経験があるとよいです。次の内容を学びます。
- Workers で作ると、コードを書くことと完成品の公開に集中できること。
- Workers KV を加えることで、データ駆動の本格的なアプリ作りの入門になること。
完成コードを見たい場合は、GitHub 上のプロジェクト ↗ を開き、ライブデモ ↗ で作る内容を確認してください。
すべてのチュートリアルは、Cloudflare Workers アカウント、C3 ↗、および Wrangler のセットアップが完了している前提です。セットアップは Get started ガイド で行います。
まず、create-cloudflare ↗ CLI ツールで、todos という名前の新しい Cloudflare Workers プロジェクトを作成します。このチュートリアルでは、既定の Hello World テンプレートを使います。
npm create cloudflare@latest -- todosyarn create cloudflare todospnpm create cloudflare@latest todosセットアップでは、次のオプションを選びます。
- What would you like to start with? では、
Hello World exampleを選びます。 - Which template would you like to use? では、
Worker onlyを選びます。 - Which language do you want to use? では、
JavaScriptを選びます。 - Do you want to use git for version control? では、
Yesを選びます。 - Do you want to deploy your application? では、
Noを選びます(デプロイ前にいくつか変更します)。
作成したディレクトリへ移動します。
cd todos新しい todos Worker プロジェクトのディレクトリ内で、index.js が Cloudflare Workers アプリケーションのエントリポイントです。
Worker への着信 HTTP リクエストはすべて、fetch() ハンドラー に request オブジェクトとして渡されます。Worker がリクエストを受け取ったあと、アプリが組み立てたレスポンスがユーザーへ返されます。このチュートリアルでは、リクエスト / レスポンスのパターンの仕組みと、それを使って本格的なアプリを作る方法を説明します。
export default {
async fetch(request, env, ctx) {
return new Response("Hello World!");
},
};既定の index.js では、このリクエスト / レスポンスのパターンが動いています。fetch は本文 'Hello World!' の新しい Response を組み立てます。
Worker が request を受け取ると、新しく組み立てたレスポンスをクライアントへ返します。Worker はオリジンサーバーへ転送せず、Cloudflare のグローバルネットワーク ↗ から直接新しいレスポンスを返します。通常のサーバーはリクエストを受けてレスポンスを返します。Cloudflare Workers では、Cloudflare のグローバルネットワーク上でレスポンスを直接組み立てて返せます。
Cloudflare Workers にデプロイするプロジェクトでは、ES modules、npm パッケージ、async / await ↗ 関数など、モダンな JavaScript のツールを使ってアプリを作れます。Worker を書くだけでなく、このチュートリアルと同じツールと手順で 本格的なアプリ も作れます。
このチュートリアルでは、KV ストアからデータを読み、そのデータで HTML レスポンスを組み立ててクライアントへ返す、Workers 上の To-Do リストアプリを作ります。
このアプリに必要な作業は、次の 3 つに分かれます。
- KV へデータを書き込む。
- KV のデータを描画する。
- アプリの UI から To-Do を追加する。
以降は、各作業を進めながらアプリを改善し、自分のドメインへ公開します。
まず、To-Do リストに実際のデータを入れる方法を理解します。そのために、Worker 内から読み書きできるキーバリューストア Cloudflare Workers KV を使います。
KV を始めるには、namespace を用意します。キャッシュしたデータはすべてその namespace に入り、設定すれば Worker 内の決まった変数からアクセスできます。Wrangler の kv namespace create コマンド で TODOS という新しい namespace を作成し、次のコマンドをターミナルで実行して、関連する namespace ID を取得します。
npx wrangler kv namespace create "TODOS" --preview関連する namespace に --preview フラグを付けると、本番ではなくプレビュー用 namespace を操作できます。namespace は、Wrangler の設定内で定義してアプリに追加します。作成した namespace ID をコピーし、Wrangler 設定ファイル で kv_namespaces キーを定義して namespace を設定します。
{
"kv_namespaces": [
{
"binding": "TODOS",
"id": "<YOUR_ID>",
"preview_id": "<YOUR_PREVIEW_ID>"
}
]
}[[kv_namespaces]]
binding = "TODOS"
id = "<YOUR_ID>"
preview_id = "<YOUR_PREVIEW_ID>"定義した namespace TODOS が、コードベース内で使えるようになります。次に KV API を理解します。KV namespace には、キャッシュとやり取りするための主なメソッドが 3 つあります。get、put、delete です。
まず初期データセットを定義し、put メソッドでキャッシュへ入れます。次の例では、To-Do 項目の配列ではなく defaultData オブジェクトを定義します。あとからこのキャッシュオブジェクトにメタデータやほかの情報を入れたくなることがあるためです。このデータオブジェクトを JSON.stringify し、文字列としてキャッシュへ追加します。
export default {
async fetch(request, env, ctx) {
const defaultData = {
todos: [
{
id: 1,
name: "Finish the Cloudflare Workers blog post",
completed: false,
},
],
};
await env.TODOS.put("data", JSON.stringify(defaultData));
return new Response("Hello World!");
},
};Workers KV は、結果整合性のあるグローバルデータストアです。同一リージョン内の書き込みは、そのリージョンですぐに反映されますが、ほかのリージョンではすぐには使えません。ただし、書き込みは最終的にどこでも使えるようになり、その時点で Workers KV は各リージョン内のデータが一貫していることを保証します。
キャッシュにデータがあり、キャッシュが結果整合である前提では、コードを少し調整する必要があります。アプリはキャッシュを確認し、キーがあればその値を使います。なければ、当面は defaultData をデータ源として使い(将来は設定される想定)、今後のためにキャッシュへ書き込みます。分かりやすくいくつか関数に分けた結果は次のとおりです。
export default {
async fetch(request, env, ctx) {
const defaultData = {
todos: [
{
id: 1,
name: "Finish the Cloudflare Workers blog post",
completed: false,
},
],
};
const setCache = (data) => env.TODOS.put("data", data);
const getCache = () => env.TODOS.get("data");
let data;
const cache = await getCache();
if (!cache) {
await setCache(JSON.stringify(defaultData));
data = defaultData;
} else {
data = JSON.parse(cache);
}
return new Response(JSON.stringify(data));
},
};コード内にデータ、つまりアプリ用のキャッシュ済みデータオブジェクトがあるので、これをユーザーインターフェースに描画します。
そのために、Workers スクリプトに新しい html 変数を作り、クライアントへ返す静的 HTML テンプレートを組み立てます。fetch で Content-Type: text/html ヘッダー付きの新しい Response を組み立て、クライアントへ返します。
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Todos</title>
</head>
<body>
<h1>Todos</h1>
</body>
</html>
`;
async fetch (request, env, ctx) {
// previous code
return new Response(html, {
headers: {
'Content-Type': 'text/html'
}
});
}静的 HTML サイトを描画できるようになったので、データを入れていきます。本文に id が todos の div タグを追加します。
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Todos</title>
</head>
<body>
<h1>Todos</h1>
<div id="todos"></div>
</body>
</html>
`;本文の末尾に <script> 要素を追加し、todos 配列を受け取ります。配列の各 todo に対して div 要素を作り、todos HTML 要素へ追加します。
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Todos</title>
</head>
<body>
<h1>Todos</h1>
<div id="todos"></div>
</body>
<script>
window.todos = []
var todoContainer = document.querySelector("#todos")
window.todos.forEach(todo => {
var el = document.createElement("div")
el.textContent = todo.name
todoContainer.appendChild(el)
})
</script>
</html>
`;静的ページは window.todos を受け取り、それに基づいて HTML を描画できます。ただし、KV からのデータはまだ渡していません。いくつか変更が必要です。
まず、html 変数を関数にします。関数は todos 引数を受け取り、上のコード例の window.todos 変数に入れます。
const html = (todos) => `
<!doctype html>
<html>
<!-- existing content -->
<script>
window.todos = ${todos}
var todoContainer = document.querySelector("#todos")
// ...
<script>
</html>
`;fetch では、取得した KV データを使って html 関数を呼び、それに基づく Response を生成します。
async fetch (request, env, ctx) {
const body = html(JSON.stringify(data.todos).replace(/</g, '\\u003c'));
return new Response(body, {
headers: { 'Content-Type': 'text/html' },
});
}ここまでで、Cloudflare KV からデータを取り、それに基づいて静的ページを描画する Cloudflare Worker ができました。静的ページはデータを読み、それに基づいて To-Do リストを生成します。残りの作業は、アプリ UI から To-Do を作成することです。KV API で To-Do を追加できます。env.TODOS.put(newData) を実行してキャッシュを更新します。
To-Do 項目を更新するには、Workers スクリプトに 2 つ目のハンドラーを追加し、/ への PUT リクエストを監視します。その URL でリクエスト本文を受け取ると、Worker は新しい To-Do データを KV ストアへ送ります。
この新しい機能を fetch に追加します。リクエストメソッドが PUT なら、リクエスト本文を取り、キャッシュを更新します。
export default {
async fetch(request, env, ctx) {
const setCache = (data) => env.TODOS.put("data", data);
if (request.method === "PUT") {
const body = await request.text();
try {
JSON.parse(body);
await setCache(body);
return new Response(body, { status: 200 });
} catch (err) {
return new Response(err, { status: 500 });
}
}
// previous code
},
};リクエストが PUT であることを確認し、残りのコードを try...catch ブロックで囲みます。まず着信リクエストの本文をパースして JSON であることを確かめてから、新しいデータでキャッシュを更新し、ユーザーへ返します。何か問題があれば、500 ステータスコードを返します。ルートが PUT 以外の HTTP メソッド(たとえば POST や DELETE)で呼ばれた場合は、404 エラーを返します。
このスクリプトがあれば、HTML ページに動的な機能を追加して、このルートを実際に呼べます。まず、To-Do 名の入力欄と、To-Do を送信するボタンを作ります。
const html = (todos) => `
<!doctype html>
<html>
<!-- existing content -->
<div>
<input type="text" name="name" placeholder="A new todo"></input>
<button id="create">Create</button>
</div>
<!-- existing script -->
</html>
`;この入力欄とボタンに対応して、ボタンのクリックを監視する JavaScript 関数を追加します。ボタンがクリックされると、ブラウザーは / へ PUT し、To-Do を送信します。
const html = (todos) => `
<!doctype html>
<html>
<!-- existing content -->
<script>
// Existing JavaScript code
var createTodo = function() {
var input = document.querySelector("input[name=name]")
if (input.value.length) {
todos = [].concat(todos, {
id: todos.length + 1,
name: input.value,
completed: false,
})
fetch("/", {
method: "PUT",
body: JSON.stringify({ todos: todos }),
})
}
}
document.querySelector("#create").addEventListener("click", createTodo)
</script>
</html>
`;このコードはキャッシュを更新します。KV キャッシュは結果整合である点に注意してください。キャッシュを読んで返すように Worker を更新しても、最新である保証はありません。代わりに、To-Do リストの描画用の元のコードを再利用可能な populateTodos 関数にし、ページ読み込み時とキャッシュリクエスト完了時に呼んで、ローカルで To-Do リストを更新します。
const html = (todos) => `
<!doctype html>
<html>
<!-- existing content -->
<script>
var populateTodos = function() {
var todoContainer = document.querySelector("#todos")
todoContainer.innerHTML = null
window.todos.forEach(todo => {
var el = document.createElement("div")
el.textContent = todo.name
todoContainer.appendChild(el)
})
}
populateTodos()
var createTodo = function() {
var input = document.querySelector("input[name=name]")
if (input.value.length) {
todos = [].concat(todos, {
id: todos.length + 1,
name: input.value,
completed: false,
})
fetch("/", {
method: "PUT",
body: JSON.stringify({ todos: todos }),
})
populateTodos()
input.value = ""
}
}
document.querySelector("#create").addEventListener("click", createTodo)
</script>
`;クライアント側のコードが揃ったので、関数の新しいバージョンをデプロイすると、これらの部品がつながります。結果として、実際に動く To-Do リストになります。
To-Do リストの最後の部品として、To-Do を更新できる必要があります。具体的には、完了としてマークすることです。
幸い、この作業の基盤の多くはすでにあります。createTodo 関数が示すとおり、キャッシュ内の To-Do リストデータは更新できます。To-Do の更新は、Worker 側よりクライアント側の作業です。
まず、populateTodos 関数を更新して、各 To-Do 用の div を生成します。あわせて、To-Do の名前をその div の子要素へ移します。
const html = (todos) => `
<!doctype html>
<html>
<!-- existing content -->
<script>
var populateTodos = function() {
var todoContainer = document.querySelector("#todos")
todoContainer.innerHTML = null
window.todos.forEach(todo => {
var el = document.createElement("div")
var name = document.createElement("span")
name.textContent = todo.name
el.appendChild(name)
todoContainer.appendChild(el)
})
}
</script>
`;クライアント側は、To-Do の配列を扱い、HTML 要素のリストを描画するように設計しています。これまで入れてきたものの中には、まだ使っていないものがあります。具体的には ID の付与と、To-Do の完了状態の更新です。これらは、アプリ UI での To-Do 更新を支えるためにうまく働きます。
まず、各 To-Do の ID を HTML に付けると便利です。そうすれば、あとから要素を参照し、JavaScript 側の To-Do と対応付けられます。データ属性と、JavaScript の対応する dataset メソッドが、この実装に向いています。各 To-Do の div を生成するときに、各 div へ todo というデータ属性を付けられます。
const html = (todos) => `
<!doctype html>
<html>
<!-- existing content -->
<script>
var populateTodos = function() {
var todoContainer = document.querySelector("#todos")
todoContainer.innerHTML = null
window.todos.forEach(todo => {
var el = document.createElement("div")
el.dataset.todo = todo.id
var name = document.createElement("span")
name.textContent = todo.name
el.appendChild(name)
todoContainer.appendChild(el)
})
}
</script>
`;HTML 内では、各 To-Do の div に次のようなデータ属性が付きます。
<div data-todo="1"></div>
<div data-todo="2"></div>次に、各 To-Do 要素用のチェックボックスを生成できます。新しい To-Do では既定で未チェックですが、ウィンドウへ描画するときにチェック済みにできます。
const html = (todos) => `
<!doctype html>
<html>
<!-- existing content -->
<script>
window.todos.forEach(todo => {
var el = document.createElement("div")
el.dataset.todo = todo.id
var name = document.createElement("span")
name.textContent = todo.name
var checkbox = document.createElement("input")
checkbox.type = "checkbox"
checkbox.checked = todo.completed ? 1 : 0
el.appendChild(checkbox)
el.appendChild(name)
todoContainer.appendChild(el)
})
</script>
`;チェックボックスは各 To-Do の completed の値を正しく反映しますが、実際にチェックしてもまだ更新されません。そのため、click イベントのリスナーとして completeTodo 関数を付けます。関数内でチェックボックス要素を調べ、親(To-Do の div)を見つけ、その todo データ属性でデータ配列内の対応する To-Do を探します。完了状態を切り替え、プロパティを更新し、UI を再描画できます。
const html = (todos) => `
<!doctype html>
<html>
<!-- existing content -->
<script>
var populateTodos = function() {
window.todos.forEach(todo => {
// Existing todo element set up code
checkbox.addEventListener("click", completeTodo)
})
}
var completeTodo = function(evt) {
var checkbox = evt.target
var todoElement = checkbox.parentNode
var newTodoSet = [].concat(window.todos)
var todo = newTodoSet.find(t => t.id == todoElement.dataset.todo)
todo.completed = !todo.completed
todos = newTodoSet
updateTodos()
}
</script>
`;最終的なコードは、todos 変数を確認し、その値で Cloudflare KV キャッシュを更新し、手元のデータに基づいて UI を再描画する仕組みです。
このチュートリアルを完了すると、Workers と Workers KV が裏側で動き、Cloudflare のグローバルネットワークを活かす、静的な HTML、CSS、JavaScript アプリができます。
プロジェクトを続けて改善したい場合は、デザインを良くする(todos.signalnerve.workers.dev ↗ のライブ版を参照できます)か、セキュリティと速度をさらに改善できます。
ユーザーごとのキャッシュを追加したくなることもあります。現在、キャッシュキーは常に data です。つまり、サイトの訪問者はほかの訪問者と同じ To-Do リストを共有します。Worker 内で、クライアントリクエストの値を使って、ユーザー固有のリストを作成・維持できます。たとえば、リクエスト元 IP に基づいてキャッシュキーを生成できます。
export default {
async fetch(request, env, ctx) {
const defaultData = {
todos: [
{
id: 1,
name: "Finish the Cloudflare Workers blog post",
completed: false,
},
],
};
const setCache = (key, data) => env.TODOS.put(key, data);
const getCache = (key) => env.TODOS.get(key);
const ip = request.headers.get("CF-Connecting-IP");
const myKey = `data-${ip}`;
if (request.method === "PUT") {
const body = await request.text();
try {
JSON.parse(body);
await setCache(myKey, body);
return new Response(body, { status: 200 });
} catch (err) {
return new Response(err, { status: 500 });
}
}
let data;
const cache = await getCache();
if (!cache) {
await setCache(myKey, JSON.stringify(defaultData));
data = defaultData;
} else {
data = JSON.parse(cache);
}
const body = html(JSON.stringify(data.todos).replace(/</g, "\\u003c"));
return new Response(body, {
headers: {
"Content-Type": "text/html",
},
});
},
};これらの変更を加えて Worker をもう一度デプロイすると、To-Do リストアプリはユーザーごとの機能を備えつつ、Cloudflare のグローバルネットワークを引き続き活かせます。
Worker スクリプトの最終版は次のようになります。
const html = (todos) => `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Todos</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss/dist/tailwind.min.css" rel="stylesheet"></link>
</head>
<body class="bg-blue-100">
<div class="w-full h-full flex content-center justify-center mt-8">
<div class="bg-white shadow-md rounded px-8 pt-6 py-8 mb-4">
<h1 class="block text-grey-800 text-md font-bold mb-2">Todos</h1>
<div class="flex">
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-grey-800 leading-tight focus:outline-none focus:shadow-outline" type="text" name="name" placeholder="A new todo"></input>
<button class="bg-blue-500 hover:bg-blue-800 text-white font-bold ml-2 py-2 px-4 rounded focus:outline-none focus:shadow-outline" id="create" type="submit">Create</button>
</div>
<div class="mt-4" id="todos"></div>
</div>
</div>
</body>
<script>
window.todos = ${todos}
var updateTodos = function() {
fetch("/", { method: "PUT", body: JSON.stringify({ todos: window.todos }) })
populateTodos()
}
var completeTodo = function(evt) {
var checkbox = evt.target
var todoElement = checkbox.parentNode
var newTodoSet = [].concat(window.todos)
var todo = newTodoSet.find(t => t.id == todoElement.dataset.todo)
todo.completed = !todo.completed
window.todos = newTodoSet
updateTodos()
}
var populateTodos = function() {
var todoContainer = document.querySelector("#todos")
todoContainer.innerHTML = null
window.todos.forEach(todo => {
var el = document.createElement("div")
el.className = "border-t py-4"
el.dataset.todo = todo.id
var name = document.createElement("span")
name.className = todo.completed ? "line-through" : ""
name.textContent = todo.name
var checkbox = document.createElement("input")
checkbox.className = "mx-4"
checkbox.type = "checkbox"
checkbox.checked = todo.completed ? 1 : 0
checkbox.addEventListener("click", completeTodo)
el.appendChild(checkbox)
el.appendChild(name)
todoContainer.appendChild(el)
})
}
populateTodos()
var createTodo = function() {
var input = document.querySelector("input[name=name]")
if (input.value.length) {
window.todos = [].concat(todos, { id: window.todos.length + 1, name: input.value, completed: false })
input.value = ""
updateTodos()
}
}
document.querySelector("#create").addEventListener("click", createTodo)
</script>
</html>
`;
export default {
async fetch(request, env, ctx) {
const defaultData = {
todos: [
{
id: 1,
name: "Finish the Cloudflare Workers blog post",
completed: false,
},
],
};
const setCache = (key, data) => env.TODOS.put(key, data);
const getCache = (key) => env.TODOS.get(key);
const ip = request.headers.get("CF-Connecting-IP");
const myKey = `data-${ip}`;
if (request.method === "PUT") {
const body = await request.text();
try {
JSON.parse(body);
await setCache(myKey, body);
return new Response(body, { status: 200 });
} catch (err) {
return new Response(err, { status: 500 });
}
}
let data;
const cache = await getCache();
if (!cache) {
await setCache(myKey, JSON.stringify(defaultData));
data = defaultData;
} else {
data = JSON.parse(cache);
}
const body = html(JSON.stringify(data.todos).replace(/</g, "\\u003c"));
return new Response(body, {
headers: {
"Content-Type": "text/html",
},
});
},
};このプロジェクトのソースコードと、デプロイ手順付きの README は GitHub ↗ にあります。