DOM 操作

DOM 操作

¥DOM Manipulation

HTMLElement 类型探索

¥An exploration into the HTMLElement type

自标准化以来的 20 多年里,JavaScript 取得了长足的进步。虽然在 2020 年,JavaScript 可以用于服务器、数据科学甚至物联网设备,但重要的是要记住它最流行的用例:网络浏览器。

¥In the 20+ years since its standardization, JavaScript has come a very long way. While in 2020, JavaScript can be used on servers, in data science, and even on IoT devices, it is important to remember its most popular use case: web browsers.

网站由 HTML 和/或 XML 文档组成。这些文件是静态的,它们不会改变。文档对象模型 (DOM) 是浏览器实现的一种编程接口,可使静态网站正常运行。DOM API 可用于更改文档结构、样式和内容。API 非常强大,围绕它开发了无数前端框架(jQuery、React、Angular 等),使动态网站的开发变得更加容易。

¥Websites are made up of HTML and/or XML documents. These documents are static, they do not change. The Document Object Model (DOM) is a programming interface implemented by browsers to make static websites functional. The DOM API can be used to change the document structure, style, and content. The API is so powerful that countless frontend frameworks (jQuery, React, Angular, etc.) have been developed around it to make dynamic websites even easier to develop.

TypeScript 是 JavaScript 的类型化超集,它为 DOM API 提供了类型定义。这些定义在任何默认的 TypeScript 项目中都很容易获得。在 lib.dom.d.ts 的 20,000 多行定义中,有一个脱颖而出:HTMLElement。这种类型是使用 TypeScript 进行 DOM 操作的主干。

¥TypeScript is a typed superset of JavaScript, and it ships type definitions for the DOM API. These definitions are readily available in any default TypeScript project. Of the 20,000+ lines of definitions in lib.dom.d.ts, one stands out among the rest: HTMLElement. This type is the backbone for DOM manipulation with TypeScript.

你可以浏览 DOM 类型定义 的源代码

¥You can explore the source code for the DOM type definitions

基本示例

¥Basic Example

给定一个简化的 index.html 文件:

¥Given a simplified index.html file:

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>

让我们探索一个将 <p>Hello, World!</p> 元素添加到 #app 元素的 TypeScript 脚本。

¥Let’s explore a TypeScript script that adds a <p>Hello, World!</p> element to the #app element.

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

¥After compiling and running the index.html page, the resulting HTML will be:

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

Document 接口

¥The Document Interface

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

¥The first line of the TypeScript code uses a global variable document. Inspecting the variable shows it is defined by the Document interface from the lib.dom.d.ts file. The code snippet contains calls to two methods, getElementById and createElement.

Document.getElementById

该方法的定义如下:

¥The definition for this method is as follows:

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

向它传递一个元素 ID 字符串,它将返回 HTMLElementnull。该方法引入了最重要的类型之一,HTMLElement。它作为所有其他元素接口的基础接口。例如,代码示例中的 p 变量是 HTMLParagraphElement 类型。另外,请注意此方法可以返回 null。这是因为该方法无法确定运行前是否能够实际找到指定的元素。在代码片段的最后一行中,新的可选链接运算符用于调用 appendChild

¥Pass it an element id string and it will return either HTMLElement or null. This method introduces one of the most important types, HTMLElement. It serves as the base interface for every other element interface. For example, the p variable in the code example is of type HTMLParagraphElement. Also, take note that this method can return null. This is because the method can’t be certain pre-runtime if it will be able to actually find the specified element or not. In the last line of the code snippet, the new optional chaining operator is used to call appendChild.

Document.createElement

此方法的定义是(我省略了已弃用的定义):

¥The definition for this method is (I have omitted the deprecated definition):

ts
createElement<K extends keyof HTMLElementTagNameMap>(tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K];
createElement(tagName: string, options?: ElementCreationOptions): HTMLElement;

这是一个重载的函数定义。第二个重载最简单,并且与 getElementById 方法非常相似。将任何 string 传递给它,它将返回一个标准的 HTMLElement。此定义使开发者能够创建唯一的 HTML 元素标签。

¥This is an overloaded function definition. The second overload is simplest and works a lot like the getElementById method does. Pass it any string and it will return a standard HTMLElement. This definition is what enables developers to create unique HTML element tags.

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

¥For example document.createElement('xyz') returns a <xyz></xyz> element, clearly not an element that is specified by the HTML specification.

对于那些感兴趣的人,你可以使用 document.getElementsByTagName 与自定义标签元素进行交互

¥For those interested, you can interact with custom tag elements using the document.getElementsByTagName

对于 createElement 的第一个定义,它使用了一些高级泛型模式。最好将其分解为块,从泛型表达式开始:<K extends keyof HTMLElementTagNameMap>。该表达式定义了一个泛型参数 K,它被限制为接口 HTMLElementTagNameMap 的键。映射接口包含每个指定的 HTML 标签名称及其对应的类型接口。例如,这里是前 5 个映射值:

¥For the first definition of createElement, it is using some advanced generic patterns. It is best understood broken down into chunks, starting with the generic expression: <K extends keyof HTMLElementTagNameMap>. This expression defines a generic parameter K that is constrained to the keys of the interface HTMLElementTagNameMap. The map interface contains every specified HTML tag name and its corresponding type interface. For example here are the first 5 mapped values:

ts
interface HTMLElementTagNameMap {
"a": HTMLAnchorElement;
"abbr": HTMLElement;
"address": HTMLElement;
"applet": HTMLAppletElement;
"area": HTMLAreaElement;
...
}

有些元素不具有唯一属性,因此它们只返回 HTMLElement,但其他类型确实具有唯一属性和方法,因此它们返回其特定接口(将从 HTMLElement 扩展或实现 HTMLElement)。

¥Some elements do not exhibit unique properties and so they just return HTMLElement, but other types do have unique properties and methods so they return their specific interface (which will extend from or implement HTMLElement).

现在,对于 createElement 定义的其余部分:(tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K]。第一个参数 tagName 被定义为泛型参数 K。TypeScript 解释器足够聪明,可以从这个参数中推断出泛型参数。这意味着开发者在使用该方法时不必指定泛型参数;传递给 tagName 参数的任何值都将被推断为 K,因此可以在定义的其余部分中使用。这正是发生的事情;返回值 HTMLElementTagNameMap[K] 采用 tagName 参数并使用它返回相应的类型。此定义是代码片段中的 p 变量如何获取 HTMLParagraphElement 类型的方式。如果代码是 document.createElement('a'),那么它将是 HTMLAnchorElement 类型的元素。

¥Now, for the remainder of the createElement definition: (tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K]. The first argument tagName is defined as the generic parameter K. The TypeScript interpreter is smart enough to infer the generic parameter from this argument. This means that the developer does not have to specify the generic parameter when using the method; whatever value is passed to the tagName argument will be inferred as K and thus can be used throughout the remainder of the definition. This is exactly what happens; the return value HTMLElementTagNameMap[K] takes the tagName argument and uses it to return the corresponding type. This definition is how the p variable from the code snippet gets a type of HTMLParagraphElement. And if the code was document.createElement('a'), then it would be an element of type HTMLAnchorElement.

Node 接口

¥The Node interface

document.getElementById 函数返回一个 HTMLElementHTMLElement 接口扩展 Element 接口,Element 接口扩展 Node 接口。此原型扩展允许所有 HTMLElements 使用标准方法的子集。在代码片段中,我们使用 Node 接口上定义的属性将新的 p 元素附加到网站。

¥The document.getElementById function returns an HTMLElement. HTMLElement interface extends the Element interface which extends the Node interface. This prototypal extension allows for all HTMLElements to utilize a subset of standard methods. In the code snippet, we use a property defined on the Node interface to append the new p element to the website.

Node.appendChild

代码片段的最后一行是 app?.appendChild(p)。前面的 document.getElementById 部分详细说明了此处使用了可选的链接运算符,因为 app 在运行时可能为 null。appendChild 方法定义如下:

¥The last line of the code snippet is app?.appendChild(p). The previous, document.getElementById, section detailed that the optional chaining operator is used here because app can potentially be null at runtime. The appendChild method is defined by:

ts
appendChild<T extends Node>(newChild: T): T;

此方法的工作方式与 createElement 方法类似,因为泛型参数 T 是从 newChild 参数推断出来的。T 被限制到另一个基本接口 Node

¥This method works similarly to the createElement method as the generic parameter T is inferred from the newChild argument. T is constrained to another base interface Node.

childrenchildNodes 的区别

¥Difference between children and childNodes

此前,本文档详细介绍了 HTMLElement 接口扩展自 ElementElement 扩展自 Node。在 DOM API 中有一个子元素的概念。例如在下面的 HTML 中,p 标签是 div 元素的子元素

¥Previously, this document details the HTMLElement interface extends from Element which extends from Node. In the DOM API there is a concept of children elements. For example in the following HTML, the p tags are children of the div element

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 节点。

¥After capturing the div element, the children prop will return an HTMLCollection list containing the HTMLParagraphElements. The childNodes property will return a similar NodeList list of nodes. Each p tag will still be of type HTMLParagraphElements, but the NodeList can contain additional HTML nodes that the HTMLCollection list cannot.

通过删除 p 标记之一修改 HTML,但保留文本。

¥Modify the HTML by removing one of the p tags, but keep the text.

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 节点。NodeListtext 部分是包含文本 TypeScript! 的字面 Nodechildren 列表不包含此 Node,因为它不被视为 HTMLElement

¥See how both lists change. children now only contains the <p>Hello, World</p> element, and the childNodes contains a text node rather than two p nodes. The text part of the NodeList is the literal Node containing the text TypeScript!. The children list does not contain this Node because it is not considered an HTMLElement.

querySelectorquerySelectorAll 方法

¥The querySelector and querySelectorAll methods

这两种方法都是获取符合更独特约束集的 dom 元素列表的好工具。它们在 lib.dom.d.ts 中定义为:

¥Both of these methods are great tools for getting lists of dom elements that fit a more unique set of constraints. They are defined in lib.dom.d.ts as:

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 只实现了以下属性和方法:lengthitem(index)forEach((value, key, parent) => void) 和数字索引。此外,此方法返回元素列表,而不是节点,这是 NodeList.childNodes 方法返回的内容。虽然这可能看起来不一致,但请注意接口 Element 是从 Node 扩展而来的。

¥The querySelectorAll definition is similar to getElementsByTagName, except it returns a new type: NodeListOf. This return type is essentially a custom implementation of the standard JavaScript list element. Arguably, replacing NodeListOf<E> with E[] would result in a very similar user experience. NodeListOf only implements the following properties and methods: length, item(index), forEach((value, key, parent) => void), and numeric indexing. Additionally, this method returns a list of elements, not nodes, which is what NodeList was returning from the .childNodes method. While this may appear as a discrepancy, take note that interface Element extends from Node.

要查看这些方法的实际效果,请将现有代码修改为:

¥To see these methods in action modify the existing code to:

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

有兴趣了解更多吗?

¥Interested in learning more?

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

¥The best part about the lib.dom.d.ts type definitions is that they are reflective of the types annotated in the Mozilla Developer Network (MDN) documentation site. For example, the HTMLElement interface is documented by this HTMLElement page on MDN. These pages list all available properties, methods, and sometimes even examples. Another great aspect of the pages is that they provide links to the corresponding standard documents. Here is the link to the W3C Recommendation for HTMLElement.

资料来源:

¥Sources: