フォームとイベント

ここからはJavaScriptでTodoアプリの動作を実際に作っていきます。

このセクションでは、前のセクションでHTMLに目印を付けたTodoリスト(#js-todo-list)に対してTodoアイテムを追加する処理を作っていきます。

Todoアイテムの追加

ユーザーが次のような操作を行い、Todoアイテムを追加します。

  1. 入力欄にTodoアイテムのタイトルを入力する
  2. 入力欄でEnterを押し送信する
  3. TodoリストにTodoアイテムが追加される

これをJavaScriptで実現するには次のことが必要です。

  • form要素から送信(submit)されたことをイベントで受け取る
  • input要素(入力欄)に入力された内容を取得する
  • 入力内容をタイトルにしたTodoアイテムを作成し、Todoリスト(#js-todo-list)にTodoアイテム要素を追加する

まずは、form要素から送信されたイベントを受け取り、入力内容をコンソールログに表示してみることから始めてみましょう。

入力内容をコンソールに表示

form要素でEnterを押し送信するとsubmitイベントが発生します。 このsubmitイベントはaddEventListenerメソッドを利用することで受け取れます。

// id="js-form`の要素を取得
const formElement = document.querySelector("#js-form");
// form要素から発生したsubmitイベントを受け取る
formElement.addEventListener("submit", (event) => {
    // イベントが発生した時に呼ばれるコールバック関数
});

フォームが送信されたときに入力内容をコンソールに表示するには、 addEventListenerコールバック関数内で入力内容をConsole APIで出力すればよいことになります。

入力内容はinput要素のvalueプロパティから取得できます。

const inputElement = document.querySelector("#js-form-input");
console.log(inputElement.value); // => "input要素の入力内容"

これらを組み合わせてApp.jsに「入力内容をコンソールに表示」する機能を実装してみましょう。 Appクラスにmountというメソッドを定義して、その中に処理を書いていきましょう。

次のようにフォーム(#js-form)をEnterで送信すると、input要素(#js-form-input)の内容が開発者ツールのコンソールに表示されるという実装を行います。

export class App {
    mount() {
        const formElement = document.querySelector("#js-form");
        const inputElement = document.querySelector("#js-form-input");
        formElement.addEventListener("submit", (event) => {
            // submitイベントの本来の動作を止める
            event.preventDefault();
            console.log(`入力欄の値: ${inputElement.value}`);
        });
    }
}

このままでは、App#mountは呼び出されないため何も行われません。 そんのため、index.jsも変更して、Appクラスのmountメソッドを呼び出すようにします。

import { App } from "./src/App.js";
const app = new App();
app.mount();

これらの変更後にブラウザでページをリロードすると、App#mountが実行されるようになります。 submitイベントがリッスンされているので、入力欄に何か入力してEnterで送信してみるとその内容がコンソールに表示されます。

入力内容がコンソールに表示される

先ほどのApp#mountでは、submitイベントのイベントリスナー内でevent.preventDefaultメソッドを呼び出しています。 event.preventDefaultメソッドは、submitイベントの発生元であるフォームがもつデフォルトの動作をキャンセルするメソッドです。

フォームがもつデフォルトの動作とは、フォームの内容を指定したURLへ送信するという動作です。 ここではform要素に送信先が指定されていないため、現在のURLに対してフォームを送信が行われます。 event.preventDefaultメソッドを呼び出すことで、このデフォルトの動作をキャンセルしています。

formElement.addEventListener("submit", (event) => {
    // submitイベントの本来の動作を止める
    event.preventDefault();
    console.log(`入力欄の値: ${inputElement.value}`);
});

現在のURLに対してフォームを送信が行われると、結果的にページがリロードされてしまうため、event.preventDefault()を呼び出していました。 これはevent.preventDefault()をコメントアウトすると、ページがリロードされてしまうことが確認できます。

formElement.addEventListener("submit", (event) => {
    // preventDefaultしないとページがリロードされてしまう
    // event.preventDefault();
    console.log(`入力欄の値: ${inputElement.value}`);
});

ここまででtodoappディレクトリは次のような変更を加えました。

todoapp
├── index.html
├── index.js (App#mountの呼び出し)
├── package.json
└── src
    └── App.js (App#mountの実装)

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

https://asciidwango.github.io/js-primer/use-case/todoapp/form-event/prevent-event/

入力内容をTodoリストに表示

フォーム送信時に入力内容を取得する方法が分かったので、次はその入力内容をTodoリスト(#js-todo-list)に表示します。

HTMLではリストのアイテムを記述する際には<li>タグを使います。 また後ほどTodoリストに表示するTodoアイテムの要素には、完了状態を表すチェックボックスや削除ボタンなども含めたいです。 これらの要素を含むものを手続き的にDOM APIで作成すると見通しが悪くなるため、HTML文字列からHTML要素を生成するユーティリティモジュールを作成しましょう。

次のhtml-util.jssrc/view/html-util.jsというパスに作成します。

このhtml-util.jsは「ajaxapp: HTML文字列をDOMに追加する」でも利用したescapeSpecialCharsをベースにしています。 ajaxappでのescapeHTMLタグ関数では出力はHTML文字列でしたが、今回作成するelementタグ関数の出力はHTML要素(Element)です。

これはTodoリスト(#js-todo-list)というすでに存在する要素に対して要素を追加するには、HTML文字列ではなく要素が必要になります。 また、HTML文字列に対してはaddEventListenerでイベントをリッスンできません。 そのため、チェックボックスの状態が変わったことや削除ボタンが押されたことを知る必要があるTodoアプリでは要素が必要になります。

export function escapeSpecialChars(str) {
    return str
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#039;");
}

export function htmlToElement(html) {
    const template = document.createElement("template");
    template.innerHTML = html;
    return template.content.firstElementChild;
}

/**
 * HTML文字列からDOM Nodeを作成して返す
 * @return {HTMLElement}
 */
export function element(strings, ...values) {
    const htmlString = strings.reduce((result, string, i) => {
        const value = values[i - 1];
        if (typeof value === "string") {
            return result + escapeSpecialChars(value) + string;
        } else {
            return result + String(value) + string;
        }
    });
    return htmlToElement(htmlString);
}

/**
 * コンテナ要素の中身をbodyElementで上書きする
 * @param {HTMLElement} bodyElement コンテナ要素の中身となる要素
 * @param {HTMLElement} containerElement コンテナ要素
 */
export function render(bodyElement, containerElement) {
    // rootElementの中身を空にする
    containerElement.innerHTML = "";
    // rootElementの直下にbodyElementを追加する
    containerElement.appendChild(bodyElement);
}

elementタグ関数では、同じファイルに定義したhtmlToElement関数を使ってHTML文字列からHTML要素を作成しています。 htmlToElement関数の中で利用しているtemplate要素はHTML5で追加された、HTML文字列の断片からHTML要素を作成できる要素です。

このelementタグ関数を使うことで、次のようにHTML文字列からHTML要素を作成できます。 作成した要素は、appendChildメソッドなどで既存の要素に子要素として追加できます。

// HTML文字列からHTML要素を作成
const newElement = element`<ul>
    <li>新しい要素</li>
</ul>`;
// 作成した要素を`document.body`の子要素として追加(appendChild)する
document.body.appendChild(newElement);

ブラウザが提供するappendChildメソッドは子要素を追加するだけであるため、すでに別の要素がある場合は末尾に追加されます。

このセクションではまだ利用しませんが、html-util.jsにはrenderという関数を定義しています。 render関数は指定したコンテナ要素(親となる要素)の子要素を上書きする関数となります。 動作的には一度子要素をすべて消したあとにappendChildで子要素として追加しています。

// `ul`要素の空タグを作成
const newElement = element`<ul />`;
// `newElement`を`document.body`の子要素として追加する
// 既に`document.body`以下に要素がある場合は上書きされる
render(newElement, document.body);

最後に、このelementタグ関数を使い、フォームから送信された入力内容をTodoリストに要素として追加してみます。

App.jsから先ほど作成したhtml-util.jselementタグ関数をimportします。 次にsubmitイベントのリスナー関数で、Todoアイテムを表現する要素を作成し、Todoリスト(#js-todo-list)の子要素として追加(appendChild)します。 最後にTodoアイテム数(#js-todo-count)のテキスト(textContent)を更新します。

import { element } from "./view/html-util.js";

export class App {
    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");
        // Todoアイテム数
        let todoItemCount = 0;
        formElement.addEventListener("submit", (event) => {
            // 本来のsubmitイベントの動作を止める
            event.preventDefault();
            // 追加するTodoアイテムの要素(li要素)を作成する
            const todoItemElement = element`<li>${inputElement.value}</li>`;
            // Todoアイテムをcontainerに追加する
            containerElement.appendChild(todoItemElement);
            // Todoアイテム数を+1し、表示されてるテキストを更新する
            todoItemCount += 1;
            todoItemCountElement.textContent = `Todoアイテム数: ${todoItemCount}`;
            // 入力欄を空文字にしてリセットする
            inputElement.value = "";
        });
    }
}

これらの変更後にブラウザでページをリロードすると、入力内容を送信するたびにTodoリスト下へTodoアイテムが追加されます。 また、入力内容を送信するたびにtodoItemCountが加算され、Todoアイテム数の表示も更新されます。

Todoリストへアイテムを追加

このセクションでの変更点は次のとおりです。

todoapp
├── index.html
├── index.js
├── package.json
└── src
    ├── App.js(Todoアイテムの表示の実装)
    └── view
        └── html-util.js(追加)

現在のTodoアプリは次のURLで実際に確認できます。

https://asciidwango.github.io/js-primer/use-case/todoapp/form-event/add-todo-item/

まとめ

このセクションではform要素のsubmitイベントをリッスンし、入力内容を元にTodoアイテムをTodoリストの追加を実装しました。 今回のTodoアイテムの追加のように多くのウェブアプリは、何らかのイベントが発生し、そのイベントをリッスンして表示を更新します。 このようなイベントが発生したことを元に処理を進める方法をイベント駆動(イベントドリブン)と呼びます。

今回のTodoアイテムの追加では、submitイベントを入力にして、直接Todoリスト要素を追加するという方法を取っていました。 このように直接DOMを更新するという方法はコードが短くなりますが、DOMのみにしか状態は残らないため柔軟性がなくなるという問題があります。

次のセクションではどのような問題がおきるかや、それを解決するための仕組みを見ていきます。