DOM 操作
探討 HTMLElement
類型
自標準化以來 20 多年以來,JavaScript 已走過漫漫長路。雖然在 2020 年,JavaScript 可用於伺服器、資料科學,甚至 IoT 裝置,但請務必記住其最受歡迎的用例:網頁瀏覽器。
網站由 HTML 和/或 XML 文件組成。這些文件是靜態的,不會改變。文件物件模型 (DOM) 是由瀏覽器實作的程式設計介面,用於使靜態網站具有功能。DOM API 可用於變更文件結構、樣式和內容。該 API 非常強大,因此已開發出無數前端框架(jQuery、React、Angular 等)來讓動態網站更易於開發。
TypeScript 是 JavaScript 的型別超集,而且它會提供 DOM API 的型別定義。在任何預設 TypeScript 專案中都可以輕易取得這些定義。在 lib.dom.d.ts 中超過 20,000 行的定義中,有一個定義脫穎而出:HTMLElement
。此型別是使用 TypeScript 進行 DOM 操作的骨幹。
您可以探索 DOM 型別定義 的原始程式碼
基本範例
給定簡化的 index.html 檔案
html
<!DOCTYPE html><html lang="en"><head><title>TypeScript Dom Manipulation</title></head><body><div id="app"></div><!-- Assume index.js is the compiled output of index.ts --><script src="index.js"></script></body></html>
讓我們探討一個 TypeScript 程式碼,它會將 <p>Hello, World!</p>
元素新增到 #app
元素。
ts
// 1. Select the div element using the id propertyconst app = document.getElementById("app");// 2. Create a new <p></p> element programmaticallyconst p = document.createElement("p");// 3. Add the text contentp.textContent = "Hello, World!";// 4. Append the p element to the div elementapp?.appendChild(p);
編譯並執行 index.html 頁面後,產生的 HTML 如下
html
<div id="app"><p>Hello, World!</p></div>
Document
介面
TypeScript 程式碼的第一行使用全域變數 document
。檢查變數會顯示它是由 lib.dom.d.ts 檔案中的 Document
介面所定義。程式碼片段包含對兩個方法的呼叫,getElementById
和 createElement
。
Document.getElementById
此方法的定義如下
ts
getElementById(elementId: string): HTMLElement | null;
傳遞一個元素 id 字串,它將會傳回 HTMLElement
或 null
。此方法引入了最重要的類型之一,HTMLElement
。它作為每個其他元素介面的基礎介面。例如,程式碼範例中的 p
變數是 HTMLParagraphElement
類型。另外,請注意此方法可能會傳回 null
。這是因為此方法在執行前無法確定它是否真的能找到指定的元素。在程式碼片段的最後一行,新的 選擇性串接 運算子用於呼叫 appendChild
。
Document.createElement
此方法的定義為(我已省略 已棄用 的定義)
ts
createElement<K extends keyof HTMLElementTagNameMap>(tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K];createElement(tagName: string, options?: ElementCreationOptions): HTMLElement;
這是一個重載函數定義。第二個重載是最簡單的,且運作方式非常類似於 getElementById
方法。傳遞任何 字串
,它將會傳回標準的 HTMLElement。此定義讓開發人員能夠建立獨特的 HTML 元素標籤。
例如,document.createElement('xyz')
會傳回 <xyz></xyz>
元素,顯然不是 HTML 規範所指定的元素。
對於有興趣的人,你可以使用
document.getElementsByTagName
與自訂標籤元素互動
對於 createElement
的第一個定義,它使用了一些進階的泛型模式。最好將其分解成區塊來理解,從泛型表達式開始:<K extends keyof HTMLElementTagNameMap>
。此表達式定義了一個泛型參數 K
,它 受限 於介面 HTMLElementTagNameMap
的鍵。此對應介面包含每個指定的 HTML 標籤名稱及其對應的類型介面。例如,以下是前 5 個對應值
ts
interface HTMLElementTagNameMap {"a": HTMLAnchorElement;"abbr": HTMLElement;"address": HTMLElement;"applet": HTMLAppletElement;"area": HTMLAreaElement;...}
有些元素不具有獨特屬性,因此只會傳回 HTMLElement
,但其他類型具有獨特屬性和方法,因此會傳回其特定介面(會從 HTMLElement
延伸或實作)。
現在,針對 createElement
定義的其餘部分:(tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K]
。第一個引數 tagName
定義為泛型參數 K
。TypeScript 解譯器足夠聰明,可以從這個引數推斷泛型參數。這表示開發人員在使用這個方法時不必指定泛型參數;傳遞給 tagName
引數的任何值都會被推斷為 K
,因此可以在定義的其餘部分使用。這正是發生的事情;傳回值 HTMLElementTagNameMap[K]
會採用 tagName
引數並使用它來傳回對應的類型。這個定義就是程式碼片段中的 p
變數取得 HTMLParagraphElement
類型的緣由。如果程式碼是 document.createElement('a')
,那麼它就會是 HTMLAnchorElement
類型的元素。
Node
介面
document.getElementById
函式會傳回 HTMLElement
。HTMLElement
介面會延伸 Element
介面,而 Element
介面會延伸 Node
介面。這個原型延伸允許所有 HTMLElements
使用標準方法的子集。在程式碼片段中,我們使用在 Node
介面上定義的屬性將新的 p
元素附加到網站。
Node.appendChild
程式碼片段的最後一行是 app?.appendChild(p)
。先前的 document.getElementById
區段詳細說明了這裡使用選擇性串連運算子,因為 app
在執行階段可能會為 null。appendChild
方法是由下列定義的
ts
appendChild<T extends Node>(newChild: T): T;
這個方法的運作方式類似於 createElement
方法,因為泛型參數 T
是從 newChild
引數推斷出來的。T
受限於另一個基礎介面 Node
。
children
和 childNodes
的差異
先前,此文件詳細說明 HTMLElement
介面延伸自 Element
,而 Element
延伸自 Node
。在 DOM API 中,有一個概念稱為子項元素。例如在下列 HTML 中,p
標籤是 div
元素的子項
tsx
<div><p>Hello, World</p><p>TypeScript!</p></div>;const div = document.getElementsByTagName("div")[0];div.children;// HTMLCollection(2) [p, p]div.childNodes;// NodeList(2) [p, p]
擷取 div
元素後,children
屬性會傳回一個包含 HTMLParagraphElements
的 HTMLCollection
清單。childNodes
屬性會傳回一個類似的 NodeList
節點清單。每個 p
標籤仍會是 HTMLParagraphElements
類型,但 NodeList
可以包含 HTMLCollection
清單無法包含的其他HTML 節點。
移除其中一個 p
標籤,但保留文字,修改 HTML。
tsx
<div><p>Hello, World</p>TypeScript!</div>;const div = document.getElementsByTagName("div")[0];div.children;// HTMLCollection(1) [p]div.childNodes;// NodeList(2) [p, text]
觀察兩個清單如何變更。children
現在只包含 <p>Hello, World</p>
元素,而 childNodes
包含一個 text
節點,而不是兩個 p
節點。NodeList
的 text
部分是包含文字 TypeScript!
的字面 Node
。children
清單不包含此 Node
,因為它不被視為 HTMLElement
。
querySelector
和 querySelectorAll
方法
這兩個方法都是取得符合更獨特約束條件的 DOM 元素清單的絕佳工具。它們在 lib.dom.d.ts 中定義為
ts
/*** Returns the first element that is a descendant of node that matches selectors.*/querySelector<K extends keyof HTMLElementTagNameMap>(selectors: K): HTMLElementTagNameMap[K] | null;querySelector<K extends keyof SVGElementTagNameMap>(selectors: K): SVGElementTagNameMap[K] | null;querySelector<E extends Element = Element>(selectors: string): E | null;/*** Returns all element descendants of node that match selectors.*/querySelectorAll<K extends keyof HTMLElementTagNameMap>(selectors: K): NodeListOf<HTMLElementTagNameMap[K]>;querySelectorAll<K extends keyof SVGElementTagNameMap>(selectors: K): NodeListOf<SVGElementTagNameMap[K]>;querySelectorAll<E extends Element = Element>(selectors: string): NodeListOf<E>;
querySelectorAll
定義類似於 getElementsByTagName
,但它會傳回新的類型:NodeListOf
。此傳回類型基本上是標準 JavaScript 清單元素的客製化實作。可以說,用 E[]
取代 NodeListOf<E>
會產生非常類似的使用者體驗。NodeListOf
只實作下列屬性和方法:length
、item(index)
、forEach((value, key, parent) => void)
,以及數字索引。此外,此方法會傳回 元素 清單,而非 節點,而這是 NodeList
從 .childNodes
方法傳回的內容。雖然這看起來像是有出入,但請注意 Element
介面會從 Node
延伸。
若要查看這些方法的實際運作,請將現有程式碼修改為
tsx
<ul><li>First :)</li><li>Second!</li><li>Third times a charm.</li></ul>;const first = document.querySelector("li"); // returns the first li elementconst all = document.querySelectorAll("li"); // returns the list of all li elements
有興趣進一步了解嗎?
lib.dom.d.ts 類型定義最棒的地方在於,它們反映了 Mozilla Developer Network (MDN) 文件網站中註解的類型。例如,HTMLElement
介面是由 MDN 上的 HTMLElement 頁面 記錄的。這些頁面會列出所有可用的屬性、方法,有時甚至還有範例。這些頁面的另一個優點是,它們會提供連結到對應標準文件的連結。以下是 HTMLElement 的 W3C 建議 的連結。
來源