Skip to content

2.2 Vanilla JS 実装 — 最初のバージョン ​

前章で決めた仕様に従って、ToDo リストを素の JavaScript で実装します。この章では、まず動くことを最優先にした、もっとも単純な方式で書きます。方針は 1 つだけです。

画面を更新するときは、タスク一覧をいったん全部消してから、状態に従って全部描き直す。

いわゆる「差分」を一切考えない、素朴なやり方です。問題点は次章で扱います。ここでは、まず動くものを見ていきましょう。

2.2.1 使う DOM API のおさらい ​

この章で使う API は 4 つだけです。いずれも MDN に詳しい説明があります。

API役割
document.createElement(tagName)新しい要素を作って返す。DOM にはまだ入っていない
element.appendChild(child)ある要素の末尾に別の要素を子として差し込む
element.addEventListener(type, listener)要素にイベントハンドラを登録する
element.textContent = "..."要素のテキストを安全にセットする

document.createElement は要素を作って返すだけで、画面には出ません。appendChild で親の中に入れるまで表示されない、という点に注意しておいてください。

2.2.2 完成コード ​

index.html 1 ファイルに以下を貼り付けて、ブラウザで開くと動きます。

html
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>Vanilla ToDo — v1</title>
    <style>
      body { font-family: sans-serif; max-width: 480px; margin: 2rem auto; }
      ul { list-style: none; padding: 0; }
      li { display: flex; gap: 0.5rem; align-items: center; padding: 0.25rem 0; }
      li.completed span { text-decoration: line-through; color: #888; }
      .filters button[aria-pressed="true"] { font-weight: bold; }
    </style>
  </head>
  <body>
    <h1>ToDo</h1>

    <form id="form">
      <input id="input" type="text" placeholder="タスクを入力" />
      <button type="submit">追加</button>
    </form>

    <ul id="list"></ul>

    <div class="filters">
      <button data-filter="all">all</button>
      <button data-filter="active">active</button>
      <button data-filter="completed">completed</button>
    </div>

    <p id="count"></p>

    <script>
      // ----- 状態 -----
      let todos = [];
      let filter = "all";
      let nextId = 1;

      // ----- 要素参照 -----
      const form = document.querySelector("#form");
      const input = document.querySelector("#input");
      const list = document.querySelector("#list");
      const count = document.querySelector("#count");
      const filterButtons = document.querySelectorAll(".filters button");

      // ----- イベント -----
      form.addEventListener("submit", (e) => {
        e.preventDefault();
        const text = input.value.trim();
        if (!text) return;
        todos.push({ id: nextId++, text, completed: false });
        input.value = "";
        render();
      });

      filterButtons.forEach((btn) => {
        btn.addEventListener("click", () => {
          filter = btn.dataset.filter;
          render();
        });
      });

      // ----- 描画 -----
      function render() {
        // タスク一覧をいったん全消去する
        list.innerHTML = "";

        // フィルタで絞り込む
        const visible = todos.filter((t) => {
          if (filter === "active") return !t.completed;
          if (filter === "completed") return t.completed;
          return true;
        });

        // 全タスクを作り直す
        for (const todo of visible) {
          const li = document.createElement("li");
          if (todo.completed) li.classList.add("completed");

          const checkbox = document.createElement("input");
          checkbox.type = "checkbox";
          checkbox.checked = todo.completed;
          checkbox.addEventListener("change", () => {
            todo.completed = checkbox.checked;
            render();
          });

          const span = document.createElement("span");
          span.textContent = todo.text;

          const del = document.createElement("button");
          del.textContent = "x";
          del.addEventListener("click", () => {
            todos = todos.filter((t) => t.id !== todo.id);
            render();
          });

          li.appendChild(checkbox);
          li.appendChild(span);
          li.appendChild(del);
          list.appendChild(li);
        }

        // フィルタボタンの押下状態
        filterButtons.forEach((btn) => {
          btn.setAttribute(
            "aria-pressed",
            btn.dataset.filter === filter ? "true" : "false",
          );
        });

        // 件数
        const active = todos.filter((t) => !t.completed).length;
        count.textContent = `未完了: ${active} 件`;
      }

      render();
    </script>
  </body>
</html>

2.2.3 ページの中で触ってみる ​

このページに、同じ挙動のデモを埋め込んでおきます。Vue で作ってありますが、画面を更新するたびに全消去 + 全描画 という Vanilla JS 版と同じ振る舞いをしています。

右側のパネルで、「render() が何回呼ばれたか」「直前のレンダーで createElement(li) が何回走ったか」が見えるようにしています。チェックを 1 つ入れるだけでも、見えている行の分だけ DOM ノードを作り直しているのが分かると思います。

ToDo(Vanilla JS と同じ挙動)

  • 牛乳を買う
  • 課題を提出する
  • React の章を読む

未完了: 2 件

render() 呼び出し回数
1
直前の createElement(li) 数
3

どんな操作をしても、画面を更新するたびに list.innerHTML = "" で全消去し、全ての <li> を作り直します。チェックを 1 つ入れただけでも、 見えている行の数ぶんだけ DOM ノードを作り直すのがこの実装の特徴です。

触ってみると、チェック 1 つ入れたときに「リスト全部が再生成される」という違和感が見えてくるはずです。ここで感じた違和感は、次章で「差分更新」に書き換える動機になります。

2.2.4 何が起きているか ​

処理の流れを時系列で見ると、こうなります。

図 2.2.1 ユーザー操作から画面更新までのフロー ​

ここで 一番の肝 は、「画面を更新する唯一の手段が render() を呼ぶこと」です。状態(todos と filter)を書き換えたら、必ず最後に render() を呼ぶ。呼び忘れると、状態は変わっているのに画面が古いまま、という不整合が起きます。

2.2.5 「全消去して全描き直し」の素朴さ ​

render() の先頭で list.innerHTML = "" と書いて、中身を空文字列で上書きしています。この 1 行で、それまで作ったタスク行はすべて破棄されます。

  • 既存の li 要素は DOM から外れる
  • 各行に付けたチェックボックスの change ハンドラや削除ボタンの click ハンドラも、要素ごと捨てられる
  • その後、全タスクを createElement で 1 からまた作り直す

図 2.2.2 全消去 + 全描画のイメージ ​

render 呼び出し前li #1li #2li #3※ ハンドラも付いている全消去innerHTML=""一瞬だけ空(中身は空)全部作り直すcreateElement×Nrender 呼び出し後li #1 (新)li #2 (新)li #3 (新)ハンドラも登録し直し同じ内容でも全部作り直す

「古い DOM と新しい DOM を比べて、変わった箇所だけ直す」という発想は、ここには一切ありません。常に全部作り直します。

この方式にも、実は利点があります。状態 → DOM の対応づけは render 関数 1 つに書けば済む ので、「どこで何を更新したっけ」という迷いがなく、状態と DOM がずれにくいのです。React の考え方とも、この一点だけは似ています。

2.2.6 試すときのチェックポイント ​

上の HTML を貼って開いたら、次の操作で期待通りに動くか確認してみてください。

  • タスクを追加すると一覧に増える
  • チェックボックスを入れると取り消し線が付く
  • 削除ボタンを押すと消える
  • フィルタを切り替えると、表示が絞られる
  • 未完了件数が操作に追従する

動作自体は仕様通りになるはずです。ただ、もう少し大きなリストで試したり、入力中に追加が発生したりすると、この素朴な実装には無視できない問題が見えてきます。次章でその問題を引き出し、「差分更新を手で書く」方へ踏み込みましょう。

参照ソース ​