【第7回】簡単なWebアプリを作ってみよう

Webフレームワークを使ってTODOアプリを作ります

Homeブログ一覧【第7回】簡単なWebアプリを作ってみよう

今回やること

今回は、Honoというフレームワークを使って、前回作ったTODOアプリを簡単に書き直してみましょう。

Honoを使ってみる

まずは、前回の作業ディレクトリsimple-todo-appに移動します。

Honoを使うためにpackage.jsonを編集します。

package.json
{
  "name": "simple-todo-app",
  "type": "module",
  "dependencies": {
    "@hono/node-server": "^1.11.1",
    "hono": "^4.3.6"
  },
  "scripts": {
    "start": "node server.js"
  }
}

次に、npmでpackage.jsonを読み込み、必要なパッケージをインストールします。

npm install

package-lock.jsonnode_modulesディレクトリが生成されたら、server.jsを以下のように編集します。

server.js
import { serve } from "@hono/node-server";
import { Hono } from "hono";

const app = new Hono();

const todoList = [
  { title: "JavaScriptを勉強する", completed: false },
  { title: "TODOアプリを自作する", completed: false },
  { title: "漫画を読み切る", completed: true },
  { title: "ゲームをクリアする", completed: false },
];

app.get("/", (c) => c.json(todoList, 200));

app.post("/", async (c) => {
  const param = await c.req.json();

  if (!param.title) {
    throw new HttpException(400, { message: "Title must be provided" });
  }

  const newTodo = {
    completed: !!param.completed,
    title: param.title,
  };

  todoList.push(newTodo);

  return c.json({ message: "Successfully created" }, 200);
});

app.onError((err, c) => {
  if (err instanceof HTTPException) {
    return err.getResponse()
  }
});

serve({
  fetch: app.fetch,
  port: 8000,
});

サーバーを起動してみよう

以下のコマンドでサーバーを起動します。

npm run start &

以下のコマンドを実行後にブラウザをリロードすると、TODOが追加されていることが確認できます。

curl -X POST -H "Content-Type: application/json" -d '{"title": "次回のWeb研に出席する"}' http://localhost:8000

実際に動作することが確認出来たら、サーバーを切断してコミットしましょう。

git add .
git commit -m "Honoに移行する"

TODOの編集・削除機能を追加しよう

server.jsを以下のように編集して、TODOの編集・削除機能を追加します。

server.js
import { serve } from "@hono/node-server";
import { Hono } from "hono";

const app = new Hono();

const todoList = [
  { id: "1", title: "JavaScriptを勉強する", completed: false },
  { id: "2", title: "TODOアプリを自作する", completed: false },
  { id: "3", title: "漫画を読み切る", completed: true },
  { id: "4", title: "ゲームをクリアする", completed: false },
];

let currentId = 1;

app.get("/", (c) => c.json(todoList, 200));

app.post("/", async (c) => {
  const param = await c.req.json();

  if (!param.title) {
    throw new Error("Title must be provided");
  }

  const newTodo = {
    id: String(currentId++),
    completed: !!param.completed,
    title: param.title,
  };

  todoList.push(newTodo);

  return c.json({ message: "Successfully created" }, 200);
});

app.put("/:id", async (c) => {
  const param = await c.req.json();
  const id = c.req.param("id");

  if (!param.title && param.completed === undefined) {
    throw new HTTPException(400, { message: "Either title or completed must be provided" });
  }

  const todo = todoList.find((todo) => todo.id === id);
  if (!todo) {
    throw new HTTPException(400, { message: "Failed to update task title" });
  }

  if (param.title) {
    todo.title = param.title;
  }

  if (param.completed !== undefined) {
    todo.completed = param.completed;
  }

  return c.json({ message: "Task updated" }, 200);
});

app.delete("/:id", async (c) => {
  const id = c.req.param("id");
  const todoIndex = todoList.findIndex((todo) => todo.id === id);
  if (todoIndex === -1) {
    throw new HttpException(400, { message: "Failed to delete task" });
  }
  todoList.splice(todoIndex, 1);

  return c.json({ message: "Task deleted" }, 200);
});

app.onError((err, c) => {
  if (err instanceof HTTPException) {
    return err.getResponse()
  }
});

serve({
  fetch: app.fetch,
  port: 8000,
});

コマンドでTODO項目を操作してみよう

まず、サーバーを起動します。

npm run start &

以下のコマンドを実行してみましょう。

  • TODOの追加コマンド

以下のコマンドで、新しいTODOを追加します。

curl -X POST -H "Content-Type: application/json" -d '{"title": "次回のWeb研に出席する"}' http://localhost:8000
  • TODOの編集コマンド

以下のコマンドで、「TODOアプリを自作する」のタスクが完了済みになります。

curl -X PUT -H "Content-Type: application/json" -d '{"completed": true}' http://localhost:8000/2

以下のコマンドで、「JavaScriptを勉強する」のタイトルが「Node.jsを勉強する」に変更されます。

curl -X PUT -H "Content-Type: application/json" -d '{"title": "Node.jsを勉強する"}' http://localhost:8000/1
  • TODOの削除コマンド

以下のコマンドで、「漫画を読み切る」のタスクが削除されます。

curl -X DELETE http://localhost:8000/3

実際に動作することが確認出来たら、サーバーを切断してコミットしましょう。

git add .
git commit -m "TODOの編集・削除機能を追加"

ちょっとした解説

server.jsの中で、app.postapp.putapp.deleteを使って、それぞれPOST、PUT、DELETEリクエストを処理しています。

app.postの部分のみ解説します。

app.postは、新しいTODOアイテムを追加するためのリクエストを処理します。

paramには、リクエストのボディをJSON形式で受け取ります。

const param = await c.req.json();

具体的には、こんな感じです。

{
  "title": "次回のWeb研に出席する"
}

新しいTODOアイテムのidcompletedtitleを受け取り、newTodoとして定義します。

const newTodo = {
  id: String(currentId++),
  completed: !!param.completed,
  title: param.title,
};

todoListnewTodoを追加します。

todoList.push(newTodo);

フロントとサーバーを連携してみよう

新しい作業ディレクトリhono-todo-appを作成し、その中にserverディレクトリとclientディレクトリを作成します。

下のようにディレクトリ構造を作成します。

hono-todo-app
├── client
└── server

Gitリポジトリの初期化を行います。

git init

フロント側の実装

clientディレクトリに移動し、index.htmlscript.jsを作成します。

hono-todo-app
├── client
│   ├── index.html
│   └── script.js
└── server

index.htmlscript.jsを以下のように編集します。

index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>TODOアプリ</title>
  </head>
  <body>
    <h1>連携テスト</h1>
    <button id="fetch">データ取得</button>
    <div id="output"></div>
    <script src="./script.js"></script>
  </body>
</html>
script.js
const fetchButton = document.getElementById("fetch");
const output = document.getElementById("output");

// ボタンがクリックされたときの処理
fetchButton.addEventListener("click", async () => {
  // サーバーにリクエストを送信する
  const response = await fetch("http://localhost:8000");
  // レスポンスの内容を取得する
  console.log(response);
  // レスポンスの内容をテキストとして取得する
  const data = await response.text();
  // レスポンスの内容を出力する
  output.innerHTML = data;
});

サーバー側の実装

serverディレクトリに移動し、package.jsonserver.jsを作成します。

hono-todo-app
├── client
│   ├── index.html
│   └── script.js
└── server
    ├── package.json
    └── server.js

package.jsonserver.jsを以下のように編集します。

package.json
{
  "name": "hono-todo-app",
  "type": "module",
  "dependencies": {
    "@hono/node-server": "^1.11.1",
    "hono": "^4.3.6"
  },
  "scripts": {
    "start": "node server.js"
  }
}
server.js
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";

const app = new Hono();

// CORSの設定
app.use(cors({ origin: "*" }));

// ルートパスにアクセスしたときの処理
app.get("/", (c) => {
  return c.text("Hello from Node.js server!");
});

serve({
  fetch: app.fetch,
  port: 8000,
});

サーバーを起動してみよう

まず、必要なパッケージをインストールします。

npm install

次に、サーバーを起動します。

npm run start

index.htmlをブラウザで開いて、ボタンをクリックしてみましょう。

result1

「Hello from Node.js server!」と表示されました。

実際に動作することが確認出来たら、サーバーを切断してhono-todo-appディレクトリでコミットしましょう。

git add .
git commit -m "フロントとサーバーを連携"

ちょっとした解説

index.htmlを開いてボタンをクリックすると、テキストが表示されますがこれはどこから来たのでしょうか?

script.jsの中で、fetch関数を使ってサーバーhttp://localhost:8000にリクエストを送信しています。

const response = await fetch("http://localhost:8000");

fetch関数は、指定したURLにリクエストを送信し、レスポンスを返します。

responseには、レスポンスの内容が入っています。

const data = await response.text();

response.text()は、サーバーにアクセスしたときに返ってきたデータをテキストとして取得します。

つまり、クライアント(ブラウザ)がサーバーにリクエストを送信し、それに対するレスポンスを受け取ってブラウザに表示しているということです。

TODOを表示してみよう

次にTODOを一覧で表示し、各TODOにチェックボックスと削除ボタンを追加してみましょう。

index.htmlscript.jsを以下のように編集します。

index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>TODOアプリ</title>
  </head>
  <body>
    <h1>TODOアプリ</h1>

    <div id="todo-list"></div>

    <input type="text" id="todo-title" placeholder="タイトル" required />
    <button type="submit" id="add-button">追加</button>

    <script src="./script.js"></script>
  </body>
</html>
script.js
// サーバーからTODOリストを取得して表示する
const fetchAndDisplayTodoList = async () => {
  // サーバーからTODOリストを取得
  const response = await fetch("http://localhost:8000/api/todo");
  // レスポンスの内容をJSON形式で取得
  const todoList = await response.json();

  const todoListElement = document.getElementById("todo-list");
  todoListElement.innerHTML = "";

  // todoList配列の各要素に対して処理を行う
  todoList.forEach((todo) => {
    // チェックボックスを生成
    const checkbox = document.createElement("input");
    checkbox.type = "checkbox";
    checkbox.checked = todo.completed;

    // チェックボックスの状態が変更されたときに、updateTodoStatus関数を呼び出す
    checkbox.addEventListener("change", function () {
      updateTodoStatus(todo.id, this.checked);
    });

    // テキストボックスを生成
    // <input type="text" value="..." />
    const inputElement = document.createElement("input");
    inputElement.type = "text";
    inputElement.value = todo.title;

    // テキストボックスの値が変更されたときに、updateTodoTitle関数を呼び出す
    inputElement.addEventListener("change", function () {
      updateTodoTitle(todo.id, this.value);
    });

    // 削除ボタンを生成
    // <button>削除</button>
    const deleteButtonElement = document.createElement("button");
    deleteButtonElement.textContent = "削除";

    // 削除ボタンがクリックされたときに、deleteTodo関数を呼び出す
    deleteButtonElement.addEventListener("click", function () {
      deleteTodo(todo.id);
    });

    // <div>
    //   <input type="checkbox" />
    //   <input type="text" value="..." />
    //   <button>削除</button>
    // </div>
    // を生成
    const todoElement = document.createElement("div");
    todoElement.appendChild(checkbox);
    todoElement.appendChild(inputElement);
    todoElement.appendChild(deleteButtonElement);

    // <div>をtodo-listに追加
    todoListElement.appendChild(todoElement);
  });
};

// ボタンが押されたときにaddTodo関数を呼び出す
const addButton = document.getElementById("add-button");

document.addEventListener("DOMContentLoaded", fetchAndDisplayTodoList);

server.jsを以下のように編集します。

server.js
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";

const app = new Hono();

app.use(cors({ origin: "*" }));

const todoList = [
  { id: "1", title: "JavaScriptを勉強する", completed: false },
  { id: "2", title: "TODOアプリを自作する", completed: false },
  { id: "3", title: "漫画を読み切る", completed: true },
  { id: "4", title: "ゲームをクリアする", completed: false },
];

// TODOリストの取得
app.get("/api/todo", (c) => c.json(todoList, 200));

serve({
  fetch: app.fetch,
  port: 8000,
});

サーバーを起動してみよう

以下のコマンドでサーバーを起動します。

npm run start

index.htmlをブラウザで開いて、TODOリストにチェックボックスと削除ボタンが表示されていることを確認しましょう。

ただし、チェックボックスや削除ボタンをクリックしても、まだ何も起こりません。

result2

実際に動作することが確認出来たら、サーバーを切断してhono-todo-appディレクトリでコミットしましょう。

git add .
git commit -m "TODOを表示する"

TODOの状態を更新してみよう(実践)

先ほど作成したチェックボックスや削除ボタン、テキストボックスにイベントを追加して、TODOの状態を更新できるようにしてみましょう。

まずは、script.jsに以下のコードを貼り付け、抜けているコードを埋めてみましょう。

script.js
// サーバーからTODOリストを取得して表示する
const fetchAndDisplayTodoList = async () => {
  const response = await fetch("http://localhost:8000/api/todo");
  const todoList = await response.json();

  const todoListElement = document.getElementById("todo-list");
  todoListElement.innerHTML = "";

  todoList.forEach((todo) => {
    // チェックボックスを生成
    const checkbox = document.createElement("input");
    checkbox.type = "checkbox";
    checkbox.checked = todo.completed;

    // チェックボックスの状態が変更されたときに、updateTodoStatus関数を呼び出す
    checkbox.addEventListener("change", function () {
      updateTodoStatus(todo.id, this.checked);
    });

    // テキストボックスを生成
    // <input type="text" value="..." />
    const inputElement = document.createElement("input");
    inputElement.type = "text";
    inputElement.value = todo.title;

    // テキストボックスの値が変更されたときに、updateTodoTitle関数を呼び出す
    inputElement.addEventListener("change", function () {
      updateTodoTitle(todo.id, this.value);
    });

    // 削除ボタンを生成
    // <button>削除</button>
    const deleteButtonElement = document.createElement("button");
    deleteButtonElement.textContent = "削除";

    // 削除ボタンがクリックされたときに、deleteTodo関数を呼び出す
    deleteButtonElement.addEventListener("click", function () {
      deleteTodo(todo.id);
    });

    // <div>
    //   <input type="checkbox" />
    //   <input type="text" value="..." />
    //   <button>削除</button>
    // </div>
    const todoElement = document.createElement("div");
    todoElement.appendChild(checkbox);
    todoElement.appendChild(inputElement);
    todoElement.appendChild(deleteButtonElement);

    todoListElement.appendChild(todoElement);
  });
};

// サーバーに新しいTODOアイテムを追加する
const addTodo = async () => {
  // フォームからTODOのタイトルを取得
  const todoTitleInput = document.getElementById("todo-title");
  const todoTitle = todoTitleInput.value;

  // タイトルが空でない場合にサーバーにTODOアイテムを追加
  if (todoTitle) {
    const response = await fetch("http://localhost:8000/api/todo", {
      // POSTリクエストを送信
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      // TODOのタイトルをサーバーに送信
      body: JSON.stringify({ title: todoTitle }),
    });

    // サーバーからのレスポンスが成功の場合はTODOリストを再取得して表示
    if (response.status === 200) {
      // フォームをクリア
      todoTitleInput.value = "";
      fetchAndDisplayTodoList();
    }
  }
};

// サーバーからTODOアイテムを削除する
const deleteTodo = async (id) => {
  /* 書いてみよう */
};

// サーバー上のTODOアイテムの completed を更新する
const updateTodoStatus = async (id, completed) => {
  /* 書いてみよう */
};

// サーバー上のTODOアイテムの title を更新する
const updateTodoTitle = async (id, title) => {
  /* 書いてみよう */
};

// ボタンが押されたときにaddTodo関数を呼び出す
const addButton = document.getElementById("add-button");
addButton.addEventListener("click", addTodo);

document.addEventListener("DOMContentLoaded", fetchAndDisplayTodoList);

次に、server.jsに以下のコードを貼り付け、抜けているコードを埋めてみましょう。

TODOの編集・削除機能を追加しようのコードを参考にして書いてみましょう。

server.js
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";

const app = new Hono();

app.use(cors({ origin: "*" }));

const todoList = [
  { id: "1", title: "JavaScriptを勉強する", completed: false },
  { id: "2", title: "TODOアプリを自作する", completed: false },
  { id: "3", title: "漫画を読み切る", completed: true },
  { id: "4", title: "ゲームをクリアする", completed: false },
];

// IDの初期値
let currentId = 1;

// TODOリストを取得
app.get("/api/todo", (c) => c.json(todoList, 200));

// 新しいTODOアイテムを作成
app.post("/api/todo", async (c) => {

});

// TODOの状態の更新
app.put("/api/todo/:id", async (c) => {
  
});

// TODOの削除
app.delete("/api/todo/:id", async (c) => {

});

// エラーハンドリング
app.onError((err, c) => {
  return c.json({ message: err.message }, 400);
});

// サーバーを起動する
serve({
  fetch: app.fetch,
  port: 8000,
});
サンプルコード
script.js
// サーバーからTODOリストを取得して表示する
const fetchAndDisplayTodoList = async () => {
  const response = await fetch("http://localhost:8000/api/todo");
  const todoList = await response.json();

  const todoListElement = document.getElementById("todo-list");
  todoListElement.innerHTML = "";

  todoList.forEach((todo) => {
    // チェックボックスを生成
    const checkbox = document.createElement("input");
    checkbox.type = "checkbox";
    checkbox.checked = todo.completed;

    // チェックボックスの状態が変更されたときに、updateTodoStatus関数を呼び出す
    checkbox.addEventListener("change", function () {
      updateTodoStatus(todo.id, this.checked);
    });

    // テキストボックスを生成
    // <input type="text" value="..." />
    const inputElement = document.createElement("input");
    inputElement.type = "text";
    inputElement.value = todo.title;

    // テキストボックスの値が変更されたときに、updateTodoTitle関数を呼び出す
    inputElement.addEventListener("change", function () {
      updateTodoTitle(todo.id, this.value);
    });

    // 削除ボタンを生成
    // <button>削除</button>
    const deleteButtonElement = document.createElement("button");
    deleteButtonElement.textContent = "削除";

    // 削除ボタンがクリックされたときに、deleteTodo関数を呼び出す
    deleteButtonElement.addEventListener("click", function () {
      deleteTodo(todo.id);
    });

    // <div>
    //   <input type="checkbox" />
    //   <input type="text" value="..." />
    //   <button>削除</button>
    // </div>
    const todoElement = document.createElement("div");
    todoElement.appendChild(checkbox);
    todoElement.appendChild(inputElement);
    todoElement.appendChild(deleteButtonElement);

    todoListElement.appendChild(todoElement);
  });
};

// サーバー上のTODOアイテムの completed を更新する
const updateTodoStatus = async (id, completed) => {
  const response = await fetch(`http://localhost:8000/api/todo/${id}`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ completed }),
  });

  if (response.status === 200) {
    fetchAndDisplayTodoList();
  }
};

// サーバー上のTODOアイテムの title を更新する
const updateTodoTitle = async (id, title) => {
  const response = await fetch(`http://localhost:8000/api/todo/${id}`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ title }),
  });

  if (response.status === 200) {
    fetchAndDisplayTodoList();
  }
};

// サーバーに新しいTODOアイテムを追加する
const addTodo = async () => {
  const todoTitleInput = document.getElementById("todo-title");
  const todoTitle = todoTitleInput.value;

  if (todoTitle) {
    const response = await fetch("http://localhost:8000/api/todo", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ title: todoTitle }),
    });

    if (response.status === 200) {
      todoTitleInput.value = "";
      fetchAndDisplayTodoList();
    }
  }
};

// サーバーからTODOアイテムを削除する
const deleteTodo = async (id) => {
  const response = await fetch(`http://localhost:8000/api/todo/${id}`, {
    method: "DELETE",
  });

  if (response.status === 200) {
    fetchAndDisplayTodoList();
  }
};

// ボタンが押されたときにaddTodo関数を呼び出す
const addButton = document.getElementById("add-button");
addButton.addEventListener("click", addTodo);

document.addEventListener("DOMContentLoaded", fetchAndDisplayTodoList);
server.js
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";

const app = new Hono();

app.use(cors({ origin: "*" }));

const todoList = [
  { id: "1", title: "JavaScriptを勉強する", completed: false },
  { id: "2", title: "TODOアプリを自作する", completed: false },
  { id: "3", title: "漫画を読み切る", completed: true },
  { id: "4", title: "ゲームをクリアする", completed: false },
];

let currentId = 1;

app.get("/api/todo", (c) => c.json(todoList, 200));

app.post("/api/todo", async (c) => {
  const param = await c.req.json();

  if (!param.title) {
    throw new Error("Title must be provided");
  }

  const newTodo = {
    id: String(currentId++),
    completed: !!param.completed,
    title: param.title,
  };

  todoList.push(newTodo);

  return c.json({ message: "Successfully created" }, 200);
});

app.put("/api/todo/:id", async (c) => {
  const param = await c.req.json();
  const id = c.req.param("id");

  if (!param.title && param.completed === undefined) {
    throw new Error("Either title or completed must be provided");
  }

  const todo = todoList.find((todo) => todo.id === id);
  if (!todo) {
    throw new Error("Failed to update task title");
  }

  if (param.title) {
    todo.title = param.title;
  }

  if (param.completed !== undefined) {
    todo.completed = param.completed;
  }

  return c.json({ message: "Task updated" }, 200);
});

app.delete("/api/todo/:id", async (c) => {
  const id = c.req.param("id");
  const todoIndex = todoList.findIndex((todo) => todo.id === id);
  if (todoIndex === -1) {
    throw new Error("Failed to delete task");
  }
  todoList.splice(todoIndex, 1);

  return c.json({ message: "Task deleted" }, 200);
});

app.onError((err, c) => {
  return c.json({ message: err.message }, 400);
});

serve({
  fetch: app.fetch,
  port: 8000,
});

サーバーを起動してみよう

もし、途中でテストをしたり動作を確認したい場合は、サーバーを起動してみましょう。

npm run start

ブラウザでindex.htmlを開いて、新しいTODOを追加、完了済みチェック、削除ボタンなどの機能が正常に動作することを確認しましょう。

実際に動作することが確認出来たら、サーバーを切断してhono-todo-appディレクトリでコミットしましょう。

git add .
git commit -m "TODOアプリの完成"

まとめ

今回のTODOアプリの解説・サンプルコードの解説は次回の講習会の最初に行います。

次回の講習会では、データベースについて学んでいきます。