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 类型定义 的源代码

基本示例

🌐 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>

让我们来看看一个 TypeScript 脚本,它将一个 <p>Hello, World!</p> 元素添加到 #app 元素中。

🌐 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

这个方法的定义是(我省略了 deprecated 定义):

🌐 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.

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

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

createElement 的第一个定义使用了一些高级泛型模式。将其分解成几个部分来理解效果最好,首先是泛型表达式:<K extends keyof HTMLElementTagNameMap>。该表达式定义了一个泛型参数 K,它被 约束 为接口 HTMLElementTagNameMap 的键。映射接口包含每个指定的 HTML 标签名称及其对应的类型接口。例如,这里是前五个映射的值:

🌐 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)。

🌐 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 接口继承自 Element,而 Element 又继承自 Node。在 DOM API 中,有一个子元素(children)的概念。例如在下面的 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 部分是字面值 Node,其中包含文本 TypeScript!children 列表不包含这个 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) 以及数字索引。此外,该方法返回的是 elements 列表,而不是 nodes,而 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 页面 中有文档说明。这些页面列出了所有可用的属性、方法,有时甚至还提供示例。页面的另一个优点是它们提供了对应标准文档的链接。这里是 HTMLElement 的 W3C 推荐文档 的链接。

🌐 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: