DOM 操作
深入探索 HTMLElement 型別
在 JavaScript 標準化後的 20 多年裡,它取得了長足的進步。雖然在 2020 年,JavaScript 已經被廣泛應用於伺服器、資料科學,甚至是物聯網裝置中,但重要的是要記住它最流行的使用場景:Web 瀏覽器。
網站是由 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>
讓我們探索一個向 #app 元素新增 <p>Hello, World!</p> 元素的 TypeScript 指令碼。
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 方法非常相似。傳入任何 string,它都會返回一個標準的 HTMLElement。正是這個定義使開發者能夠建立獨特的 HTML 元素標籤。
例如,document.createElement('xyz') 返回一個 <xyz></xyz> 元素,這顯然不是 HTML 規範所指定的元素。
有興趣的話,你可以使用
document.getElementsByTagName與自定義標籤元素進行互動。
對於 createElement 的第一個定義,它使用了一些高階的泛型模式。最好將其拆解開來理解,從泛型表示式 <K extends keyof HTMLElementTagNameMap> 開始。此表示式定義了一個受約束於 HTMLElementTagNameMap 介面鍵的泛型引數 K。該對映介面包含了每個指定的 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 介面。這種基於原型的繼承允許所有 HTMLElement 使用標準方法的一個子集。在程式碼片段中,我們使用 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 中存在一個子元素 (children) 的概念。例如在以下 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 屬性將返回一個包含 HTMLParagraphElement 的 HTMLCollection 列表。而 childNodes 屬性將返回一個類似的 NodeList 節點列表。每個 p 標籤的型別仍然是 HTMLParagraphElement,但 NodeList 可以包含 HTMLCollection 列表所無法包含的其他 HTML 節點。
修改 HTML,移除其中一個 p 標籤,但保留文字。
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 列表元素的自定義實現。可以說,將 NodeListOf<E> 替換為 E[] 會帶來非常相似的使用者體驗。NodeListOf 僅實現了以下屬性和方法:length、item(index)、forEach((value, key, parent) => void) 以及數字索引。此外,此方法返回的是元素列表,而不是節點列表,而這正是 .childNodes 方法返回 NodeList 的情況。雖然這看起來是一個差異,但請注意 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 開發者網路 (MDN) 文件站點中註釋的型別。例如,HTMLElement 介面在 MDN 的 HTMLElement 頁面 上有詳細記錄。這些頁面列出了所有可用的屬性、方法,有時甚至包含示例。這些頁面的另一個優點是它們提供了指向相應標準文件的連結。這是 W3C HTMLElement 推薦標準 的連結。
來源