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要素を作成する処理です。 このような表示のための処理をViewクラスとしてモジュールにして、Appクラスから作成したViewモジュールを使うような形にリファクタリングをしていきます。

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

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

この部品に対応するように次のViewのモジュールを作成していきます。

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

TodoItemViewを作成する

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

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

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 {HTMLElement}
     */
    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を作成します。

view/TodoListView.jsには次のようなTodoListViewクラスをexportします。 このTodoListViewTodoItemModelの配列に対応するTodoリストのHTML要素を返すcreateElementメソッドを持ちます。

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 {HTMLElement} TodoItemModelの配列に対応したリストのHTML要素
     */
    createElement(todoItems, { onUpdateTodo, onDeleteTodo }) {
        const todoListElement = element`<ul />`;
        // todoItemsに対応するアイテム要素を作りリストへ追加する
        todoItems.forEach(todoItem => {
            const todoItemView = new TodoItemView();
            // todoItemに対応したHTML要素を作成する
            const todoItemElement = todoItemView.createElement(todoItem, {
                onDeleteTodo,
                onUpdateTodo
            });
            todoListElement.appendChild(todoItemElement);
        });
        // todoListElementを返す
        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メソッドを呼ぶ
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.totalCount}`;
        });
        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クラスのメソッドとして定義しなおしてみましょう。

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 {number} id
     * @param {boolean} completed
     */
    handleUpdate({ id, completed }) {
        this.todoListModel.updateTodo({ id, completed });
    };

    /**
     * Todoを削除時に呼ばれるリスナー関数
     * @param {number} id
     */
    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.totalCount}`;
        });

        formElement.addEventListener("submit", (event) => {
            event.preventDefault();
            this.todoListModel.addTodo(new TodoItemModel({
                title: inputElement.value,
                completed: false
            }));
            inputElement.value = "";
        });
    }
}

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

セクションのまとめ

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

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

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

Todoアプリのまとめ

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

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

現実ではライブラリを使わずウェブアプリケーションを実装することは少なくなってきています。 しかし、ライブラリを使って開発する場合でも、第一部の基本文法や第二部のユースケースで紹介したようなJavaScriptの基礎は重要です。 なぜならライブラリもこれらの基礎の上に実装されているためです。

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

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

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

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