文書オブジェクトモデル

なんとも残念な!よくある話だ!家を建て終えてから、本当に知っておくべきだったことを—始める前に—うっかり学んでしまったことに気づくのだ。

フリードリヒ・ニーチェ、『善悪の彼岸』
Illustration showing a tree with letters, pictures, and gears hanging on 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>

このページは次の構造になっています。

Diagram showing an HTML document as a set of nested boxes. The outer box is labeled 'html' and contains two boxes labeled 'head' and 'body'. Inside those are further boxes, with some of the innermost boxes containing the document's text.

ブラウザがドキュメントを表すために使用するデータ構造はこの形状に従います。各ボックスには、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`)です。

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

Diagram showing the HTML document as a tree, with arrows from parent nodes to child nodes

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

標準

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

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

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

それから、単に設計のまずさによって引き起こされる問題があります。たとえば、新しいノードを作成してすぐに子や属性を追加する方法はありません。代わりに、最初に作成してから、副作用を使用して子と属性を1つずつ追加する必要があります。DOMと頻繁に対話するコードは、長くて繰り返しが多く、醜くなる傾向があります。

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

ツリー内を移動する

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

Diagram that shows the links between DOM nodes. The 'body' node is shown as a box, with a 'firstChild' arrow pointing at the 'h1' node at its start, a 'lastChild' arrow pointing at the last paragraph node, and 'childNodes' arrow pointing at an array of links to all its children. The middle paragraph has a 'previousSibling' arrow pointing at the node before it, a 'nextSibling' arrow to the node after it, and a 'parentNode' arrow pointing at the 'body' node.

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

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

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

このような入れ子になったデータ構造を扱う場合、再帰関数はしばしば役立ちます。次の関数は、ドキュメントで指定された文字列を含むテキストノードをスキャンし、見つかった場合は`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`から始めてプロパティの固定パスをたどることはお勧めできません。そうすると、ドキュメントの正確な構造に関する前提がプログラムに組み込まれます。これは、後で変更したくなる可能性のある構造です。もう1つの複雑な要因は、ノード間の空白に対してもテキストノードが作成されることです。サンプルドキュメントの`<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か所だけ存在できます。したがって、段落 _3_ を段落 _1_ の前に挿入すると、最初にドキュメントの末尾から削除され、次に先頭に挿入されるため、_3_/_1_/_2_ となります。ノードをどこかに挿入するすべての操作は、副作用として、現在の位置(存在する場合)から削除されます。

`replaceChild`メソッドは、子ノードを別のノードに置き換えるために使用されます。引数として、新しいノードと置き換えられるノードの2つのノードを取ります。置き換えられるノードは、メソッドが呼び出される要素の子である必要があります。`replaceChild`と`insertBefore`はどちらも、最初の引数として_新しい_ノードを想定していることに注意してください。

ノードの作成

ドキュメント内のすべての画像(`<img>`タグ)を、画像の代替テキスト表現を指定する`alt`属性に含まれるテキストに置き換えるスクリプトを作成したいとします。これには、画像を削除するだけでなく、置き換える新しいテキストノードを追加することも含まれます。

<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では、ノードに任意の属性を設定できます。これは、ドキュメントに zusätzliche Informationen を保存できるため便利です。通常のオブジェクトプロパティとして使用できないカスタム属性を読み取ったり変更したりするには、`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);
  // → 19
  console.log("offsetHeight:", para.offsetHeight);
  // → 25
</script>

画面上の要素の正確な位置を見つける最も効果的な方法は、`getBoundingClientRect`メソッドです。画面の左上を基準とした要素の側面のピクセル位置を示す、`top`、`bottom`、`left`、`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>

style属性には、1つ以上の_宣言_を含めることができます。宣言は、プロパティ(`color`など)と、それに続くコロンと値(`green`など)です。複数の宣言がある場合は、` "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>

名前の_カスケーディング_とは、複数のルールが組み合わされて要素の最終的なスタイルが生成されることを指します。この例では、`<strong>`タグのデフォルトのスタイル(`font-weight:bold`を指定)は、`<style>`タグのルールによってオーバーレイされ、`font-style`と`color`が追加されます。

複数のルールが同じプロパティに値を定義する場合、最も最近読み込まれたルールが優先され、適用されます。たとえば、<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 に設定されている場合、要素は通常のドキュメントフローから削除されます。つまり、スペースを占有しなくなり、他の要素と重なる可能性があります。topleft プロパティを使用して、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>

画像はページの中央に配置され、positionrelative に設定されています。画像を移動するために、画像の topleft スタイルを繰り返し更新します。

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

ループでDOMを更新するだけだと、ページがフリーズして画面に何も表示されません。ブラウザは、JavaScriptプログラムの実行中は画面表示を更新せず、ページとのインタラクションも許可しません。そのため、requestAnimationFrame が必要です。これにより、ブラウザは処理が完了したことを認識し、画面の更新やユーザー操作への応答など、ブラウザの処理を実行できます。

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

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

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

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

Diagram showing the use of cosine and sine to compute coordinates. A circle with radius 1 is shown with two points on it. The angle from the right side of the circle to the point, in radians, is used to compute the position of each point by using 'cos(angle)' for the horizontal distance from the center of the circle and sin(angle) for the vertical distance.

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

スタイルには通常、*単位*が必要であることに注意してください。この場合、数値に "px" を追加して、ピクセル単位でカウントしていることをブラウザに伝える必要があります(センチメートル、「ems」、または他の単位とは異なります)。これは忘れがちです。単位のない数値を使用すると、数値が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"#mountains" と共に使用してノードを見つけます。

タグ名による要素

document.getElementsByTagName メソッドは、指定されたタグ名を持つすべての子要素を返します。これを、ノードと文字列(タグ名)を引数に取り、指定されたタグ名を持つすべての子孫要素ノードを含む配列を返す関数として独自に実装してください。関数はドキュメント自体を処理する必要があります。querySelectorAll のようなメソッドを使用して作業を行うことはできません。

要素のタグ名を見つけるには、その 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 を加算することで得られます。これは、軌道の反対側に帽子を置くのに役立ちます。