【JS 口袋書】第 9 章:使用 JS 操作 HTML 元素

前端小智發表於2019-10-23

作者:valentinogagliardi

譯者:前端小智

來源:github


文件物件模型(DOM)

JS 有很多地方讓我們們吐槽,但沒那麼糟糕。作為一種在瀏覽器中執行的指令碼語言,它對於處理web頁面非常有用。在本中,我們將看到我們有哪些方法來互動和修改HTML文件及其元素。但首先讓我們來揭開文件物件模型的神祕面紗。

文件物件模型是一個基本概念,它是我們們在瀏覽器中所做的一切工作的基礎。但那到底是什麼? 當我們們訪問一個 web 頁面時,瀏覽器會指出如何解釋每個 HTML 元素。這樣它就建立了 HTML 文件的虛擬表示,並儲存在記憶體中。HTML 頁面被轉換成樹狀結構,每個 HTML 元素都變成一個葉子,連線到父分支。考慮這個簡單的HTML 頁面:

<!DOCTYPE html>
<html lang="en">
<head>
    <title>A super simple title!</title>
</head>
<body>
<h1>A super simple web page!</h1>
</body>
</html
複製程式碼

當瀏覽器掃描上面的 HTML 時,它建立了一個文件物件模型,它是HTML結構的映象。在這個結構的頂部有一個 document 也稱為根元素,它包含另一個元素:htmlhtml 元素包含一個 headhead 又有一個 title。然後是含有 h1body。每個 HTML 元素由特定型別(也稱為介面)表示,並且可能包含文字或其他巢狀元素

document (HTMLDocument)
  |
  | --> html (HTMLHtmlElement)
          |  
          | --> head (HtmlHeadElement)
          |       |
          |       | --> title (HtmlTitleElement)
          |                | --> text: "A super simple title!"
          |
          | --> body (HtmlBodyElement)
          |       |
          |       | --> h1 (HTMLHeadingElement)
          |              | --> text: "A super simple web page!"
複製程式碼

每個 HTML 元素都是從 Element 派生而來的,但是它們中的很大一部分是進一步專門化的。我們們可以檢查原型,以查明元素屬於什麼“種類”。例如,h1 元素是 HTMLHeadingElement

document.quertSelector('h1').__proto__
// 輸出: HTMLHeadingElement
複製程式碼

HTMLHeadingElement 又是 HTMLElement 的“後代”

document.querySelector('h1').__proto__.__proto__

// Output: HTMLElement
複製程式碼

Element 是一個通用性非常強的基類,所有 Document 物件下的物件都繼承自它。這個介面描述了所有相同種類的元素所普遍具有的方法和屬性。一些介面繼承自 Element 並且增加了一些額外功能的介面描述了具體的行為。例如, HTMLElement 介面是所有 HTML 元素的基本介面,而 SVGElement 介面是所有 SVG 元素的基礎。大多數功能是在這個類的更深層級(hierarchy)的介面中被進一步制定的。

在這一點上(特別是對於初學者),documentwindow 之間可能有些混淆。window 指的是瀏覽器,而 document 指的是當前的 HTML 頁面。window 是一個全域性物件,可以從瀏覽器中執行的任何 JS 程式碼直接訪問它。它不是 JS 的“原生”物件,而是由瀏覽器本身公開的。window 有很多屬性和方法,如下所示:

window.alert('Hello world'); // Shows an alert
window.setTimeout(callback, 3000); // Delays execution
window.fetch(someUrl); // makes XHR requests
window.open(); // Opens a new tab
window.location; // Browser location
window.history; // Browser history
window.navigator; // The actual device
window.document; // The current page
複製程式碼

由於這些屬性是全域性屬性,因此也可以省略 window

alert('Hello world'); // Shows an alert
setTimeout(callback, 3000); // Delays execution
fetch(someUrl); // makes XHR requests
open(); // Opens a new tab
location; // Browser location
history; // Browser history
navigator; // The actual device
document; // The current page
複製程式碼

你應該已經熟悉其中的一些方法,例如 setTimeout()window.navigator,它可以獲取當前瀏覽器使用的語言:

if (window.navigator) {
  var lang = window.navigator.language;
  if (lang === "en-US") {
    // show something
  }

  if (lang === "it-IT") {
    // show something else
  }
}
複製程式碼

要了解更多 window 上的方法,請檢視MDN文件。在下一節中,我們們深入地研究一下 DOM

節點、元素 和DOM 操作

document 介面有許多實用的方法,比如 querySelector(),它是用於選擇當前 HTML 頁面內的任何 HTML 元素:

document.querySelector('h1');
複製程式碼

window 表示當前視窗的瀏覽器,下面的指令與上面的相同:

window.document.querySelector('h1');
複製程式碼

不管怎樣,下面的語法更常見,在下一節中我們們將大量使用這種形式:

document.methodName();
複製程式碼

除了 querySelector() 用於選擇 HTML 元素之外,還有很多更有用的方法

// 返回單個元素
document.getElementById('testimonials'); 

// 返回一個 HTMLCollection
document.getElementsByTagName('p'); 

// 返回一個節點列表
document.querySelectorAll('p');
複製程式碼

我們們不僅可以選 擇HTML 元素,還可以互動和修改它們的內部狀態。例如,希望讀取或更改給定元素的內部內容:

// Read or write
document.querySelector('h1').innerHtml; // Read
document.querySelector('h1').innerHtml = ''; // Write! Ouch!
複製程式碼

DOM 中的每個 HTML 元素也是一個**“節點”**,實際上我們們可以像這樣檢查節點型別:

document.querySelector('h1').nodeType;
複製程式碼

上述結果返回 1,表示是 Element 型別的節點的識別符號。我們們還可以檢查節點名:

document.querySelector('h1').nodeName;

"H1"
複製程式碼

這裡,節點名以大寫形式返回。通常我們處理 DOM 中的四種型別的節點

  • document: 根節點(nodeType 9)

  • 型別為Element的節點:實際的HTML標籤(nodeType 1),例如 <p><div>

  • 型別屬性的節點:每個HTML元素的屬性(屬性)

  • Text 型別的節點:元素的實際文字內容(nodeType 3)

由於元素是節點,節點可以有屬性(properties )(也稱為attributes),我們們可以檢查和操作這些屬性:

// 返回 true 或者 false
document.querySelector('a').hasAttribute('href');

// 返回屬性文字內容,或 null
document.querySelector('a').getAttribute('href');

// 設定給定的屬性
document.querySelector('a').setAttribute('href', 'someLink');
複製程式碼

前面我們說過 DOM 是一個類似於樹的結構。這種特性也反映在 HTML 元素上。每個元素都可能有父元素和子元素,我們可以通過檢查元素的某些屬性來檢視它們:

// 返回一個 HTMLCollection
document.children;

// 返回一個節點列表
document.childNodes;

// 返回一個節點
document.querySelector('a').parentNode;

// 返回HTML元素
document.querySelector('a').parentElement;
複製程式碼

瞭解瞭如何選擇和查詢 HTML 元素。那建立元素又是怎麼樣?為了建立 Element 型別的新節點,原生 DOM API 提供了 createElement 方法:

var heading = document.createElement('h1');
複製程式碼

使用 createTextNode 建立文字節點:

var text = document.createTextNode('Hello world');
複製程式碼

通過將 text 附加到新的 HTML 元素中,可以將這兩個節點組合在一起。最後,還可以將heading元素附加到根文件中:

var heading = document.createElement('h1');
var text = document.createTextNode('Hello world');
heading.appendChild(text);
document.body.appendChild(heading);
複製程式碼

還可以使用 remove() 方法從 DOM 中刪除節點。 在元素上呼叫方法,該節點將從頁面中消失:

document.querySelector('h1').remove();
複製程式碼

這些是我們們開始在瀏覽器中使用 JS 操作 DOM 所需要知道的全部內容。在下一節中,我們們將靈活地使用 DOM,但首先要繞個彎,因為我們們還需要討論**“DOM事件”**。

DOM 和事件

DOM 元素是很智慧的。它們不僅可以包含文字和其他 HTML 元素,還可以“發出”和響應“事件”。瀏覽任何網站並開啟瀏覽器控制檯。使用以下命令選擇一個元素:

document.querySelector('p')
複製程式碼

看看這個屬性

document.querySelector('p').onclick
複製程式碼

它是什麼型別:

typeof document.querySelector('p').onclick // "object"
複製程式碼

"object"! 為什麼它被稱為“onclick”? 憑一點直覺我們可以想象它是元素上的某種神奇屬性,能夠對點選做出反應? 完全正確。

如果你感興趣,可以檢視任何 HTML 元素的原型鏈。會發現每個元素也是一個 Element,而元素又是一個節點,而節點又是一個EventTarget。可以使用 instanceof 來驗證這一點。

document.querySelector('p') instanceof EventTarget // true
複製程式碼

我很樂意稱 EventTarget 為所有 HTML 元素之父,但在JS中沒有真正的繼承,它更像是任何 HTML 元素都可以看到另一個連線物件的屬性。因此,任何 HTML 元素都具有與 EventTarget 相同的特性:釋出事件的能力

但事件到底是什麼呢?以 HTML 按鈕為例。如果你點選它,那就是一個事件。有了這個.onclick物件,我們們可以註冊事件,只要元素被點選,它就會執行。傳遞給事件的函式稱為**“事件監聽器”“事件控制程式碼”**。

事件和監聽

在 DOM 中註冊事件監聽器有三種方法。第一種形式比較陳舊,應該避免,因為它耦合了邏輯操作和標籤

<!-- 不好的方式 -->
<button onclick="console.log('clicked')">喜歡,就點點我</button>
複製程式碼

第二個選項依賴於以事件命名的物件。例如,我們們可以通過在物件.onclick上註冊一個函式來監聽click事件:

document.querySelector("button").onclick = handleClick;

function handleClick() {
  console.log("Clicked!");
}
複製程式碼

此語法更加簡潔,是內聯處理程式的不錯替代方案。 還有另一種基於addEventListener的現代形式:

document.querySelector("button").addEventListener("click", handleClick);

function handleClick() {
  console.log("Clicked!");
}
複製程式碼

就我個人而言,我更喜歡這種形式,但如果想爭取最大限度的瀏覽器相容性,請使用 .on 方式。現在我們們已經有了一 個 HTML 元素和一個事件監聽器,接著進一步研究一下 DOM 事件。

事件物件、事件預設值和事件冒泡

作為事件處理程式傳遞的每個函式預設接收一個名為“event”的物件

var button = document.querySelector("button");
button.addEventListener("click", handleClick);

function handleClick() {
  console.log(event);
}
複製程式碼

它可以直接在函式體中使用,但是在我的程式碼中,我更喜歡將它顯式地宣告為引數:

function handleClick(event) {
  console.log(event);
}
複製程式碼

事件物件是**“必須要有的”,因為我們們可以通過呼叫事件上的一些方法來控制事件的行為。事件實際上有特定的特徵,尤其是“預設”“冒泡”**。考慮一 個HTML 連結。使用以下標籤建立一個名為click-event.html的新HTML檔案:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Click event</title>
</head>
<body>
<div>
    <a href="/404.html">click me!</a>
</div>
</body>
<script src="click-event.js"></script>
</html>
複製程式碼

在瀏覽器中執行該檔案並嘗試單擊連結。它將跳轉到一個404的介面。連結上單擊事件的預設行為是轉到href屬性中指定的實際頁面。但如果我告訴你有辦法阻止預設值呢?輸入preventDefault(),該方法可用於事件物件。使用以下程式碼建立一個名為click-event.js的新檔案:

var button = document.querySelector("a");
button.addEventListener("click", handleClick);

function handleClick(event) {
  event.preventDefault();
}
複製程式碼

在瀏覽器中重新整理頁面並嘗試現在單擊連結:它不會跳轉了。因為我們們阻止了瀏覽器的“事件預設” 連結不是預設操作的惟一HTML 元素,表單具有相同的特性。

當 HTML 元素巢狀在另一個元素中時,還會出現另一個有趣的特性。考慮以下 HTML

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Nested events</title>
</head>
<body>
<div id="outer">
    I am the outer div
    <div id="inner">
        I am the inner div
    </div>
</div>
</body>
<script src="nested-events.js"></script>
</html>
複製程式碼

和下面的 JS 程式碼:

// nested-events.js

var outer = document.getElementById('inner');
var inner = document.getElementById('outer');

function handleClick(event){
    console.log(event);
}

inner.addEventListener('click', handleClick);
outer.addEventListener('click', handleClick);
複製程式碼

有兩個事件監聽器,一個用於外部 div,一個用於內部 div。準確地點選內部div,你會看到:

【JS 口袋書】第 9 章:使用 JS 操作 HTML 元素

兩個事件物件被列印。這就是事件冒泡在起作用。它看起來像是瀏覽器行為中的一個 bug,使用 stopPropagation() 方法可以禁用,這也是在事件物件上呼叫的

//
function handleClick(event) {
  event.stopPropagation();
  console.log(event);
}
///
複製程式碼

儘管看起來實現效果很差,但在註冊過多事件監聽器確實對效能不利的情況下,冒泡還是會讓人眼前一亮。 考慮以下示例:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Event bubbling</title>
</head>
<body>
<ul>
    <li>one</li>
    <li>two</li>
    <li>three</li>
    <li>four</li>
    <li>five</li>
</ul>
</body>
<script src="event-bubbling.js"></script>
</html>
複製程式碼

如果要兼聽列表的點選事件,需要在列表中註冊多少事件監聽器?答案是:一個。只需要一個在ul上註冊的偵聽器就可以截獲任何li上的所有單擊:

// event-bubbling.js

var ul = document.getElementsByTagName("ul")[0];

function handleClick(event) {
  console.log(event);
}

ul.addEventListener("click", handleClick);
複製程式碼

可以看到,事件冒泡是提高效能的一種實用方法。實際上,對瀏覽器來說,註冊事件監聽器是一項昂貴的操作,而且在出現大量元素列表的情況下,可能會導致效能損失。

用 JS 生成表格

現在我們們開始編碼。給定一個物件陣列,希望動態生成一個HTML 表格。HTML 表格由 <table> 元素表示。每個表也可以有一個頭部,由 <thead> 元素表示。頭部可以有一個或多個行 <tr>,每個行都有一個單元格,由一個 <th>元 素表示。如下所示:

<table>
    <thead>
    <tr>
        <th>name</th>
        <th>height</th>
        <th>place</th>
    </tr>
    </thead>
    <!-- more stuff here! -->
</table>
複製程式碼

不止這樣,大多數情況下,每個表都有一個主體,由 <tbody> 定義,而 <tbody> 又包含一組行<tr>。每一行都可以有包含實際資料的單元格。表單元格由<td>定義。完整如下所示:

<table>
    <thead>
    <tr>
        <th>name</th>
        <th>height</th>
        <th>place</th>
    </tr>
    </thead>
    <tbody>
    <tr>
        <td>Monte Falco</td>
        <td>1658</td>
        <td>Parco Foreste Casentinesi</td>
    </tr>
    <tr>
        <td>Monte Falterona</td>
        <td>1654</td>
        <td>Parco Foreste Casentinesi</td>
    </tr>
    </tbody>
</table>
複製程式碼

現在的任務是從 JS 物件陣列開始生成表格。首先,建立一個名為build-table.html的新檔案,內容如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Build a table</title>
</head>
<body>
<table>
<!-- here goes our data! -->
</table>
</body>
<script src="build-table.js"></script>
</html>
複製程式碼

在相同的資料夾中建立另一個名為build-table.js的檔案,並使用以下陣列開始:

"use strict";

var mountains = [
  { name: "Monte Falco", height: 1658, place: "Parco Foreste Casentinesi" },
  { name: "Monte Falterona", height: 1654, place: "Parco Foreste Casentinesi" },
  { name: "Poggio Scali", height: 1520, place: "Parco Foreste Casentinesi" },
  { name: "Pratomagno", height: 1592, place: "Parco Foreste Casentinesi" },
  { name: "Monte Amiata", height: 1738, place: "Siena" }
];
複製程式碼

考慮這個表格。首先,我們們需要一個 <thead>

document.createElement('thead')
複製程式碼

這沒有錯,但是仔細檢視MDN的表格文件會發現一個有趣的細節。<table> 是一個 HTMLTableElement,它還包含有趣方法。其中最有用的是HTMLTableElement.createTHead(),它可以幫助建立我們們需要的 <thead>

首先,編寫一個生成 thead 標籤的函式 generateTableHead

function generateTableHead(table) {
  var thead = table.createTHead();
}
複製程式碼

該函式接受一個選擇器並在給定的表上建立一個 <thead>:

function generateTableHead(table) {
  var thead = table.createTHead();
}

var table = document.querySelector("table");

generateTableHead(table);
複製程式碼

在瀏覽器中開啟 build-table.html:什麼都沒有.但是,如果開啟瀏覽器控制檯,可以看到一個新的 <thead> 附加到表。

接著填充 header 內容。首先要在裡面建立一行。還有另一個方法可以提供幫助:HTMLTableElement.insertRow()。有了這個,我們們就可以擴充套件方法了:

function generateTableHead (table) {
  var thead = table,createThead();
  var row = thead.insertRow();
}
複製程式碼

此時,我們可以生成我們的行。通過檢視源陣列,可以看到其中的任何物件都有我們們需要資訊:

var mountains = [
  { name: "Monte Falco", height: 1658, place: "Parco Foreste Casentinesi" },
  { name: "Monte Falterona", height: 1654, place: "Parco Foreste Casentinesi" },
  { name: "Poggio Scali", height: 1520, place: "Parco Foreste Casentinesi" },
  { name: "Pratomagno", height: 1592, place: "Parco Foreste Casentinesi" },
  { name: "Monte Amiata", height: 1738, place: "Siena" }
];
複製程式碼

這意味著我們們可以將另一個引數傳遞給我們的函式:一個遍歷以生成標題單元格的陣列:

function generateTableHead(table, data) {
  var thead = table.createTHead();
  var row = thead.insertRow();
  for (var i = 0; i < data.length; i++) {
    var th = document.createElement("th");
    var text = document.createTextNode(data[i]);
    th.appendChild(text);
    row.appendChild(th);
  }
}
複製程式碼

不幸的是,沒有建立單元格的原生方法,因此求助於document.createElement("th")。同樣值得注意的是,document.createTextNode(data[i])用於建立文字節點,appendChild()用於向每個標記新增新元素。

當以這種方式建立和操作元素時,我們稱之為**“命令式”** DOM 操作。現代前端庫通過支援**“宣告式”**方法來解決這個問題。我們可以宣告需要哪些 HTML 元素,而不是一步一步地命令瀏覽器,其餘的由庫處理。

回到我們的程式碼,可以像下面這樣使用第一個函式

var table = document.querySelector("table");
var data = Object.keys(mountains[0]);
generateTableHead(table, data);
複製程式碼

現在我們可以進一步生成實際表的資料。下一個函式將實現一個類似於generateTableHead的邏輯,但這一次我們們需要兩個巢狀的for迴圈。在最內層的迴圈中,使用另一種原生方法來建立一系列td。方法是HTMLTableRowElement.insertCell()。在前面建立的檔案中新增另一個名為generateTable的函式

function generateTable(table, data) {
  for (var i = 0; i < data.length; i++) {
    var row = table.insertRow();
    for (var key in data[i]) {
      var cell = row.insertCell();
      var text = document.createTextNode(data[i][key]);
      cell.appendChild(text);
    }
  }
}
複製程式碼

呼叫上面的函式,將 HTML表 和物件陣列作為引數傳遞:

generateTable(table, mountains);
複製程式碼

我們們深入研究一下 generateTable 的邏輯。引數 data 是一個與 mountains 相對應的陣列。最外層的 for 迴圈遍歷陣列併為每個元素建立一行:

function generateTable(table, data) {
  for (var i = 0; i < data.length; i++) {
    var row = table.insertRow();
    // omitted for brevity
  }
}
複製程式碼

最內層的迴圈遍歷任何給定物件的每個鍵,併為每個物件建立一個包含鍵值的文字節點

function generateTable(table, data) {
  for (var i = 0; i < data.length; i++) {
    var row = table.insertRow();
    for (var key in data[i]) {
      // inner loop
      var cell = row.insertCell();
      var text = document.createTextNode(data[i][key]);
      cell.appendChild(text);
    }
  }
}
複製程式碼

最終程式碼:

var mountains = [
  { name: "Monte Falco", height: 1658, place: "Parco Foreste Casentinesi" },
  { name: "Monte Falterona", height: 1654, place: "Parco Foreste Casentinesi" },
  { name: "Poggio Scali", height: 1520, place: "Parco Foreste Casentinesi" },
  { name: "Pratomagno", height: 1592, place: "Parco Foreste Casentinesi" },
  { name: "Monte Amiata", height: 1738, place: "Siena" }
];

function generateTableHead(table, data) {
  var thead = table.createTHead();
  var row = thead.insertRow();
  for (var i = 0; i < data.length; i++) {
    var th = document.createElement("th");
    var text = document.createTextNode(data[i]);
    th.appendChild(text);
    row.appendChild(th);
  }
}

function generateTable(table, data) {
  for (var i = 0; i < data.length; i++) {
    var row = table.insertRow();
    for (var key in data[i]) {
      var cell = row.insertCell();
      var text = document.createTextNode(data[i][key]);
      cell.appendChild(text);
    }
  }
}
複製程式碼

其中呼叫:

var table = document.querySelector("table");
var data = Object.keys(mountains[0]);
generateTable(table, mountains);
generateTableHead(table, data);
複製程式碼

執行結果:

【JS 口袋書】第 9 章:使用 JS 操作 HTML 元素

當然,我們們的方法還可以該進,下個章節將介紹。

總結

DOM 是 web 瀏覽器儲存在記憶體中的 web 頁面的虛擬副本。DOM 操作是指從 DOM 中建立、修改和刪除 HTML 元素的操作。在過去,我們們常常依賴 jQuery 來完成更簡單的任務,但現在原生 API 已經足夠成熟,可以讓 jQuery 過時了。另一方面,jQuery 不會很快消失,但是每個 JS 開發人員都必須知道如何使用原生 API 操作 DOM。

這樣做的理由有很多,額外的庫增加了載入時間和 JS 應用程式的大小。更不用說 DOM 操作在面試中經常出現。

DOM 中每個可用的 HTML 元素都有一個介面,該介面公開一定數量的屬性和方法。當你對使用何種方法有疑問時,參考MDN文件。操作 DOM 最常用的方法是 document.createElement() 用於建立新的 HTML 元素,document.createTextNode() 用於在 DOM 中建立文字節點。最後但同樣重要的是 .appendchild(),用於將新的 HTML 元素或文字節點附加到現有元素。

HTML 元素還能夠發出事件,也稱為DOM事件。值得注意的事件為“click”“submit”“drag”“drop”等等。DOM 事件有一些特殊的行為,比如“預設”和冒泡。

JS 開發人員可以利用這些屬性,特別是對於事件冒泡,這些屬性對於加速 DOM 中的事件處理非常有用。雖然對原生 API 有很好的瞭解是件好事,但是現代前端庫提供了不容置疑的好處。用 AngularReactVue 來構建一個大型的JS應用程式確實是可行的。

程式碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug

**原文:**github.com/valentinoga…

交流

乾貨系列文章彙總如下,覺得不錯點個Star,歡迎 加群 互相學習。

github.com/qq449245884…

因為篇幅的限制,今天的分享只到這裡。如果大家想了解更多的內容的話,可以去掃一掃每篇文章最下面的二維碼,然後關注我們們的微信公眾號,瞭解更多的資訊和有價值的內容。

clipboard.png

每次整理文章,一般都到2點才睡覺,一週4次左右,挺苦的,還望支援,給點鼓勵

【JS 口袋書】第 9 章:使用 JS 操作 HTML 元素

相關文章