一覧WB-10-1 · DOMとイベント

DOMの取得と更新

二件の作業一覧を用意し、選択結果の保持、文字列とHTML、属性と入力値、class、要素の追加・移動・削除を順に試します。最後に、同じHTMLへ置き換えても元の要素と同一にはならないことを確認します。

説明

HTMLを読み込んだブラウザは、要素や文字をノードの木として保持します。このDOMをJavaScriptで変更すると、表示も更新されます。文字列を書き換える操作、要素を移動する操作、別の要素に作り替える操作では、残る参照やイベント処理が異なります。

二件の作業一覧を用意し、選択結果の保持、文字列とHTML、属性と入力値、class、要素の追加・移動・削除を順に試します。最後に、同じHTMLへ置き換えても元の要素と同一にはならないことを確認します。

1要素と文字ノードの並び

作業一覧と入力欄を持つ文書を作り、一覧の子ノードを表示します。

(globalThis.doc=createFormDocument作業一覧の文書を作成('<!doctype html><html lang="ja"><head><style>body{background:#111;color:#eee;font:16px/1.6 system-ui;margin:16px}.done{text-decoration:line-through;color:#aaa}</style></head><body><h1>作業一覧</h1><ul id="tasks">\n<li id="one" class="task">下書き</li>\n<li id="two" class="task">確認</li>\n</ul><label>担当 <input id="owner" value="あおい"></label><p id="message"></p><div id="archive"></div></body></html>'),globalThis.list=doc.getElementById("tasks"),{children:[...list.children要素だけの子一覧].map(e=>e.id),nodes:[...list.childNodes文字も含む子ノード一覧].map(n=>({type:n.nodeType,name:n.nodeName,text:n.textContent}))})

childrenは要素二つだけを返します。childNodesには改行を含む文字ノードも入り、要素はnodeType=1、文字は3です。HTMLの見た目の行分けもDOMの一部になります。

2選択結果を保存する

同じ二件をquerySelectorAllとgetElementsByClassNameで取得しておきます。

(globalThis.fixed=list.querySelectorAllその時点の一致一覧(".task"),globalThis.live=list.getElementsByClassName変更に追随する一覧("task"),globalThis.first=doc.querySelector("#one"),{fixed:fixed.length,live:live.length,same:first===同じ要素かを比較doc.getElementById("one")})

二つの検索はいま二件を返します。querySelectorとgetElementByIdは、この例では同じ要素オブジェクトを返すためsameはtrueです。fixedとliveを保持し、追加後に件数がどうなるか確かめます。

3要素を追加した後の一覧

三件目を作り、末尾へ追加します。

(globalThis.third=doc.createElement("li")新しい要素,third.id="three",third.className="task",third.textContent="公開",list.append(third)末尾へ追加,{fixed:fixed.length以前に取得した一覧の件数,live:live.length,fresh:list.querySelectorAll(".task").length})

fixedは2のまま、liveと新しく検索した一覧は3になります。querySelectorAllのNodeListは取得時点の構成を保持します。保存した件数を最新状態だと思い込むと、追加要素の処理漏れが起こります。

4タグを文字として表示する

messageへ山括弧を含む文字列を設定します。

(globalThis.message=doc.getElementById("message"),message.textContent=文字列を設定"<strong>完了</strong> & 確認",{text:message.textContent,html:message.innerHTMLHTMLにした表現,children:message.children.length作られた要素数})

表示されるのはタグを含む文字列です。要素は増えず、HTMLへ直列化したinnerHTMLでは山括弧と&がエスケープされます。textContentは渡された値をHTMLとして解析しません。

5同じ場所へHTMLを設定する

固定のstrong要素をinnerHTMLで設定します。

(message.innerHTML=HTMLとして解析して置換"<strong>完了</strong> &amp; 確認",{text:message.textContent要素内の文字を取得,html:message.innerHTML,children:[...message.children].map(e=>e.localName)})

今度はstrong要素が作られ、&amp;は文字の&になります。HTMLを挿入するAPIはマークアップを解釈するため、外部の文字列を表示するだけならtextContentを選びます。固定の構造を組み立てる場合もcreateElementとtextContentを組み合わせられます。

6属性と現在の入力値

入力欄のvalueプロパティだけを変えます。

(globalThis.owner=doc.getElementById("owner"),owner.value="はる"現在の入力値を変更,{attribute:owner.getAttribute("value")HTML属性の値,default:owner.defaultValue初期値,current:owner.value現在値})

現在値は「はる」ですが、HTML属性とdefaultValueは「あおい」のままです。入力欄の現在値と初期値は別に保持されます。DOMをHTMLへ変換した文字列だけで、利用者が入力した最新値を取得できるとは限りません。

7classとdata属性を更新する

一件目を完了にし、作業番号をdata属性へ保存します。

(first.classList.add("done")既存classを残して追加,first.dataset.taskId="7"data-task-idを設定,{class:first.className,taskId:first.dataset.taskId,type:typeof first.dataset.taskId保存値の型,attribute:first.getAttribute("data-task-id")})

doneを加えても元のtaskは残り、CSSで取り消し線が付きます。dataset.taskIdはdata-task-idに対応し、取得値は文字列です。数値計算に使うなら数値への変換が必要になります。

8classの有無を明示する

完了classを外し、作業番号を数値に変換します。

(first.classList.toggle("done",false)doneを外した状態にする,{class:first.className,id:Number(first.dataset.taskId)文字列から数値へ,same:first===fixed[0]要素の同一性})

toggleの第二引数falseは「必ず外す」という指定です。単純なtoggleと違い、繰り返しても同じ状態になります。classが変わっても要素そのものは同じなので、保存したfirstとfixed[0]は一致します。

9既存要素を先頭へ移動する

三件目をprependで先頭へ移します。

(list.prepend(third)既存要素を先頭へ移動,{order:[...list.children].map(e=>e.id),count:list.children.length,same:list.firstElementChild===third移動前と同じ要素})

prependへ既存の要素を渡すと移動になります。複製は増えず、件数は3のままです。参照しているthirdも同じ要素を指し続けます。

10取り外した要素の参照

一件目を文書から外し、保持した参照を調べます。

(first.remove()親から取り外す,{connected:first.isConnected文書につながっているか,parent:first.parentElement,fixed:fixed.length,live:live.length,text:fixed[0].textContent保存済み参照から文字を取得})

firstは文書から外れ、親はnullです。それでも変数やfixedが参照しているため、要素の文字は取得できます。削除は参照先を消去する操作ではなく、親子関係を切り離す操作です。

11別の場所へ同じ要素を戻す

保管領域へfirstを追加します。

(doc.getElementById("archive").append(first)保持した要素を再接続,{connected:first.isConnected,parent:first.parentElement.id新しい親,list:live.length,all:doc.querySelectorAll文書全体を検索(".task").length})

同じ要素がarchive配下へ接続されます。listを起点とするliveは2件、文書全体の検索は3件です。検索の起点によって対象範囲が変わります。

12同じHTMLでも別の要素になる

二件目に処理を付け、一覧を同じHTML文字列で置き換えます。

(globalThis.old=doc.getElementById("two"),globalThis.hits=0,old.addEventListener元の要素へ処理を登録("click",()=>hits++),list.innerHTML=list.innerHTML同じHTMLから作り直す,doc.getElementById("two").click()新しい要素を起動,{same:old===doc.getElementById("two"),oldConnected:old.isConnected,hits})

画面の文字が同じでも要素は新しく作られます。古い要素へ登録したlistenerは新要素へ引き継がれず、hitsは0です。HTMLの文字列一致と要素オブジェクトの同一性は別の性質です。

13古い要素の処理は残る

取り外されたoldに対してclickを呼びます。

(old.click()元の要素を起動,{hits,connected:old.isConnected文書から外れたまま,newElement:doc.getElementById("two").textContent})

hitsは1になります。古い要素は文書から外れていても、参照と登録済みlistenerを保持しています。画面の更新方法によって、既存の要素を残すか作り替えるかが処理の継続にも影響します。

まとめ

DOMには要素だけでなく文字ノードもあり、検索結果には固定一覧と更新に追随する一覧があります。

文字列・HTML・入力値・属性は、それぞれのAPIが何を変更するか確認して扱います。

要素の移動・取外しと、HTMLからの作り直しでは、保持した参照とlistenerの行先が異なります。

操作履歴と解説を振り返れます。

ブラウザ / DOM・イベント

1
(globalThis.doc=createFormDocument('<!doctype html><html lang="ja"><head><style>body{background:#111;color:#eee;font:16px/1.6 system-ui;margin:16px}.done{text-decoration:line-through;color:#aaa}</style></head><body><h1>作業一覧</h1><ul id="tasks">\n<li id="one" class="task">下書き</li>\n<li id="two" class="task">確認</li>\n</ul><label>担当 <input id="owner" value="あおい"></label><p id="message"></p><div id="archive"></div></body></html>'),globalThis.list=doc.getElementById("tasks"),{children:[...list.children].map(e=>e.id),nodes:[...list.childNodes].map(n=>({type:n.nodeType,name:n.nodeName,text:n.textContent}))})
{
  "children": [
    "one",
    "two"
  ],
  "nodes": [
    {
      "type": 3,
      "name": "#text",
      "text": "\n"
    },
    {
      "type": 1,
      "name": "LI",
      "text": "下書き"
    },
    {
      "type": 3,
      "name": "#text",
      "text": "\n"
    },
    {
      "type": 1,
      "name": "LI",
      "text": "確認"
    },
    {
      "type": 3,
      "name": "#text",
      "text": "\n"
    }
  ]
}
2
(globalThis.fixed=list.querySelectorAll(".task"),globalThis.live=list.getElementsByClassName("task"),globalThis.first=doc.querySelector("#one"),{fixed:fixed.length,live:live.length,same:first===doc.getElementById("one")})
{
  "fixed": 2,
  "live": 2,
  "same": true
}
3
(globalThis.third=doc.createElement("li"),third.id="three",third.className="task",third.textContent="公開",list.append(third),{fixed:fixed.length,live:live.length,fresh:list.querySelectorAll(".task").length})
{
  "fixed": 2,
  "live": 3,
  "fresh": 3
}
4
(globalThis.message=doc.getElementById("message"),message.textContent="<strong>完了</strong> & 確認",{text:message.textContent,html:message.innerHTML,children:message.children.length})
{
  "text": "<strong>完了</strong> & 確認",
  "html": "&lt;strong&gt;完了&lt;/strong&gt; &amp; 確認",
  "children": 0
}
5
(message.innerHTML="<strong>完了</strong> &amp; 確認",{text:message.textContent,html:message.innerHTML,children:[...message.children].map(e=>e.localName)})
{
  "text": "完了 & 確認",
  "html": "<strong>完了</strong> &amp; 確認",
  "children": [
    "strong"
  ]
}
6
(globalThis.owner=doc.getElementById("owner"),owner.value="はる",{attribute:owner.getAttribute("value"),default:owner.defaultValue,current:owner.value})
{
  "attribute": "あおい",
  "default": "あおい",
  "current": "はる"
}
7
(first.classList.add("done"),first.dataset.taskId="7",{class:first.className,taskId:first.dataset.taskId,type:typeof first.dataset.taskId,attribute:first.getAttribute("data-task-id")})
{
  "class": "task done",
  "taskId": "7",
  "type": "string",
  "attribute": "7"
}
8
(first.classList.toggle("done",false),{class:first.className,id:Number(first.dataset.taskId),same:first===fixed[0]})
{
  "class": "task",
  "id": 7,
  "same": true
}
9
(list.prepend(third),{order:[...list.children].map(e=>e.id),count:list.children.length,same:list.firstElementChild===third})
{
  "order": [
    "three",
    "one",
    "two"
  ],
  "count": 3,
  "same": true
}
10
(first.remove(),{connected:first.isConnected,parent:first.parentElement,fixed:fixed.length,live:live.length,text:fixed[0].textContent})
{
  "connected": false,
  "parent": null,
  "fixed": 2,
  "live": 2,
  "text": "下書き"
}
11
(doc.getElementById("archive").append(first),{connected:first.isConnected,parent:first.parentElement.id,list:live.length,all:doc.querySelectorAll(".task").length})
{
  "connected": true,
  "parent": "archive",
  "list": 2,
  "all": 3
}
12
(globalThis.old=doc.getElementById("two"),globalThis.hits=0,old.addEventListener("click",()=>hits++),list.innerHTML=list.innerHTML,doc.getElementById("two").click(),{same:old===doc.getElementById("two"),oldConnected:old.isConnected,hits})
{
  "same": false,
  "oldConnected": false,
  "hits": 0
}
13
(old.click(),{hits,connected:old.isConnected,newElement:doc.getElementById("two").textContent})
{
  "hits": 1,
  "connected": false,
  "newElement": "確認"
}

演習完了