Skip to content
本页内容

DOM Manipulation

An exploration into the HTMLElement type

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

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

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

You can explore the source code for the DOM type definitions

Basic Example

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

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);
// 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);

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

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

The Document Interface

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;
getElementById(elementId: string): HTMLElement | null;

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 in order 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;
createElement<K extends keyof HTMLElementTagNameMap>(tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K];
createElement(tagName: string, options?: ElementCreationOptions): HTMLElement;

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.

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

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;
        ...
}
interface HTMLElementTagNameMap {
    "a": HTMLAnchorElement;
    "abbr": HTMLElement;
    "address": HTMLElement;
    "applet": HTMLAppletElement;
    "area": HTMLAreaElement;
        ...
}

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

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 actually 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. Which 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.

The Node interface

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

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;
appendChild<T extends Node>(newChild: T): T;

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.

Difference between children and childNodes

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

After capturing the div element, the children prop will return a 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.

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]
<div>
  <p>Hello, World</p>
  TypeScript!
</div>;

const div = document.getElementsByTagName("div")[0];

div.children;
// HTMLCollection(1) [p]

div.childNodes;
// NodeList(2) [p, text]

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.

The querySelector and querySelectorAll methods

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>;
/**
 * 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>;

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

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: