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要素を作成する処理です。
このような表示のための処理を部品ごとのモジュールに分け、App
クラスから作成したモジュールを使うような形にリファクタリングしていきます。
ここでは、表示のための処理を扱うクラスをViewコンポーネントと呼び、ここではView
をファイル名の末尾につけることで区別します。
Todoリストの表示は次の2つの部品(Viewコンポーネント)から成り立っています。
- TodoアイテムViewコンポーネント
- TodoアイテムをリストとしてまとめたTodoリストViewコンポーネント
この部品に対応するように次のViewのモジュールを作成していきます。
これらのViewのモジュールは、src/view/
ディレクトリに作成していきます。
TodoItemView
: TodoアイテムViewコンポーネントTodoListView
: TodoリストViewコンポーネント
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:number, completed: boolean})} onUpdateTodo チェックボックスの更新イベントリスナー
* @param {function({id:number})} 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>
</li>`
: element`<li><input type="checkbox" class="checkbox">
${todoItem.title}
<button class="delete">x</button>
</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
のインスタンスだけではなくonUpdateTodo
とonDeleteTodo
というリスナー関数を受け取っています。
この受け取ったリスナー関数はそれぞれ対応するイベントがViewで発生した際に呼び出されます。
このように引数としてリスナー関数を外から受け取ることで、イベントが発生したときの具体的な処理はViewクラスの外側に定義できます。
たとえば、このTodoItemView
クラスは次のように利用できます。
TodoItemModel
のインスタンスとイベントリスナーのオブジェクトを受け取り、TodoアイテムのHTML要素を返します。
TodoItemViewを利用するサンプルコード
import { render } from "./html-util.js";
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: () => {
console.log("チェックボックスが更新されたときに呼ばれるリスナー関数");
},
onDeleteTodo: () => {
console.log("削除ボタンがクリックされたときに呼ばれるリスナー関数");
}
});
render(todoItemElement, document.body); // <li>要素をdocument.bodyへレンダリング
TodoListViewを作成する
次はTodoリストに対応するTodoListView
を作成します。
src/view/TodoListView.js
ファイルを作成し、次のようなTodoListView
クラスをexport
します。
このTodoListView
はTodoItemModel
の配列から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:number, completed: boolean})} onUpdateTodo チェックボックスの更新イベントリスナー
* @param {function({id:number})} onDeleteTodo 削除ボタンのクリックイベントリスナー
* @returns {Element} TodoItemModelの配列に対応したリストのHTML要素
*/
createElement(todoItems, { onUpdateTodo, onDeleteTodo }) {
const todoListElement = element`<ul></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要素を作り、todoListElement
へと追加していきます。
このTodoListViewのcreateElement
メソッドもonUpdateTodo
とonDeleteTodo
のリスナー関数を受け取ります。
しかし、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 {
#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
クラスのメソッドとして定義し直してみましょう。
次のように、それぞれ対応するリスナー関数をhandle
メソッドとして実装して、それを呼び出すように変更しました。
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 {
#todoListView = new TodoListView();
#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 todoItemCountElement = document.querySelector("#js-todo-count");
const containerElement = 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, containerElement);
todoItemCountElement.textContent = `Todoアイテム数: ${this.#todoListModel.getTotalCount()}`;
});
formElement.addEventListener("submit", (event) => {
event.preventDefault();
this.handleAdd(inputElement.value);
inputElement.value = "";
});
}
}
このようにApp
クラスのメソッドとしてリスナー関数を並べることで、Todoアプリの機能がコード上の見た目としてわかりやすくなりました。
セクションのまとめ
このセクションでは、次のことを行いました。
- Appから表示に関する処理をViewコンポーネントに分割した
- Todoアプリの機能と対応するリスナー関数を
App
クラスのメソッドへ移動した - Todoアプリを完成させた
完成したTodoアプリは次のURLで確認できます。
実はこのTodoアプリにはまだアプリケーションとして、完成していない部分があります。
入力欄でEnterキーを連打すると、空のTodoアイテムが追加されてしまうのは意図しない挙動です。
また、Appのmount
メソッドでTodoListModelのonChange
メソッドなどにイベントリスナーを登録していますが、そのイベントリスナーを解除していません。
このTodoアプリではあまり問題にはなりませんが、イベントリスナーは登録したままだとメモリリークにつながる場合もあります。
余力がある人は、次の機能を追加してTodoアプリを完成させてみてください。
- タイトルが空の場合は、フォームを送信してもTodoアイテムを追加できないようにする
- Appの
mount
メソッドでのイベントリスナー登録に対応して、Appにunmount
メソッドを実装し、イベントリスナーを解除できるようにする
Appのmount
メソッドと対応するunmount
メソッドを作成するというTodoは、アプリケーションのライフサイクルを意識するという課題になります。
ウェブページにはページ読み込みが完了したときに発生するload
イベントと、読み込んだページを破棄したときに発生するunload
イベントがあります。
Todoアプリもmount
とunmount
を実装し、次のようにウェブページのライフサイクルに合わせられます。
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と呼ぶことがあります。 ↩