ブラウザで JavaScript が動く仕組み

ブラウザが HTML から DOM を作る流れと、link と script を処理する順番、DOM の完成を待つ defer 属性、DevTools のコンソールでの確認、練習問題のコンソールで結果を文字で確かめる方法を扱います。

JavaScript で画面の文字を書き換えるコードを書いても、読み込ませる場所によって動いたり動かなかったりします。動かないときも画面にはエラーが出ないため、コードの誤りなのか、実行する順番の問題なのかを見分けられません。

この記事では、ブラウザが HTML を読み込み、JavaScript を実行する順番を扱います。

この記事のコードは HTML として開く例

練習問題のコンソールでは HTML を読み込む順番を再現できないため、この記事には練習問題がありません。コードは index.html と app.js を同じフォルダに置き、Chrome で index.html を開いたときの動きを示す例で、結果は行末のコメントに書いてあります。コンソールでの確かめ方は最後の章で扱います。

HTML から要素の入れ子を作る — DOM ツリー

注文確認ページの「計算中…」を、app.js から「3,300 円」に書き換えたいとします。このとき JavaScript が書き換えるのは index.html ファイルの文字ではありません。ブラウザが HTML を読んで組み立てた、別のデータを操作します。

HTML の 要素<p></p> のようにタグで区切った部品)を、ブラウザは 1 つずつオブジェクトにし、その親子関係の集まりを DOM(Document Object Model。DOM ツリーとも呼び、入口は document)と呼びます。

タグの中の id="total"defer を属性と呼び、id 属性にはページ内で重ならない名前を書きます。

<!-- ---- index.html(注文確認ページ) ---- -->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>注文の確認</title>
    <link rel="stylesheet" href="style.css">   <!-- 見た目を決める CSS -->
  </head>
  <body>
    <h1>注文の確認</h1>
    <ul id="items">                           <!-- li 2 つを子に持つ -->
      <li>マグカップ × 2</li>
      <li>コースター × 1</li>
    </ul>
    <p id="total">計算中…</p>                 <!-- app.js が書き換えたい要素 -->
  </body>
</html>
index.html から作る DOM ツリー
html 要素 — document から入る DOM の最上位
head 要素 — 画面に出ない情報
  • meta — 文字コードは UTF-8
  • title — 中身は「注文の確認」
  • link — style.css を指す
body 要素 — 画面に出る部分
h1 要素
  • 中身は「注文の確認」
ul 要素 — id は items
  • li — 中身は「マグカップ × 2」
  • li — 中身は「コースター × 1」
p 要素 — id は total
  • 中身は「計算中…」
  • app.js が書き換えたい要素
head と body は html の子として並び、li は ul の中に入ります。タグの入れ子が、そのまま DOM の親子関係になります

app.js は document.querySelector("#total")(# の後ろの id を持つ要素を探し、無ければ null を返す)で p 要素を取り出し、textContent(要素の中の文字)を書き換えます。p 要素は body の子なので、見つかるかどうかは body が DOM に入っているかで決まります。

HTML を上から読む — link と script の順番

合計を書き換える app.js を、CSS と並べて <head> で読み込んだとします。index.html には <p id="total"> が確かに書いてあるのに、ページを開くと表示は「計算中…」のままで、app.js の書き換えが効いていません。

HTML の解析(ブラウザが HTML を先頭から読み、見つけた要素を順に DOM へ追加する処理)の途中で <link> を見つけると、CSS を取りに行きながら解析を続けます。<script src> を見つけたときは解析を止め、JavaScript を取得して実行し終えてから続きを読みます。

// ---- index.html(head で app.js を読み込む) ----
// <head>
//   <link rel="stylesheet" href="style.css">   ← 解析は続ける
//   <script src="app.js"></script>             ← 解析を止めて実行
// </head>
// <body>
//   <p id="total">計算中…</p>                   ← app.js の後で DOM に入る
// </body>

// ---- app.js ----
const total = document.querySelector("#total");   // null(まだ DOM に無い)
total.textContent = "3,300 円";
// TypeError: Cannot set properties of null (setting 'textContent')
script が動くとき p はまだ無い
<link href="style.css">CSS を取りに行く解析は止まらず次の行へ<script src="app.js">解析を止めてapp.js を実行#total が無くnull が返る<p id="total">解析を再開してDOM に追加#total が入るのはnull の後</html>解析が終わりDOM が完成表示は「計算中…」のまま
解析は script の行で止まり、app.js の実行後に body を読みます。app.js が止まっても、解析は再開してページは表示されます

書き換えが効かなかった原因は、実行した時点で p 要素がまだ DOM に無かったことです。次の章では、app.js の 2 行は変えずに、実行するタイミングを DOM の完成後へ移します。

DOM の完成を待って実行する — defer 属性

<script></body> の直前へ移せば、p 要素を読んだ後に実行されるので #total は見つかります。ただし動くかどうかが置き場所で決まるため、読み込む行を CSS と一緒に <head> へまとめ直すと、同じ null がまた返ります。

defer 属性(HTML の解析を止めずに JavaScript を取得し、DOM が完成してから実行させる <script> の指定)は <script defer src="app.js"></script> と書き、CSS の <link> と同じく <head> に置けます。

// ---- index.html(defer を付けて head で読み込む) ----
// <head>
//   <link rel="stylesheet" href="style.css">
//   <script defer src="app.js"></script>       ← 取得を始め、解析は続ける
// </head>
// <body>
//   <p id="total">計算中…</p>                   ← </html> まで読み終えてから app.js を実行
// </body>

// ---- app.js(前の章と同じ 2 行+確認の 1 行) ----
const total = document.querySelector("#total");   // p 要素
total.textContent = "3,300 円";                   // 画面の表示が変わる
console.log(total.textContent);                   // 3,300 円
defer の取得は並行、実行は最後
<script defersrc="app.js">続きを読みbody を DOM に追加</html> まで読み終えるDOM が完成app.js の取得を始める取得が進む解析は止まらない取得は済んだが実行はまだapp.js を実行#total が見つかる
app.js の取得は解析と同時に進み、先に終わっても実行は待ちます。実行するのは、DOM が完成した後です

defer を付けた script が複数あるときは、取得が終わった順ではなく、HTML に書いた順に実行されます。下の表は、script の書き方ごとに、app.js を実行するときと #total が見つかるかをまとめたものです。

script の書き方app.js を実行するとき#total
head に <script src="app.js">解析を止めて、その場で実行見つからない(null)
</body> の直前に <script src="app.js">p 要素を読んだ後に実行見つかる
head に <script defer src="app.js">DOM が完成してから実行見つかる
head の <script defer> の中に直接コードを書くsrc が無いと defer は効かず、その場で実行見つからない(null)
head に <script type="module" src="app.js">defer を付けなくても DOM の完成後に実行見つかる(index.html を直接開くと読み込めない)

止まった行と DOM を調べる — DevTools

defer を付ける前のページでは、app.js が TypeError で止まっても、画面には何も表示されませんでした。どのファイルの何行目で止まったのかが分からないと、表示が「計算中…」のまま変わらない原因を探せません。

DevTools(ブラウザに付いている、ページの DOM やエラーを調べるための画面)の Console タブは、Chrome なら Windows で Ctrl + Shift + J、Mac で Command + Option + J を押すと開きます。ここに console.log の出力と、止まったエラーのファイル名と行番号が並びます。

// ---- Console タブに出たエラー(右端の app.js:2 を押すとその行が開く) ----
// Uncaught TypeError: Cannot set properties of null (setting 'textContent')
//     at app.js:2:19       ← 2 行目の 19 文字目で止まった

// ---- 読み込み後に、Console の > の後ろへ入力して Enter ----
document.querySelector("#total");                 // <p id="total">計算中…</p>
document.querySelector("#total").textContent;     // '計算中…'

// ---- app.js の 1 行目の後に足す行(Console には入力しない) ----
console.log("実行時の total:", total);             // 実行時の total: null
同じ式が読み込み後は p を返す
読み込み中にapp.js が実行解析は head で止まっているquerySelectorが null を返す2 行目でTypeError読み込み後にConsole へ入力解析は終わりDOM が完成querySelectorが p を返す中の文字「計算中…」が読める
app.js では null だった式が、Console では p 要素を返します。Console で動いても、app.js で動くとは限りません

Console に入力した式は、入力した時点の DOM に対して実行されます。app.js の中の値を確かめたいときは、疑わしい行の前に console.log を書き足し、実行した時点の値を Console タブに出します。

結果を文字で確かめる — innerHTML と outerHTML

この先の記事の演習では、要素を探して書き換えたり追加したりします。ところが練習問題のコンソールには画面が無く、再読み込みするまではすべての練習問題が 1 つの見えないページを共有するため、前に実行したときの要素が残ったまま次の実行が始まります。

innerHTML(要素の中身を HTML の文字列で読み書きするプロパティ)に代入すると、中身がまるごと置き換わります。演習の先頭で document.body(body 要素)の innerHTML に代入して前の要素を消し、結果は outerHTML(要素自身のタグを含む HTML の文字列)を出力して確かめます。

// ① body の中身をまるごと置き換える(前の実行で残った要素はここで消える)
document.body.innerHTML = `<p id="total">計算中…</p>`;

// ② 要素を探して、中の文字を書き換える
const total = document.querySelector("#total");
total.textContent = "3,300 円";

// ③ 画面は見えないので、文字列にして出力する
console.log(total.textContent);          // 3,300 円
console.log(total.outerHTML);            // <p id="total">3,300 円</p>
console.log(document.body.innerHTML);    // <p id="total">3,300 円</p>
演習が共有する見えないページ
記事のページ — 練習問題のコンソール
  • 結果は console.log の出力だけが文字で返る
見えないページ — すべての練習問題で 1 つ
  • document.body の中身 — 次の実行まで残る
1 回目の実行
  • const total — この実行の中だけで使える
  • document.body.innerHTML に p を代入する
2 回目の実行
  • const total — 前回とは別の変数として作られる
  • 先頭の代入で、1 回目の p が消える
body の中身は、次の実行まで残ります。先頭の innerHTML への代入が、残った要素を消します

コンソールのコードは実行のたびに新しい関数の中で動くので、const total は前回とは別の変数として作られ、再宣言のエラーになりません。また、ページの読み込みが終わった後に実行されるため、defer を考える必要もありません。

要素をそのまま出力すると {} になる

練習問題のコンソールは画面を持たず、出力を文字に変換して返します。要素をそのまま console.log に渡すと中身の見えない {} と表示され、書き換えが効いたかを判断できません。要素は outerHTMLtextContent を出力して確かめます。

QUIZ

理解度チェック

まずは1問ずつ答えてみましょう。

Q1defer の無い head の script で document.querySelector("#total") の値は?

Q2<script defer src="app.js"> の app.js が実行されるのはいつですか?

Q3document.body.innerHTML = '<p>計算中…</p>'; を 2 回実行すると、p 要素はいくつですか?