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><!-- Assume index.js is the compiled output of index.ts --><script src="index.js"></script></body></html>
让我们来看一个 TypeScript 脚本,它向 #app 元素添加一个 <p>Hello, World!</p> 元素。
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>。这个表达式定义了一个泛型参数 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 接口,而后者继承 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,而后者继承自 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 部分就是字面量 Node,其中包含文本 TypeScript!。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 页面。这些页面列出了所有可用的属性和方法,有时甚至还有示例。这些页面的另一个亮点是提供了指向相应标准文档的链接。下面是 W3C 的 HTMLElement 建议标准链接。
来源: