DOM 操作

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 property
const app = document.getElementById("app");
// 2. Create a new <p></p> element programmatically
const p = document.createElement("p");
// 3. Add the text content
p.textContent = "Hello, World!";
// 4. Append the p element to the div element
app?.appendChild(p);

编译并运行 index.html 页面后,得到的 HTML 将会是

html
<div id="app">
<p>Hello, World!</p>
</div>

Document 接口

TypeScript 代码的第一行使用了全局变量 document。检查该变量可知,它是由 lib.dom.d.ts 文件中的 Document 接口定义的。代码片段中包含了对两个方法 getElementByIdcreateElement 的调用。

Document.getElementById

该方法的定义如下

ts
getElementById(elementId: string): HTMLElement | null;

传入一个元素 ID 字符串,它将返回 HTMLElementnull。此方法引入了最重要的类型之一: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 方法非常相似。传入任何 string,它都会返回一个标准的 HTMLElement。正是这个定义使开发者能够创建独特的 HTML 元素标签。

例如,document.createElement('xyz') 返回一个 <xyz></xyz> 元素,这显然不是 HTML 规范所指定的元素。

有兴趣的话,你可以使用 document.getElementsByTagName 与自定义标签元素进行交互。

对于 createElement 的第一个定义,它使用了一些高级的泛型模式。最好将其拆解开来理解,从泛型表达式 <K extends keyof HTMLElementTagNameMap> 开始。此表达式定义了一个受约束HTMLElementTagNameMap 接口键的泛型参数 K。该映射接口包含了每个指定的 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 函数返回一个 HTMLElementHTMLElement 接口继承自 Element 接口,而 Element 又继承自 Node 接口。这种基于原型的继承允许所有 HTMLElement 使用标准方法的一个子集。在代码片段中,我们使用 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

childrenchildNodes 的区别

前面提到过,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 属性将返回一个包含 HTMLParagraphElementHTMLCollection 列表。而 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! 的字面量 Nodechildren 列表不包含此 Node,因为它不被视为 HTMLElement

querySelectorquerySelectorAll 方法

这两个方法都是获取符合更独特约束条件的 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 仅实现了以下属性和方法:lengthitem(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 element
const all = document.querySelectorAll("li"); // returns the list of all li elements

想了解更多?

lib.dom.d.ts 类型定义最棒的地方在于,它们反映了 Mozilla 开发者网络 (MDN) 文档站点中注释的类型。例如,HTMLElement 接口在 MDN 的 HTMLElement 页面 上有详细记录。这些页面列出了所有可用的属性、方法,有时甚至包含示例。这些页面的另一个优点是它们提供了指向相应标准文档的链接。这是 W3C HTMLElement 推荐标准 的链接。

来源

TypeScript 文档是一个开源项目。欢迎提交 Pull Request 来帮助我们改进这些页面 ❤

此页面的贡献者
EAEthan Arrowood (6)
OTOrta Therox (5)
SASafei Ashraf (1)
MMateusz (1)
IOIván Ovejero (1)
6+

最后更新:2026 年 3 月 27 日