Skip to content
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待

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 文件:

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> 元素。

ts
// 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 将是:

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>。该表达式定义了一个泛型参数 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 函数返回一个 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 接口扩展自 ElementElement 扩展自 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 属性将返回一个包含 HTMLParagraphElementsHTMLCollection 列表。childNodes 属性将返回一个类似的节点 NodeList 列表。每个 p 标签仍然是 HTMLParagraphElements 类型,但 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
/**
 * 返回匹配选择器的节点的第一个后代元素。
 */
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 只实现以下属性和方法: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"); // 返回第一个 li 元素
const all = document.querySelectorAll("li"); // 返回所有 li 元素的列表

有兴趣了解更多?

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

来源: