第九章:浏览器环境:DOM 深度剖析

第九章:浏览器环境:DOM 深度剖析

摘要: 欢迎来到 JavaScript
应用最广泛、最核心的舞台——浏览器。在此前的章节中,我们已经完全掌握了 JavaScript
语言本身的内部原理。从本章开始,我们将把这些能力应用于与用户直接交互的界面。我们将深度剖析文档对象模型
(DOM)
,理解浏览器是如何将一份静态的 HTML
文档,转化为一个我们可以用代码动态操控的、活生生的对象树。您将学会如何精准地查找、遍历、修改、创建和删除页面上的任何元素,并掌握这一切操作背后的性能原理。


在本章中,我们将系统地学习 DOM 操作的每一个环节:

  1. 首先,我们将从 DOM 的核心概念 出发,理解其树形结构与节点类型。
  2. 接着,我们将学习如何 捕获和遍历 页面上的元素,这是所有操作的第一步。
  3. 然后,我们将掌握如何 修改元素的内容、属性与样式
  4. 紧接着,我们将学习如何 动态地创建和销毁元素,为页面赋予生命。
  5. 最后,我们将深入 DOM 的性能原理,学习如何编写高效、不卡顿的交互代码。

9.1. DOM 基础:文档树与节点

承上启下: 在我们能够用 JavaScript
“做”任何事之前,我们必须先理解我们操作的 对象 是什么。当浏览器加载一个 HTML
文件时,它做的不仅仅是显示文本。它在内存中进行了一项至关重要的工作:将这份纯文本的
HTML 代码,解析成一个 JavaScript
可以理解和操作的、结构化的对象模型。这个模型,就是 DOM。

9.1.1. DOM 到底是什么?

DOM (Document Object Model) 的核心思想是,将一份 HTML
文档表示为一个倒置的 树形结构 (Node Tree)

  • 文档的每一个部分——无论是整个 <html> 标签、一个 <p>
    元素、一段文本,甚至是一行注释——都被视为这个树上的一个 节点 (Node)
  • 这些节点之间存在着清晰的层级关系,就像一个家谱:<html> 是根节点,它有 head
    body 两个子节点;body 又可以有 div, p 等多个子节点。

通过这种方式,原本无序的文本标记,就被转换成了一个 JavaScript 可以通过标准 API
进行访问和修改的、严谨的对象集合。


9.1.2. Node 接口与节点类型

DOM 树中的所有节点,无论是什么类型,都继承自一个共同的 Node
接口。这意味着它们都共享一些通用的属性和方法。我们可以通过 nodeType
nodeName 这两个属性来区分不同类型的节点。

节点类型nodeType 常量nodeTypenodeName 返回值示例
元素节点 (Element)Node.ELEMENT_NODE1大写的标签名DIV, P, BODY
文本节点 (Text)Node.TEXT_NODE3#text<h1> 标签内的文字
注释节点 (Comment)Node.COMMENT_NODE8#comment``
文档节点 (Document)Node.DOCUMENT_NODE9`#document``document` 对象本身

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>

<body>
<div id="container">
Hello World
</div>
<script>
const container = document.getElementById("container");
// 容器: type=1, name=DIV
console.log(
`容器: type=${container.nodeType}, name=${container.nodeName}`,
);

const firstChild = container.firstChild;
// 第一个子节点: type=8, name=#text
console.log(
`第一个子节点: type=${firstChild.nodeType}, name=${firstChild.nodeName}`,
);
</script>
</body>
</html>
  • textContent: 这是一个非常实用的 Node
    属性,它会返回该节点及其所有后代节点中的 纯文本内容,并忽略所有 HTML 标签。

9.1.3. document 对象:一切的起点

document 对象是整个 DOM 树的根节点,也是 JavaScript
与页面交互的 唯一入口。它作为 window
对象的一个属性被全局提供,我们可以直接使用它来访问文档的任何部分。

document 对象提供了许多便捷的属性,用于快速访问文档中的关键节点和信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1. 访问文档的根元素 <html>
console.log("documentElement:", document.documentElement);

// 2. 访问 <head> 和 <body>
console.log("head:", document.head);
console.log("body:", document.body);

// 3. 读写文档标题
console.log("title:", document.title);
document.title = "New Page Title";
console.log("New title:", document.title);

// 4. 获取文档的完整 URL
console.log("URL:", document.URL);

// 5. 获取页面上的所有图片和表单集合 (返回 HTMLCollection)
console.log("Images count:", document.images.length);
console.log("Forms count:", document.forms.length);

9.2. 元素的查找与遍历

要操作页面,首先必须精准地找到我们想要操作的那个元素。

9.2.1. 元素查找 API

方法描述返回值
querySelector(selector)(推荐) 使用 CSS 选择器查找 第一个 匹配的元素。单个元素或 null
querySelectorAll(selector)(推荐) 使用 CSS 选择器查找 所有 匹配的元素。静态 NodeList
getElementById(id)通过 id 属性查找元素,速度最快单个元素或 null
getElementsByClassName(className)通过 class 名称查找。动态 HTMLCollection
getElementsByTagName(tagName)通过标签名查找。动态 HTMLCollection

示例 1: querySelectorgetElementById

1
2
3
4
5
6
7
8
9
10
11
<div id="box1">盒子1</div>
<div class="item">项目A</div>
<script>
// 使用 ID 选择器,效果同 getElementById
const box = document.querySelector('#box1');
console.log('通过 querySelector 找到:', box.textContent); // 盒子1

// 使用 getElementById,性能更优
const boxById = document.getElementById('box1');
console.log('通过 getElementById 找到:', boxById.textContent); // 盒子1
</script>

示例 2: querySelectorAllgetElementsByClassName

1
2
3
4
5
6
7
8
9
10
11
12
13
<ul>
<li class="fruit">苹果</li>
<li class="fruit">香蕉</li>
<li>面包(不是水果)</li>
</ul>
<script>
// 使用 querySelectorAll,返回静态 NodeList
const fruits = document.querySelectorAll('.fruit');
const fruitClass = document.getElementsByClassName("fruit")
console.log(fruitClass)
console.log('querySelectorAll 找到的水果数量:', fruits.length); // 2
fruits.forEach(fruit => console.log(`水果: ${fruit.textContent}`)); // 苹果 香蕉
</script>

9.2.2. 集合类型深度辨析:

HTMLCollection vs. NodeList 这是 DOM 操作中一个非常重要的原理性知识。getElementsBy... 系列方法返回的是 动态的 HTMLCollection,而 querySelectorAll 返回的是 静态的 NodeList

  • HTMLCollection (动态/实时): 如果在获取集合后,DOM 结构发生了变化(如新增或删除了匹配的元素),这个集合会 自动更新 以反映这些变化。
  • NodeList (静态快照): 它是在执行 querySelectorAll 时对 DOM 拍下的一张“照片”。之后 DOM 的任何变化都 不会影响 这个 NodeList 的内容。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<div id="container">
<p class="item">项目 1</p>
<p class="item">项目 2</p>
</div>
<button id="add-btn">添加新项目</button>

<script>
const container = document.getElementById('container');
const addBtn = document.getElementById('add-btn');

const liveCollection = container.getElementsByClassName('item');
const staticNodeList = container.querySelectorAll('.item');

console.log('获取时,动态集合长度:', liveCollection.length); // 2
console.log('获取时,静态集合长度:', staticNodeList.length); // 2

addBtn.onclick = () => {
const newItem = document.createElement('p');
newItem.className = 'item';
newItem.textContent = '新增项目';
container.append(newItem);

console.log('--- DOM 变化后 ---');
console.log('动态集合长度自动更新为:', liveCollection.length); // 3
console.log('静态集合长度保持不变:', staticNodeList.length); // 仍然是 2
};
</script>

9.2.3. 元素遍历 API

一旦获取了一个元素,我们就可以基于它来查找其亲属元素。

元素节点遍历 (推荐)

这组 API 只会返回 元素节点,自动忽略了元素间空白导致的文本节点,是日常开发的首选。

属性描述
parentElement返回父 元素
children返回一个包含所有子 元素HTMLCollection
firstElementChild返回第一个子 元素
lastElementChild返回最后一个子 元素
nextElementSibling返回下一个兄弟 元素
previousElementSibling返回上一个兄弟 元素
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
    <div id="container">
<p class="item">项目 1</p>
<p class="item">项目 2</p>
</div>
<button id="add-btn">添加新项目</button>


<script>
const container = document.querySelector("#container");
// 获取container的子元素
const container_child = container.children;
console.log(container_child); // HTMLCollection(2) [p.item, p.item]

// 获取container的父元素
const container_parent = container.parentElement;
console.log(container_parent); // body

// 获取container的第一个子元素
const container_frist_child = container.firstElementChild;
console.log(container_frist_child); // p.item

// 获取container的最后一个子元素
const container_last_child = container.lastElementChild;
console.log(container_last_child); // p.item

// 获取container的下一个兄弟元素
const container_next_sibling = container.nextElementSibling;
console.log(container_next_sibling); // button#add-btn

// 获取container的上一个兄弟元素
const container_previous_sibling = container.previousElementSibling;
console.log(container_previous_sibling); // null,这是因为在div的上方没有兄弟元素


</script>

通用节点遍历 (含陷阱)

这组 API 会返回 所有类型 的节点,包括元素、文本、注释等。在格式化的 HTML 代码中,元素间的换行和缩进会被视为空白 文本节点,使用这组 API 容易踩坑。

属性描述
parentNode返回父 节点
childNodes返回一个包含所有子 节点NodeList
firstChild返回第一个子 节点
lastChild返回最后一个子 节点
nextSibling返回下一个兄弟 节点
previousSibling返回上一个兄弟 节点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    <div id="container">
<p class="item">项目 1</p>
<p class="item">项目 2</p>
</div>
<button id="add-btn">添加新项目</button>

<script>
const container = document.querySelector("#container");

// 获取父节点
const container_parent_node = container.parentNode;
console.log(container_parent_node); // body

const container_childNodes = container.childNodes;
// 注意,上方代码是有换行的,所以他被视作为一个text节点
console.log(container_childNodes); // NodeList(5) [text, p.item, text, p.item, text]

// 其他方法就不演示了
</script>

9.3. 元素的操作:内容、属性与样式

承上启下:上一节我们学会了如何精准地“捕获”到页面元素。现在,是时候学习如何“改造”它们了。本节将聚焦于最常见的 DOM 操作:修改元素内部的 内容,管理它的 属性,以及动态切换它的 样式。我们将通过一系列小型实战场景,逐一攻克这些核心操作。

9.3.1. 微场景(一):动态更新用户资料卡

核心痛点: 假设我们从服务器获取了最新的用户信息,需要将其动态地展示在一个用户资料卡上。这涉及到安全地更新文本内容、修改图片地址以及绑定自定义数据。

1
2
3
4
5
6
7
8
9
10
11
<style>
.user-card { border: 1px solid #ccc; padding: 15px; width: 250px; border-radius: 8px; font-family: sans-serif; }
.user-card img { width: 80px; height: 80px; border-radius: 50%; float: left; margin-right: 15px; }
.user-card h3 { margin: 0 0 10px; }
.user-card p { color: #666; }
</style>
<div id="profile-card" class="user-card" data-user-id="1">
<img id="profile-avatar" src="https://picsum.photos/200/300" alt="用户头像">
<h3 id="profile-name">旧用户名</h3>
<p id="profile-bio">这是一段旧的个人简介。</p>
</div>

操作一:更新文本内容 (textContent vs innerHTML)

我们需要将获取到的用户名和简介更新到页面上。这里有两个主要的属性可用:

  • element.textContent: (安全/推荐) 它会将所有内容都作为纯文本处理。任何传入的 HTML 标签都会被直接显示为字符串,而不会被浏览器解析。这是更新文本内容的 最佳实践,能从根本上杜绝 XSS 跨站脚本攻击。
  • element.innerHTML: (危险/慎用) 它会将传入的字符串作为 HTML 解析。虽然功能强大,但如果内容来源不可信(如用户输入),恶意脚本(<script>, onerror)就可能被执行,导致严重的安全漏洞。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.user-card {
border: 1px solid #ccc;
padding: 15px;
width: 250px;
border-radius: 8px;
font-family: sans-serif;
}

.user-card img {
width: 80px;
height: 80px;
border-radius: 50%;
float: left;
margin-right: 15px;
}

.user-card h3 {
margin: 0 0 10px;
}

.user-card p {
color: #666;
}
</style>
</head>

<body>
<div id="profile-card" class="user-card" data-user-id="1">
<img id="profile-avatar" src="https://picsum.photos/200/300" alt="用户头像">
<h3 id="profile-name">旧用户名</h3>
<p id="profile-bio">这是一段旧的个人简介。</p>
<button onclick="updateProfile()">更新</button>
</div>

</body>
<script>
// 模拟获取到的新数据
const newData = {
name: "Prorise 博客",
bio: "一个追求极致的技术笔记平台。",
avatarUrl: "https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/avatar.png"
};

const nameEL = document.getElementById("profile-name");
const bioEL = document.getElementById("profile-bio");

function updateProfile() {
nameEL.textContent = newData.name;
bioEL.textContent = newData.bio;
}
</script>

</html>

操作二:管理 HTML 属性 (setAttribute & dataset)

接下来,我们需要更新头像的 src 属性,并可能需要读取或更新绑定在元素上的自定义数据(如用户 ID)。

  • 标准属性 (getAttribute/setAttribute): 用于读写 href, src, class, id 等 HTML 标准属性。
  • 自定义数据属性 (dataset): (推荐) 现代前端开发的最佳实践是,将与业务逻辑相关的自定义数据,通过 data-* 属性存储在 HTML 中。JavaScript 中可以通过 element.dataset 对象来方便地访问它们(data-user-id 会被自动转换为 dataset.userId)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.user-card {
border: 1px solid #ccc;
padding: 15px;
width: 250px;
border-radius: 8px;
font-family: sans-serif;
}

.user-card img {
width: 80px;
height: 80px;
border-radius: 50%;
float: left;
margin-right: 15px;
}

.user-card h3 {
margin: 0 0 10px;
}

.user-card p {
color: #666;
}
</style>
</head>

<body>
<div id="profile-card" class="user-card" data-user-id="1">
<img id="profile-avatar" src="https://picsum.photos/200/300" alt="用户头像">
<h3 id="profile-name">旧用户名</h3>
<p id="profile-bio">这是一段旧的个人简介。</p>
<button onclick="updateProfile()">更新</button>
</div>

</body>
<script>
// 模拟获取到的新数据
const newData = {
name: "Prorise 博客",
bio: "一个追求极致的技术笔记平台。",
avatarUrl: "https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/avatar.png"
};

const nameEL = document.getElementById("profile-name");
const bioEL = document.getElementById("profile-bio");
const cardEl = document.getElementById('profile-card');
const avatarEl = document.getElementById('profile-avatar');
const userId = cardEl.dataset.userId;
console.log(`资料卡的用户 ID 为: ${userId}`);

function updateProfile() {
nameEL.textContent = newData.name;
bioEL.textContent = newData.bio;
// 使用 setAttribute 更新标准属性 src
avatarEl.setAttribute('src', newData.avatarUrl);
console.log("头像 URL 已更新。");
}
</script>

</html>

9.3.2. 微场景(二):实现动态的视觉反馈

核心痛点 (Why): 在 Web 应用中,我们需要为用户的操作提供即时的视觉反馈。例如,点击一个按钮后,它的状态应该改变;或者,当用户在输入框中输入时,根据内容的有效性,输入框的边框应该变色。直接在 JavaScript 中逐行修改 style 属性会导致逻辑和表现的强耦合,难以维护。

解决方案: 通过 JavaScript 切换 CSS 类名。我们将不同状态的样式预先定义在 CSS 类中,JavaScript 只负责添加或移除这些类,实现行为与表现的分离。element.classList 是完成此任务的完美工具。

classList 方法描述
.add('class')添加一个类。
.remove('class')移除一个类。
.toggle('class')如果类存在则移除,不存在则添加。非常适合做状态切换。
.contains('class')检查是否包含指定的类,返回布尔值。

示例一:状态切换按钮 (使用 toggle)

这是 toggle 方法最经典的应用场景,用一行代码实现两种状态的切换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.follow-btn {
border: 1px solid #ccc;
padding: 10px 20px;
background-color: #fff;
border-radius: 15px;
cursor: pointer;
font-size: 16px;
font-weight: bold;
color: #333;
transition: all 0.3s ease;

}

.following {
background-color: #ff03ea;
color: #fff;
}
</style>
</head>

<body>
<button id="follow-btn" class="follow-btn" onclick="follow()">关注</button>

</body>
<script>
function follow() {
const followBtn = document.getElementById('follow-btn');
followBtn.classList.toggle('following');
}
</script>

</html>

示例二:表单输入验证反馈 (使用 add, remove)

这个场景更清晰地展示了如何根据不同的逻辑条件,精确地添加和移除类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.input-field {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
transition: border-color 0.3s ease;
}

.input-field.valid {
border-color: #2e7d32;
/* 绿色边框 */
}

.input-field.invalid {
border-color: #d32f2f;
/* 红色边框 */
}

.feedback-msg {
font-size: 12px;
height: 16px;
margin-top: 4px;
}

.feedback-msg.valid {
color: #2e7d32;
}

.feedback-msg.invalid {
color: #d32f2f;
}
</style>
</head>

<body>
<input type="password" id="password-input" class="input-field" placeholder="请输入至少8位的密码">
<p id="feedback-msg" class="feedback-msg"></p>
</body>
<script>
const passwordInput = document.getElementById('password-input');
const feedbackMsg = document.getElementById('feedback-msg');
// 我们通过后续要学习的DOM事件来监听input事件
passwordInput.addEventListener("input", () => {
const value = passwordInput.value;
if (value.length >= 8) {
passwordInput.classList.add('valid');
passwordInput.classList.remove('invalid');
feedbackMsg.textContent = '密码强度符合要求';
feedbackMsg.classList.add('valid');
feedbackMsg.classList.remove('invalid');
} else {
// 不满足条件:添加 'invalid',移除 'valid'
passwordInput.classList.add('invalid');
passwordInput.classList.remove('valid');

feedbackMsg.textContent = '密码长度不能少于8位';
feedbackMsg.classList.add('invalid');
feedbackMsg.classList.remove('valid');
}
})
</script>


</html>

9.3.3 微场景(三):实现一个可拖拽的卡片 (操作行内样式)

核心痛点: 有时,我们需要根据用户的实时交互(如鼠标移动)来计算并设置元素的样式,例如一个可拖拽元素的 lefttop 值。这些值是高度动态、实时计算的,不适合预先定义在 CSS 类中。

解决方案: 在这种场景下,直接操作元素的 element.style 对象是最直接有效的方式。

element.style 属性返回一个对象,它对应于该元素的 HTML style 行内属性。我们可以像操作普通 JS 对象一样,给它的属性赋值来动态修改样式。

注意:

  1. CSS 属性名中的连字符 (-) 需要转换为驼峰命名法(例如 background-color 变为 backgroundColor)。
  2. element.style 只能 读取 到行内样式,无法获取来自 <style> 标签或外部 CSS 文件的样式。

重要信息: 如果你是新手,不懂 Dom 的事件的话,可以先继续往后看,后续的章节我们会详细介绍

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<style>
#draggable-box {
position: absolute; /* 必须是可定位的元素 */
width: 100px;
height: 100px;
background-color: #007bff;
color: white;
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
user-select: none; /* 防止拖动时选中文本 */
}
#draggable-box:active {
cursor: grabbing;
}
</style>

<div id="draggable-box">拖动我</div>

<script>
const box = document.getElementById('draggable-box');
let isDragging = false;
let offsetX, offsetY;

box.addEventListener('mousedown', (e) => {
isDragging = true;
// 计算鼠标点击位置相对于元素左上角的偏移
offsetX = e.clientX - box.offsetLeft;
offsetY = e.clientY - box.offsetTop;
// 设置为 grabbing 光标样式
box.style.cursor = 'grabbing';
});

document.addEventListener('mousemove', (e) => {
if (isDragging) {
// 核心:实时更新 style.left 和 style.top
box.style.left = `${e.clientX - offsetX}px`;
box.style.top = `${e.clientY - offsetY}px`;
}
});

document.addEventListener('mouseup', () => {
isDragging = false;
box.style.cursor = 'grab'; // 恢复光标样式
});
</script>

9.4. 动态构建:创造与销毁元素

承上启下: 前面我们学会了如何查找和修改 已存在 的元素。但 Web 应用的魅力在于其 动态性——根据用户的操作或数据的变化,实时地在页面上创建全新的内容,或移除不再需要的部分。本节,我们将聚焦于 DOM 操作中最激动人心的部分:从无到有地创造元素,并将其注入页面,以及在适当的时候销毁它们。

9.4.1. 从零到一:创建新节点

在将一个新元素添加到页面之前,我们首先需要在内存中将它“创造”出来。这主要通过两个方法完成。

方法描述
document.createElement(tagName)创建一个指定标签名(如 'div', 'p')的 元素节点
document.createTextNode(text)创建一个包含指定文本的 文本节点

这两个方法创建出的节点,仅仅是存在于 JavaScript 的内存中,与页面上的 DOM 树还没有任何关系。我们可以像操作普通 JS 对象一样,先对它进行各种设置。

示例: 创建一个结构完整的、待插入的段落元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1. 创建一个 <p> 元素节点
const newParagraph = document.createElement('p');

// 2. 为其设置 class 和 id
newParagraph.className = 'comment';
newParagraph.id = 'comment-1';

// 3. 创建一个文本节点
const textContent = document.createTextNode('这是一条新的评论内容。');

// 4. 将文本节点作为子节点添加到 <p> 元素中
newParagraph.append(textContent);

// 此时,newParagraph 已经是一个完整的 DOM 节点,但还未显示在页面上
console.log('内存中创建的元素:', newParagraph);

9.4.2. 放入页面:插入节点

当节点在内存中准备就绪后,我们就可以使用插入 API 将它“挂载”到 DOM 树的指定位置。

现代节点插入 API

这组 API 更灵活、更直观,并且允许一次性插入多个节点,老版本的 API 我们就不学习了

方法描述
parentElement.append(...nodes)parentElement最后一个子节点之后 插入一个或多个节点。
parentElement.prepend(...nodes)parentElement第一个子节点之前 插入一个或多个节点。
element.before(...nodes)element 之前 插入一个或多个兄弟节点。
element.after(...nodes)element 之后 插入一个或多个兄弟节点。

示例: 实现一个简单的评论发布功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Elegant Comment Box</title>
<style>
.comment-box-container { margin: 0 auto; background-color: #ffffff; border-radius: 12px; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08); padding: 24px; width: 100%; max-width: 550px; box-sizing: border-box; }

.comment-textarea { width: 100%; height: 120px; padding: 12px; border: 1px solid #ccd0d5; border-radius: 8px; font-size: 16px; line-height: 1.4; resize: vertical; box-sizing: border-box; outline: none; transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; }

.comment-textarea:focus { border-color: #007bff; box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25); }

.send-button { background-color: #007bff; color: white; border: none; border-radius: 8px; padding: 10px 20px; font-size: 16px; font-weight: 600; cursor: pointer; outline: none; transition: background-color 0.2s ease-in-out; }


.comment-list ul {padding: 0;}

.comment-list ul li { list-style: none; }

.comment-content { background-color: #f0f2f5; padding: 12px; border-radius: 8px; margin-top: 12px; }

#comment-content { font-size: 16px; line-height: 1.4; margin: 0; }

</style>
</head>

<body>

<div class="comment-box-container">
<textarea class="comment-textarea" placeholder="在此输入您的评论..."></textarea>
<div style="display: flex; justify-content: flex-end; margin-top: 16px;">
<button class="send-button" onclick="sendComment()">发送</button>
</div>
<div class="comment-list" style="margin-top: 16px;">
<ul>
</ul>
</div>
</div>

</body>

<script>
function sendComment() {
const comment = document.querySelector('.comment-textarea').value;
if (!comment.trim()) return;
// 以comment_list ul 作为父节点,一下的所有操作都基于他来插入
const commentList = document.querySelector('.comment-list ul');

// 演示现代节点插入 API

// 1. append() - 在父元素的最后一个子节点之后插入
const newComment = document.createElement('li');
newComment.innerHTML = `
<div class="comment-content">
<p>末尾评论:${comment}</p>
</div>
`;
commentList.append(newComment); // 添加到列表末尾

// 2. prepend() - 在父元素的第一个子节点之前插入
const headerComment = document.createElement('li');
headerComment.innerHTML = `
<div class="comment-content" style="background-color: #e3f2fd;">
<p><strong>最新评论:</strong>${comment}</p>
</div>
`;
commentList.prepend(headerComment); // 添加到列表开头

// 3. before() - 在指定元素之前插入兄弟节点
const beforeElement = document.createElement('div');
beforeElement.innerHTML = '<p style="color: #666; font-size: 14px;">--- 评论分隔线 ---</p>';
newComment.before(beforeElement); // 在末尾评论前添加分隔线

// 4. after() - 在指定元素之后插入兄弟节点
const afterElement = document.createElement('div');
afterElement.innerHTML = '<p style="color: #666; font-size: 12px;">评论时间: ' + new Date().toLocaleString() + '</p>';
newComment.after(afterElement); // 在末尾评论后添加时间戳

// 清空输入框
document.querySelector('.comment-textarea').value = '';
}
</script>

</html>

9.4.3. 完成使命:移除与替换节点

现代节点移除/替换 API (2025 推荐)

这组 API 直接在目标元素上调用,非常直观。

方法描述
element.remove()element 从其父节点中 移除
element.replaceWith(...nodes)用一个或多个新节点 替换element

示例: 为每条评论添加删除功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Elegant Comment Box</title>
<style>
.comment-box-container { margin: 0 auto; background-color: #ffffff; border-radius: 12px; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08); padding: 24px; width: 100%; max-width: 550px; box-sizing: border-box; }

.comment-textarea { width: 100%; height: 120px; padding: 12px; border: 1px solid #ccd0d5; border-radius: 8px; font-size: 16px; line-height: 1.4; resize: vertical; box-sizing: border-box; outline: none; transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; }

.comment-textarea:focus { border-color: #007bff; box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25); }

.send-button { background-color: #007bff; color: white; border: none; border-radius: 8px; padding: 10px 20px; font-size: 16px; font-weight: 600; cursor: pointer; outline: none; transition: background-color 0.2s ease-in-out; }


.comment-list ul {padding: 0;}

.comment-list ul li { list-style: none; }

.comment-content { background-color: #f0f2f5; padding: 12px; border-radius: 8px; margin-top: 12px; position: relative; }

#comment-content { font-size: 16px; line-height: 1.4; margin: 0; }

.delete-button { position: absolute; top: 8px; right: 8px; background-color: #dc3545; color: white; border: none; border-radius: 4px; padding: 4px 8px; font-size: 12px; cursor: pointer; }

.delete-button:hover { background-color: #c82333; }

</style>
</head>

<body>

<div class="comment-box-container">
<textarea class="comment-textarea" placeholder="在此输入您的评论..."></textarea>
<div style="display: flex; justify-content: flex-end; margin-top: 16px;">
<button class="send-button" onclick="sendComment()">发送</button>
</div>
<div class="comment-list" style="margin-top: 16px;">
<ul>
</ul>
</div>
</div>

</body>

<script>
function deleteComment(element) {
// 演示 element.remove() - 将评论从DOM中移除
// closest() 会从 element 开始向上遍历其所有祖先节点,找到第一个匹配 'li' 的元素,并返回该元素。
element.closest('li').remove();
}

function replaceComment(element) {
// 演示 element.replaceWith() - 用新内容替换评论
const replacementComment = document.createElement('li');
replacementComment.innerHTML = `
<div class="comment-content" style="background-color: #ffeaa7;">
<p><em>此评论已被替换</em></p>
<button class="delete-button" onclick="deleteComment(this)">删除</button>
</div>
`;
element.closest('li').replaceWith(replacementComment);
}

function sendComment() {
const comment = document.querySelector('.comment-textarea').value;
if (!comment.trim()) return;
// 以comment_list ul 作为父节点,一下的所有操作都基于他来插入
const commentList = document.querySelector('.comment-list ul');

// 演示现代节点插入 API

// 1. append() - 在父元素的最后一个子节点之后插入
const newComment = document.createElement('li');
newComment.innerHTML = `
<div class="comment-content">
<p>末尾评论:${comment}</p>
<button class="delete-button" onclick="deleteComment(this)">删除</button>
</div>
`;
commentList.append(newComment); // 添加到列表末尾

// 2. prepend() - 在父元素的第一个子节点之前插入
const headerComment = document.createElement('li');
headerComment.innerHTML = `
<div class="comment-content" style="background-color: #e3f2fd;">
<p><strong>最新评论:</strong>${comment}</p>
<button class="delete-button" onclick="deleteComment(this)">删除</button>
<button class="delete-button" onclick="replaceComment(this)" style="right: 60px;">替换</button>
</div>
`;
commentList.prepend(headerComment); // 添加到列表开头

// 3. before() - 在指定元素之前插入兄弟节点
const beforeElement = document.createElement('div');
beforeElement.innerHTML = '<p style="color: #666; font-size: 14px;">--- 评论分隔线 ---</p>';
newComment.before(beforeElement); // 在末尾评论前添加分隔线

// 4. after() - 在指定元素之后插入兄弟节点
const afterElement = document.createElement('div');
afterElement.innerHTML = '<p style="color: #666; font-size: 12px;">评论时间: ' + new Date().toLocaleString() + '</p>';
newComment.after(afterElement); // 在末尾评论后添加时间戳

// 清空输入框
document.querySelector('.comment-textarea').value = '';
}
</script>

</html>

9.5. DOM 事件基础:监听与响应

我们已经学会了如何用 JavaScript 像上帝一样创造、修改和销毁页面元素。但目前,这些操作都只能在代码加载时执行一次。如何让页面“活”起来,响应用户的点击、鼠标的移动和键盘的输入?答案就是 事件。本节,我们将开启 DOM 交互的核心——事件监听,对于事件我们通常不需要去特殊记忆,在什么场景需要用到就去查一下就可以了

一个 事件 (Event),是浏览器通知我们“某件事发生了”的信号。例如:

  • 用户点击了一个按钮 (click 事件)
  • 鼠标指针移入一张图片 (mouseover 事件)
  • 用户在输入框中按下了键盘 (keydown 事件)
  • 整个页面资源加载完毕 (load 事件)

事件监听,就是我们编写一段代码(称为“事件监听器”或“事件处理器”),并将其“绑定”到某个元素上,告诉浏览器:“当这个元素上发生这种事件时,请执行我这段代码。”

9.5.1. 事件监听器的演进

在历史上,为元素绑定事件监听器的方式经历了几个阶段的演进。

阶段一:HTML on-* 属性

最早的方式是直接在 HTML 标签上使用 on<事件名> 属性。

1
<button onclick="alert('你点击了按钮!'); console.log('这是一个已废弃的用法。');">不要这样用</button>

缺点: 这种方式严重违反了“结构与行为分离”的原则,将 JavaScript 代码和 HTML 标记混杂在一起,极难维护。

阶段二:DOM0 级事件处理 (element.on*)

通过直接给元素的 on<事件名> 属性赋值一个函数,可以实现行为与结构的分离。

1
2
3
4
const btn = document.querySelector('#my-btn');
btn.onclick = function() {
console.log('DOM0 方式绑定的点击事件。');
};

缺点: 一个元素的一个事件只能绑定一个处理器。如果对同一个 btn.onclick 多次赋值,后面的会 覆盖 前面的,导致逻辑丢失。

9.5.2. 现代事件监听:addEventListener

addEventListener 是 W3C DOM2 级事件规范中定义的方法,也是当今所有现代浏览器支持的 标准事件监听方式

核心语法: element.addEventListener(type, listener, [options/useCapture]);

  • type: 事件类型字符串,没有 on 前缀,例如 'click', 'mouseover', 'keydown'
  • listener: 事件触发时要执行的函数(回调函数)。
  • 第三个参数(可选): 通常是一个布尔值,用于指定是在“捕获”阶段还是“冒泡”阶段执行(我们将在后续章节深入讲解事件流)。默认为 false(冒泡阶段)。

核心优势:

  1. 可以为一个事件绑定多个监听器,它们会按照添加的顺序依次执行,互不覆盖。
  2. 提供了更精细的控制,如通过 removeEventListener 移除监听。

示例一:为一个按钮添加多个点击事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<button id="multi-listener-btn">点我试试</button>
<script>
const btn = document.getElementById('multi-listener-btn');

// 添加第一个监听器
btn.addEventListener('click', () => {
console.log('监听器 1 被触发了!');
});

// 添加第二个监听器
btn.addEventListener('click', (event) => {
console.log(`监听器 2 被触发了!事件类型是: ${event.type}`);
});
</script>

移除事件监听器: removeEventListener

要移除一个事件监听器,必须使用 removeEventListener,并且需要满足两个条件:

  1. 事件类型、监听函数、以及第三个参数必须与添加时 完全一致
  2. 因此,被移除的监听函数 不能是匿名函数,必须是一个有引用的函数(如命名函数或函数变量)。

示例二:一个只触发一次的事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>

<body>
<button id="once-btn">click me</button>
</body>
<script>
const btn = document.getElementById('once-btn');

function handleFirstClick() {
alert("click");
btn.removeEventListener("click", handleFirstClick);
}
btn.addEventListener("click", handleFirstClick);
</script>

</html>

点击按钮,控制台会输出消息。再次点击,将不再有任何反应,因为监听器已被成功移除。


9.6. DOM 事件(下):事件流与事件对象

9.6.1. 事件流:捕获与冒泡

核心原理: 当您点击一个深层嵌套的按钮时,您不仅仅是点击了那个按钮。从根节点 <html> 到那个按钮,路径上的所有祖先元素,实际上都“感知”到了这次点击。事件在 DOM 树中传播的顺序,就称为 事件流 (Event Flow)

W3C 标准规定,事件流分为三个阶段:

  1. 捕获阶段: 事件从文档的根节点(window -> document -> <html>)开始,逐级 向下 传播,直到达到真正的目标元素。
  2. 目标阶段: 事件到达目标元素。
  3. 冒泡阶段: 事件从目标元素开始,逐级 向上 冒泡,直到再次回到文档的根节点。

默认情况下,我们使用 addEventListener 绑定的事件监听器,只会在 冒泡阶段 被触发。

addEventListener 的第三个参数:

  • element.addEventListener('click', handler, false); (或不写): handler冒泡阶段 执行。
  • element.addEventListener('click', handler, true);: handler捕获阶段 执行。

我们来看一个在实际开发中非常常见的场景,这个场景经常因为对事件流理解不清而导致 bug。
场景:一个可以点击的卡片,但卡片内有一个功能独立的“删除”按钮。

  • 目标:点击卡片空白处,触发“查看详情”;点击卡片内的“删除”按钮, 触发“删除”操作。
  • 常见的坑:点击“删除”按钮后,不仅触发了“删除”,还意外地触发了“查看详情”。

下面是这个问题的 错误实现,它清晰地暴露了不处理事件冒泡所带来的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<!DOCTYPE html>
<html>
<head>
<title>事件冒泡问题演示</title>
<style>
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f0f0f0; margin: 0; }
.card {
width: 250px;
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
background-color: white;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
cursor: pointer;
transition: transform 0.2s;
}
.card:hover { transform: translateY(-5px); }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.delete-btn {
padding: 5px 10px;
border: none;
background-color: #e74c3c;
color: white;
border-radius: 5px;
cursor: pointer;
}
</style>
</head>
<body>

<div id="product-card" class="card">
<div class="card-header">
<h4>商品卡片</h4>
<button id="delete-button" class="delete-btn">删除</button>
</div>
<p>点击卡片空白处查看详情。</p>
</div>

<script>
const card = document.getElementById('product-card');
const deleteBtn = document.getElementById('delete-button');

// 给整个卡片绑定"查看详情"事件
card.addEventListener('click', () => {
alert('事件冒泡了!触发了卡片的点击事件 -> 准备跳转详情页...');
});

// 给删除按钮绑定"删除"事件
deleteBtn.addEventListener('click', () => {
alert('删除了!触发了删除按钮的点击事件 -> 正在执行删除操作...');
});
</script>

</body>
</html>

你会发现,两个事件都被触发了。这是因为 click 事件在“删除”按钮上触发后,会继续向 上冒泡 到它的父元素 div#product-card,从而也触发了绑定在卡片上的监听器。这显然不是我们想要的结果。


现在,我们用 event.stopPropagation() 来修正这个问题。这个方法可以阻止事件继续向上冒泡。

1
2
3
4
5
6
// 给删除按钮绑定 "删除" 事件
deleteBtn.addEventListener('click', (event) => {
// 阻止事件冒泡
event.stopPropagation();
alert('删除了!触发了删除按钮的点击事件 -> 正在执行删除操作...');
});

现在点击删除按钮您将看不到上述的问题了!


9.6.2. 事件对象 (Event Object)

当一个事件发生时,浏览器会自动创建一个 事件对象(通常命名为 evente),并将其作为唯一的参数传递给事件监听函数。这个对象包含了关于该事件的所有详细信息,是我们与事件交互的关键。

核心属性/方法描述
event.target事件的真正发起者。在事件流中,它始终是那个被用户直接交互的、最深层的元素(例如,被点击的按钮或链接)。
event.currentTarget当前正在执行监听器的那个元素。也就是你调用 addEventListener 时绑定的那个元素。
event.type事件的类型(如 'click', 'keydown')。
event.preventDefault()(方法) 阻止元素的 默认行为。例如,阻止 <a> 标签的跳转,或 <form> 的提交。
event.stopPropagation()(方法) 阻止事件继续传播(通常指阻止冒泡)。事件流将在此处停止,不会再触发父级元素的同类型事件监听器。

场景示例:一个可交互的待办事项列表

想象一个待办事项列表,我们希望实现以下功能:

  1. 事件委托:只在父容器 <ul> 上设置一个点击监听器来管理所有子项,而不是给每个 <li> 或按钮都单独绑定事件,这样更高效。
  2. 点击待办事项的 文本,可以将其标记为“已完成”(添加删除线)。
  3. 点击每项后面的 “移除”链接,应该 只删除 该项,而 不触发“标记完成”的逻辑,并且 不刷新 页面。

这个场景完美地展示了上述所有概念的协同工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.todo-list {
border: 1px solid #ccc;
padding: 10px;
width: 300px;
list-style: none;
}

.todo-list li {
padding: 8px;
margin: 5px 0;
background-color: #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}

.todo-list li.completed .text {
text-decoration: line-through;
color: #888;
}

.todo-list .remove-btn {
color: red;
text-decoration: none;
font-size: 12px;
}
</style>
</head>

<body>
<ul id="todo-list" class="todo-list">
<li><span class="text">学习 JavaScript 事件流</span><a href="#" class="remove-btn">移除</a></li>
<li><span class="text">写一个很棒的例子</span><a href="#" class="remove-btn">移除</a></li>
<li><span class="text">休息一下</span><a href="#" class="remove-btn">移除</a></li>
</ul>
</body>
<script>
const list = document.getElementById('todo-list');
list.addEventListener('click', (event) => {
console.log('--- 事件触发 ---');
console.log('event.target:', event.target.tagName, `(class: ${event.target.className})`); // 用户实际点击的元素
console.log('event.currentTarget:', event.currentTarget.tagName); // 监听器绑定的元素,永远是 UL

// 判断用户点击的是不是“移除”按钮
if (event.target.classList.contains('remove-btn')) {
// 1. 阻止 <a> 标签的默认跳转行为
event.preventDefault();

// 2. 阻止事件向上冒泡到 li 或 ul,避免触发下面的“标记完成”逻辑
event.stopPropagation();

// 找到父级 li 元素并移除它
const itemToRemove = event.target.parentElement;
itemToRemove.remove();

} else {
// 如果点击的不是“移除”按钮,就执行“标记完成”逻辑
// event.target 可能是 span 或 li,我们需要找到 li
const targetLi = event.target.closest('li');
if (targetLi) {
// 切换类状态
targetLi.classList.toggle('completed');
}
}
})
</script>

</html>

9.7. 高级事件模式:事件委托

在上一节的待办事项列表中,我们只给父元素 <ul> 添加了一个事件监听器,却成功地管理了所有 <li> 子项的点击事件,包括它们的完成状态和删除按钮。这种高效的模式,就是 事件委托

核心痛点: 想象一下,一个拥有 1000 个列表项的 <ul>。如果我们为每一个 <li> 都单独调用 addEventListener,会发生什么?

  1. 性能问题: 内存中会创建 1000 个独立的监听器函数,占用大量内存,可能导致页面响应变慢。
  2. 动态内容问题: 如果我们通过 JavaScript 动态地向这个列表中添加一个新的 <li>,新添加的项 不会 自动绑定上事件监听器,我们需要手动再次绑定,代码会变得非常复杂。

解决方案: 事件委托 正是为了解决这两个问题而生。它的核心原理是利用我们上一节学到的 事件冒泡 机制。我们不再给每个子元素单独设置监听器,而是只给它们的共同父元素设置一个监听器。当任何一个子元素被点击时,事件会冒泡到父元素,父元素的监听器就会被触发。

实现的关键: 在父元素的监听器中,我们通过 event.target 属性,就能精确地判断出事件的真正来源是哪个子元素,然后执行相应的逻辑。

实战场景:一个动态的、可交互的标签选择器

我们将构建一个标签选择器。初始时有几个标签,用户可以点击“添加新标签”来动态增加更多标签。我们希望实现:

  • 无论何时,点击任何一个标签,都能在 alert 打印出它的内容。
  • 整个过程只使用 一个 事件监听器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#tag-container {
border: 1px solid #ccc;
padding: 10px;
width: 400px;
}

.tag {
display: inline-block;
padding: 5px 10px;
margin: 5px;
background-color: #007bff;
color: white;
border-radius: 15px;
cursor: pointer;
transition: background-color 0.2s;
}

.tag:hover {
background-color: #0056b3;
}

#add-tag-btn {
margin-top: 10px;
}
</style>
</head>

<body>
<div id="tag-container">
<span class="tag">JavaScript</span>
<span class="tag">HTML</span>
<span class="tag">CSS</span>
</div>
<button id="add-tag-btn">添加新标签</button>
</body>
<script>
const tagContainer = document.getElementById('tag-container');
const addTagBtn = document.getElementById('add-tag-btn');
let tagCounter = 0;

// 核心:只在父容器上设置一个监听器
tagContainer.addEventListener('click', (event) => {
// 步骤1: 检查事件来源 (event.target) 是否是我们关心的子元素
if (event.target.classList.contains('tag')) {
// 步骤2: 如果是,则执行针对该子元素的逻辑
const tagName = event.target.textContent;
alert(`你点击了标签: ${tagName}`);
}
});

// 用于动态添加新标签的逻辑
addTagBtn.addEventListener('click', () => {
tagCounter++;
const newTag = document.createElement('span');
newTag.className = 'tag';
newTag.textContent = `新标签 ${tagCounter}`;
tagContainer.append(newTag);
});
</script>

</html>

9.8. 精准定位:获取元素尺寸与位置 (重点)

在前端开发中,我们不仅能改变元素,更需要能够 读取 它在页面布局中的精确信息。无论是实现“Tooltip 提示”、“无限滚动加载”还是“拖拽功能”,都离不开对元素尺寸和位置的精确掌控。

然而,DOM 提供了多套看似相似的 API,它们的参照物和计算方式各不相同,极易混淆。本章将彻底厘清这些概念,并把它们分为两大核心任务:

  1. 测量盒子:这个元素到底有多大?(涉及 offsetWidth, clientHeight, scrollHeight
  2. 定位盒子:这个元素在页面的什么位置?(涉及 getBoundingClientRect, offsetTop/offsetLeft

我们将通过一个个具体的实战场景,来掌握这些关键的 API。

9.8.1. 测量盒子:元素有多大?

在定位一个元素之前,我们首先要知道它自身的尺寸信息。DOM 提供了三套核心的“测量尺”,分别用于不同的场景。

属性包含内容一句话解释
offsetWidth/offsetHeight内容 + padding + border视觉尺寸:元素在屏幕上占据的完整空间。
clientWidth/clientHeight内容 + padding内部尺寸:元素内部可供内容显示的区域大小,不含边框和滚动条。
scrollHeight/scrollWidth所有 内容(包括被隐藏的)内容总尺寸:如果把所有内容平铺开,它所需要的总高度/宽度。

实战场景:实现“无限滚动加载”

核心痛点: 当列表内容非常多时,一次性加载会非常慢。最佳体验是当用户滚动到列表底部时,再自动加载下一页数据。这就需要我们精确判断“用户是否已滚动到底部”。

解决方案: 判断滚动条是否触底,完美地诠释了上述三个尺寸属性的协作关系。我们需要用到:

  1. element.scrollHeight: 内容的总高度。
  2. element.clientHeight: 容器的可视高度。
  3. element.scrollTop: 内容已经被向上卷去的距离。

“已滚动距离 (scrollTop)” + “可视区高度 (clientHeight)” ≈ “内容总高度 (scrollHeight)” 时,我们就可以判定用户已经滚动到了底部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#scroll-list {
position: relative;
height: 300px;
width: 300px;
/* 当内容超出容器时,显示滚动条 */
overflow-y: scroll;
border: 1px solid #625f5f;
padding: 10px;
}

#scroll-list p {
margin: 0 0 10px;
padding: 5px;
background: #f4f4f4;
}

#loading-indicator {
display: none;
text-align: center;
color: #999;
margin-top: 10px;
}
</style>
</head>

<body>
<div id="scroll-list">

</div>

</body>
<script>
const list = document.getElementById('scroll-list');
let itemCounter = 1;
let isLoading = false;
let loading;

// 创建一个加载更多的样式函数作为备用
function createLoadingIndicator() {
loading = document.createElement('div');
loading.id = 'loading-indicator';
loading.textContent = '加载更多中...';
loading.style.display = 'none';
loading.style.textAlign = 'center';
loading.style.color = '#999';
loading.style.marginTop = '10px';
}

function loadMoreItems() {
isLoading = true;
if (!loading) {
// 如果loading不存在,则创建一个
createLoadingIndicator();
}
// 通过每一次加载,给他放到list元素的最后方
list.append(loading);
loading.style.display = "block";
setTimeout(() => {
for (let i = 0; i < 10; i++) {
const newItem = document.createElement('p');
newItem.textContent = `Item ${itemCounter + i}`;
list.append(newItem);
}
itemCounter += 10;
loading.style.display = "none";
isLoading = false;
}, 1000);
}
loadMoreItems(); // 初始化函数

list.addEventListener('scroll', () => {
const scrollPosition = list.scrollTop + list.clientHeight;
// 减 5 是设置一个缓冲距离,避免因计算误差等因素导致频繁触发加载,让滚动到底部的判断更合理。
if (scrollPosition >= list.scrollHeight - 5 && !isLoading) {
loadMoreItems();
}
})
</script>

</html>

9.8.2. 定位盒子:元素在哪里?

知道了元素多大,下一步就是确定它的位置。这也是最容易混淆的地方。DOM 提供了两套主要的“定位系统”:一套是基于 浏览器视口 的现代方案,另一套是基于 父元素 的传统方案。

A. 现代首选:相对于浏览器视口定位

核心痛点: 当鼠标悬停在一个元素上时,我们希望在它旁边弹出一个“提示框”(Tooltip)。这个提示框的位置必须根据目标元素 当前在浏览器窗口中的位置 来动态计算,无论页面滚动到哪里。

解决方案: 这种相对于 浏览器视口 (viewport) 的定位需求,最现代、最直接的工具就是 element.getBoundingClientRect()

它返回一个 DOMRect 对象,包含 left, top, right, bottom, width, height 等相对于 视口左上角 的精确坐标。

  • 最大特点:它的返回值是 动态的。当页面滚动时,元素相对于视口的位置会改变,getBoundingClientRect() 返回的 topleft 值也会随之改变。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<style>
.container { position: relative; padding-top: 50px; padding-left: 100px; height: 300px; }
#tooltip-target { padding: 10px 20px; border: 1px solid #666; cursor: pointer; }
#tooltip {
position: absolute; /* 关键:相对于文档或最近的定位祖先定位 */
display: none; /* 默认隐藏 */
background-color: #333; color: white; padding: 8px; border-radius: 4px;
pointer-events: none; /* 让鼠标可以“穿透”提示框 */
}
</style>

<div class="container">
<button id="tooltip-target">悬停在我身上</button>
<div id="tooltip">这是一个动态计算位置的提示框。</div>
</div>

<script>
const target = document.getElementById('tooltip-target');
const tooltip = document.getElementById('tooltip');

target.addEventListener('mouseenter', () => {
const rect = target.getBoundingClientRect();
// 不加window.scrollY和window.scrollX的话,当页面有滚动时,提示框位置会不准确,它只基于视口位置计算,不会随页面滚动而正确移动,导致提示框和目标元素位置错乱。
// .style.top/left 是相对于 offsetParent 的,此处为 body
// 所以需要加上页面的滚动距离来得到相对于文档的绝对位置
tooltip.style.top = `${rect.bottom + window.scrollY + 5}px`;
tooltip.style.left = `${rect.left + window.scrollX}px`;
tooltip.style.display = 'block';
});

target.addEventListener('mouseleave', () => {
tooltip.style.display = 'none';
});
</script>

注意getBoundingClientRect() 获取的是相对于视口的坐标。如果你的提示框是 position: absolute 并且其定位父级是 <body>,那么设置 .style.top 时需要加上 window.scrollY,才能将其从“视口坐标”转换为“文档坐标”。

B. 传统方案:相对于父元素定位

核心痛点: 在复杂的布局中,我们常常需要将一个元素(如弹出的下拉菜单)精确地定位在某个已定位的父容器 内部,而不是相对于整个浏览器窗口。

解决方案: 这正是 offset 家族属性的用武之地。

  • offsetParent: 一个至关重要的概念,它指的是在 DOM 树中,离当前元素最近的、CSS position 属性 不为 static 的祖先元素。
  • offsetLeft / offsetTop: 元素的外边框边缘,到其 offsetParent 内边距边缘的距离。
  • 最大特点:它的值是 静态的,基于 DOM 结构和 CSS 布局决定,不会因为页面滚动而改变

getBoundingClientRect vs offsetTop/Left 核心区别

特性element.getBoundingClientRect().topelement.offsetTop
参照物浏览器视口offsetParent
是否随滚动变化不会
常见用途Tooltip、判断元素是否可见、吸顶效果在已定位的容器内布局子元素(如自定义下拉菜单)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<style>
#wrapper {
position: relative;
/* 这是按钮的 offsetParent */
width: 400px;
height: 150px;
border: 2px solid green;
padding: 10px;
margin-left: 50px;
}

#menu-button {
position: absolute;
top: 20px;
left: 30px;
}

#dropdown-menu {
position: absolute;
/* 它的定位将基于 wrapper */
display: none;
border: 1px solid #ccc;
background: white;
list-style: none;
padding: 0;
margin: 0;
}
</style>

<body>
<div id="wrapper">
<button id="menu-button">点击展开菜单</button>
<ul id="dropdown-menu">
<li>选项 1</li>
<li>选项 2</li>
</ul>
</div>
</body>
<script>
const button = document.getElementById('menu-button');
const menu = document.getElementById('dropdown-menu');

button.addEventListener('click', () => {
// offsetLeft/Top 是相对于 offsetParent (即 #wrapper) 的坐标
// 按钮的 offsetParent 是: #wrapper
console.log(`按钮的 offsetParent 是: #${button.offsetParent.id}`);
// 按钮的 offsetTop: 20, offsetLeft: 30
console.log(`按钮的 offsetTop: ${button.offsetTop}, offsetLeft: ${button.offsetLeft}`);
// 按钮的 offsetHeight: 24 (默认大小)
console.log(`按钮的 offsetHeight: ${button.offsetHeight}`);


// 计算菜单的位置
// 菜单的 top = 按钮的 top + 按钮的“外尺寸”高度
const menuTop = button.offsetTop + button.offsetHeight;
// 菜单的 left = 按钮的 left
const menuLeft = button.offsetLeft;


menu.style.top = `${menuTop}px`;
menu.style.left = `${menuLeft}px`;
menu.style.display = 'block';

})
</script>

</html>

9.8.3. 动态定位:捕获鼠标的位置

掌握了元素的静态位置信息后,我们还需要处理动态的交互——鼠标。当用户点击、移动鼠标时,我们需要知道鼠标指针的精确坐标,才能将元素定位与用户操作关联起来。

event 对象提供了多套坐标属性,它们的 坐标系参照物 也各不相同。

坐标属性参照物描述
clientX / clientY浏览器视口最常用。鼠标指针相对于浏览器 当前可见窗口 左上角的坐标。
pageX / pageY文档鼠标指针相对于整个 HTML 文档左上角的坐标。pageY = clientY + 页面垂直滚动距离
offsetX / offsetY目标元素鼠标指针相对于触发事件的那个元素的 内边距(padding)左上角 的坐标。
screenX / screenY电脑屏幕鼠标指针相对于用户 整个显示器屏幕 左上角的坐标。不常用。

9.8.4. 综合实战:实现电商平台“图片放大镜”效果

承上启下: 理论学习的最终目的是应用于实战。为了将本章所学的 元素查找样式操作事件监听 以及 元素几何定位 等核心知识点融会贯通,我们将从零开始,用原生 JavaScript 复刻一个在电商平台中极其常见的高级交互——“图片放大镜”

这个案例将完美展现我们如何协同运用多个 DOM API 来构建一个复杂的、有状态的 UI 组件。它综合运用了:

  • 事件监听: mouseenter, mouseleave, mousemove 捕捉用户意图。
  • 几何定位: getBoundingClientRect() 获取中图容器的位置,结合 event.clientX/Y 计算出鼠标在容器内的 相对坐标
  • 尺寸测量: offsetWidth/Height 用于计算遮罩层移动的边界。
  • 样式操作: 实时更新遮罩层 (layer) 和大图背景 (large) 的位置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.goods-image {
width: 480px;
height: 400px;
position: relative;
display: flex;
font-family: sans-serif;
}

/* 中图容器 */
.middle {
width: 400px;
height: 400px;
background: #f5f5f5;
position: relative;
border-radius: 8px;
overflow: hidden;
cursor: move;
}

.middle img {
width: 100%;
height: 100%;
object-fit: cover;
}

/* 小图列表 */
.small {
width: 80px;
margin-left: 12px;
list-style: none;
padding: 0;
}

.small li {
width: 68px;
height: 68px;
margin-bottom: 12px;
cursor: pointer;
border: 2px solid #e8e8e8;
border-radius: 4px;
overflow: hidden;
transition: all 0.2s;
}

.small li:hover,
.small li.active {
border-color: #007bff;
}

.small li img {
width: 100%;
height: 100%;
object-fit: cover;
}

/* 放大镜的遮罩层 */
.layer {
width: 200px;
height: 200px;
background: rgba(0, 0, 0, 0.2);
left: 0;
top: 0;
position: absolute;
pointer-events: none;
border-radius: 4px;
backdrop-filter: blur(2px);
display: none;
/* 默认隐藏 */
}

/* 放大后的预览图容器 */
.large {
position: absolute;
top: 0;
left: 412px;
width: 400px;
height: 400px;
z-index: 500;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
background-repeat: no-repeat;
background-size: 800px 800px;
/* 大图尺寸为中图的两倍 */
background-color: #f8f8f8;
border: 1px solid #e8e8e8;
border-radius: 8px;
display: none;
/* 默认隐藏 */
}
</style>

</head>

<body>
<div class="goods-image">
<div class="middle">
<img src="https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=400&h=400&fit=crop" alt="" />
<div class="layer"></div>
</div>
<ul class="small">
<li class="active"><img
src="https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=400&h=400&fit=crop" alt="" />
</li>
<li><img src="https://images.unsplash.com/photo-1434389677669-e08b4cac3105?w=400&h=400&fit=crop" alt="" />
</li>
<li><img src="https://images.unsplash.com/photo-1445205170230-053b83016050?w=400&h=400&fit=crop" alt="" />
</li>
</ul>
<div class="large"></div>
</div>
</body>
<script>
// --- 步骤 1: 初始化与元素获取 ---
const middleBox = document.querySelector('.goods-image .middle');
const middleImg = document.querySelector('.middle img');
const layer = document.querySelector('.middle .layer');
const largeBox = document.querySelector('.goods-image .large');
const smallList = document.querySelector('.goods-image .small');

// 初始化大图的背景图片
largeBox.style.backgroundImage = `url(${middleImg.src})`;

// --- 步骤 2: 实现小图切换功能 (事件委托 + classList) ---
smallList.addEventListener('mouseover', (event) => {
// 确保事件源是 li 元素或其内部的 img
let targetLi = null;
if (event.target.tagName === 'IMG') {
targetLi = event.target.parentElement;
}

if (targetLi) {
// 移除旧的 active class
const currentActive = smallList.querySelector('.active');
if (currentActive) {
currentActive.classList.remove('active');
}

// 给当前 li 添加 active class
targetLi.classList.add('active');

// 更新中图和大图的背景
const newImgSrc = targetLi.querySelector('img').src;
middleImg.src = newImgSrc;
largeBox.style.backgroundImage = `url(${newImgSrc})`;
}
});

// --- 步骤 3: 实现放大镜的显示与隐藏 (mouseenter, mouseleave) ---
middleBox.addEventListener('mouseenter', () => {
layer.style.display = 'block';
largeBox.style.display = 'block';
});

middleBox.addEventListener('mouseleave', () => {
layer.style.display = 'none';
largeBox.style.display = 'none';
});

// --- 步骤 4: 核心逻辑 - 计算并更新滑块与大图位置 (mousemove + 几何定位) ---
middleBox.addEventListener("mousemove", (event) => {
// 1. 获取鼠标在 middleBox 内的坐标
const rect = middleBox.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;

// 2. 计算滑块 (layer) 的目标位置 (让鼠标位于滑块中心)
let layerX = mouseX - layer.offsetWidth / 2;
let layerY = mouseY - layer.offsetHeight / 2;

// 3. 边界约束:确保滑块不会移出 middleBox
const maxX = middleBox.offsetWidth - layer.offsetWidth;
const maxY = middleBox.offsetHeight - layer.offsetHeight;
//
// Math.max(0, layerX) 确保 layerX 不小于 0(不会超出左边界)
// Math.min(layerX, maxX) 确保 layerX 不大于 maxX(不会超出右边界)
layerX = Math.max(0, Math.min(layerX, maxX));
// Math.max(0, layerY) 确保 layerY 不小于 0(不会超出上边界)
// Math.min(layerY, maxY) 确保 layerY 不大于 maxY(不会超出下边界)
layerY = Math.max(0, Math.min(layerY, maxY));

// 4. 应用滑块位置
layer.style.left = `${layerX}px`;
layer.style.top = `${layerY}px`;


// 5. 计算并应用大图的背景位置
// background-position 的参数说明:
// - 第一个参数控制水平方向的偏移量 (x轴),负值表示向左偏移
// - 第二个参数控制垂直方向的偏移量 (y轴),负值表示向上偏移
// 这里使用负值是因为:当滑块向右移动时,我们希望大图显示更右边的区域
// 通过负值偏移,实现了"相反方向"的视觉效果,模拟放大镜的真实体验
const scale = 2;
largeBox.style.backgroundPosition = `${-layerX * scale}px ${-layerY * scale}px`;

})
</script>

</html>

案例总结
这个看似复杂的交互,完全是由我们本章学习的基础知识构建而成。通过这个案例,我们可以深刻体会到,只有精准地获取和计算元素的尺寸与坐标,才能构建出像素级完美的动态交互效果。掌握本章内容,是您从基础 DOM 操作迈向高级 UI 开发的关键一步。


9.9. DOM 性能优化:重绘与回流

我们已经掌握了各式各样强大的 DOM API,可以随心所欲地改变页面。但一个不容忽视的问题是:DOM 操作是昂贵的。不恰当的、频繁的 DOM 操作是导致页面卡顿、动画掉帧、用户体验下降的头号元凶。

本节,我们将通过一个可视化的性能对比实验,来深入理解其背后的原理,并学会如何写出高性能的 DOM 操作代码。

9.9.1. 实战场景:瀑布流布局的初始定位

核心痛点: 假设我们需要为一个包含 1000 个图片卡的瀑布流布局进行初始定位。每个卡片的位置都需要通过 JavaScript 动态计算并设置。

让我们用两种截然不同的方式来实现这个需求,并直观地感受它们的性能差异。

版本一:性能灾难 (循环内“读写”交错)

在这个版本中,我们在循环内部对每个元素进行“写”操作(设置 style.top),然后又立即进行一次“读”操作(item.offsetTop,即使这个读取没有实际用途)。这种“写后即读”的模式是性能杀手。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#container {
position: relative;
width: 100%;
border: 1px solid #ccc;
height: 300px;
overflow-y: scroll;
}

.item {
position: absolute;
width: 100px;
height: 30px;
border: 1px solid steelblue;
background: aliceblue;
text-align: center;
line-height: 30px;
}
</style>

</head>

<body>
<div id="controls">
<button id="bad-btn">运行性能灾难版 (1000个元素)</button>
<div id="log-output">等待操作...</div>
</div>
<div id="container"></div>

</body>
<script>
const container = document.getElementById('container');
const badBtn = document.getElementById('bad-btn');
const log = document.getElementById('log-output');

badBtn.addEventListener('click', () => {
container.innerHTML = ''; // 清空容器
log.textContent = '开始渲染 (性能灾难版)...';
const startTime = performance.now();

for (let i = 0; i < 1000; i++) {
const item = document.createElement('div');
item.className = 'item';

// --- 性能杀手:写后即读 ---
// 1. "写"操作:修改元素的样式,这会被浏览器暂存
item.style.top = (i * 32) + 'px';
item.textContent = `Item ${i}`;
container.append(item);

// 2. "读"操作:为了获取准确的 top 值,强制浏览器清空队列,立刻执行回流
// 即使我们不使用这个值,这个读取行为本身就是命令
const uselessValue = item.offsetTop;
}

const endTime = performance.now();
log.textContent = `[性能灾难版] 渲染完成!耗时: ${ (endTime - startTime).toFixed(2) }ms`;
});
</script>

</html>

点击按钮,你会发现页面可能会卡顿数秒! 这就是“强制同步布局”的威力。每次循环,浏览器都不得不执行一次完整的“计算布局 -> 渲染”流程,1000 个元素就意味着近 1000 次回流。

9.9.2. 性能瓶颈的根源:浏览器的渲染队列

要理解为什么上述代码如此之慢,我们必须了解浏览器为了优化性能所做的一项重要工作:异步渲染队列

当你通过 JS 修改 DOM 样式时(“写”操作),浏览器并不会立即执行渲染。它会将这些操作先存放到一个队列中。然后,在某个合适的时机(通常是当前 JS 任务执行完毕后),浏览器会批量处理这个队列里的所有修改,计算一次布局(回流),然后绘制一次屏幕(重绘)。这种“攒一批再干”的模式极大地提升了性能。

然而,如果你在“写”操作之后,立即进行“读”操作(如获取 offsetTop, clientWidth 等需要精确布局信息的属性),就打破了这个机制。为了给你一个准确的值,浏览器必须 立即清空队列,强制执行回行和重绘

  • 回流: 当修改影响了元素的 几何属性(尺寸、位置、边距等),浏览器需重新计算页面上所有受影响元素的位置和大小。这是最昂贵的操作。
  • 重绘: 当修改只影响元素的 外观样式(颜色、背景等)而不影响布局时,浏览器只需重新“粉刷”受影响的区域。

核心原理: 回流必然导致重绘,重绘不一定导致回流。而 在“写”操作后立即“读”,会强制触发回流。我们的优化目标就是避免这种强制同步行为。

9.9.3. 解决方案:读写分离与批量操作

策略一:彻底分离“读”和“写”

解决方案: 遵循一个简单的黄金法则——先集中读取所有你需要的信息,再集中进行所有 DOM 的写入操作

在这个版本中,我们先在一个循环里计算出所有元素的目标 top 值并存储在一个数组中。这个过程完全不涉及 DOM 操作。然后,在第二个循环里,我们一次性地将所有样式应用到 DOM 元素上,中间不进行任何读取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#container {
position: relative;
width: 100%;
border: 1px solid #ccc;
height: 300px;
overflow-y: scroll;
}

.item {
position: absolute;
width: 100px;
height: 30px;
border: 1px solid steelblue;
background: aliceblue;
text-align: center;
line-height: 30px;
}
</style>

</head>

<body>
<div id="controls">
<button id="bad-btn">运行性能优化版 (1000个元素)</button>
<div id="log-output">等待操作...</div>
</div>
<div id="container"></div>

</body>
<script>
const container = document.getElementById('container');
const badBtn = document.getElementById('bad-btn');
const log = document.getElementById('log-output');

badBtn.addEventListener('click', () => {
container.innerHTML = '';
log.textContent = '开始渲染 (高性能版)...';
const startTime = performance.now();

const positions = [];
// --- 步骤 1: 集中"读" ---
// 在这个例子中,我们的计算不依赖于读取DOM,所以更简单。
// 但如果需要,所有读取操作都应在此阶段完成。
for (let i = 0; i < 1000; i++) {
positions.push(i * 32);
}

// --- 步骤 2: 集中"写" ---
for (let i = 0; i < 1000; i++) {
const item = document.createElement('div');
item.className = 'item';
item.style.top = positions[i] + 'px';
item.textContent = `Item ${i}`;
container.appendChild(item); // 只是放入队列,不会强制回流
}
const endTime = performance.now();
log.textContent = `[高性能版] 渲染完成!耗时: ${ (endTime - startTime).toFixed(2) }ms`;
});
</script>

</html>

点击按钮,你会发现渲染几乎是瞬时完成的! 因为所有的“写”操作都被浏览器有效地缓存和批量处理了,整个过程可能只触发了 一次回流

策略二:使用 DocumentFragment 进行批量插入

当需要创建并插入大量新元素时,DocumentFragment 是一个绝佳的工具。它是一个存在于内存中的、临时的 DOM 容器。

  • 工作原理: 先将所有新创建的元素添加到这个“内存碎片”中,这个过程 完全不会触发回流和重绘。最后,再一次性地将这个完整的“碎片”插入到真实 DOM 中。
  • 效果: 将上千次独立的插入操作,优化为 仅仅一次 对真实 DOM 的修改。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#container {
position: relative;
width: 100%;
border: 1px solid #ccc;
height: 300px;
overflow-y: scroll;
}

.item {
position: absolute;
width: 100px;
height: 30px;
border: 1px solid steelblue;
background: aliceblue;
text-align: center;
line-height: 30px;
}
</style>

</head>

<body>
<div id="controls">
<button id="fragment-btn">运行DocumentFragment优化版 (1000个元素)</button>
<div id="log-output">等待操作...</div>
</div>
<div id="container"></div>

</body>
<script>
const container = document.getElementById('container');
const fragmentBtn = document.getElementById('fragment-btn');
const log = document.getElementById('log-output');

fragmentBtn.addEventListener('click', () => {
container.innerHTML = '';
log.textContent = '开始渲染 (DocumentFragment优化版)...';
const startTime = performance.now();

// --- 使用DocumentFragment优化 ---
// DocumentFragment是一个轻量级的文档片段,不会触发回流
const fragment = document.createDocumentFragment();

for (let i = 0; i < 1000; i++) {
const item = document.createElement('div');
item.className = 'item';
item.style.top = (i * 32) + 'px';
item.textContent = `Item ${i}`;
// 将元素添加到fragment中,不会触发DOM回流
fragment.append(item);
}

// 一次性将所有元素添加到DOM中,只触发一次回流
container.append(fragment);

const endTime = performance.now();
log.textContent = `[DocumentFragment优化版] 渲染完成!耗时: ${ (endTime - startTime).toFixed(2) }ms`;
});
</script>

</html>

9.9.4. 深入理解:回流与重绘

我们在前面已经知道,在“写”操作后立即“读”,会强制浏览器清空渲染队列,触发回流。现在,让我们更系统地定义这两个概念。

浏览器的渲染过程可以大致分为:

  1. 解析 HTML 构建 DOM 树。
  2. 解析 CSS 构建 CSSOM 树。
  3. 将 DOM 和 CSSOM 合并,生成 渲染树
  4. 根据渲染树,计算每个节点在屏幕上的确切位置和大小,这个过程称为 布局回流
  5. 根据计算好的布局信息,将节点绘制到屏幕上,这个过程称为 绘制重绘

页面首次加载时,至少会经历一次回流和重绘。而我们后续的 DOM 和 CSSOM 操作,则会触发后续的回流与重绘。

什么是回流

回流 是指当渲染树中的一部分因为元素的规模尺寸、布局、隐藏等改变而需要重新构建的过程。可以把它想象成对网页的“骨架”或“蓝图”进行重新计算。一个节点的回流可能会导致其所有子节点以及 DOM 中紧随其后的同级节点、甚至父节点的“连锁反应”。

常见触发回流的操作:

  • 页面初始加载:这是不可避免的第一次回流。
  • 添加或删除可见的 DOM 元素
  • 元素位置改变position, top, left 等。
  • 元素尺寸改变width, height, padding, border, margin 等。
  • 元素内容改变:例如,文本数量或图片大小的改变,导致元素尺寸变化。
  • 字体大小改变
  • 调整浏览器窗口大小 (resize 事件)。
  • 获取需要计算的 DOM 属性:这就是我们之前遇到的“性能杀手”。当你读取如 offsetTop, offsetLeft, offsetWidth, offsetHeight, scrollTop, scrollLeft, clientTop, clientWidth, getComputedStyle() 等属性时,浏览器为了返回精确值,必须立即执行回流。

什么是重绘

重绘 是指当渲染树中的一些元素需要更新属性,而这些属性只影响元素的外观、风格,而不会影响其布局时,所发生的过程。可以把它想象成只对网页的某个部分重新“上色”或“化妆”,而不需要动其骨架。

常见触发重绘的属性:

  • color
  • border-style / border-radius
  • background 相关属性 (background-color, background-image 等)
  • visibility
  • text-decoration
  • outline 相关属性
  • box-shadow

核心关系: 回流必然导致重绘,但重绘不一定导致回流。比如,改变元素的 width,既改变了布局(回流),也需要重新绘制它(重绘)。而改变元素的 background-color,只影响外观,不影响布局,所以只会触发重绘,性能开销小得多。我们的首要优化目标是 尽量避免和减少回流

9.9.5. DOM 性能优化黄金法则总结

基于以上原理,我们可以总结出一些简单实用的高性能 DOM 操作法则:

  1. 读写分离,集中操作:这是最重要的法则。在修改 DOM 之前,先通过循环或其它方式将所有需要读取的值(如元素尺寸、位置)缓存到变量中。然后,在另一个集中的步骤中,完成所有的“写”操作(修改样式、增删元素)。

  2. 使用 CSS class 合并样式变更:不要逐条修改 style 属性,这可能导致多次回流。更好的做法是预先定义好一个 CSS 类,然后一次性地用 classNameclassList.add() 来切换样式。

    1
    2
    3
    4
    5
    6
    7
    8
    // 不推荐
    el.style.width = '100px';
    el.style.height = '100px';
    el.style.border = '1px solid red';

    // 推荐
    el.classList.add('new-style');
    /* CSS: .new-style { width: 100px; height: 100px; border: 1px solid red; } */
  3. 批量操作 DOM,善用 DocumentFragment:如前所述,当需要添加多个元素时,先将它们添加到 DocumentFragment 中,最后一次性追加到真实 DOM,将多次回流合并为一次。

  4. 对复杂动画使用 absolutefixed 定位:将需要执行动画的元素脱离文档流(position: absolute/fixed)。这样,它的变化只会影响自身和一个小的图层,而不会引起整个页面的回流,极大地提升动画性能。

  5. 谨慎使用 display: none:使用 display: none 隐藏元素会触发回流,而使用 visibility: hidden 只会触发重绘,因为它虽然不可见,但仍在布局中占据空间。根据需求选择合适的方式。

  6. 使用虚拟 DOM:现代前端框架(如 Vue, React)的性能法宝之一。它们通过在内存中维护一个轻量的 JavaScript 对象树(虚拟 DOM)来模拟真实 DOM。当状态变更时,它们会计算出新旧虚拟树的差异(Diff),然后只将这些最小化的差异批量应用到真实 DOM 上,从而最大限度地减少了直接、昂贵的 DOM 操作。


9.9.6. 控制执行时机:定时器、防抖与节流

除了减少单次操作的开销,控制 操作的频率 也是性能优化的关键,尤其是在处理高频触发的事件(如 resize, scroll, input)时。

基础工具:定时器

JavaScript 提供了 setTimeoutsetInterval 两个函数,它们向任务队列中添加定时任务,让我们能够延迟或周期性地执行代码。

  • setTimeout(callback, delay): 指定 callback 函数在 delay 毫秒之后 执行一次。它返回一个定时器 ID,可用于 clearTimeout() 取消。

    1
    2
    3
    4
    5
    6
    const timerId = setTimeout(() => {
    console.log("这段代码在1秒后执行");
    }, 1000);

    // 如果需要,可以取消它
    // clearTimeout(timerId);

    this 指向问题: 如果 setTimeout 的回调函数是一个对象的方法,该方法内部的 this 将默认指向全局对象(在浏览器中是 window),而不是该对象本身。可以使用箭头函数或 .bind() 来修正 this 指向。

  • setInterval(callback, delay): 指定 callback 函数 每隔 delay 毫秒就执行一次。它同样返回一个 ID,用于 clearInterval() 停止。

    下面的例子使用 setInterval 实现一个简单的淡出动画。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <title>SetInterval Animation</title>
    <style>
    #someDiv {
    width: 100px;
    height: 100px;
    background: steelblue;
    opacity: 1;
    transition: opacity 0.5s; /* 配合CSS过渡更平滑 */
    }
    </style>
    </head>
    <body>
    <div id="someDiv"></div>
    <script>
    const div = document.getElementById('someDiv');
    let opacity = 1;
    const fader = setInterval(function() {
    opacity -= 0.05;
    if (opacity > 0) {
    // 每次修改style都会触发重绘,频率过高会影响性能
    div.style.opacity = opacity;
    } else {
    opacity = 0;
    div.style.opacity = opacity;
    clearInterval(fader); // 必须清除,否则会无限执行
    }
    }, 30);
    </script>
    </body>
    </html>

    动画性能警示:虽然 setInterval 可以实现动画,但它不是最佳选择。它的执行时机不精确,且与浏览器渲染刷新率无关,可能导致掉帧。现代 Web 动画的首选是 requestAnimationFrame,它能确保动画函数在浏览器下一次重绘之前执行,从而实现更流畅、更高性能的动画效果。

实战进阶:防抖

想象一个场景:监听窗口的 scroll 事件来打印滚动条位置。

1
2
3
4
// 未优化的代码
window.addEventListener('scroll', () => {
console.log('滚动条位置:', window.scrollY);
});

如果你快速滚动页面,会发现控制台疯狂输出,函数执行频率极高!如果这个回调函数内部有复杂的 DOM 操作,将导致严重的性能问题。

防抖 的策略是:对于在短时间内连续触发的事件,只执行最后一次。就像电梯关门,只要有人在指定时间内进来,关门计时器就重置,直到最后一个人进来后计时结束,门才关闭。

实现防抖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function debounce(fn, delay) {
let timer = null; // 借助闭包保存定时器 ID

return function(...args) {
// 如果定时器已存在,则清除它,重新开始计时
if (timer) {
clearTimeout(timer);
}

// 设置新的定时器,delay 毫秒后执行真正的函数
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
}
}

应用防抖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<!DOCTYPE html>
<html>

<head>
<title>Debounce Demo</title>
<style>
body {
height: 2000px;
font-family: sans-serif;
}

#log-output {
position: fixed;
top: 10px;
left: 10px;
background: #fff;
border: 1px solid #ccc;
padding: 10px;
}
</style>
</head>

<body>
<div id="log-output">
<p><strong>未防抖:</strong> <span id="log-raw">0</span></p>
<p><strong>防抖 (500ms):</strong> <span id="log-debounced">0</span></p>
</div>
<script>
function debounce(fn, delay) {
let timer = null; // 借助闭包保存定时器 ID

return function (...args) {
// 如果定时器已存在,则清除它,重新开始计时
if (timer) {
clearTimeout(timer);
}

// 设置新的定时器,delay 毫秒后执行真正的函数
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
}
}
const logRaw = document.getElementById('log-raw');
const logDebounced = document.getElementById('log-debounced');
let rawCount = 0;
let debouncedCount = 0;

function updateLog() {
logDebounced.textContent = ++debouncedCount;
}

const debouncedUpdate = debounce(updateLog, 500);

window.addEventListener('scroll', () => {
logRaw.textContent = ++rawCount; // 未防抖,疯狂触发
debouncedUpdate(); // 防抖,只有停止滚动500ms后才触发
});
</script>
</body>

</html>

动手试试:快速滚动上面的沙箱页面,你会看到“未防抖”的计数器飞速增长,而“防抖”的计数器只有在你停止滚动半秒后才会更新。这对于搜索框输入建议、窗口 resize 事件等场景非常有用。

实战进阶:节流

防抖在某些场景下并不完美。比如,如果我们想在拖拽或滚动时实时更新某个元素的位置,防抖会导致只有在停止时才更新,体验不佳。这时就需要 节流

节流 的策略是:在指定的时间间隔内,事件处理函数 最多只执行一次,确保函数以一个可控的频率被调用。就像技能冷却,放了一次技能后,必须等 CD 转好才能放第二次。

实现节流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function throttle(fn, delay) {
let canRun = true; // 通过闭包保存一个状态锁

return function(...args) {
if (!canRun) {
return; // 如果锁是关闭的,直接返回
}

canRun = false; // 立即关闭锁
setTimeout(() => {
fn.apply(this, args);
canRun = true; // delay 毫秒后打开锁
}, delay);
}
}

应用节流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<!DOCTYPE html>
<html>

<head>
<title>Throttle Demo</title>
<style>
body {
height: 2000px;
font-family: sans-serif;
}

#log-output {
position: fixed;
top: 10px;
left: 10px;
background: #fff;
border: 1px solid #ccc;
padding: 10px;
}
</style>
</head>

<body>
<div id="log-output">
<p><strong>未节流:</strong> <span id="log-raw">0</span></p>
<p><strong>节流 (500ms):</strong> <span id="log-throttled">0</span></p>
</div>
<script>
function throttle(fn, delay) {
let canRun = true; // 通过闭包保存一个状态锁

return function (...args) {
if (!canRun) {
return; // 如果锁是关闭的,直接返回
}

canRun = false; // 立即关闭锁
setTimeout(() => {
fn.apply(this, args);
canRun = true; // delay 毫秒后打开锁
}, delay);
}
}
const logRaw = document.getElementById('log-raw');
const logThrottled = document.getElementById('log-throttled');
let rawCount = 0;
let throttledCount = 0;

function updateLog() {
logThrottled.textContent = ++throttledCount;
}

const throttledUpdate = throttle(updateLog, 500);

window.addEventListener('scroll', () => {
logRaw.textContent = ++rawCount; // 未节流,疯狂触发
throttledUpdate(); // 节流,每500ms最多触发一次
});
</script>
</body>

</html>

动手试试:持续上下滚动上面的沙箱页面,你会看到“未节流”的计数器依然飞速增长,而“节流”的计数器会以大约每半秒一次的稳定频率进行更新,既保证了响应性,又避免了性能浪费。

防抖与节流的应用场景总结

  • 使用防抖

    • 搜索框输入建议:用户输完一串字符后才发送请求,而不是每输入一个字母都发。
    • 文本编辑器自动保存:用户停止打字一段时间后才执行保存。
    • 窗口 resize 事件:当用户调整完窗口大小后,再重新计算布局。
  • 使用节流

    • DOM 元素拖拽:在拖拽过程中,按一定频率更新元素位置。
    • 页面滚动事件:如实现滚动加载、高亮导航栏等,需要持续响应但又不能过于频繁。
    • 游戏中的射击:按住开火键,子弹以固定的频率射出。

核心区别:防抖是“你别急,等你停了我再做”,强调 最终结果;节流是“你慢点,按我的节奏来”,强调 过程中的平均响应


9.10. 本章核心原理与高频面试题

核心原理速查

分类关键项 / 概念核心描述与最佳实践
核心模型DOM (文档对象模型)浏览器将 HTML 解析成的、可用 JS 操控的 树形节点结构
元素查找querySelector / querySelectorAll(推荐) 使用 CSS 选择器查找,返回单个元素或 静态 NodeList,功能强大且行为可预测。
内容操作textContent vs innerHTML优先使用 textContent 以避免 XSS 安全风险。仅在确切需要解析 HTML 时才使用 innerHTML
样式操作element.classList(推荐) 通过 add/remove/toggle 操作 CSS 类,实现行为与表现的分离,比直接操作 style 对象更优。
结构变更现代 API (append, remove 等)appendChild, removeChild 等传统 API 更简洁、功能更强(例如支持多个参数)。
事件模型事件流 (捕获与冒泡)事件在 DOM 树中从外到内(捕获),再从内到外(冒泡)的传播过程。
事件委托(核心模式) 在父元素上监听,利用事件冒泡和 event.target 处理子元素事件,高效且支持动态内容。
几何定位getBoundingClientRect()(推荐) 获取元素相对于 视口 的位置和尺寸,是实现 Tooltip、可视区判断等高级交互的首选。
性能优化回流 与 重绘回流(计算布局)代价极高。应避免频繁触发,如在循环中读取布局属性。
DocumentFragment(核心工具) 在内存中进行批量 DOM 操作的容器,最后一次性插入真实 DOM,可将多次回流优化为一次。

高频面试题与陷阱

面试官深度追问
2025-08-29

总结一下,DOM 的常见操作有哪些?

DOM 的常见操作可以归为四大类:

查找:这是所有操作的基础。主要是通过 querySelectorquerySelectorAll 使用 CSS 选择器来精准地找到一个或一批元素。

修改:找到元素后,我们可以修改它的内容(通过 textContentinnerHTML)、HTML 属性(通过 setAttributedataset)以及 CSS 样式(主要通过 classList)。

  1. 结构变更:这指的是动态地改变页面结构,包括创建新元素 (createElement)、将元素添加到页面 (append)、以及从页面移除元素 (remove)。
  1. 事件处理:为元素添加事件监听器 (addEventListener),以响应用户的交互,这是让页面“活”起来的关键。

很好。那你能解释一下什么是“事件代理”或“事件委托”吗?它主要解决了什么问题?

事件委托是一种利用事件冒泡机制的 DOM 事件处理模式。它的核心思想是,不给大量的子元素逐一绑定事件监听器,而是只给它们的共同父元素绑定一个监听器。

当某个子元素被触发事件时,这个事件会沿着 DOM 树向上冒泡,最终被父元素的监听器捕获。在父元素的监听函数中,我们可以通过检查 event.target 属性,来判断事件的真正来源是哪个子元素,然后执行相应的逻辑。

它主要解决了两个核心问题:第一是性能问题,极大地减少了事件监听器的数量,节省了内存;第二是动态内容问题,对于后续通过 JS 动态添加到父容器中的新子元素,这个委托的监听器依然对它们有效,无需重新绑定。

非常好。最后一个问题,如果让你写一个函数,判断一个元素是否完全出现在了浏览器的可视区域内,你会怎么实现?

我会使用 element.getBoundingClientRect() 方法来实现。这个方法返回一个对象,包含了元素相对于浏览器视口的 top, bottom, left, right 等坐标。

一个元素完全可见,必须同时满足四个条件:它的顶部必须在视口的上边界之下,它的底部必须在视口的下边界之上,它的左边必须在视口的左边界之右,它的右边必须在视口的右边界之左。