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

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

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

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

コンポーネント

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

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

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

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

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

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:string, completed: boolean})} onUpdateTodo チェックボックスの更新イベントリスナー
     * @param {function({id:string})} 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>
                                </input></li>`
            : element`<li><input type="checkbox" class="checkbox">
                                    ${todoItem.title}
                                    <button class="delete">x</button>
                                </input></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クラスの外側に定義できます。

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

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: () => {
        // チェックボックスが更新されたときに呼ばれるリスナー関数
    },
    onDeleteTodo: () => {
        // 削除ボタンがクリックされたときによばれるリスナー関数
    }
});
console.log(todoItemElement); // <li>要素が入る

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:string, completed: boolean})} onUpdateTodo チェックボックスの更新イベントリスナー
     * @param {function({id:string})} onDeleteTodo 削除ボタンのクリックイベントリスナー
     * @returns {Element} TodoItemModelの配列に対応したリストのHTML要素
     */
    createElement(todoItems, { onUpdateTodo, onDeleteTodo }) {
        const todoListElement = element`<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要素を作り、<li>要素に追加していきます。 この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 {
    constructor() {
        this.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種類となっています。

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

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

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 {
    constructor() {
        this.todoListView = new TodoListView();
        this.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 todoCountElement = document.querySelector("#js-todo-count");
        const todoListContainerElement = 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, todoListContainerElement);
            todoCountElement.textContent = `Todoアイテム数: ${this.todoListModel.getTotalCount()}`;
        });

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

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

セクションのまとめ

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

  • ModelとViewをモジュールに分割した
  • Todoアプリの機能と対応するリスナー関数をAppクラスのメソッドへ移動した
  • Todoアプリを完成させた

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

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

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

余力がある人は、次の残ったTodoを完成させてみてください。

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

App#mountと対応するApp#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と呼ぶことがあります。