DOM 操作
DOM 操作
探索 HTMLElement 类型
自标准化以来的 20 多年里,JavaScript 已经取得了长足的进步。虽然在 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 文件:
<!DOCTYPE html>
<html lang="en">
<head><title>TypeScript Dom Manipulation</title></head>
<body>
<div id="app"></div>
<!-- 假设 index.js 是 index.ts 的编译输出 -->
<script src="index.js"></script>
</body>
</html>让我们探索一个 TypeScript 脚本,它向 #app 元素添加一个 <p>Hello, World!</p> 元素。
// 1. 使用 id 属性选择 div 元素
const app = document.getElementById("app");
// 2. 以编程方式创建一个新的 <p></p> 元素
const p = document.createElement("p");
// 3. 添加文本内容
p.textContent = "Hello, World!";
// 4. 将 p 元素附加到 div 元素
app?.appendChild(p);编译并运行 index.html 页面后,生成的 HTML 将是:
<div id="app">
<p>Hello, World!</p>
</div>Document 接口
TypeScript 代码的第一行使用了一个全局变量 document。检查该变量可知,它由 lib.dom.d.ts 文件中的 Document 接口定义。代码片段调用了两个方法:getElementById 和 createElement。
Document.getElementById
该方法的定义如下:
getElementById(elementId: string): HTMLElement | null;传入一个元素 id 字符串,它将返回 HTMLElement 或 null。此方法引入了最重要的类型之一:HTMLElement。它是其他所有元素接口的基础接口。例如,代码示例中的 p 变量的类型是 HTMLParagraphElement。另外,请注意此方法可能返回 null。这是因为该方法在运行时无法确定是否能够实际找到指定的元素。在代码片段的最后一行,使用了新的可选链操作符来调用 appendChild。
Document.createElement
该方法的定义是(我省略了已弃用的定义):
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>。该表达式定义了一个泛型参数 K,它被约束到接口 HTMLElementTagNameMap 的键。这个映射接口包含了每个指定的 HTML 标签名称及其对应的类型接口。例如,以下是前 5 个映射值:
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 接口。这种原型链扩展允许所有 HTMLElement 使用一部分标准方法。在代码片段中,我们使用了 Node 接口上定义的属性,将新的 p 元素附加到网站。
Node.appendChild
代码片段的最后一行是 app?.appendChild(p)。前面的 document.getElementById 部分详细说明了此处使用可选链操作符是因为 app 在运行时可能为 null。appendChild 方法的定义是:
appendChild<T extends Node>(newChild: T): T;该方法与 createElement 方法类似,泛型参数 T 是从 newChild 参数推断出来的。T 被约束到另一个基础接口 Node。
children 和 childNodes 的区别
前面提到,HTMLElement 接口扩展自 Element,Element 扩展自 Node。在 DOM API 中,有一个子元素的概念。例如,在下面的 HTML 中,p 标签是 div 元素的子元素。
<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 节点。
修改 HTML,删除其中一个 p 标签,但保留文本。
<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 中定义为:
/**
* 返回匹配选择器的节点的第一个后代元素。
*/
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;
/**
* 返回匹配选择器的节点的所有后代元素。
*/
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) 和数字索引。此外,此方法返回一个元素列表,而不是节点列表,而 .childNodes 方法返回的是 NodeList。虽然这看起来可能不一致,但请注意 Element 接口扩展自 Node。
要查看这些方法的实际应用,请将现有代码修改为:
<ul>
<li>First :)</li>
<li>Second!</li>
<li>Third times a charm.</li>
</ul>;
const first = document.querySelector("li"); // 返回第一个 li 元素
const all = document.querySelectorAll("li"); // 返回所有 li 元素的列表有兴趣了解更多?
lib.dom.d.ts 类型定义的最好之处在于,它们反映了 Mozilla 开发者网络(MDN)文档网站上注释的类型。例如,MDN 上的 HTMLElement 页面 记录了 HTMLElement 接口。这些页面列出了所有可用的属性、方法,有时甚至还有示例。这些页面的另一个优点是,它们提供了指向相应标准文档的链接。这是 W3C 关于 HTMLElement 的建议 的链接。
来源: