8.6 スクリプトの実行タイミング

DOM 操作を行う JavaScript は、操作対象の HTML 要素が存在していなければ正しく動きません。これまでの 演習で<script>タグを常に body の末尾に書いてきたのは、この理由からです。本節では、その仕組みを整理した上で、実務でよく使われる defer 属性と DOMContentLoaded イベントを学びます。

8.6.1 defer 属性と DOMContentLoaded イベント

なぜ body 末尾に書いてきたか

ブラウザは HTML を先頭から順番に読み込みます。<script>タグに差し掛かった時点で JavaScript を実行するため、まだ読み込まれていない HTML 要素(id や class を持つタグなど)を操作しようとするとエラーが発生します。body 末尾に<script>を配置することで、「その行に到達した時点ではページ上のすべての要素が読み込み済み」という状態を保証してきました。

defer 属性

実務では、パフォーマンスや保守性の観点から<script>タグを<head>内にまとめて書くケースがあります。その際に使うのが defer 属性です。

<script src="app.js" defer></script>

defer を付けると、「HTML の解析がすべて終わるまでスクリプトの実行を待つ」という指定になります。<head>内に書いても body 末尾に置いた場合と同じ動作が保証されます。

ポイント

  • defer 属性を付けることで、<script>タグを内に書いても HTML の読み込み完了後にスクリプトが実行される。

DOMContentLoaded イベント

JavaScript コード側から同じ保証を行う方法が DOMContentLoaded イベントです。

document.addEventListener("DOMContentLoaded", () => {
    // HTML の解析完了後に実行したい処理
});

DOMContentLoaded は、HTML の解析(パース)がすべて終わったタイミングで発火するイベントです。このリスナーの中に処理を書くことで、どこに<script>タグを置いても HTML 要素が確実に存在している状態で実行されます。defer 属性があれば DOMContentLoaded は必須ではありません。しかし、あえて両方書くことで、将来スクリプトの読み込み設定が変わっても壊れない堅牢なコードになります。第 12 章の統合演習でもこのパターンを使います。

ポイント

  • DOMContentLoaded イベントのリスナー内に処理を書くことで、HTML の読み込み完了後にスクリプトを実行できる。
  • defer 属性と DOMContentLoaded の組み合わせは、実務でよく使われる定番パターンである。

8.6.2 defer 属性を使ったプログラム

前の項で作成した dynamic-list.html を改造して、<script>タグを<head>内に移動させます。defer 属性とDOMContentLoaded を追加しても動作が変わらないことを確認しましょう。

ソースフォルダ:public/ch08

ファイル名:dynamic-list.js

➢ dynamic-list.html

<script>タグを body 末尾から内に移動し、defer 属性を追加します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>JavaScriptの練習: dynamic-list.jsを実行中</title>
    <script src="dynamic-list.js" defer></script>
</head>

<body>
    <h1>TODOリスト</h1>
    
    <input type="text" id="todoInput" placeholder="タスクを入力">
    <button id="addBtn">追加</button>
    
    <ul id="todoList"></ul>
</body>
</html>

➢ dynamic-list.js

ファイル全体の処理を DOMContentLoaded のリスナーで囲みます。

// dynamic-list.js

document.addEventListener("DOMContentLoaded", () => {

    // 要素を取得
    const input = document.querySelector("#todoInput");
    const addBtn = document.querySelector("#addBtn");
    const todoList = document.querySelector("#todoList");

    // タスクを追加する関数
    function addTodo() {
        const task = input.value;

        // 入力が空の場合は処理しない
        if (task === "") {
            alert("タスクを入力してください");
            return;
        }

        // 新しい li 要素を作成
        const li = document.createElement("li");
        li.textContent = task;

        // 削除ボタンを作成
        const deleteBtn = document.createElement("button");
        deleteBtn.textContent = "削除";

        // 削除ボタンがクリックされたときの処理
        deleteBtn.addEventListener("click", function() {
            todoList.removeChild(li);
        });

        // li に削除ボタンを追加
        li.appendChild(deleteBtn);

        // ul に li を追加
        todoList.appendChild(li);

        // 入力フィールドをクリア
        input.value = "";
    }

    // 追加ボタンのクリックイベント
    addBtn.addEventListener("click", addTodo);

    // Enter キーでも追加できるようにする
    input.addEventListener("keydown", function(event) {
        if (event.key === "Enter") {
            addTodo();
        }
    });

}); // DOMContentLoaded ここまで

実行結果

修正前と動作が変わらないことを確認してください。
変更したのは 2 点のみです。<script>タグが<head>内に移動し、defer 属性が付いたことで読み込みタイミングが保証されています。JavaScript 側は処理全体を DOMContentLoaded のリスナーで囲みました。内側の処理内容は前の項と完全に同じです。

解説

3 行目は、「HTML の解析が完了したら、この{ }内の処理を実行する」という命令です。6〜51 行目のコードはすべてこのリスナーの内側にあるため、#todoInput や#addBtn といった HTML 要素が確実に存在している状態で実行されます。

3: document.addEventListener("DOMContentLoaded", () => {

53 行目の});は、3 行目で開いた DOMContentLoaded リスナーを閉じています。インデントを 1 段深くしてすべての処理を内側に収めているため、コードの範囲を見落とさないよう注意しましょう。

53: }); // DOMContentLoaded ここまで

ポイント

  • defer と DOMContentLoaded を使っても、処理の内容自体は変わらない。変わるのは「実行タイミングの制御方法」だけである。
  • 第 12 章の統合演習では、この DOMContentLoaded リスナーの中に async を付けて Fetch API と組み合わせる。本節で構造を把握しておくと理解がスムーズになる。