Skip to content

非公式本サイトは非公式の日本語ドキュメントであり、Cloudflare 公式サイトではありません。最新情報はdevelopers.cloudflare.comをご確認ください。

To-Do リストの Jamstack アプリを作る

最終更新 Markdown で表示Agent セットアップ

このチュートリアルでは、HTML、CSS、JavaScript で To-Do リストアプリを作ります。アプリのデータは Workers KV に保存します。

完成した To-Do リストのプレビュー。続きを読んで、To-Do リストのセットアップ手順を確認してください。

このプロジェクトを始める前に、HTML、CSS、JavaScript の経験があるとよいです。次の内容を学びます。

  1. Workers で作ると、コードを書くことと完成品の公開に集中できること。
  2. Workers KV を加えることで、データ駆動の本格的なアプリ作りの入門になること。

完成コードを見たい場合は、GitHub 上のプロジェクト を開き、ライブデモ で作る内容を確認してください。

始める前に

すべてのチュートリアルは、Cloudflare Workers アカウント、C3、および Wrangler のセットアップが完了している前提です。セットアップは Get started ガイド で行います。

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

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

npm 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 のグローバルネットワーク上でレスポンスを直接組み立てて返せます。

2. プロジェクトの内容を確認する

Cloudflare Workers にデプロイするプロジェクトでは、ES modulesnpm パッケージ、async / await 関数など、モダンな JavaScript のツールを使ってアプリを作れます。Worker を書くだけでなく、このチュートリアルと同じツールと手順で 本格的なアプリ も作れます。

このチュートリアルでは、KV ストアからデータを読み、そのデータで HTML レスポンスを組み立ててクライアントへ返す、Workers 上の To-Do リストアプリを作ります。

このアプリに必要な作業は、次の 3 つに分かれます。

  1. KV へデータを書き込む。
  2. KV のデータを描画する。
  3. アプリの UI から To-Do を追加する。

以降は、各作業を進めながらアプリを改善し、自分のドメインへ公開します。

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

まず、To-Do リストに実際のデータを入れる方法を理解します。そのために、Worker 内から読み書きできるキーバリューストア Cloudflare Workers KV を使います。

KV を始めるには、namespace を用意します。キャッシュしたデータはすべてその namespace に入り、設定すれば Worker 内の決まった変数からアクセスできます。Wrangler の kv namespace create コマンドTODOS という新しい namespace を作成し、次のコマンドをターミナルで実行して、関連する namespace ID を取得します。

Create a new KV namespacesh
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 つあります。getputdelete です。

まず初期データセットを定義し、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));
	},
};

KV のデータを描画する

コード内にデータ、つまりアプリ用のキャッシュ済みデータオブジェクトがあるので、これをユーザーインターフェースに描画します。

そのために、Workers スクリプトに新しい html 変数を作り、クライアントへ返す静的 HTML テンプレートを組み立てます。fetchContent-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 サイトを描画できるようになったので、データを入れていきます。本文に 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>
  <!-- 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' },
  });
}

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

ここまでで、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 メソッド(たとえば POSTDELETE)で呼ばれた場合は、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 リストになります。

5. アプリ UI から 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 を再描画する仕組みです。

6. まとめと次のステップ

このチュートリアルを完了すると、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 にあります。

役に立ちましたか?