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
此方法的定義如下
tsgetElementById(elementId: string): HTMLElement | null;
傳遞一個元素 id 字串,它將會傳回 HTMLElement 或 null。此方法引入了最重要的類型之一,HTMLElement。它作為每個其他元素介面的基礎介面。例如,程式碼範例中的 p 變數是 HTMLParagraphElement 類型。另外,請注意此方法可能會傳回 null。這是因為此方法在執行前無法確定它是否真的能找到指定的元素。在程式碼片段的最後一行,新的 選擇性串接 運算子用於呼叫 appendChild。
Document.createElement
此方法的定義為(我已省略 已棄用 的定義)
tscreateElement<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 個對應值
tsinterface 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 方法是由下列定義的
tsappendChild<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 建議 的連結。
來源