コンテンツにスキップ

TodoリストのJamstackアプリケーションを構築する

Last reviewed: 4 months ago

このチュートリアルでは、HTML、CSS、およびJavaScriptを使用してTodoリストアプリケーションを構築します。アプリケーションデータはWorkers KVに保存されます。

完成したTodoリストのプレビュー。Todoリストのセットアップ方法についての指示を続けてお読みください。

このプロジェクトを始める前に、HTML、CSS、およびJavaScriptの経験があることを確認してください。以下のことを学びます:

  1. Workersを使用して構築することで、コードの記述に集中し、完成した製品を出荷できるようになります。
  2. Workers KVの追加により、このチュートリアルは完全なデータ駆動型アプリケーションを構築するための素晴らしい入門となります。

このプロジェクトの完成コードを見たい場合は、GitHubのプロジェクトを見つけ、ライブデモを参照して、構築する内容を確認してください。

Before you start

All of the tutorials assume you have already completed the Get started guide, which gets you set up with a Cloudflare Workers account, C3, and Wrangler.

1. 新しいWorkersプロジェクトを作成する

まず、create-cloudflare CLIツールを使用して、todosという名前の新しいCloudflare Workersプロジェクトを作成します。このチュートリアルでは、デフォルトのHello Worldテンプレートを使用してWorkersプロジェクトを作成します。

Terminal window
npm create cloudflare@latest -- todos

新しく作成したディレクトリに移動します:

Terminal window
cd todos

新しいtodos Workerプロジェクトディレクトリ内のindex.jsは、Cloudflare Workersアプリケーションへのエントリポイントを表します。

WorkerへのすべてのHTTPリクエストは、fetch()ハンドラーリクエストオブジェクトとして渡されます。Workerがリクエストを受信すると、アプリケーションが構築したレスポンスがユーザーに返されます。このチュートリアルでは、リクエスト/レスポンスパターンがどのように機能するか、そしてそれを使用して完全な機能を持つアプリケーションを構築する方法を理解する手助けをします。

export default {
async fetch(request, env, ctx) {
return new Response("Hello World!");
},
};

デフォルトのindex.jsファイルでは、リクエスト/レスポンスパターンがどのように機能するかを見ることができます。fetchは、本文テキスト'Hello World!'を持つ新しいResponseを構築します。

Workerがrequestを受信すると、Workerは新しく構築されたレスポンスをクライアントに返します。あなたのWorkerは、オリジンサーバーに続くのではなく、Cloudflareのグローバルネットワークから直接新しいレスポンスを提供します。標準のサーバーはリクエストを受け入れ、レスポンスを返します。Cloudflare Workersは、Cloudflareのグローバルネットワーク上で直接レスポンスを構築することによって応答することを可能にします。

2. プロジェクトの詳細を確認する

Cloudflare Workersにデプロイするプロジェクトは、ESモジュールnpmパッケージ、async/await関数などの最新のJavaScriptツールを利用してアプリケーションを構築できます。Workersを書くことに加えて、同じツールとプロセスを使用して完全なアプリケーションを構築するためにWorkersを使用できます。

このチュートリアルでは、Cloudflare KVからデータを読み取り、そのデータを使用してクライアントに送信するHTMLレスポンスを生成するWorkers上で動作するTodoリストアプリケーションを構築します。

このアプリケーションを作成するために必要な作業は、3つのタスクに分かれています:

  1. KVにデータを書き込む。
  2. KVからデータをレンダリングする。
  3. アプリケーションUIからTodoを追加する。

このチュートリアルの残りの部分では、各タスクを完了し、アプリケーションを反復し、最終的に自分のドメインに公開します。

3. KVにデータを書き込む

まず、実際のデータでTodoリストを埋める方法を理解する必要があります。これを行うために、Cloudflare Workers KVを使用します。これは、Worker内でデータを読み書きするためにアクセスできるキー・バリューストアです。

KVを始めるには、名前空間を設定します。すべてのキャッシュデータはその名前空間内に保存され、設定により、あらかじめ定義された変数を使用してWorker内でその名前空間にアクセスできます。Wranglerを使用して、kv:namespace createコマンドTODOSという新しい名前空間を作成し、次のコマンドをターミナルで実行して関連する名前空間IDを取得します:

新しいKV名前空間を作成する
npx wrangler kv:namespace create "TODOS" --preview

関連する名前空間は、--previewフラグと組み合わせて、プロダクション名前空間ではなくプレビュー名前空間と対話することができます。名前空間は、Wrangler設定内で定義することによってアプリケーションに追加できます。新しく作成した名前空間IDをコピーし、wrangler.toml内でkv_namespacesキーを定義して名前空間を設定します:

kv_namespaces = [
{binding = "TODOS", id = "<YOUR_ID>", preview_id = "<YOUR_PREVIEW_ID>"}
]

定義された名前空間TODOSは、コードベース内で利用可能になります。それでは、KV APIを理解する時です。KV名前空間には、キャッシュとインターフェースするために使用できる3つの主要なメソッドがあります:getput、およびdelete

データを保存するために、キャッシュ内に配置する初期データセットを定義します。以下の例では、Todoアイテムの配列の代わりにdefaultDataオブジェクトを定義します。後でこのキャッシュオブジェクト内にメタデータやその他の情報を保存することを検討するかもしれません。そのデータオブジェクトを考慮して、JSON.stringifyを使用して文字列をキャッシュに追加します:

export default {
async fetch(request, env, ctx) {
const defaultData = {
todos: [
{
id: 1,
name: "Cloudflare Workersのブログ投稿を完成させる",
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: "Cloudflare Workersのブログ投稿を完成させる",
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));
},
};

KVからデータをレンダリングする

コード内に存在するデータ、すなわちアプリケーションのキャッシュデータオブジェクトを考慮すると、このデータを取り出してユーザーインターフェースにレンダリングする必要があります。

これを行うために、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) {
// 以前のコード
return new Response(html, {
headers: {
'Content-Type': 'text/html'
}
});
}

静的HTMLサイトがレンダリングされ、データで埋め始めることができます。ボディ内にidtodosdivタグを追加します:

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>
<!-- 既存のコンテンツ -->
<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' },
});
}

4. ユーザーインターフェース(UI)からTodoを追加する

この時点で、Cloudflare Workerを構築し、Cloudflare KVからデータを取得し、そのWorkerに基づいて静的ページをレンダリングしました。その静的ページはデータを読み取り、そのデータに基づいてTodoリストを生成します。残りのタスクは、アプリケーションUI内からTodoを作成することです。KV APIを使用してTodoを追加できます — env.TODOS.put(newData)を実行してキャッシュを更新します。

Todoアイテムを更新するには、Workersスクリプトに2番目のハンドラーを追加し、PUTリクエストを/で監視するように設計します。そのURLでリクエストボディが受信されると、Workerは新しいTodoデータを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 });
}
}
// 以前のコード
},
};

リクエストがPUTであることを確認し、残りのコードをtry/catchブロックでラップします。まず、受信したリクエストのボディを解析し、それがJSONであることを確認してから、新しいデータでキャッシュを更新し、ユーザーに返します。何か問題が発生した場合は、500ステータスコードを返します。HTTPメソッドがPUT以外(例えば、POSTDELETE)でこのルートにアクセスされた場合は、404エラーを返します。

このスクリプトを使用すると、HTMLページに動的な機能を追加して、このルートに実際にアクセスできるようになります。まず、Todo名の入力フィールドとTodoを送信するためのボタンを作成します。

const html = (todos) => `
<!doctype html>
<html>
<!-- 既存のコンテンツ -->
<div>
<input type="text" name="name" placeholder="新しいTodo"></input>
<button id="create">作成</button>
</div>
<!-- 既存のスクリプト -->
</html>
`;

この入力フィールドとボタンを考慮して、ボタンのクリックを監視する対応するJavaScript関数を追加します — ボタンがクリックされると、ブラウザはPUT/に送信し、Todoを送信します。

const html = (todos) => `
<!doctype html>
<html>
<!-- 既存のコンテンツ -->
<script>
// 既存のJavaScriptコード
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を更新してキャッシュから読み取り、それを返すようにしても、実際に最新の状態である保証はありません。代わりに、元のtodoリストをレンダリングするコードを取り、再利用可能な関数populateTodosとして定義し、ページが読み込まれたときとキャッシュリクエストが完了したときに呼び出すことで、ローカルでtodoのリストを更新します:

const html = (todos) => `
<!doctype html>
<html>
<!-- 既存のコンテンツ -->
<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>
`;

クライアントサイドのコードが整ったので、新しいバージョンの関数をデプロイすれば、これらのすべての要素が組み合わさります。その結果、実際の動的なtodoリストが得られます。

5. アプリケーションUIからtodosを更新する

todoリストの最終的な部分として、todosを更新できる必要があります。具体的には、完了としてマークすることです。

幸いなことに、この作業のためのインフラストラクチャの多くはすでに整っています。createTodo関数によって示されるように、キャッシュ内のtodoリストデータを更新できます。todoの更新は、Worker側のタスクよりもクライアント側のタスクです。

まず、populateTodos関数を更新して、各todoのためにdivを生成します。さらに、todoの名前をそのdivの子要素に移動します:

const html = (todos) => `
<!doctype html>
<html>
<!-- 既存のコンテンツ -->
<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>
`;

このコードのクライアントサイド部分は、todosの配列を処理し、HTML要素のリストをレンダリングするように設計されています。まだ使用していないいくつかのことがあります。具体的には、IDの含有とtodoの完了状態の更新です。これらの要素は、アプリケーションUIでtodosを更新するのを実際にサポートするためにうまく機能します。

まず、HTML内で各todoのIDを添付することが有用です。これにより、後で要素を参照して、JavaScriptコード内のtodoに対応させることができます。データ属性とJavaScriptの対応するdatasetメソッドは、これを実装するための完璧な方法です。各todoのためにdiv要素を生成するとき、各divにtodoというデータ属性を添付できます:

const html = (todos) => `
<!doctype html>
<html>
<!-- 既存のコンテンツ -->
<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内の各todoのdivには、次のようなデータ属性が添付されています:

<div data-todo="1"></div>
<div data-todo="2"></div>

これで、各todo要素のためにチェックボックスを生成できます。このチェックボックスは、新しいtodoに対してはデフォルトで未チェックですが、要素がウィンドウにレンダリングされるときにチェック済みにすることができます:

const html = (todos) => `
<!doctype html>
<html>
<!-- 既存のコンテンツ -->
<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>
`;

チェックボックスは、各todoの完了状態の値を正しく反映するように設定されていますが、実際にボックスをチェックしたときにまだ更新されません。これを行うために、completeTodo関数をclickイベントのイベントリスナーとして添付します。関数内で、チェックボックス要素を調べ、その親(todoのdiv)を見つけ、todoデータ属性を使用してデータ配列内の対応するtodoを見つけます。完了状態を切り替え、プロパティを更新し、UIを再レンダリングできます:

const html = (todos) => `
<!doctype html>
<html>
<!-- 既存のコンテンツ -->
<script>
var populateTodos = function() {
window.todos.forEach(todo => {
// 既存のtodo要素のセットアップコード
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を再レンダリングするシステムです。

6. 結論と次のステップ

このチュートリアルを完了することで、Cloudflareのグローバルネットワークを最大限に活用したWorkersとWorkers KVによって透明に動作する静的なHTML、CSS、JavaScriptアプリケーションを構築しました。

プロジェクトをさらに改善したい場合は、より良いデザインを実装することができます(todos.signalnerve.workers.devで利用可能なライブバージョンを参照できます)または、セキュリティと速度の追加の改善を行うことができます。

ユーザー固有のキャッシングを追加したい場合もあります。現在、キャッシュキーは常にdataです。これは、サイトの訪問者が他の訪問者と同じtodoリストを共有することを意味します。Worker内で、クライアントリクエストからの値を使用してユーザー固有のリストを作成および維持できます。たとえば、リクエストIPに基づいてキャッシュキーを生成できます:

export default {
async fetch(request, env, ctx) {
const defaultData = {
todos: [
{
id: 1,
name: "Cloudflare Workersのブログ投稿を完成させる",
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をもう一度デプロイすると、todoリストアプリケーションはユーザーごとの機能を含むようになり、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="新しい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">作成</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: "Cloudflare Workersのブログ投稿を完成させる",
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で見つけることができます。