イベントとモデル

Todoアイテムを追加する機能を実装しましたが、イベントを受け取って直接DOMを更新する方法には柔軟性がないという問題があります。 また「Todoアイテムの更新」という機能を実装するには、追加したTodoアイテム要素を識別する方法が必要です。 具体的には、Todoアイテムごとにid属性などのユニークな識別子がないため、特定のアイテムを指定して更新や削除をする機能が実装できません。

このセクションでは、まずどのような点で柔軟性の問題が起きやすいのかを見ていきます。 そして、柔軟性や識別子の問題を解決するためにモデルという概念を導入し、「Todoアイテムの追加」の機能をリファクタリングしていきます。

直接DOMを更新する問題

Todoアイテムの追加を実装する」では、操作した結果発生したイベントという入力に対して、DOM(表示)を直接更新していました。 そのため、TodoリストにTodoアイテムが何個あるか、どのようなアイテムがあるかという状態がDOM上にしか存在しないことになります。

この場合にTodoアイテムの状態を更新するには、HTML要素にTodoアイテムの情報(タイトルや識別子となるidなど)をすべて埋め込む必要があります。 しかし、HTML要素は文字列しか扱えないため、Todoアイテムのデータを文字列にしないといけないという制限が発生します。

また、1つの操作に対して複数の箇所の表示が更新されることもあります。 今回のTodoアプリでもTodoリスト(#js-todo-list)とTodoアイテム数(#js-todo-count)の2箇所を更新する必要があります。

次の表に操作に対して更新する表示をまとめてみます。

機能 操作 表示
Todoアイテムの追加 フォームを入力して送信 Todoリスト(#js-todo-list)にTodoアイテム要素を作成して子要素として追加。合わせてTodoアイテム数(#js-todo-count)を更新
Todoアイテムの更新 チェックボックスをクリック Todoリスト(#js-todo-list)にある指定したTodoアイテム要素のチェック状態を更新
Todoアイテムの削除 削除ボタンをクリック Todoリスト(#js-todo-list)にある指定したTodoアイテム要素を削除。合わせてTodoアイテム数(#js-todo-count)を更新

1つの操作に対する表示の更新箇所が増えるほど、操作に対する処理(リスナーの処理)が複雑化していくことが予想できます。

ここでは、次の2つの問題が見つかりました。

  • Todoリストの状態がDOM上にしか存在しないため、状態をすべてDOM上に文字列で埋め込まないといけない
  • 操作に対して更新する表示箇所が増えてくると、表示の処理が複雑化する

モデルを導入する

この問題を避けるために、Todoアイテムという情報をJavaScriptクラスとしてモデル化します。 ここでのモデルとはTodoアイテムやTodoリストなどのモノの状態や操作方法を定義したオブジェクトという意味です。 クラスでは操作方法はメソッドとして実装し、状態はインスタンスのプロパティで管理できるため、今回はクラスでモデルを表現します。

たとえば、Todoリストを表現するモデルとしてTodoListModelクラスを考えます。 TodoリストにはTodoアイテムを追加できるので、TodoListModelにaddItemというメソッドがあると良さそうです。 また、Todoリストからアイテムの一覧を取得できる必要もあるので、TodoListModelにgetAllItemsというメソッドも必要そうです。 このようにTodoリストをクラスで表現する際に、オブジェクトがどのような処理や状態を持つかを考えて実装します。

このようにモデルを考えた後、先ほどの操作と表示の間にモデルを入れることを考えてみます。 「フォームを入力して送信」という操作をした場合には、TodoListModel(Todoリスト)に対してTodoItemModel(Todoアイテム)を追加します。 そして、TodoListModelからTodoアイテムの一覧を取得し、それを元にDOMを組み立て、表示を更新します。

先ほどの表にモデルを入れてみます。 操作に対するモデルの処理はさまざまですが、操作に対する表示の処理はどの場合も同じになります。 これは表示箇所が増えた場合でも表示の処理の複雑さが一定に保てることを意味しています。

機能 操作 モデルの処理 表示
Todoアイテムの追加 フォームを入力して送信 TodoListModelへ新しいTodoItemModelを追加 TodoListModelを元に表示を更新
Todoアイテムの更新 チェックボックスをクリック TodoListModelの指定したTodoItemModelの状態を更新 TodoListModelを元に表示を更新
Todoアイテムの削除 削除ボタンをクリック TodoListModelから指定のTodoItemModelを削除 TodoListModelを元に表示を更新

この表を元に改めて先ほどの問題点を見ていきましょう。

Todoリストの状態がDOM上にしか存在しないため、状態をすべてDOM上に文字列で埋め込まないといけない

モデルであるクラスのインスタンスを参照すれば、Todoアイテムの情報が手に入ります。 またモデルはただのJavaScriptクラスであるため、文字列ではない情報も保持できます。 そのため、DOMにすべての情報を埋め込む必要はありません。

操作に対して更新する表示箇所が増えてくると、表示の処理が複雑化する

表示はモデルの状態を元にしてHTML要素を作成し、表示を更新します。 モデルの状態が変化していなければ、表示は変わらなくても問題ありません。

そのため操作したタイミングではなく、モデルの状態が変化したタイミングで表示を更新すればよいはずです。 具体的には「フォームを入力して送信」されたから表示を更新するのではなく、 「TodoListModelというモデルの状態が変化」したから表示を更新すればいいはずです。

そのためには、TodoListModelというモデルの状態が変化したことを表示側から知る必要があります。 ここで再び出てくるのがイベントです。

モデルの変化を伝えるイベント

フォームを送信したらform要素からsubmitイベントが発生します。 これと同じようにTodoListModelの状態が変化したら自分自身へchangeイベントを発生(ディスパッチ)させます。 表示側はそのイベントをリッスンしてイベントが発生したら表示を更新すればよいはずです。

TodoListModelの状態の変化とは、「TodoListModelに新しいTodoItemModelが追加される」などが該当します。 先ほどの表の「モデルの処理」は何かしら状態が変化しているので、表示を更新する必要があるわけです。

DOM APIのイベントの仕組みをモデルでも利用できれば、モデルが更新されたら表示を更新する仕組みを作れそうです。 ブラウザのDOM APIでは、EventTargetと呼ばれるイベントの仕組みが利用できます。 Node.jsでは、eventsと呼ばれる組み込みのモジュールで同様のイベントの仕組みが利用できます。

実行環境が提供するイベントの仕組みを利用すると簡単ですが、ここではイベントの仕組みを理解するために、イベントのディスパッチとリッスンする機能を持つクラスを作ってみましょう。

とても難しく聞こえますが、今まで学んだクラスやコールバック関数などを使えば実現できます。

EventEmitter

イベントの仕組みとは「イベントをディスパッチする側」と「イベントをリッスンする側」の2つの面から成り立ちます。 場合によっては自分自身へイベントをディスパッチし、自分自身でイベントをリッスンすることもあります。

このイベントの仕組みを言い換えると「イベントをディスパッチした(イベントを発生させた)ときにイベントをリッスンしているコールバック関数(イベントリスナー)を呼び出す」となります。

モデルが更新されたら表示を更新するには「TodoListModelが更新されたときに指定したコールバック関数を呼び出すクラス」を作れば目的は達成できます。 しかし、「TodoListModelが更新されたとき」というのはとても具体的な処理であるため、モデルを増やすたびに同じ処理をそれぞれのモデルへ実装するのは大変です。

そのため、先ほどのイベントの仕組みを持った概念としてEventEmitterというクラスを作成します。 そしてTodoListModelは作成したEventEmitterを継承することでイベントの仕組みを導入していきます。

  • 親クラス(EventEmitter): イベントをディスパッチしたとき、登録されているコールバック関数(イベントリスナー)を呼び出すクラス
  • 子クラス(TodoListModel): 値を更新したとき、登録されているコールバック関数を呼び出すクラス

まずは、親クラスとなるEventEmitterを作成していきます。

EventEmitterはイベントの仕組みで書いたディスパッチ側とリッスン側の機能を持ったクラスとなります。

  • ディスパッチ側: emitメソッドは、指定されたイベント名に登録済みのすべてのコールバック関数を呼び出す
  • リッスン側: addEventListenerメソッドは、指定したイベント名に任意のコールバック関数を登録できる

これによって、emitメソッドを呼び出すと指定したイベントに関係する登録済みのコールバック関数を呼び出せます。 このようなパターンはObserverパターンとも呼ばれ、ブラウザやNode.jsなど多くの実行環境に類似するAPIが存在します。

次のようにsrc/EventEmitter.jsEventEmitterクラスを定義します。

src/EventEmitter.js

export class EventEmitter {
    // 登録する [イベント名, Set(リスナー関数)] を管理するMap
    #listeners = new Map();
    /**
     * 指定したイベントが実行されたときに呼び出されるリスナー関数を登録する
     * @param {string} type イベント名
     * @param {Function} listener イベントリスナー
     */
    addEventListener(type, listener) {
        // 指定したイベントに対応するSetを作成しリスナー関数を登録する
        if (!this.#listeners.has(type)) {
            this.#listeners.set(type, new Set());
        }
        const listenerSet = this.#listeners.get(type);
        listenerSet.add(listener);
    }

    /**
     * 指定したイベントをディスパッチする
     * @param {string} type イベント名
     */
    emit(type) {
        // 指定したイベントに対応するSetを取り出し、すべてのリスナー関数を呼び出す
        const listenerSet = this.#listeners.get(type);
        if (!listenerSet) {
            return;
        }
        listenerSet.forEach(listener => {
            listener.call(this);
        });
    }

    /**
     * 指定したイベントのイベントリスナーを解除する
     * @param {string} type イベント名
     * @param {Function} listener イベントリスナー
     */
    removeEventListener(type, listener) {
        // 指定したイベントに対応するSetを取り出し、該当するリスナー関数を削除する
        const listenerSet = this.#listeners.get(type);
        if (!listenerSet) {
            return;
        }
        listenerSet.forEach(ownListener => {
            if (ownListener === listener) {
                listenerSet.delete(listener);
            }
        });
    }
}

このEventEmitterでは次のようにイベントのリッスンとイベントのディスパッチの機能が利用できます。 リッスン側はaddEventListenerメソッドでイベントの種類(type)に対するイベントリスナー(listener)を登録します。 ディスパッチ側はemitメソッドでイベントをディスパッチし、イベントリスナーを呼び出します。

次のコードでは、addEventListenerメソッドでtest-eventイベントに対して2つのイベントリスナーを登録しています。 そのため、emitメソッドでtest-eventイベントをディスパッチすると、登録済みのイベントリスナーが呼び出されています。

EventEmitterの実行サンプル

import { EventEmitter } from "./EventEmitter.js";
const event = new EventEmitter();
// イベントリスナー(コールバック関数)を登録
event.addEventListener("test-event", () => console.log("One!"));
event.addEventListener("test-event", () => console.log("Two!"));
// イベントをディスパッチする
event.emit("test-event");
// コールバック関数がそれぞれ呼びだされ、コンソールには次のように出力される
// "One!"
// "Two!"

EventEmitterを継承したTodoListモデル

次は作成したEventEmitterクラスを継承したTodoListModelクラスを作成しています。 src/model/ディレクトリを新たに作成し、このディレクトリに各モデルクラスを実装したファイルを作成します。

作成するモデルは、Todoリストを表現するTodoListModelと各Todoアイテムを表現するTodoItemModelです。 TodoListModelが複数のTodoItemModelを保持することでTodoリストを表現することになります。

  • TodoListModel: Todoリストを表現するモデル
  • TodoItemModel: Todoアイテムを表現するモデル

まずはTodoItemModelsrc/model/TodoItemModel.jsというファイル名で作成します。

TodoItemModelクラスは各Todoアイテムに必要な情報を定義します。 各Todoアイテムにはタイトル(title)、アイテムの完了状態(completed)、アイテムごとにユニークな識別子(id)を持たせます。 ただのデータの集合であるため、クラスではなくオブジェクトでも問題はありませんが、今回はクラスとして作成します。

次のようにsrc/model/TodoItemModel.jsTodoItemModelクラスを定義します。

src/model/TodoItemModel.js

// ユニークなIDを管理する変数
let todoIdx = 0;

export class TodoItemModel {
    /** @type {number} TodoアイテムのID */
    id;
    /** @type {string} Todoアイテムのタイトル */
    title;
    /** @type {boolean} Todoアイテムが完了済みならばtrue、そうでない場合はfalse */
    completed;

    /**
     * @param {{ title: string, completed: boolean }}
     */
    constructor({ title, completed }) {
        // idは連番となり、それぞれのインスタンス毎に異なるものとする
        this.id = todoIdx++;
        this.title = title;
        this.completed = completed;
    }
}

次のコードではTodoItemModelクラスはインスタンス化でき、それぞれのidが自動的に異なる値となっていることが確認できます。 このidは後ほど特定のTodoアイテムを指定して更新する処理のときに、アイテムを区別する識別子として利用します。

TodoItemModel.jsを利用するサンプルコード

import { TodoItemModel } from "./TodoItemModel.js";
const item = new TodoItemModel({
    title: "未完了のTodoアイテム",
    completed: false
});
const completedItem = new TodoItemModel({
    title: "完了済みのTodoアイテム",
    completed: true
});
// それぞれの`id`は異なる
console.log(item.id !== completedItem.id); // => true

次にTodoListModelsrc/model/TodoListModel.jsというファイル名で作成します。

TodoListModelクラスは、先ほど作成したEventEmitterクラスを継承します。 TodoListModelクラスはTodoItemModelの配列を保持し、新しいTodoアイテムを追加する際はその配列に追加します。 このときTodoListModelの状態が変更したことを通知するために自分自身へchangeイベントをディスパッチします。

src/model/TodoListModel.js

import { EventEmitter } from "../EventEmitter.js";

export class TodoListModel extends EventEmitter {
    #items;
    /**
     * @param {TodoItemModel[]} [items] 初期アイテム一覧(デフォルトは空の配列)
     */
    constructor(items = []) {
        super();
        this.#items = items;
    }

    /**
     * TodoItemの合計個数を返す
     * @returns {number}
     */
    getTotalCount() {
        return this.#items.length;
    }

    /**
     * 表示できるTodoItemの配列を返す
     * @returns {TodoItemModel[]}
     */
    getTodoItems() {
        return this.#items;
    }

    /**
     * TodoListの状態が更新されたときに呼び出されるリスナー関数を登録する
     * @param {Function} listener
     */
    onChange(listener) {
        this.addEventListener("change", listener);
    }

    /**
     * 状態が変更されたときに呼ぶ。登録済みのリスナー関数を呼び出す
     */
    emitChange() {
        this.emit("change");
    }

    /**
     * TodoItemを追加する
     * @param {TodoItemModel} todoItem
     */
    addTodo(todoItem) {
        this.#items.push(todoItem);
        this.emitChange();
    }
}

次のコードはTodoListModelクラスのインスタンスに対して、新しいTodoItemModelを追加するサンプルコードです。 TodoListModelのaddTodoメソッドで新しいTodoアイテムを追加したときに、TodoListModelのonChangeメソッドで登録したイベントリスナーが呼び出されます。

TodoListModel.jsを利用するサンプルコード

import { TodoItemModel } from "./TodoItemModel.js";
import { TodoListModel } from "./TodoListModel.js";
// 新しいTodoリストを作成する
const todoListModel = new TodoListModel();
// 現在のTodoアイテム数は0
console.log(todoListModel.getTotalCount()); // => 0
// Todoリストが変更されたら呼ばれるイベントリスナーを登録する
todoListModel.onChange(() => {
    console.log("TodoListの状態が変わりました");
});
// 新しいTodoアイテムを追加する
// => `onChange`で登録したイベントリスナーが呼び出される
todoListModel.addTodo(new TodoItemModel({
    title: "新しいTodoアイテム",
    completed: false
}));
// Todoリストにアイテムが増える
console.log(todoListModel.getTotalCount()); // => 1

これでTodoリストに必要なそれぞれのモデルクラスが作成できました。 次はこれらのモデルを使って、表示の更新をしてみましょう。

モデルを使って表示を更新する

先ほど作成したTodoListModelTodoItemModelクラスを使って、「Todoアイテムの追加」を書き直してみます。

前回のコードでは、フォームを送信すると直接DOMへ要素を追加していました。 今回のコードでは、フォームを送信するとTodoListModelTodoItemModelを追加します。 TodoListModelに新しいTodoアイテムが増えると、onChangeに登録したイベントリスナーが呼び出されるため、 そのリスナー関数内でDOM(表示)を更新します。

まずは書き換え後のApp.jsを見ていきます。

src/App.js

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

export class App {
    // 1. TodoListModelの初期化
    #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");
        // 2. TodoListModelの状態が更新されたら表示を更新する
        this.#todoListModel.onChange(() => {
            // TodoリストをまとめるList要素
            const todoListElement = element`<ul></ul>`;
            // それぞれのTodoItem要素をtodoListElement以下へ追加する
            const todoItems = this.#todoListModel.getTodoItems();
            todoItems.forEach(item => {
                const todoItemElement = element`<li>${item.title}</li>`;
                todoListElement.appendChild(todoItemElement);
            });
            // コンテナ要素の中身をTodoリストをまとめるList要素で上書きする
            render(todoListElement, containerElement);
            // アイテム数の表示を更新
            todoItemCountElement.textContent = `Todoアイテム数: ${this.#todoListModel.getTotalCount()}`;
        });
        // 3. フォームを送信したら、新しいTodoItemModelを追加する
        formElement.addEventListener("submit", (event) => {
            event.preventDefault();
            // 新しいTodoItemをTodoListへ追加する
            this.#todoListModel.addTodo(new TodoItemModel({
                title: inputElement.value,
                completed: false
            }));
            inputElement.value = "";
        });
    }
}

変更後のApp.jsでは大きく分けて3つの部分が変更されているので、順番に見ていきます。

1. TodoListModelの初期化

作成したTodoListModelTodoItemModelをインポートしています。

import { TodoListModel } from "./model/TodoListModel.js";
import { TodoItemModel } from "./model/TodoItemModel.js";

そして、AppクラスにPrivateクラスフィールドでTodoListModelを初期化したものを定義しています。 TodoListModelはAppクラスの外からは触る必要がないため、#todoListModelというPrivateクラスフィールドとして定義しています。 このTodoアプリでは、開始時(Appクラスのインスタンス化時)にTodoリストの中身が空の状態で開始されるのに合わせるためです。

src/App.jsより抜粋

// ...省略...
export class App {
    // 1. TodoListModelの初期化
    #todoListModel = new TodoListModel();
    // ...省略...
}

2. TodoListModelの状態が更新されたら表示を更新する

mountメソッド内でTodoListModelが更新されたら表示を更新するという処理を実装します。 TodoListModelのonChangeメソッドで登録したリスナー関数は、TodoListModelの状態が更新されたら呼び出されます。

このリスナー関数内ではTodoListModelのgetTodoItemsメソッドでTodoアイテムを取得しています。 そして、アイテム一覧から次のようなリスト要素(todoListElement)を作成しています。

<!-- todoListElementの実質的な中身 -->
<ul>
    <li>Todoアイテム1のタイトル</li>
    <li>Todoアイテム2のタイトル</li>
</ul>

この作成したtodoListElement要素を、前回作成したhtml-util.jsrender関数を使ってコンテナ要素の中身に上書きしています。 また、アイテム数はTodoListModelのgetTotalCountメソッドで取得できるため、アイテム数を管理していたtodoItemCountという変数は削除できます。

src/App.jsより抜粋

import { TodoListModel } from "./model/TodoListModel.js";
// render関数をimportに追加する
import { element, render } from "./view/html-util.js";
export class App {
    #todoListModel = new TodoListModel();

    mount() {
        // ...省略...
        this.#todoListModel.onChange(() => {
            // ...省略...
            // コンテナ要素の中身をTodoリストをまとめるList要素で上書きする
            render(todoListElement, containerElement);
            // アイテム数の表示を更新
            todoItemCountElement.textContent = `Todoアイテム数: ${this.#todoListModel.getTotalCount()}`;
        });
        // ...省略...
    }
}

3. フォームを送信したら、新しいTodoItemを追加する

前回のコードでは、フォームを送信(submit)すると直接DOMへ要素を追加していました。 今回のコードでは、TodoListModelの状態が更新されたら表示を更新する仕組みがすでにできています。

そのため、submitイベントのリスナー関数内ではTodoListModelに対して新しいTodoItemModelを追加するだけで表示が更新されます。 直接DOMへappendChildしていた部分をTodoListModelのaddTodoメソッドを使ってモデルを更新する処理へ置き換えるだけです。

まとめ

今回のセクションでは、前セクションの「Todoアイテムの追加を実装する」をモデルとイベントの仕組みを使うようにリファクタリングしました。 コード量は増えましたが、次に実装する「Todoアイテムの更新」や「Todoアイテムの削除」も同様の仕組みで実装できます。 前回のセクションのように操作に対してDOMを直接更新した場合、追加は簡単ですが既存の要素を指定する必要がある更新や削除は難しくなります。

次のセクションでは、残りの機能である「Todoアイテムの更新」や「Todoアイテムの削除」を実装していきます。

このセクションのチェックリスト

  • 直接DOMを更新する問題について理解した
  • EventEmitterクラスでイベントの仕組みを実装した
  • TodoリストとTodoアイテムをモデルとして実装した
  • TodoListModelEventEmitterクラスを継承して実装した
  • Todoアイテムの追加の機能をモデルを使ってリファクタリングした

ここまでのTodoアプリは次のURLで確認できます。