イベントとモデル

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

まずはどのような点で柔軟性の問題が起きやすかについてを見ていきます。 その後、柔軟性や識別子の問題を解決するためにモデルという概念を導入し、あらためて「Todoアイテムの追加」の機能を見ていきます。

直接DOMを更新する問題

前回のセクションでは、操作した結果発生したイベントという入力に対して、DOM(表示)の更新という出力が1対1でおこなわれていました。 つまりTodoリストにTodoアイテムが何個あるか、どのようなアイテムがあるかという状態がDOM上にしか存在しないことになります。

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

また操作と表示が1対1で更新される場合、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)を更新

これは表示を更新しなければいけない箇所が増えるほど、操作に対する処理が複雑化していくことが予想できます。

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

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

モデルを導入する

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

DOM APIのイベントの仕組みをモデルでも利用できれば、モデルが更新されたら表示を更新する仕組みを作れそうです。 ブラウザのDOM APIではこのようなイベント仕組みをDOM Eventsと呼びます。 Node.jsではeventsと呼ばれるモジュールでAPIは異なりますが同様のイベントの仕組みが利用できます。 ここではイベントの仕組みを理解するために、イベントのディスパッチとリッスンする機能をもつクラスを作ってみましょう。

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

EventEmitter

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

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

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

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

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

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

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

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

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

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

src/EventEmitter.js

export class EventEmitter {
    constructor() {
        // 登録する [イベント名, Set(リスナー関数)] を管理するMap
        this._listeners = new Map();
    }

    /**
     * 指定したイベントが実行されたときに呼び出されるリスナー関数を登録する
     * @param {string} type イベント名
     * @param {Function} listener イベントリスナー
     */
    addEventLister(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 イベントリスナー
     */
    removeEventLister(type, listener) {
        // 指定したイベントに対応するSetを取り出し、該当するリスナー関数を削除する
        const listenerSet = this._listeners.get(type);
        if (!listenerSet) {
            return;
        }
        listenerSet.forEach(ownListener => {
            if (ownListener === listener) {
                listenerSet.delete(listener);
            }
        });
    }
}

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

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

EventEmitterの実行サンプル

import { EventEmitter } from "./EventEmitter.js";
const event = new EventEmitter();
// イベントリスナー(コールバック関数)を登録
event.addEventLister("test-event", () => console.log("One!"));
event.addEventLister("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 {
    /**
     * @param {string} title Todoアイテムのタイトル
     * @param {boolean} completed Todoアイテムが完了済みならばtrue、そうでない場合はfalse
     */
    constructor({ title, completed }) {
        // idは自動的に連番となりそれぞれのインスタンス毎に異なるものとする
        this.id = todoIdx++;
        this.title = title;
        this.completed = completed;
    }
}

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

import { TodoItemModel } from "./TodoItemModel";
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 {
    /**
     * @param {TodoItemModel[]} [items] 初期アイテム一覧(デフォルトは空の配列)
     */
    constructor(items = []) {
        super();
        this.items = items;
    }

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

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

    /**
     * TodoListの状態が更新されたときに呼び出されるリスナー関数を登録する
     * @param {Function} listener
     * @returns {Function} イベントリスナーの登録を解除する関数を返す
     */
    onChange(listener) {
        this.addEventLister("change", listener);
        return () => {
            this.removeEventLister("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で登録したイベントリスナーが呼び出されます。

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

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

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

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

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

まずは書き換え後の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 {
    constructor() {
        // 1. TodoListの初期化
        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");
        // 2. TodoListModelの状態が更新されたら表示を更新する
        this.todoListModel.onChange(() => {
            // TodoリストをまとめるList要素
            const todoListElement = element`<ul />`;
            // それぞれのTodoItem要素をtodoListElement以下へ追加する
            const todoItems = this.todoListModel.getTodoItems();
            todoItems.forEach(item => {
                const todoItemElement = element`<li>${item.title}</li>`;
                todoListElement.appendChild(todoItemElement);
            });
            // containerElementの中身をtodoListElementで上書きする
            render(todoListElement, containerElement);
            // アイテム数の表示を更新
            todoItemCountElement.textContent = `Todoアイテム数: ${this.todoListModel.totalCount}`;
        });
        // 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. TodoListの初期化

作成したTodoListModelTodoItemModelを取り込んでいます。

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

そして、Appクラスのコンストラクタ内でTodoListModelを初期化しています。 AppのコンストラクタでTodoListModelを初期化しているのは、 このTodoアプリでは開始時にTodoリストの中身が空の状態で開始されるのに合わせるためです。

class App {
    constructor() {
        // 1. TodoListの初期化
        this.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関数を使いcontainerElementの中身を上書きしてます。 また、アイテム数はTodoListModel#totalCountで取得できるため、アイテム数だけを管理していたtodoItemCountという変数は削除できます。

// render関数をimportに追加する
import { element, render } from "./view/html-util.js";
// ...省略...
// `containerElement`の中身を`todoListElement`で上書きして表示を更新
render(todoListElement, containerElement);
// アイテム数の表示を更新
todoItemCountElement.textContent = `Todoアイテム数: ${this.todoListModel.totalCount}`;

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

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

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

まとめ

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

次のセクションでは、今回のモデルと同じように「表示」に関しても整理を行い、残りの「Todoアイテムの更新」や「Todoアイテムの削除」の機能を実装しています。

このセクションのチェックボックス

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

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

https://jsprimer.net/use-case/todoapp/event-model/event-emitter/