🤖

JavaScript PrimerのTodoアプリ作成でJS復習(直接DOMを更新する問題について)

2023/12/31に公開

記事概要

JSの復習として、JavaScript PrimerのTodoアプリ作成をやってみて、結構いい復習になったので紹介
https://jsprimer.net/

この続き
https://zenn.dev/runnaoya/articles/a2c35c74e4b61b

対象読者

JavaScriptの基本的な構文は知っている初学者向けです。

復習内容

直接DOMを更新する問題

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

これらの状況が発生すると、アプリケーションの開発や保守が複雑化し、以下のような問題が生じる可能性があります。

状態をDOMに文字列で埋め込む制約:
状態を全てDOMに文字列で埋め込むと、状態が非常に大きくなると、DOMが複雑化し、管理が難しくなります。また、セキュリティ上の問題が生じる可能性があります。例えば、不正なJavaScriptコードが含まれることでクロスサイトスクリプティング(XSS)攻撃などが発生するリスクがあります。
表示の処理の複雑化:
操作に対する更新が増えると、表示の更新処理が複雑化し、保守性が低下します。各操作に対する表示の変更が分散してしまうと、コードの理解が難しくなり、バグの発生や新しい機能の追加が困難になります。
コードの再利用性の低下:
各表示の更新処理が密接に結びついてしまうと、コードの再利用性が低下します。ある表示の変更が他の表示に影響を与える場合、それぞれの表示を別々に取り扱うことが難しくなります。結果として、同じような機能が異なる箇所で実装され、冗長なコードが増える可能性があります。
テストの困難さ:
表示の状態がDOM上に直接依存していると、テストが困難になります。DOMの構造が変わるとテストが壊れやすくなり、テストの安定性が低下します。代わりに、アプリケーションの状態を抽象化し、DOMとは独立した形で管理することで、テストのしやすいコードを実現できます。

上記問題の解決法

TodoアイテムやTodoリストなどのモノの状態や操作方法を定義したオブジェクトであるモデルを導入する。

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

具体的には、上記の2点の問題に対して

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

  • 操作に対して更新する表示箇所が増えてくると、表示の処理が複雑化する
    → 表示はモデルの状態を元にしてHTML要素を作成し、表示を更新します。
    モデルの状態が変化していなければ、表示は変わらなくても問題ありません。

実際のコード

  • App.js
    • TodoListModelの状態が更新された場合、表示を更新する
      • TodoListModelの中をforEachで回してtodoListElementに追加
    • フォームを送信された場合、新しいTodoItemModelを追加する
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 = "";
        });
    }
}
  • TodoListModel.js
    App.jsで使われていた関数
    • this.#todoListModel.onChange(())が発火されたとき、EventEmitterのaddEventListenerが発火
      • TodoListModel内で、addEventListenerを呼び出すだけです。"change" というイベントのリスナーを登録することで、TodoListModel の状態が変更されたときに、登録されたリスナー関数が呼び出されるようになります。
    • this.#todoListModel.getTodoItems()で、TodoItemの配列を返す
    • this.#todoListModel.getTotalCount()で、TodoItemの合計個数を返す
    • this.#todoListModel.addTodo()で、TodoItemを追加する
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();
    }
}
  • EventEmitter.js
    • addEventListener()はイベントの種類ごとに、そのイベントに対応するリスナー関数のセットを作成し、それに新しいリスナー関数を追加します。
      • App.jsのthis.#todoListModel.onChangeでは、onChange メソッドによって、"change" イベントに対するリスナーとして、引数で渡された無名関数が登録されています。この無名関数は、TodoListModel の状態が変更されると呼び出され、Todoリストの表示を更新する処理が実行されます。
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);
          }
      });
  }
}
  • TodoItemModel.js
    TodoItemModelクラスはインスタンス化でき、それぞれのidが自動的に異なる値となる。
    todoIdx++でクラス変数の値を変えているため。
// ユニークな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;
    }
}

次回の修正点

  • アイテムの更新、削除
    をしていく

Discussion