2022 年 4 月 30 日

修改文件

DOM 修改是建立「動態」網頁的關鍵。

我們將在此了解如何「動態」建立新元素,以及修改現有的網頁內容。

範例:顯示訊息

我們將透過一個範例來示範。我們將在網頁上新增一則訊息,其外觀比 alert 更好看。

以下是它的外觀

<style>
.alert {
  padding: 15px;
  border: 1px solid #d6e9c6;
  border-radius: 4px;
  color: #3c763d;
  background-color: #dff0d8;
}
</style>

<div class="alert">
  <strong>Hi there!</strong> You've read an important message.
</div>

這是 HTML 範例。現在讓我們使用 JavaScript 建立相同的 div(假設樣式已經在 HTML/CSS 中)。

建立元素

要建立 DOM 節點,有兩種方法

document.createElement(tag)

使用指定的標籤建立新的元素節點

let div = document.createElement('div');
document.createTextNode(text)

使用指定的文字建立新的文字節點

let textNode = document.createTextNode('Here I am');

我們大多數時候需要建立元素節點,例如訊息的 div

建立訊息

建立訊息 div 需要 3 個步驟

// 1. Create <div> element
let div = document.createElement('div');

// 2. Set its class to "alert"
div.className = "alert";

// 3. Fill it with the content
div.innerHTML = "<strong>Hi there!</strong> You've read an important message.";

我們已經建立了元素。但目前它只在名為 div 的變數中,尚未在頁面中。因此我們無法看到它。

插入方法

要讓 div 顯示,我們需要將它插入 document 中的某個位置。例如,插入由 document.body 參照的 <body> 元素中。

有一個特別的方法 append 可以做到這一點:document.body.append(div)

以下是完整的程式碼

<style>
.alert {
  padding: 15px;
  border: 1px solid #d6e9c6;
  border-radius: 4px;
  color: #3c763d;
  background-color: #dff0d8;
}
</style>

<script>
  let div = document.createElement('div');
  div.className = "alert";
  div.innerHTML = "<strong>Hi there!</strong> You've read an important message.";

  document.body.append(div);
</script>

我們在這裡對 document.body 呼叫 append,但我們可以在任何其他元素上呼叫 append 方法,將另一個元素放入其中。例如,我們可以透過呼叫 div.append(anotherElement) 將某些內容附加到 <div>

以下是更多插入方法,它們指定要插入的不同位置

  • node.append(...nodes or strings) – 在 node結尾附加節點或字串,
  • node.prepend(...nodes or strings) – 在 node開頭插入節點或字串,
  • node.before(...nodes or strings) – 在 node之前插入節點或字串,
  • node.after(...nodes or strings) – 在 node之後插入節點或字串,
  • node.replaceWith(...nodes or strings) – 使用指定的節點或字串取代 node

這些方法的引數是任意清單,其中包含要插入的 DOM 節點或文字字串(會自動變成文字節點)。

讓我們看看它們的實際應用。

以下是使用這些方法將項目新增到清單及其前後文字的範例

<ol id="ol">
  <li>0</li>
  <li>1</li>
  <li>2</li>
</ol>

<script>
  ol.before('before'); // insert string "before" before <ol>
  ol.after('after'); // insert string "after" after <ol>

  let liFirst = document.createElement('li');
  liFirst.innerHTML = 'prepend';
  ol.prepend(liFirst); // insert liFirst at the beginning of <ol>

  let liLast = document.createElement('li');
  liLast.innerHTML = 'append';
  ol.append(liLast); // insert liLast at the end of <ol>
</script>

以下是這些方法功能的視覺化圖片

因此最終清單將會是

before
<ol id="ol">
  <li>prepend</li>
  <li>0</li>
  <li>1</li>
  <li>2</li>
  <li>append</li>
</ol>
after

如前所述,這些方法可以在單一呼叫中插入多個節點和文字片段。

例如,這裡插入了一個字串和一個元素

<div id="div"></div>
<script>
  div.before('<p>Hello</p>', document.createElement('hr'));
</script>

請注意:文字是「作為文字」插入,而不是「作為 HTML」插入,並適當地跳脫字元,例如 <>

因此最終的 HTML 是

&lt;p&gt;Hello&lt;/p&gt;
<hr>
<div id="div"></div>

換句話說,字串是以安全的方式插入,就像 elem.textContent 所做的一樣。

因此,這些方法只能用於插入 DOM 節點或文字片段。

但是,如果我們想要像 elem.innerHTML 一樣「作為 html」插入 HTML 字串,讓所有標籤和內容都能正常運作,該怎麼辦?

insertAdjacentHTML/Text/Element

我們可以使用另一個相當通用的方法:elem.insertAdjacentHTML(where, html)

第一個參數是一個代碼字,指定相對於 elem 的插入位置。必須是下列其中一個

  • "beforebegin" – 在 elem 之前立即插入 html
  • "afterbegin" – 在 elem 中插入 html,在開頭,
  • "beforeend" – 在 elem 中插入 html,在結尾,
  • "afterend" – 在 elem 之後立即插入 html

第二個參數是一個 HTML 字串,會「作為 HTML」插入。

例如

<div id="div"></div>
<script>
  div.insertAdjacentHTML('beforebegin', '<p>Hello</p>');
  div.insertAdjacentHTML('afterend', '<p>Bye</p>');
</script>

…會導致

<p>Hello</p>
<div id="div"></div>
<p>Bye</p>

這就是我們可以將任意 HTML 附加到頁面上的方式。

以下是插入變體的圖片

我們可以輕易地注意到這張圖片與前一張圖片之間的相似性。插入點實際上是相同的,但這個方法會插入 HTML。

這個方法有兩個兄弟

  • elem.insertAdjacentText(where, text) – 語法相同,但會將 text 字串「作為文字」插入,而不是 HTML,
  • elem.insertAdjacentElement(where, elem) – 語法相同,但會插入一個元素。

它們主要存在於讓語法「統一」。在實務上,大部分時間只會使用 insertAdjacentHTML。因為對於元素和文字,我們有 append/prepend/before/after 方法 – 它們的寫法較短,而且可以插入節點/文字片段。

以下是顯示訊息的替代變體

<style>
.alert {
  padding: 15px;
  border: 1px solid #d6e9c6;
  border-radius: 4px;
  color: #3c763d;
  background-color: #dff0d8;
}
</style>

<script>
  document.body.insertAdjacentHTML("afterbegin", `<div class="alert">
    <strong>Hi there!</strong> You've read an important message.
  </div>`);
</script>

節點移除

要移除一個節點,有一個方法 node.remove()

讓我們讓我們的訊息在一秒後消失

<style>
.alert {
  padding: 15px;
  border: 1px solid #d6e9c6;
  border-radius: 4px;
  color: #3c763d;
  background-color: #dff0d8;
}
</style>

<script>
  let div = document.createElement('div');
  div.className = "alert";
  div.innerHTML = "<strong>Hi there!</strong> You've read an important message.";

  document.body.append(div);
  setTimeout(() => div.remove(), 1000);
</script>

請注意:如果我們想要將一個元素移動到另一個地方 – 沒有必要從舊的地方移除它。

所有的插入方法會自動從舊的地方移除節點。

例如,讓我們交換元素

<div id="first">First</div>
<div id="second">Second</div>
<script>
  // no need to call remove
  second.after(first); // take #second and after it insert #first
</script>

複製節點:cloneNode

如何插入另一個類似的訊息?

我們可以建立一個函式並將程式碼放在那裡。但另一個方法是複製現有的 div 並修改裡面的文字(如果需要)。

有時當我們有一個大型元素時,這可能會更快更簡單。

  • 呼叫 elem.cloneNode(true) 會建立一個元素的「深度」複製 – 包含所有屬性和子元素。如果我們呼叫 elem.cloneNode(false),那麼複製會在沒有子元素的情況下建立。

複製訊息的範例

<style>
.alert {
  padding: 15px;
  border: 1px solid #d6e9c6;
  border-radius: 4px;
  color: #3c763d;
  background-color: #dff0d8;
}
</style>

<div class="alert" id="div">
  <strong>Hi there!</strong> You've read an important message.
</div>

<script>
  let div2 = div.cloneNode(true); // clone the message
  div2.querySelector('strong').innerHTML = 'Bye there!'; // change the clone

  div.after(div2); // show the clone after the existing div
</script>

DocumentFragment

DocumentFragment 是一個特殊的 DOM 節點,用作傳遞節點清單的包裝器。

我們可以附加其他節點,但當我們將其插入某處時,會插入其內容。

例如,以下的 getListContent 會產生一個包含 <li> 項目的片段,稍後會插入到 <ul>

<ul id="ul"></ul>

<script>
function getListContent() {
  let fragment = new DocumentFragment();

  for(let i=1; i<=3; i++) {
    let li = document.createElement('li');
    li.append(i);
    fragment.append(li);
  }

  return fragment;
}

ul.append(getListContent()); // (*)
</script>

請注意,在最後一行 (*) 我們附加 DocumentFragment,但它會「融入」,因此產生的結構將會是

<ul>
  <li>1</li>
  <li>2</li>
  <li>3</li>
</ul>

DocumentFragment 很少被明確使用。如果我們可以回傳一個節點陣列,為什麼要附加到一種特殊類型的節點?改寫的範例

<ul id="ul"></ul>

<script>
function getListContent() {
  let result = [];

  for(let i=1; i<=3; i++) {
    let li = document.createElement('li');
    li.append(i);
    result.push(li);
  }

  return result;
}

ul.append(...getListContent()); // append + "..." operator = friends!
</script>

我們提到 DocumentFragment 主要是因為它有一些概念,例如 template 元素,我們會在稍後討論。

舊式的插入/移除方法

舊式
這些資訊有助於理解舊腳本,但不需要用於新的開發。

還有「舊式」的 DOM 處理方法,它們存在於歷史原因。

這些方法來自於非常久遠的時代。現在,沒有理由使用它們,因為現代方法,例如 appendprependbeforeafterremovereplaceWith,更具彈性。

我們在此列出這些方法的唯一原因是,你可以在許多舊腳本中找到它們

parentElem.appendChild(node)

node 附加為 parentElem 的最後一個子節點。

以下範例會在 <ol> 的結尾新增一個新的 <li>

<ol id="list">
  <li>0</li>
  <li>1</li>
  <li>2</li>
</ol>

<script>
  let newLi = document.createElement('li');
  newLi.innerHTML = 'Hello, world!';

  list.appendChild(newLi);
</script>
parentElem.insertBefore(node, nextSibling)

node 插入 parentElem 中的 nextSibling 之前。

以下程式碼會在第二個 <li> 之前插入一個新的清單項目

<ol id="list">
  <li>0</li>
  <li>1</li>
  <li>2</li>
</ol>
<script>
  let newLi = document.createElement('li');
  newLi.innerHTML = 'Hello, world!';

  list.insertBefore(newLi, list.children[1]);
</script>

若要將 newLi 插入為第一個元素,我們可以這樣做

list.insertBefore(newLi, list.firstChild);
parentElem.replaceChild(node, oldChild)

node 取代 parentElem 子節點中的 oldChild

parentElem.removeChild(node)

parentElem 中移除 node(假設 node 是其子節點)。

以下範例會從 <ol> 中移除第一個 <li>

<ol id="list">
  <li>0</li>
  <li>1</li>
  <li>2</li>
</ol>

<script>
  let li = list.firstElementChild;
  list.removeChild(li);
</script>

所有這些方法都會回傳已插入/移除的節點。換句話說,parentElem.appendChild(node) 會回傳 node。但通常不會使用回傳值,我們只會執行這個方法。

關於「document.write」的一句話

還有一個非常古老的方法可以將內容新增到網頁中:document.write

語法

<p>Somewhere in the page...</p>
<script>
  document.write('<b>Hello from JS</b>');
</script>
<p>The end</p>

呼叫 document.write(html) 會將 html 立即寫入頁面中。html 字串可以動態產生,因此具有一定的彈性。我們可以使用 JavaScript 建立一個完整的網頁並寫入。

此方法來自於沒有 DOM、沒有標準的時代… 真的非常古老。它仍然存在,因為有些腳本仍然在使用它。

在現代腳本中,我們很少看到它,因為它有以下重要的限制

document.write 呼叫只能在頁面載入時使用。

如果我們在載入後呼叫它,現有的文件內容將會被清除。

例如

<p>After one second the contents of this page will be replaced...</p>
<script>
  // document.write after 1 second
  // that's after the page loaded, so it erases the existing content
  setTimeout(() => document.write('<b>...By this.</b>'), 1000);
</script>

因此,與我們上面介紹的其他 DOM 方法不同,它在「載入後」階段無法使用。

這是缺點。

它也有優點。技術上來說,當瀏覽器正在讀取(「解析」)輸入的 HTML 時,如果呼叫 document.write 並寫入一些內容,瀏覽器會將其視為一開始就存在於 HTML 文字中。

因此,它的執行速度非常快,因為它不會修改 DOM。它會直接寫入頁面文字中,而 DOM 尚未建立。

因此,如果我們需要動態地將大量文字新增到 HTML 中,而且我們處於頁面載入階段,並且速度很重要,這可能會有所幫助。但實際上,這些需求很少會同時出現。而且我們通常在腳本中看到此方法,只是因為它們很舊。

摘要

  • 建立新節點的方法

    • document.createElement(tag) – 建立具有指定標籤的元素,
    • document.createTextNode(value) – 建立文字節點(很少使用),
    • elem.cloneNode(deep) – 複製元素,如果 deep==true 則連同所有子代一起複製。
  • 插入和移除

    • node.append(...nodes or strings) – 插入到 node 的最後面,
    • node.prepend(...nodes or strings) – 插入到 node 的最前面,
    • node.before(...nodes or strings) –- 插入到 node 的正前方,
    • node.after(...nodes or strings) –- 插入到 node 的正後方,
    • node.replaceWith(...nodes or strings) –- 替換 node
    • node.remove() –- 移除 node

    文字字串會「以文字形式」插入。

  • 還有一些「老派」的方法

    • parent.appendChild(node)
    • parent.insertBefore(node, nextSibling)
    • parent.removeChild(node)
    • parent.replaceChild(newElem, node)

    所有這些方法都會傳回 node

  • 如果 html 中有一些 HTML,elem.insertAdjacentHTML(where, html) 會根據 where 的值插入它

    • "beforebegin" – 在 elem 的正前方插入 html
    • "afterbegin" – 在 elem 中插入 html,在開頭,
    • "beforeend" – 在 elem 中插入 html,在結尾,
    • "afterend" – 在 elem 的正後方插入 html

    還有類似的函式,elem.insertAdjacentTextelem.insertAdjacentElement,可插入文字字串和元素,但很少使用。

  • 在頁面載入完成前附加 HTML

    • document.write(html)

    頁面載入後,此類呼叫會清除文件。大多出現在舊腳本中。

任務

重要性:5

我們有一個空的 DOM 元素 elem 和一個字串 text

下列哪 3 個指令會執行完全相同的動作?

  1. elem.append(document.createTextNode(text))
  2. elem.innerHTML = text
  3. elem.textContent = text

答案:1 和 3

這兩個指令都會將 text「作為文字」新增到 elem 中。

以下是一個範例

<div id="elem1"></div>
<div id="elem2"></div>
<div id="elem3"></div>
<script>
  let text = '<b>text</b>';

  elem1.append(document.createTextNode(text));
  elem2.innerHTML = text;
  elem3.textContent = text;
</script>
重要性:5

建立一個函式 clear(elem),用來移除元素中的所有內容。

<ol id="elem">
  <li>Hello</li>
  <li>World</li>
</ol>

<script>
  function clear(elem) { /* your code */ }

  clear(elem); // clears the list
</script>

首先,我們來看一下該如何做

function clear(elem) {
  for (let i=0; i < elem.childNodes.length; i++) {
      elem.childNodes[i].remove();
  }
}

這不會奏效,因為呼叫 remove() 會轉移集合 elem.childNodes,因此元素每次都從索引 0 開始。但 i 會增加,因此會跳過一些元素。

for..of 迴圈也會執行相同的動作。

正確的變體可以是

function clear(elem) {
  while (elem.firstChild) {
    elem.firstChild.remove();
  }
}

還有一個更簡單的方法可以執行相同的動作

function clear(elem) {
  elem.innerHTML = '';
}
重要性:1

在以下範例中,呼叫 table.remove() 會從文件中移除表格。

但如果你執行它,你會看到文字 "aaa" 仍然可見。

為什麼會這樣?

<table id="table">
  aaa
  <tr>
    <td>Test</td>
  </tr>
</table>

<script>
  alert(table); // the table, as it should be

  table.remove();
  // why there's still "aaa" in the document?
</script>

任務中的 HTML 不正確。這就是奇怪現象的原因。

瀏覽器必須自動修正它。但 <table> 內部可能沒有文字:根據規範,只允許特定於表格的標籤。因此瀏覽器會在 <table>之前顯示 "aaa"

現在很明顯,當我們移除表格時,它仍然存在。

透過使用瀏覽器工具探索 DOM,可以輕鬆回答這個問題。你會在 <table> 之前看到 "aaa"

HTML 標準詳細說明如何處理錯誤的 HTML,而瀏覽器的這種行為是正確的。

重要性:4

撰寫一個介面,用於根據使用者的輸入建立清單。

對於每個清單項目

  1. 使用 prompt 詢問使用者其內容。
  2. 使用它建立 <li> 並將其新增至 <ul>
  3. 持續進行,直到使用者取消輸入(按 Esc 或輸入空白)。

所有元素都應動態建立。

如果使用者輸入 HTML 標籤,應將其視為文字。

在新視窗中示範

請注意使用 textContent 來指定 <li> 內容。

在沙盒中開啟解答。

重要性:5

撰寫函式 createTree,從巢狀物件建立巢狀 ul/li 清單。

例如

let data = {
  "Fish": {
    "trout": {},
    "salmon": {}
  },

  "Tree": {
    "Huge": {
      "sequoia": {},
      "oak": {}
    },
    "Flowering": {
      "apple tree": {},
      "magnolia": {}
    }
  }
};

語法

let container = document.getElementById('container');
createTree(container, data); // creates the tree in the container

結果(樹狀結構)應如下所示

選擇兩種方法之一來解決此任務

  1. 建立樹狀結構的 HTML,然後指定給 container.innerHTML
  2. 建立樹狀結構節點,並使用 DOM 方法附加。

如果您能同時執行這兩項,那就太好了。

附註:樹狀結構不應有「額外的」元素,例如葉子的空白 <ul></ul>

開啟沙盒以執行任務。

遍歷物件最簡單的方法是使用遞迴。

  1. 使用 innerHTML 的解答.
  2. 使用 DOM 的解答.
重要性:5

有一個以巢狀 ul/li 組織的樹狀結構。

撰寫程式碼,將每個 <li> 的後代數量新增至其中。略過葉子(沒有子項目的節點)。

結果

開啟沙盒以執行任務。

若要將文字附加至每個 <li>,我們可以變更文字節點 data

在沙盒中開啟解答。

重要性:4

撰寫函式 createCalendar(elem, year, month)

呼叫應為指定的年/月建立日曆,並將其置於 elem 中。

日曆應為表格,其中一週為 <tr>,一天為 <td>。表格頂端應為 <th>,其中包含星期名稱:第一天應為星期一,以此類推,直到星期日。

例如,createCalendar(cal, 2012, 9) 應在元素 cal 中產生下列日曆

附註:對於此任務,產生日曆就已足夠,尚不需可按一下。

開啟沙盒以執行任務。

我們將表格建立為字串:"<table>...</table>",然後將其指定給 innerHTML

演算法

  1. 使用 <th> 和星期名稱建立表格標頭。
  2. 建立日期物件 d = new Date(year, month-1)。這是 month 的第一天(考慮到 JavaScript 中的月份從 0 開始,而不是 1)。
  3. 直到月份的第一天 d.getDay() 的前幾個儲存格可能會是空的。我們用 <td></td> 填入它們。
  4. 增加 d 中的天數:d.setDate(d.getDate()+1)。如果 d.getMonth() 尚未到下個月,則將新的儲存格 <td> 新增到日曆中。如果那是星期日,則新增換行 “</tr><tr>”
  5. 如果月份已結束,但表格列尚未填滿,則在其中新增空的 <td>,以使其成正方形。

在沙盒中開啟解答。

重要性:4

建立一個像這裡的彩色時鐘

使用 HTML/CSS 進行樣式設定,JavaScript 只更新元素中的時間。

開啟沙盒以執行任務。

首先,我們來製作 HTML/CSS。

時間的每個組成部分在自己的 <span> 中看起來會很棒

<div id="clock">
  <span class="hour">hh</span>:<span class="min">mm</span>:<span class="sec">ss</span>
</div>

我們也需要 CSS 來為它們上色。

update 函式將更新時鐘,每秒由 setInterval 呼叫

function update() {
  let clock = document.getElementById('clock');
  let date = new Date(); // (*)
  let hours = date.getHours();
  if (hours < 10) hours = '0' + hours;
  clock.children[0].innerHTML = hours;

  let minutes = date.getMinutes();
  if (minutes < 10) minutes = '0' + minutes;
  clock.children[1].innerHTML = minutes;

  let seconds = date.getSeconds();
  if (seconds < 10) seconds = '0' + seconds;
  clock.children[2].innerHTML = seconds;
}

在行 (*) 中,我們每次都檢查目前的日期。呼叫 setInterval 並不可靠:它們可能會延遲發生。

時鐘管理函式

let timerId;

function clockStart() { // run the clock
  if (!timerId) { // only set a new interval if the clock is not running
    timerId = setInterval(update, 1000);
  }
  update(); // (*)
}

function clockStop() {
  clearInterval(timerId);
  timerId = null; // (**)
}

請注意,呼叫 update() 不僅在 clockStart() 中排程,而且在行 (*) 中立即執行。否則,訪客必須等到 setInterval 的第一次執行。在那之前,時鐘將會是空的。

此外,只有在時鐘未執行時,才在 clockStart() 中設定新的間隔。否則,按開始按鈕多次將會設定多個同時的間隔。更糟的是,我們只會保留最後一個間隔的 timerID,失去對所有其他間隔的參考。然後,我們將永遠無法再次停止時鐘!請注意,當時鐘在行 (**) 中停止時,我們需要清除 timerID,以便可以透過執行 clockStart() 再次啟動它。

在沙盒中開啟解答。

重要性:5

撰寫程式碼,將 <li>2</li><li>3</li> 插入兩個 <li> 之間

<ul id="ul">
  <li id="one">1</li>
  <li id="two">4</li>
</ul>

當我們需要在某處插入一段 HTML 時,insertAdjacentHTML 最為合適。

解決方案

one.insertAdjacentHTML('afterend', '<li>2</li><li>3</li>');
重要性:5

有一個表格

<table>
<thead>
  <tr>
    <th>Name</th><th>Surname</th><th>Age</th>
  </tr>
</thead>
<tbody>
  <tr>
    <td>John</td><td>Smith</td><td>10</td>
  </tr>
  <tr>
    <td>Pete</td><td>Brown</td><td>15</td>
  </tr>
  <tr>
    <td>Ann</td><td>Lee</td><td>5</td>
  </tr>
  <tr>
    <td>...</td><td>...</td><td>...</td>
  </tr>
</tbody>
</table>

其中可能有多列。

撰寫程式碼,根據 "name" 欄位對其進行排序。

開啟沙盒以執行任務。

解決方案很短,但看起來可能有點棘手,因此我在這裡提供詳細的註解

let sortedRows = Array.from(table.tBodies[0].rows) // 1
  .sort((rowA, rowB) => rowA.cells[0].innerHTML.localeCompare(rowB.cells[0].innerHTML));

table.tBodies[0].append(...sortedRows); // (3)

逐步演算法

  1. 取得所有 <tr>,從 <tbody>
  2. 接著根據第一個 <td> (名稱欄位) 的內容進行排序。
  3. 現在透過 .append(...sortedRows) 以正確的順序插入節點。

我們不需要移除列元素,只要「重新插入」即可,它們會自動離開舊位置。

附註:在我們的案例中,表格中有一個明確的 <tbody>,但即使 HTML 表格沒有 <tbody>,DOM 結構中也總是會有。

在沙盒中開啟解答。

教學地圖

留言

留言前請先閱讀…
  • 如果您有建議要如何改進,請 提交 GitHub 議題 或提交拉取請求,而非留言。
  • 如果您無法理解文章中的某些內容,請說明。
  • 若要插入幾行程式碼,請使用 <code> 標籤;若要插入多行,請將其包覆在 <pre> 標籤中;若要插入超過 10 行,請使用沙盒 (plnkrjsbincodepen…)