Todoアプリのリファクタリング

前のセクションで、予定していたTodoアプリの機能はすべて実装できました。 しかし、App.jsを見てみるとほとんどがHTML要素の処理になっています。 このようなHTML要素の作成処理は表示する内容が増えるほど、コードの行数が線形的に増えていきます。 このままTodoアプリを拡張していくとApp.jsが肥大化してコードが読みにくくなり、メンテナンス性が低下してしまいます。

ここで、App.jsの役割を振り返ってみましょう。 Appというクラスを持ち、このクラスではModelの初期化やHTML要素とModel間で発生するイベントを中継する役割を持っています。 表示から発生したイベントをModelに伝え、Modelから発生した変更イベントを表示に伝えている管理者と言えます。

このセクションではAppクラスをイベントの管理者という役割に集中させるため、Appクラスに書かれているHTML要素を作成する処理を別のクラスへ切り出すリファクタリングを行います。

Viewコンポーネント

Appクラスの大部分を占めているのはTodoItemModelの配列に対応するTodoリストのHTML要素を作成する処理です。 このような表示のための処理を部品ごとのモジュールに分け、Appクラスから作成したモジュールを使うような形にリファクタリングしていきます。 ここでは、表示のための処理を扱うクラスをViewコンポーネントと呼び、ここではViewをファイル名の末尾につけることで区別します。

Todoリストの表示は次の2つの部品(Viewコンポーネント)から成り立っています。

  • TodoアイテムViewコンポーネント
  • TodoアイテムをリストとしてまとめたTodoリストViewコンポーネント

この部品に対応するように次のViewのモジュールを作成していきます。 これらのViewのモジュールは、src/view/ディレクトリに作成していきます。

  • TodoItemView: TodoアイテムViewコンポーネント
  • TodoListView: TodoリストViewコンポーネント

TodoItemViewを作成する

まずは、Todoアイテムに対応するTodoItemViewから作成しています。

src/view/TodoItemView.jsファイルを作成して、次のようなTodoItemViewクラスをexportします。 このTodoItemViewは、Todoアイテムに対応するHTML要素を返すcreateElementメソッドを持ちます。

src/view/TodoItemView.js

import { element } from "./html-util.js";

export class TodoItemView {
    /**
     * `todoItem`に対応するTodoアイテムのHTML要素を作成して返す
     * @param {TodoItemModel} todoItem
     * @param {function({id:number, completed: boolean})} onUpdateTodo チェックボックスの更新イベントリスナー
     * @param {function({id:number})} onDeleteTodo 削除ボタンのクリックイベントリスナー
     * @returns {Element}
     */
    createElement(todoItem, { onUpdateTodo, onDeleteTodo }) {
        const todoItemElement = todoItem.completed
            ? element`<li><input type="checkbox" class="checkbox" checked>
                                    <s>${todoItem.title}</s>
                                    <button class="delete">x</button>
                                </li>`
            : element`<li><input type="checkbox" class="checkbox">
                                    ${todoItem.title}
                                    <button class="delete">x</button>
                                </li>`;
        const inputCheckboxElement = todoItemElement.querySelector(".checkbox");
        inputCheckboxElement.addEventListener("change", () => {
            // コールバック関数に変更
            onUpdateTodo({
                id: todoItem.id,
                completed: !todoItem.completed
            });
        });
        const deleteButtonElement = todoItemElement.querySelector(".delete");
        deleteButtonElement.addEventListener("click", () => {
            // コールバック関数に変更
            onDeleteTodo({
                id: todoItem.id
            });
        });
        // 作成したTodoアイテムのHTML要素を返す
        return todoItemElement;
    }
}

TodoItemViewのcreateElementメソッドの中身はAppクラスでのHTML要素を作成する部分を元にしています。 createElementメソッドは、TodoItemModelのインスタンスだけではなくonUpdateTodoonDeleteTodoというリスナー関数を受け取っています。 この受け取ったリスナー関数はそれぞれ対応するイベントがViewで発生した際に呼び出されます。

このように引数としてリスナー関数を外から受け取ることで、イベントが発生したときの具体的な処理はViewクラスの外側に定義できます。

たとえば、このTodoItemViewクラスは次のように利用できます。 TodoItemModelのインスタンスとイベントリスナーのオブジェクトを受け取り、TodoアイテムのHTML要素を返します。

TodoItemViewを利用するサンプルコード

import { render } from "./html-util.js";
import { TodoItemModel } from "../model/TodoItemModel.js";
import { TodoItemView } from "./TodoItemView.js";

// TodoItemViewをインスタンス化
const todoItemView = new TodoItemView();
// 対応するTodoItemModelを作成する
const todoItemModel = new TodoItemModel({
    title: "あたらしいTodo",
    completed: false
});
// TodoItemModelからHTML要素を作成する
const todoItemElement = todoItemView.createElement(todoItemModel, {
    onUpdateTodo: () => {
        console.log("チェックボックスが更新されたときに呼ばれるリスナー関数");
    },
    onDeleteTodo: () => {
        console.log("削除ボタンがクリックされたときに呼ばれるリスナー関数");
    }
});
render(todoItemElement, document.body); // <li>要素をdocument.bodyへレンダリング

TodoListViewを作成する

次はTodoリストに対応するTodoListViewを作成します。

src/view/TodoListView.jsファイルを作成し、次のようなTodoListViewクラスをexportします。 このTodoListViewTodoItemModelの配列からTodoリストのHTML要素を作成して返すcreateElementメソッドを持ちます。

src/view/TodoListView.js

import { element } from "./html-util.js";
import { TodoItemView } from "./TodoItemView.js";

export class TodoListView {
    /**
     * `todoItems`に対応するTodoリストのHTML要素を作成して返す
     * @param {TodoItemModel[]} todoItems TodoItemModelの配列
     * @param {function({id:number, completed: boolean})} onUpdateTodo チェックボックスの更新イベントリスナー
     * @param {function({id:number})} onDeleteTodo 削除ボタンのクリックイベントリスナー
     * @returns {Element} TodoItemModelの配列に対応したリストのHTML要素
     */
    createElement(todoItems, { onUpdateTodo, onDeleteTodo }) {
        const todoListElement = element`<ul></ul>`;
        // 各TodoItemモデルに対応したHTML要素を作成し、リスト要素へ追加する
        todoItems.forEach(todoItem => {
            const todoItemView = new TodoItemView();
            const todoItemElement = todoItemView.createElement(todoItem, {
                onDeleteTodo,
                onUpdateTodo
            });
            todoListElement.appendChild(todoItemElement);
        });
        return todoListElement;
    }
}

TodoListViewのcreateElementメソッドはTodoItemViewを使ってTodoアイテムのHTML要素を作り、todoListElementへと追加していきます。 このTodoListViewのcreateElementメソッドもonUpdateTodoonDeleteTodoのリスナー関数を受け取ります。 しかし、TodoListViewではこのリスナー関数をTodoItemViewにそのまま渡しています。 なぜなら具体的なDOMイベントを発生させる要素が作られるのはTodoItemViewの中となるためです。

Appのリファクタリング

最後に作成したTodoItemViewクラスとTodoListViewクラスを使ってAppクラスをリファクタリングしていきます。

App.jsを次のようにTodoListViewクラスを使うように書き換えます。 onChangeのリスナー関数でTodoListViewクラスを使ってTodoリストのHTML要素を作るように変更します。 このときTodoListViewのcreateElementメソッドには次のようにそれぞれ対応するコールバック関数を渡します。

  • onUpdateTodoのコールバック関数では、TodoListModelのupdateTodoメソッドを呼ぶ
  • onDeleteTodoのコールバック関数では、TodoListModelのdeleteTodoメソッドを呼ぶ

src/App.js

import { TodoListModel } from "./model/TodoListModel.js";
import { TodoItemModel } from "./model/TodoItemModel.js";
import { TodoListView } from "./view/TodoListView.js";
import { render } from "./view/html-util.js";

export class App {
    #todoListModel = new TodoListModel();

    mount() {
        const formElement = document.querySelector("#js-form");
        const inputElement = document.querySelector("#js-form-input");
        const containerElement = document.querySelector("#js-todo-list");
        const todoItemCountElement = document.querySelector("#js-todo-count");
        this.#todoListModel.onChange(() => {
            const todoItems = this.#todoListModel.getTodoItems();
            const todoListView = new TodoListView();
            // todoItemsに対応するTodoListViewを作成する
            const todoListElement = todoListView.createElement(todoItems, {
                // Todoアイテムが更新イベントを発生したときに呼ばれるリスナー関数
                onUpdateTodo: ({ id, completed }) => {
                    this.#todoListModel.updateTodo({ id, completed });
                },
                // Todoアイテムが削除イベントを発生したときに呼ばれるリスナー関数
                onDeleteTodo: ({ id }) => {
                    this.#todoListModel.deleteTodo({ id });
                }
            });
            render(todoListElement, containerElement);
            todoItemCountElement.textContent = `Todoアイテム数: ${this.#todoListModel.getTotalCount()}`;
        });
        formElement.addEventListener("submit", (event) => {
            event.preventDefault();
            this.#todoListModel.addTodo(new TodoItemModel({
                title: inputElement.value,
                completed: false
            }));
            inputElement.value = "";
        });
    }
}

これでAppクラスからHTML要素の作成処理がViewクラスに移動でき、AppクラスはModelとView間のイベントを管理するだけになりました。

Appのイベントリスナーを整理する

Appクラスで登録しているイベントのリスナー関数を見てみると次の4種類となっています。

イベントの流れ リスナー関数 役割
ModelView this.#todoListModel.onChange(listener) TodoListModelが変更イベントを受け取る
ViewModel formElement.addEventListener("submit", listener) フォームの送信イベントを受け取る
ViewModel onUpdateTodo: listener Todoアイテムのチェックボックスの更新イベントを受け取る
ViewModel onDeleteTodo: listener Todoアイテムの削除イベントを受け取る

イベントの流れがViewからModelとなっているリスナー関数が3箇所あり、それぞれリスナー関数はコード上バラバラな位置に書かれています。 また、それぞれのリスナー関数はTodoアプリの機能と対応していることがわかります。 これらのリスナー関数がTodoアプリの扱っている機能であるということをわかりやすくするため、リスナー関数をAppクラスのメソッドとして定義し直してみましょう。

次のように、それぞれ対応するリスナー関数をhandleメソッドとして実装して、それを呼び出すように変更しました。

src/App.js

import { render } from "./view/html-util.js";
import { TodoListView } from "./view/TodoListView.js";
import { TodoItemModel } from "./model/TodoItemModel.js";
import { TodoListModel } from "./model/TodoListModel.js";

export class App {
    #todoListView = new TodoListView();
    #todoListModel = new TodoListModel([]);

    /**
     * Todoを追加するときに呼ばれるリスナー関数
     * @param {string} title
     */
    handleAdd(title) {
        this.#todoListModel.addTodo(new TodoItemModel({ title, completed: false }));
    }

    /**
     * Todoの状態を更新したときに呼ばれるリスナー関数
     * @param {{ id:number, completed: boolean }}
     */
    handleUpdate({ id, completed }) {
        this.#todoListModel.updateTodo({ id, completed });
    }

    /**
     * Todoを削除したときに呼ばれるリスナー関数
     * @param {{ id: number }}
     */
    handleDelete({ id }) {
        this.#todoListModel.deleteTodo({ id });
    }

    mount() {
        const formElement = document.querySelector("#js-form");
        const inputElement = document.querySelector("#js-form-input");
        const todoItemCountElement = document.querySelector("#js-todo-count");
        const containerElement = document.querySelector("#js-todo-list");
        this.#todoListModel.onChange(() => {
            const todoItems = this.#todoListModel.getTodoItems();
            const todoListElement = this.#todoListView.createElement(todoItems, {
                // Appに定義したリスナー関数を呼び出す
                onUpdateTodo: ({ id, completed }) => {
                    this.handleUpdate({ id, completed });
                },
                onDeleteTodo: ({ id }) => {
                    this.handleDelete({ id });
                }
            });
            render(todoListElement, containerElement);
            todoItemCountElement.textContent = `Todoアイテム数: ${this.#todoListModel.getTotalCount()}`;
        });

        formElement.addEventListener("submit", (event) => {
            event.preventDefault();
            this.handleAdd(inputElement.value);
            inputElement.value = "";
        });
    }
}

このようにAppクラスのメソッドとしてリスナー関数を並べることで、Todoアプリの機能がコード上の見た目としてわかりやすくなりました。

セクションのまとめ

このセクションでは、次のことを行いました。

  • Appから表示に関する処理をViewコンポーネントに分割した
  • Todoアプリの機能と対応するリスナー関数をAppクラスのメソッドへ移動した
  • Todoアプリを完成させた

完成したTodoアプリは次のURLで確認できます。

実はこのTodoアプリにはまだアプリケーションとして、完成していない部分があります。

入力欄でEnterキーを連打すると、空のTodoアイテムが追加されてしまうのは意図しない挙動です。 また、AppのmountメソッドでTodoListModelのonChangeメソッドなどにイベントリスナーを登録していますが、そのイベントリスナーを解除していません。 このTodoアプリではあまり問題にはなりませんが、イベントリスナーは登録したままだとメモリリークにつながる場合もあります。

余力がある人は、次の機能を追加してTodoアプリを完成させてみてください。

  • タイトルが空の場合は、フォームを送信してもTodoアイテムを追加できないようにする
  • Appのmountメソッドでのイベントリスナー登録に対応して、Appにunmountメソッドを実装し、イベントリスナーを解除できるようにする

Appのmountメソッドと対応するunmountメソッドを作成するというTodoは、アプリケーションのライフサイクルを意識するという課題になります。 ウェブページにはページ読み込みが完了したときに発生するloadイベントと、読み込んだページを破棄したときに発生するunloadイベントがあります。 Todoアプリもmountunmountを実装し、次のようにウェブページのライフサイクルに合わせられます。

const app = new App();
// ページのロードが完了したときのイベント
window.addEventListener("load", () => {
    app.mount();
});
// ページがアンロードされたときのイベント
window.addEventListener("unload", () => {
    app.unmount();
});

残ったTodoを実装したコードは、次のURLで確認できます。 ぜひ、自分で実装してみてウェブページやアプリの動きについて考えてみてください。

Todoアプリのまとめ

今回は、Todoアプリを構成する要素をModelとViewという単位でモジュールに分けていました。 モジュールを分けることでコードの見通しを良くしたり、Todoアプリにさらなる機能を追加しやすい形にしました。 このようなモジュールの分け方などの設計には正解はなく、さまざまな考え方があります。

今回Todoアプリという題材をユースケースに選んだのは、JavaScriptのウェブアプリケーションではよく利用されている題材であるためです。 さまざまなライブラリを使ったTodoアプリの実装がTodoMVCと呼ばれるサイトにまとめられています。 今回作成したTodoアプリは、TodoMVCからフィルター機能などを削ったものをライブラリを使わずに実装したものです。1

現実では、ライブラリをまったく使わずウェブアプリケーションを実装することはほとんどありません。 ライブラリを使うことで、html-util.jsのようなものは自分で書く必要がなくなったり、最後の課題として残ったライフサイクルの問題なども解決しやすくなります。

しかし、ライブラリを使って開発する場合でも、第一部の基本文法や第二部のユースケースで紹介したようなJavaScriptの基礎は重要です。 なぜならライブラリも、これらの基礎の上に実装されているためです。

また、作るアプリケーションの種類や目的によって適切なライブラリは異なります。 ライブラリによっては魔法のような機能を提供しているものもありますが、それらも基礎となる技術を使っていることは覚えておいてください。

この書籍ではJavaScriptの基礎を中心に紹介しましたが、「ECMAScript」の章で紹介したようにJavaScriptの基礎も年々更新されています。 基礎が更新されると応用であるライブラリも新しいものが登場し、定番だったものも徐々に変化していきます。 知らなかったものが出てくるのは、JavaScript自体が成長しているということです。

この書籍を読んでまだ理解できなかったことや知らなかったことがあるのは問題ありません。 知らなかったことを見つけたときにそれが何かを調べられるということが、 JavaScriptという変化していく言語やそれを利用する環境においては重要です。

1. ライブラリやフレームワークを使わずに実装したJavaScriptをVanilla JSと呼ぶことがあります。