イベントとモデル
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.js
へEventEmitter
クラスを定義します。
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アイテムを表現するモデル
まずはTodoItemModel
をsrc/model/TodoItemModel.js
というファイル名で作成します。
TodoItemModel
クラスは各Todoアイテムに必要な情報を定義します。
各Todoアイテムにはタイトル(title
)、アイテムの完了状態(completed
)、アイテムごとにユニークな識別子(id
)を持たせます。
ただのデータの集合であるため、クラスではなくオブジェクトでも問題はありませんが、今回はクラスとして作成します。
次のようにsrc/model/TodoItemModel.js
へTodoItemModel
クラスを定義します。
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
次にTodoListModel
をsrc/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リストに必要なそれぞれのモデルクラスが作成できました。 次はこれらのモデルを使って、表示の更新をしてみましょう。
モデルを使って表示を更新する
先ほど作成したTodoListModel
とTodoItemModel
クラスを使って、「Todoアイテムの追加」を書き直してみます。
前回のコードでは、フォームを送信すると直接DOMへ要素を追加していました。
今回のコードでは、フォームを送信するとTodoListModel
へTodoItemModel
を追加します。
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の初期化
作成したTodoListModel
とTodoItemModel
をインポートしています。
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.js
のrender
関数を使ってコンテナ要素の中身に上書きしています。
また、アイテム数は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アイテムをモデルとして実装した
TodoListModel
をEventEmitter
クラスを継承して実装した- Todoアイテムの追加の機能をモデルを使ってリファクタリングした
ここまでのTodoアプリは次のURLで確認できます。