第4版が利用可能です。 こちらでお読みください

第14章ドキュメントオブジェクトモデル

残念!またしても同じ話!家を建て終わってから、始める前に知っておくべきだったことをうっかり学んでしまったことに気づくんだ。

フリードリヒ・ニーチェ, 善悪の彼岸
Picture of a tree with letters and scripts hanging from its branches

ブラウザでWebページを開くと、ブラウザはページのHTMLテキストを取得し、第12章のパーサーがプログラムを解析したのと同様の方法で解析します。ブラウザはドキュメントの構造のモデルを構築し、このモデルを使用して画面にページを描画します。

ドキュメントのこの表現は、JavaScriptプログラムがサンドボックス内で利用できるおもちゃの1つです。これは、読み取りや変更ができるデータ構造です。これはライブデータ構造として機能します。変更されると、画面上のページが変更を反映して更新されます。

ドキュメントの構造

HTMLドキュメントは、入れ子になった箱のセットとして想像できます。<body></body>などのタグは、他のタグを囲み、それらのタグはさらに他のタグやテキストを含みます。前の章からのサンプルドキュメントを次に示します。

<!doctype html>
<html>
  <head>
    <title>My home page</title>
  </head>
  <body>
    <h1>My home page</h1>
    <p>Hello, I am Marijn and this is my home page.</p>
    <p>I also wrote a book! Read it
      <a href="https://eloquentjavascript.dokyumento.jp">here</a>.</p>
  </body>
</html>

このページの構造は次のとおりです。

HTML document as nested boxes

ブラウザがドキュメントを表現するために使用するデータ構造は、この形状に従います。各箱にはオブジェクトがあり、それを操作して、それが表すHTMLタグや、それが含む箱やテキストなどの情報を調べることができます。この表現はドキュメントオブジェクトモデル、略してDOMと呼ばれます。

グローバルバインディングのdocumentは、これらのオブジェクトへのアクセスを提供します。そのdocumentElementプロパティは、<html>タグを表すオブジェクトを参照します。すべてのHTMLドキュメントにはheadとbodyがあるため、それらの要素を指すheadプロパティとbodyプロパティも含まれています。

木構造

少しの間、第12章の構文木を思い出してください。それらの構造は、ブラウザのドキュメントの構造と驚くほど似ています。各ノードは、他のノード、を参照することができ、その子も独自の子を持つことができます。この形状は、要素がそれ自身と類似したサブ要素を含むことができる入れ子構造に典型的です。

分岐構造を持ち、サイクルがなく(ノードが直接的または間接的にそれ自身を含むことができない)、単一の明確なルートを持つデータ構造を木構造と呼びます。DOMの場合、document.documentElementがルートとして機能します。

木構造はコンピュータサイエンスでよく使用されます。HTMLドキュメントやプログラムなどの再帰的な構造を表現するだけでなく、要素が通常、フラットな配列よりも木構造の方が効率的に見つけたり挿入したりできるため、ソートされたデータのセットを維持するためにもよく使用されます。

典型的な木構造には、さまざまな種類のノードがあります。Egg言語の構文木には、識別子、値、およびアプリケーションノードがありました。アプリケーションノードは子を持つことができますが、識別子と値はリーフ、つまり子を持たないノードです。

DOMも同様です。HTMLタグを表す要素のノードは、ドキュメントの構造を決定します。これらは子ノードを持つことができます。このようなノードの例はdocument.bodyです。これらの子の一部は、テキストまたはコメントノードなどのリーフノードになる場合があります。

各DOMノードオブジェクトには、ノードのタイプを識別するコード(数値)を含むnodeTypeプロパティがあります。要素のコードは1で、これは定数プロパティNode.ELEMENT_NODEとしても定義されています。ドキュメント内のテキストセクションを表すテキストノードには、コード3(Node.TEXT_NODE)が割り当てられます。コメントにはコード8(Node.COMMENT_NODE)が割り当てられます。

ドキュメントツリーを視覚化する別の方法は、次のとおりです。

HTML document as a tree

リーフはテキストノードであり、矢印はノード間の親子関係を示しています。

標準

ノードタイプを表すために不可解な数値コードを使用することは、JavaScriptらしいことではありません。この章の後半で、DOMインターフェイスの他の部分も扱いにくく、異質に感じるでしょう。その理由は、DOMがJavaScript専用に設計されたものではないからです。むしろ、HTMLだけでなく、HTMLのような構文を持つ汎用データ形式であるXMLにも使用できる、他のシステムでも使用できる言語中立のインターフェイスにしようとしているからです。

これは残念です。標準はしばしば役立ちます。しかし、この場合、利点(言語間の整合性)はそれほど説得力がありません。使用している言語に適切に統合されたインターフェイスを持つことは、言語間で使い慣れたインターフェイスを持つよりも時間を節約できます。

この不十分な統合の例として、DOM内の要素ノードが持つchildNodesプロパティを考えてみましょう。このプロパティは、子ノードにアクセスするためのlengthプロパティと数値でラベル付けされたプロパティを持つ配列のようなオブジェクトを保持します。しかし、これは実際の配列ではなく、NodeListタイプのインスタンスであるため、slicemapなどのメソッドはありません。

また、単に設計が悪いという問題もあります。たとえば、新しいノードを作成して、すぐにお子様や属性を追加する方法はありません。代わりに、最初にノードを作成し、副作用を使用して、子と属性を1つずつ追加する必要があります。DOMを頻繁に操作するコードは、長くて反復的で、醜くなる傾向があります。

しかし、これらの欠点は致命的ではありません。JavaScriptでは独自の抽象化を作成できるため、実行する操作を表現するための改善された方法を設計することが可能です。ブラウザプログラミングを目的とした多くのライブラリには、そのようなツールが付属しています。

木構造の移動

DOMノードには、近くの他のノードへのリンクが豊富に含まれています。次の図は、これらのリンクを示しています。

Links between DOM nodes

図は各タイプのリンクを1つだけ示していますが、すべてのノードには、それが属しているノードを指すparentNodeプロパティ(存在する場合)があります。同様に、すべての要素ノード(ノードタイプ1)には、その子を保持する配列のようなオブジェクトを指すchildNodesプロパティがあります。

理論的には、これらの親と子のリンクだけを使用してツリー内のどこにでも移動できます。しかし、JavaScriptは、便利な追加リンクにもアクセスできます。firstChildプロパティとlastChildプロパティは、最初と最後の子要素を指すか、子を持たないノードの場合は値nullを持ちます。同様に、previousSiblingnextSiblingは、隣接するノードを指します。これは、ノード自体とすぐに前後に表示される同じ親を持つノードです。最初の子の場合、previousSiblingはnullになり、最後の子の場合、nextSiblingはnullになります。

childrenプロパティもあります。これはchildNodesに似ていますが、他のタイプの子ノードではなく、要素(タイプ1)の子のみを含みます。これは、テキストノードに関心がない場合に役立ちます。

このような入れ子になったデータ構造を扱う場合、再帰関数が役立つことがよくあります。次の関数は、特定の文字列を含むテキストノードをドキュメントでスキャンし、見つけた場合にtrueを返します。

function talksAbout(node, string) {
  if (node.nodeType == Node.ELEMENT_NODE) {
    for (let child of node.childNodes) {
      if (talksAbout(child, string)) {
        return true;
      }
    }
    return false;
  } else if (node.nodeType == Node.TEXT_NODE) {
    return node.nodeValue.indexOf(string) > -1;
  }
}

console.log(talksAbout(document.body, "book"));
// → true

テキストノードのnodeValueプロパティは、それが表すテキストの文字列を保持します。

要素の検索

親、子、兄弟間のこれらのリンクをナビゲートすることは、しばしば役立ちます。しかし、ドキュメント内の特定のノードを見つけたい場合、document.bodyから開始してプロパティの固定パスに従って到達することは悪い考えです。そうすることで、ドキュメントの正確な構造に関する仮定がプログラムに組み込まれてしまいます。これは、後で変更する可能性があります。別の複雑な要因は、ノード間の空白にもテキストノードが作成されることです。サンプルドキュメントの<body>タグには、3つの子(<h1>と2つの<p>要素)だけではなく、実際には7つの子があります。つまり、それらの3つに加えて、その前、後、および間のスペースです。

したがって、ドキュメント内のリンクのhref属性を取得したい場合、「ドキュメント本体の6番目の子の2番目の子を取得する」のようなことを言いたくありません。 「ドキュメントの最初のリンクを取得する」と言うことができれば、より良いでしょう。そして、私たちはそれをすることができます。

let link = document.body.getElementsByTagName("a")[0];
console.log(link.href);

すべての要素ノードには、そのノードの子孫(直接または間接の子)である指定されたタグ名のすべての要素を収集し、それらを配列のようなオブジェクトとして返すgetElementsByTagNameメソッドがあります。

特定の単一のノードを見つけるには、それにid属性を指定し、代わりにdocument.getElementByIdを使用できます。

<p>My ostrich Gertrude:</p>
<p><img id="gertrude" src="img/ostrich.png"></p>

<script>
  let ostrich = document.getElementById("gertrude");
  console.log(ostrich.src);
</script>

3つ目の同様のメソッドはgetElementsByClassNameで、これはgetElementsByTagNameと同様に、要素ノードの内容を検索し、class属性に指定された文字列を持つすべての要素を取得します。

ドキュメントの変更

DOMデータ構造に関するほとんどすべてを変更できます。ドキュメントツリーの形状は、親子関係を変更することで変更できます。ノードには、現在の親ノードからノードを削除するremoveメソッドがあります。要素ノードに子ノードを追加するには、子のリストの末尾に追加するappendChild、または最初の引数として指定されたノードを2番目の引数として指定されたノードの前に挿入するinsertBeforeを使用できます。

<p>One</p>
<p>Two</p>
<p>Three</p>

<script>
  let paragraphs = document.body.getElementsByTagName("p");
  document.body.insertBefore(paragraphs[2], paragraphs[0]);
</script>

ノードはドキュメント内の1か所にしか存在できません。したがって、段落Threeを段落Oneの前に挿入すると、まずドキュメントの末尾から削除され、次に先頭に挿入されるため、Three/One/Twoとなります。ノードをどこかに挿入するすべての操作は、副作用として、現在の位置(存在する場合)から削除されます。

replaceChildメソッドは、子ノードを別のノードに置き換えるために使用されます。このメソッドは、新しいノードと置き換えるノードの2つのノードを引数として受け取ります。置き換えられるノードは、メソッドが呼び出される要素の子である必要があります。replaceChildinsertBeforeの両方が、最初の引数として新しいノードを期待することに注意してください。

ノードの作成

ドキュメント内のすべての画像(<img>タグ)を、画像の代替テキスト表現を指定するalt属性に保持されているテキストに置き換えるスクリプトを記述したいとします。

これには、画像の削除だけでなく、それらを置き換えるための新しいテキストノードの追加も含まれます。テキストノードは、document.createTextNodeメソッドを使用して作成されます。

<p>The <img src="img/cat.png" alt="Cat"> in the
  <img src="img/hat.png" alt="Hat">.</p>

<p><button onclick="replaceImages()">Replace</button></p>

<script>
  function replaceImages() {
    let images = document.body.getElementsByTagName("img");
    for (let i = images.length - 1; i >= 0; i--) {
      let image = images[i];
      if (image.alt) {
        let text = document.createTextNode(image.alt);
        image.parentNode.replaceChild(text, image);
      }
    }
  }
</script>

文字列が与えられると、createTextNodeは、ドキュメントに挿入して画面に表示できるようにするテキストノードを提供します。

画像に対して反復処理を行うループは、リストの末尾から開始します。これは、getElementsByTagNameのようなメソッド(またはchildNodesのようなプロパティ)によって返されるノードリストがライブであるため必要です。つまり、ドキュメントが変更されると更新されます。先頭から開始した場合、最初の画像を削除すると、リストから最初の要素が失われるため、ループが2回目に繰り返されるときに、iが1の場合、コレクションの長さも1になるため停止します。

ライブコレクションではなく、ノードの固定コレクションが必要な場合は、Array.fromを呼び出すことでコレクションを実際​​の配列に変換できます。

let arrayish = {0: "one", 1: "two", length: 2};
let array = Array.from(arrayish);
console.log(array.map(s => s.toUpperCase()));
// → ["ONE", "TWO"]

要素ノードを作成するには、document.createElementメソッドを使用できます。このメソッドはタグ名を受け取り、指定されたタイプの新しい空のノードを返します。

次の例では、要素ノードを作成し、残りの引数をそのノードの子として扱うユーティリティeltを定義します。この関数は、引用に帰属を追加するために使用されます。

<blockquote id="quote">
  No book can ever be finished. While working on it we learn
  just enough to find it immature the moment we turn away
  from it.
</blockquote>

<script>
  function elt(type, ...children) {
    let node = document.createElement(type);
    for (let child of children) {
      if (typeof child != "string") node.appendChild(child);
      else node.appendChild(document.createTextNode(child));
    }
    return node;
  }

  document.getElementById("quote").appendChild(
    elt("footer", "—",
        elt("strong", "Karl Popper"),
        ", preface to the second edition of ",
        elt("em", "The Open Society and Its Enemies"),
        ", 1950"));
</script>

属性

リンクのhrefのような一部の要素属性には、要素のDOMオブジェクトの同じ名前のプロパティを介してアクセスできます。これは、最も一般的に使用される標準属性の場合です。

ただし、HTMLでは、ノードに任意の属性を設定できます。これにより、ドキュメントに追加情報を格納できるため便利です。独自の属性名を付ける場合でも、そのような属性は要素のノードのプロパティとして存在しません。代わりに、それらを操作するにはgetAttributeおよびsetAttributeメソッドを使用する必要があります。

<p data-classified="secret">The launch code is 00000000.</p>
<p data-classified="unclassified">I have two feet.</p>

<script>
  let paras = document.body.getElementsByTagName("p");
  for (let para of Array.from(paras)) {
    if (para.getAttribute("data-classified") == "secret") {
      para.remove();
    }
  }
</script>

他の属性と競合しないように、そのような独自の属性の名前にはdata-をプレフィックスとして付けることをお勧めします。

JavaScript言語のキーワードである、一般的に使用される属性classがあります。歴史的な理由(一部の古いJavaScript実装ではキーワードと一致するプロパティ名を処理できなかった)により、この属性にアクセスするために使用されるプロパティはclassNameと呼ばれます。getAttributeおよびsetAttributeメソッドを使用することにより、その実名である"class"でアクセスすることもできます。

レイアウト

さまざまなタイプの要素が異なるレイアウトで表示されることに気付いたかもしれません。段落(<p>)や見出し(<h1>)など、ドキュメントの幅全体を占め、別の行にレンダリングされるものもあります。これらはブロック要素と呼ばれます。リンク(<a>)や<strong>要素など、他の要素は周囲のテキストと同じ行にレンダリングされます。このような要素はインライン要素と呼ばれます。

特定のドキュメントについて、ブラウザはレイアウトを計算することができます。これにより、各要素にそのタイプと内容に基づいてサイズと位置が与えられます。このレイアウトは、実際にドキュメントを描画するために使用されます。

要素のサイズと位置は、JavaScriptからアクセスできます。offsetWidthおよびoffsetHeightプロパティは、要素が占めるスペースをピクセル単位で提供します。ピクセルは、ブラウザの測定の基本単位です。従来は、画面が描画できる最小のドットに対応していましたが、非常に小さなドットを描画できる最新のディスプレイでは、そうではなくなっている場合があり、ブラウザのピクセルが複数のディスプレイドットにまたがることがあります。

同様に、clientWidthおよびclientHeightは、境界線の幅を無視して、要素のスペースのサイズを提供します。

<p style="border: 3px solid red">
  I'm boxed in
</p>

<script>
  let para = document.body.getElementsByTagName("p")[0];
  console.log("clientHeight:", para.clientHeight);
  console.log("offsetHeight:", para.offsetHeight);
</script>

画面上の要素の正確な位置を見つける最も効果的な方法は、getBoundingClientRectメソッドです。これは、topbottomleft、およびrightプロパティを持つオブジェクトを返し、画面の左上を基準とした要素の辺のピクセル位置を示します。ドキュメント全体を基準とした位置が必要な場合は、pageXOffsetおよびpageYOffsetバインディングにある現在のスクロール位置を追加する必要があります。

ドキュメントのレイアウトにはかなりの作業が必要になる場合があります。速度を重視するため、ブラウザエンジンはドキュメントを変更するたびにすぐにドキュメントを再レイアウトするのではなく、できる限り待機します。ドキュメントを変更したJavaScriptプログラムが実行を終了すると、ブラウザは変更されたドキュメントを画面に描画するために新しいレイアウトを計算する必要があります。プログラムがoffsetHeightなどのプロパティを読み取ったり、getBoundingClientRectを呼び出したりして、何かの位置またはサイズを要求すると、正しい情報を提供するためにもレイアウトを計算する必要があります。

DOMレイアウト情報の読み取りとDOMの変更を交互に繰り返すプログラムは、多数のレイアウト計算を強制的に発生させ、結果として非常に遅く実行されます。次のコードは、この例です。これには、幅2,000ピクセルのX文字の行を作成し、それぞれの所要時間を測定する2つの異なるプログラムが含まれています。

<p><span id="one"></span></p>
<p><span id="two"></span></p>

<script>
  function time(name, action) {
    let start = Date.now(); // Current time in milliseconds
    action();
    console.log(name, "took", Date.now() - start, "ms");
  }

  time("naive", () => {
    let target = document.getElementById("one");
    while (target.offsetWidth < 2000) {
      target.appendChild(document.createTextNode("X"));
    }
  });
  // → naive took 32 ms

  time("clever", function() {
    let target = document.getElementById("two");
    target.appendChild(document.createTextNode("XXXXX"));
    let total = Math.ceil(2000 / (target.offsetWidth / 5));
    target.firstChild.nodeValue = "X".repeat(total);
  });
  // → clever took 1 ms
</script>

スタイリング

異なるHTML要素が異なる描画をされることを確認しました。ブロックとして表示されるものもあれば、インラインで表示されるものもあります。スタイルを追加する要素(<strong>はコンテンツを太字にし、<a>は青色にして下線を引きます)。

<img>タグが画像を表示する方法、または<a>タグをクリックするとリンクがたどられる方法は、要素のタイプに強く関連付けられています。ただし、テキストの色や下線など、要素に関連付けられたスタイルを変更できます。次に、styleプロパティを使用する例を示します。

<p><a href=".">Normal link</a></p>
<p><a href="." style="color: green">Green link</a></p>

スタイル属性には、プロパティ(colorなど)の後にコロンと値(greenなど)が続く1つ以上の宣言を含めることができます。複数の宣言がある場合は、"color: red; border: none"のように、セミコロンで区切る必要があります。

ドキュメントの多くの側面は、スタイルによって影響を受ける可能性があります。たとえば、displayプロパティは、要素をブロックとして表示するかインライン要素として表示するかを制御します。

This text is displayed <strong>inline</strong>,
<strong style="display: block">as a block</strong>, and
<strong style="display: none">not at all</strong>.

ブロック要素は周囲のテキストとインラインで表示されないため、blockタグは独自の行に表示されます。最後のタグはまったく表示されません—display: noneは要素が画面に表示されないようにします。これは要素を非表示にする方法です。後で再び表示しやすくするため、ドキュメントから完全に削除するよりも望ましいことがよくあります。

JavaScriptコードは、要素のstyleプロパティを介して要素のスタイルを直接操作できます。このプロパティは、可能なすべてのスタイルプロパティのプロパティを持つオブジェクトを保持します。これらのプロパティの値は文字列であり、これに書き込むことで、要素のスタイルの特定のアスペクトを変更できます。

<p id="para" style="color: purple">
  Nice text
</p>

<script>
  let para = document.getElementById("para");
  console.log(para.style.color);
  para.style.color = "magenta";
</script>

font-familyなど、一部のスタイルプロパティ名にはハイフンが含まれています。このようなプロパティ名はJavaScriptで扱いづらいため(style["font-family"]と言う必要がありました)、そのようなプロパティのstyleオブジェクトのプロパティ名にはハイフンが削除され、その後の文字が大文字になります(style.fontFamily)。

カスケードスタイル

HTMLのスタイリングシステムは、カスケードスタイルシートであるCSSと呼ばれます。スタイルシートは、ドキュメント内の要素をスタイル設定する方法に関するルールのセットです。これは<style>タグ内に記述できます。

<style>
  strong {
    font-style: italic;
    color: gray;
  }
</style>
<p>Now <strong>strong text</strong> is italic and gray.</p>

名前のカスケードとは、複数のそのようなルールが組み合わされて要素の最終的なスタイルが生成されることを指します。この例では、font-weight: boldを与える<strong>タグのデフォルトのスタイル設定は、font-styleおよびcolorを追加する<style>タグのルールによって上書きされます。

複数のルールが同じプロパティの値を定義している場合、最も後で読み込まれたルールが優先され、それが適用されます。したがって、<style>タグ内のルールに、デフォルトのfont-weightルールと矛盾するfont-weight: normalが含まれている場合、テキストは太字ではなく、通常になります。ノードに直接適用されるstyle属性のスタイルは、最も優先度が高く、常に優先されます。

CSSルールでは、タグ名以外のものをターゲットにすることができます。.abcのルールは、class属性に"abc"を持つすべての要素に適用されます。#xyzのルールは、id属性が"xyz"の要素に適用されます(これはドキュメント内で一意である必要があります)。

.subtle {
  color: gray;
  font-size: 80%;
}
#header {
  background: blue;
  color: white;
}
/* p elements with id main and with classes a and b */
p#main.a.b {
  margin-bottom: 20px;
}

最も後で定義されたルールが優先されるという優先順位ルールは、ルールが同じ特異度を持つ場合にのみ適用されます。ルールの特異度とは、それが一致する要素をどれだけ正確に記述しているかを測るもので、必要な要素の側面(タグ、クラス、またはID)の数と種類によって決定されます。たとえば、p.aをターゲットとするルールは、pまたは.aだけをターゲットとするルールよりも具体的であるため、それらよりも優先されます。

p > a {…}という表記は、<p>タグの直接の子であるすべての<a>タグに、指定されたスタイルを適用します。同様に、p a {…}は、直接の子であるか間接的な子であるかに関わらず、<p>タグ内のすべての<a>タグに適用されます。

クエリセレクター

この本では、スタイルシートをそれほど頻繁には使用しません。ブラウザでプログラミングする際には理解しておくと役立ちますが、それらは十分に複雑であるため、別の本が必要です。

私がセレクター構文(スタイルシートで使用され、どの要素にスタイルのセットを適用するかを決定する表記法)を紹介した主な理由は、この同じミニ言語をDOM要素を見つけるための効果的な方法として使用できるからです。

documentオブジェクトと要素ノードの両方で定義されているquerySelectorAllメソッドは、セレクター文字列を受け取り、それに一致するすべての要素を含むNodeListを返します。

<p>And if you go chasing
  <span class="animal">rabbits</span></p>
<p>And you know you're going to fall</p>
<p>Tell 'em a <span class="character">hookah smoking
  <span class="animal">caterpillar</span></span></p>
<p>Has given you the call</p>

<script>
  function count(selector) {
    return document.querySelectorAll(selector).length;
  }
  console.log(count("p"));           // All <p> elements
  // → 4
  console.log(count(".animal"));     // Class animal
  // → 2
  console.log(count("p .animal"));   // Animal inside of <p>
  // → 2
  console.log(count("p > .animal")); // Direct child of <p>
  // → 1
</script>

getElementsByTagNameなどのメソッドとは異なり、querySelectorAllによって返されるオブジェクトは、ライブではありません。ドキュメントを変更しても変更されません。ただし、それでも実際の配列ではないため、配列のように扱いたい場合は、Array.fromを呼び出す必要があります。

querySelectorメソッド(Allの部分なし)も同様に機能します。特定の単一の要素が必要な場合に役立ちます。これは、最初に一致する要素のみを返し、一致する要素がない場合はnullを返します。

位置決めとアニメーション

positionスタイルプロパティは、レイアウトに大きな影響を与えます。デフォルトでは、staticの値を持っており、これは要素がドキュメント内の通常の位置にあることを意味します。relativeに設定すると、要素はドキュメント内でスペースを占有しますが、topおよびleftスタイルプロパティを使用して、その通常の位置を基準に移動させることができます。positionabsoluteに設定されている場合、要素は通常のドキュメントフローから削除されます。つまり、スペースを占有しなくなり、他の要素と重なる可能性があります。また、topおよびleftプロパティを使用して、positionプロパティがstaticではない最も近い包含要素の左上隅を基準に絶対位置決めできます。そのような包含要素が存在しない場合は、ドキュメントを基準に絶対位置決めできます。

これを使用してアニメーションを作成できます。次のドキュメントでは、楕円状に動き回る猫の画像が表示されます。

<p style="text-align: center">
  <img src="img/cat.png" style="position: relative">
</p>
<script>
  let cat = document.querySelector("img");
  let angle = Math.PI / 2;
  function animate(time, lastTime) {
    if (lastTime != null) {
      angle += (time - lastTime) * 0.001;
    }
    cat.style.top = (Math.sin(angle) * 20) + "px";
    cat.style.left = (Math.cos(angle) * 200) + "px";
    requestAnimationFrame(newTime => animate(newTime, time));
  }
  requestAnimationFrame(animate);
</script>

私たちの画像はページの中央に配置され、relativepositionが与えられています。その画像のtopleftのスタイルを繰り返し更新して、移動させます。

スクリプトは、ブラウザが画面を再描画する準備ができたときにanimate関数を実行するようにスケジュールするためにrequestAnimationFrameを使用します。animate関数自体は、次の更新をスケジュールするために再びrequestAnimationFrameを呼び出します。ブラウザのウィンドウ(またはタブ)がアクティブな場合、これにより1秒あたり約60回のレートで更新が発生し、見栄えの良いアニメーションが生成される傾向があります。

ループ内でDOMを更新するだけでは、ページがフリーズし、画面に何も表示されません。ブラウザは、JavaScriptプログラムが実行されている間は表示を更新せず、ページとの対話も許可しません。これが、requestAnimationFrameが必要な理由です。これにより、ブラウザは今のところ完了したことを認識し、画面の更新やユーザー操作への応答など、ブラウザが行うことを実行できます。

アニメーション関数には、現在の時間が引数として渡されます。ミリ秒あたりの猫の動きが安定していることを確認するために、角度が変化する速度は、現在の時間と関数が最後に実行された時間の差に基づいています。ステップごとに一定量だけ角度を移動した場合、たとえば、同じコンピュータで実行されている別の重いタスクによって関数が数分の1秒間実行されないと、動きが途切れてしまいます。

円を描く動きは、三角関数Math.cosMath.sinを使用して行われます。これらに馴染みのない方のために、この本で時々使用するので、簡単に紹介します。

Math.cosMath.sinは、半径1の(0,0)を中心とする円上にある点を見つけるのに役立ちます。どちらの関数も、引数をこの円上の位置として解釈し、ゼロは円の一番右側の点を表し、2π(約6.28)まで時計回りに進むと、円全体を一周することになります。Math.cosは、指定された位置に対応する点のx座標を返し、Math.sinはy座標を生成します。2πより大きい位置(または角度)または0未満の位置は有効です。回転は繰り返されるため、a + 2πはaと同じ角度を指します。

角度を測定するこの単位はラジアンと呼ばれます。全円は2πラジアンで、度で測定するときに360度に似ています。定数πは、JavaScriptでMath.PIとして使用できます。

Using cosine and sine to compute coordinates

猫のアニメーションコードは、アニメーションの現在の角度を示すカウンタangleを保持し、animate関数が呼び出されるたびにそれをインクリメントします。次に、この角度を使用して、画像要素の現在の位置を計算できます。topスタイルはMath.sinで計算され、楕円の垂直半径である20が掛けられます。leftスタイルはMath.cosに基づいており、楕円が高さよりもはるかに幅広くなるように200が掛けられます。

スタイルには通常、単位が必要です。この場合、ブラウザにピクセル数でカウントしていることを伝えるために、数値に"px"を追加する必要があります(センチメートル、"ems"、またはその他の単位とは対照的です)。これは忘れがちです。単位なしで数値を使用すると、数値が0の場合を除き、スタイルは無視されます。0は常に、単位に関係なく同じことを意味します。

まとめ

JavaScriptプログラムは、DOMと呼ばれるデータ構造を介して、ブラウザが表示しているドキュメントを検査および操作することができます。このデータ構造は、ブラウザのドキュメントモデルを表しており、JavaScriptプログラムはそれを変更して、表示されているドキュメントを変更できます。

DOMはツリーのように構成されており、要素はドキュメントの構造に従って階層的に配置されています。要素を表すオブジェクトには、このツリーをナビゲートするために使用できるparentNodechildNodesなどのプロパティがあります。

ドキュメントの表示方法は、ノードに直接スタイルをアタッチすることと、特定のノードに一致するルールを定義することの両方で、スタイリングによって影響を受ける可能性があります。colordisplayなど、さまざまなスタイルプロパティがあります。JavaScriptコードは、要素のstyleプロパティを介して、要素のスタイルを直接操作できます。

演習

テーブルの作成

HTMLテーブルは、次のタグ構造で構築されます

<table>
  <tr>
    <th>name</th>
    <th>height</th>
    <th>place</th>
  </tr>
  <tr>
    <td>Kilimanjaro</td>
    <td>5895</td>
    <td>Tanzania</td>
  </tr>
</table>

について、<table>タグには<tr>タグが含まれています。これらの<tr>タグの内側には、セル要素を配置できます。見出しセル(<th>)または通常のセル(<td>)のいずれかです。

山々のデータセット、つまり nameheightplace プロパティを持つオブジェクトの配列が与えられたとき、オブジェクトを列挙するテーブルのDOM構造を生成してください。テーブルは、キーごとに1つの列、オブジェクトごとに1つの行を持ち、さらに最上部には列名をリストする <th> 要素を持つヘッダー行が必要です。

データ内の最初のオブジェクトのプロパティ名を取得することで、列がオブジェクトから自動的に導出されるように記述してください。

結果のテーブルを、id属性が "mountains" である要素に追加して、ドキュメント内で表示されるようにしてください。

これが動作したら、数値を含むセルを、それらの style.textAlign プロパティを "right" に設定することで右揃えにしてください。

<h1>Mountains</h1>

<div id="mountains"></div>

<script>
  const MOUNTAINS = [
    {name: "Kilimanjaro", height: 5895, place: "Tanzania"},
    {name: "Everest", height: 8848, place: "Nepal"},
    {name: "Mount Fuji", height: 3776, place: "Japan"},
    {name: "Vaalserberg", height: 323, place: "Netherlands"},
    {name: "Denali", height: 6168, place: "United States"},
    {name: "Popocatepetl", height: 5465, place: "Mexico"},
    {name: "Mont Blanc", height: 4808, place: "Italy/France"}
  ];

  // Your code here
</script>

新しい要素ノードを作成するには document.createElement を、テキストノードを作成するには document.createTextNode を、ノードを他のノードに入れるには appendChild メソッドを使用できます。

最上部の行を埋めるためにキー名を一度ループし、次にデータ行を構築するために配列内の各オブジェクトに対して再度ループすることになるでしょう。最初のオブジェクトからキー名の配列を取得するには、Object.keys が役立ちます。

テーブルを正しい親ノードに追加するには、document.getElementById または document.querySelector を使用して、適切な id 属性を持つノードを見つけることができます。

タグ名による要素

document.getElementsByTagName メソッドは、指定されたタグ名を持つすべての子要素を返します。この独自のバージョンを、ノードと文字列(タグ名)を引数として受け取り、指定されたタグ名を持つすべての子孫要素ノードを含む配列を返す関数として実装してください。

要素のタグ名を見つけるには、その nodeName プロパティを使用します。ただし、これはタグ名をすべて大文字で返すことに注意してください。これを補正するには、toLowerCase または toUpperCase 文字列メソッドを使用してください。

<h1>Heading with a <span>span</span> element.</h1>
<p>A paragraph with <span>one</span>, <span>two</span>
  spans.</p>

<script>
  function byTagName(node, tagName) {
    // Your code here.
  }

  console.log(byTagName(document.body, "h1").length);
  // → 1
  console.log(byTagName(document.body, "span").length);
  // → 3
  let para = document.querySelector("p");
  console.log(byTagName(para, "span").length);
  // → 2
</script>

この解決策は、この章で以前に定義した talksAbout 関数と同様に、再帰関数で最も簡単に表現できます。

byTagname 自体を再帰的に呼び出し、結果の配列を連結して出力を生成することができます。または、再帰的に自身を呼び出し、外側の関数で定義された配列バインディングにアクセスできる内部関数を作成し、そこで見つかった一致する要素を追加することができます。プロセスを開始するために、外側の関数から内部関数を一度呼び出すことを忘れないでください。

再帰関数はノードタイプをチェックする必要があります。ここでは、ノードタイプ 1 (Node.ELEMENT_NODE) のみに注目します。そのようなノードについては、それらの子をループ処理し、各子に対して、子がクエリに一致するかどうかを確認すると同時に、その子自身の子供を検査するために再帰呼び出しを行う必要があります。

猫の帽子

以前に定義した猫のアニメーションを拡張して、猫と帽子 (<img src="img/hat.png">) の両方が楕円の両側で軌道を描くようにしてください。

または、帽子を猫の周りを円で回るようにしてください。または、アニメーションを他の興味深い方法で変更してください。

複数のオブジェクトの位置決めを容易にするために、絶対位置指定に切り替えるのがおそらく良いアイデアです。これは、topleft がドキュメントの左上隅を基準にカウントされることを意味します。画像の表示をページの可視領域外に移動させてしまう負の座標の使用を避けるために、位置の値に固定されたピクセル数を追加できます。

<style>body { min-height: 200px }</style>
<img src="img/cat.png" id="cat" style="position: absolute">
<img src="img/hat.png" id="hat" style="position: absolute">

<script>
  let cat = document.querySelector("#cat");
  let hat = document.querySelector("#hat");

  let angle = 0;
  let lastTime = null;
  function animate(time) {
    if (lastTime != null) angle += (time - lastTime) * 0.001;
    lastTime = time;
    cat.style.top = (Math.sin(angle) * 40 + 40) + "px";
    cat.style.left = (Math.cos(angle) * 200 + 230) + "px";

    // Your extensions here.

    requestAnimationFrame(animate);
  }
  requestAnimationFrame(animate);
</script>

Math.cosMath.sin は、完全な円が 2π であるラジアン単位で角度を測定します。与えられた角度に対して、この半分である Math.PI を加算することで反対側の角度を得ることができます。これは、帽子の軌道の反対側に配置するのに役立ちます。