对于前端开发者来说,让静态的 HTML 页面变得生动、可交互是核心技能之一。实现这一切的关键在于理解和运用文档对象模型 (DOM) 以及 JavaScript 的事件处理机制。本文将带你深入浅出地探索 DOM 操作的奥秘,并掌握JavaScript 事件处理的方方面面。
目录
Part 1: 文档对象模型 (DOM) 揭秘
1. 什么是 DOM?
2. DOM 树结构
Part 2: 核心 DOM 操作:查询、创建、修改、删除
1. 查询 DOM 元素 ("查")
2. 创建新 DOM 元素 ("增")
3. 添加/插入 DOM 元素 ("增")
4. 修改 DOM 元素 ("改")
5. 删除 DOM 元素 ("删")
6. 替换 DOM 元素 ("改")
Part 3: JavaScript 事件处理机制
1. 什么是事件?
2. 事件监听器
3. 事件对象
4. 事件流:捕获与冒泡的奇妙旅程
5. 事件委托/代理:聪明的事件管理员
6. 常见事件类型
Part 4: 实践与最佳做法:写出高效、优雅的 DOM 与事件代码
1. 提升性能的考量
2. 提升代码组织与可读性
3. 关注可访问性 (Accessibility - a11y)
Part 1: 文档对象模型 (DOM) 揭秘
1. 什么是 DOM?
DOM (Document Object Model) 即文档对象模型,是浏览器为 HTML 或 XML 文档创建的一个编程接口。简单来说,当浏览器加载一个 HTML 页面时,它会解析 HTML 代码并创建一个树状的数据结构来表示这个页面——这个树就是 DOM。
- 桥梁作用: DOM 是 HTML 文档与 JavaScript 之间沟通的桥梁。JavaScript 不能直接理解原始的 HTML 文本,但它可以理解和操作 DOM 这个对象集合。
- 树形结构: DOM 将文档中的每一个部分(如元素、文本、注释等)都视为一个节点 (Node)。这些节点按照它们在 HTML 中的层级关系组织起来,形成一个树形结构。
- 节点类型: 主要有元素节点 (Element Node)、文本节点 (Text Node)、属性节点 (Attribute Node) 和注释节点 (Comment Node)。我们最常打交道的是元素节点和文本节点。
2. DOM 树结构
想象一下你的 HTML 文档是一棵家谱树:
<html>
标签是树根。<head>
和<body>
是<html>
的直接子节点。<body>
内部的<div>
,<p>
,<ul>
等元素是其子节点,它们之间也可能存在父子或兄弟关系。
document
对象是整个 DOM 树的入口点,代表了整个文档。我们可以通过 document
对象来访问和操作页面上的任何元素。
HTML 示例:
<html>
<head>
<title>我的页面</title>
</head>
<body>
<header>
<h1>欢迎!</h1>
</header>
<main>
<p>这是一个段落。</p>
<button id="myButton">点我</button>
</main>
</body>
</html>
DOM 树 (简化表示):
document
└── html
├── head
│ └── title
│ └── "我的页面" (文本节点)
└── body
├── header
│ └── h1
│ └── "欢迎!" (文本节点)
└── main
├── p
│ └── "这是一个段落。" (文本节点)
└── button (id="myButton")
└── "点我" (文本节点)
Part 2: 核心 DOM 操作:查询、创建、修改、删除
JavaScript 提供了丰富的 API 来对 DOM 进行增、删、改、查操作。
1. 查询 DOM 元素 ("查")
获取页面上已存在的元素是进行后续操作的第一步。
document.getElementById('id')
: 通过元素的id
属性获取单个元素。ID 在文档中应该是唯一的。const myButton = document.getElementById('myButton'); console.log(myButton); // 输出 <button id="myButton">...</button>
document.getElementsByClassName('className')
: 通过元素的class
名称获取一个HTMLCollection (元素的集合)。const items = document.getElementsByClassName('list-item'); // 假设页面有多个 class="list-item" 的元素 for (let i = 0; i < items.length; i++) { console.log(items[i]); }
document.getElementsByTagName('tagName')
: 通过元素的标签名 (如'p'
,'div'
) 获取一个HTMLCollection。const paragraphs = document.getElementsByTagName('p'); console.log(paragraphs.length); // 输出页面中 <p> 元素的数量
document.querySelector('cssSelector')
: 使用 CSS 选择器语法获取匹配到的第一个元素。非常强大和灵活。const firstListItem = document.querySelector('ul li.active'); // 获取 ul 下第一个 class 为 active 的 li const header = document.querySelector('#main-header h1'); console.log(header);
document.querySelectorAll('cssSelector')
: 使用 CSS 选择器语法获取匹配到的所有元素,返回一个NodeList (静态的节点集合)。
注意:const allLinks = document.querySelectorAll('a[target="_blank"]'); // 获取所有 target="_blank" 的链接 allLinks.forEach(link => { console.log(link.href); });
HTMLCollection
是动态的,意味着如果文档结构发生变化,它会实时反映这些变化。NodeList
(由querySelectorAll
返回的) 通常是静态的,它是在查询那一刻的快照。HTMLCollection
和NodeList
都可以通过索引访问元素,并且有length
属性,但NodeList
可以使用forEach
方法,而HTMLCollection
通常需要转换成数组 (Array.from()
) 后才能使用forEach
。
2. 创建新 DOM 元素 ("增")
我们可以动态地创建新的 HTML 元素。
document.createElement('tagName')
: 创建指定标签名的新元素节点。const newDiv = document.createElement('div'); const newParagraph = document.createElement('p');
document.createTextNode('text')
: 创建一个包含指定文本的文本节点。const paragraphText = document.createTextNode('这是新段落的内容。');
document.createDocumentFragment()
: 创建一个文档片段。这是一个轻量级的 DOM 容器,当你需要一次性向 DOM 中添加多个元素时,先将它们添加到 DocumentFragment 中,然后再将 DocumentFragment 一次性添加到主 DOM 树,这样可以减少页面重绘和回流,提高性能。const fragment = document.createDocumentFragment(); const item1 = document.createElement('li'); item1.textContent = '列表项1'; const item2 = document.createElement('li'); item2.textContent = '列表项2'; fragment.appendChild(item1); fragment.appendChild(item2); // 稍后可以将 fragment 添加到 ul 元素中
3. 添加/插入 DOM 元素 ("增")
创建元素后,需要将它们插入到 DOM 树的特定位置。
parentNode.appendChild(childNode)
: 将childNode
作为parentNode
的最后一个子节点添加。const parentDiv = document.getElementById('parent'); const newChild = document.createElement('p'); newChild.textContent = '我是新来的子节点。'; parentDiv.appendChild(newChild);
parentNode.insertBefore(newNode, referenceNode)
: 在parentNode
的子节点referenceNode
之前插入newNode
。如果referenceNode
为null
,则其行为类似appendChild
。const parent = document.getElementById('listContainer'); const newItem = document.createElement('li'); newItem.textContent = '新列表项 (插入)'; const firstItem = parent.querySelector('li'); // 假设已有列表项 if (firstItem) { parent.insertBefore(newItem, firstItem); } else { parent.appendChild(newItem); // 如果没有参考节点,则添加到末尾 }
- 现代插入方法 (更便捷):
element.append(...nodesOrDOMStrings)
: 在element
的子节点的末尾插入一个或多个节点或 DOM 字符串。element.prepend(...nodesOrDOMStrings)
: 在element
的子节点的开头插入。element.before(...nodesOrDOMStrings)
: 在element
之前插入。element.after(...nodesOrDOMStrings)
: 在element
之后插入。
const container = document.getElementById('container'); const p1 = document.createElement('p'); p1.textContent = '段落1 (append)'; const p2 = document.createElement('p'); p2.textContent = '段落2 (prepend)'; container.append(p1, '一些文本'); // 可以同时添加节点和文本 container.prepend(p2);
4. 修改 DOM 元素 ("改")
获取或创建元素后,我们可以修改其内容、属性和样式。
-
修改内容:
element.textContent
: 获取或设置元素的纯文本内容。它会自动转义 HTML 标签,所以相对安全,性能也较好。const greeting = document.getElementById('greeting'); greeting.textContent = '你好,世界!'; // 设置文本 console.log(greeting.textContent); // 获取文本
element.innerHTML
: 获取或设置元素的 HTML 内容。可以用来插入 HTML 结构,但要注意 XSS (跨站脚本) 风险。只应在内容来源可信或已做适当清理时使用。const contentDiv = document.getElementById('content'); contentDiv.innerHTML = '<strong>加粗文本</strong> 和一个 <a href="#">链接</a>';
element.innerText
: 与textContent
类似,但它会考虑元素的CSS样式(例如display: none
的元素内容不会被获取),并且在设置时会触发重排。通常推荐使用textContent
。
-
修改属性 :
element.getAttribute('attrName')
: 获取指定属性的值。element.setAttribute('attrName', 'value')
: 设置或更新指定属性的值。element.hasAttribute('attrName')
: 检查元素是否拥有指定属性。element.removeAttribute('attrName')
: 移除指定属性。const myImage = document.getElementById('myImage'); myImage.setAttribute('src', 'new_image.jpg'); myImage.setAttribute('alt', '一张新图片'); console.log(myImage.getAttribute('src')); // new_image.jpg if (myImage.hasAttribute('data-custom')) { myImage.removeAttribute('data-custom'); }
- 直接属性访问: 对于标准的 HTML 属性,通常可以直接作为元素的 JavaScript 对象属性来访问和修改,如
element.id
,element.className
,element.src
,element.href
,element.value
(对于表单元素)。
属性 (Attributes) vs 特性 (Properties): HTML 标签上写的叫属性 (attribute),JS 对象上的叫特性 (property)。多数情况下它们是同步的,但有些例外 (如const link = document.querySelector('a'); link.href = 'https://www.example.com'; link.id = 'mySpecialLink';
input
的value
attribute 反映初始值,而value
property 反映当前值)。
-
修改样式 :
element.style.property = 'value'
: 直接修改元素的内联样式。CSS 属性名需要转换为驼峰式命名 (如background-color
变为backgroundColor
)。const box = document.getElementById('box'); box.style.width = '200px'; box.style.height = '200px'; box.style.backgroundColor = 'lightblue'; box.style.border = '1px solid blue';
element.className
: 获取或设置元素的class
属性字符串。设置会覆盖所有现有类名。const message = document.getElementById('message'); message.className = 'alert success'; // 设置多个类
element.classList
(推荐): 提供更方便的方法来操作类名,返回一个DOMTokenList
。add('className1', 'className2', ...)
: 添加一个或多个类名。remove('className1', 'className2', ...)
: 移除一个或多个类名。toggle('className', forceBoolean?)
: 切换类名。如果存在则移除,不存在则添加。可选的forceBoolean
参数可以强制添加或移除。contains('className')
: 检查是否存在指定类名。
const panel = document.getElementById('infoPanel'); panel.classList.add('visible', 'highlight'); panel.classList.remove('hidden'); if (panel.classList.contains('highlight')) { console.log('Panel is highlighted.'); } panel.classList.toggle('expanded');
getComputedStyle(element).property
: 获取元素最终计算后的样式值(包括来自外部CSS文件、内部样式表和内联样式的综合结果)。返回的是只读的。const styledElement = document.getElementById('styledElement'); const computedColor = window.getComputedStyle(styledElement).color; console.log('Computed color:', computedColor);
5. 删除 DOM 元素 ("删")
parentNode.removeChild(childNode)
: 从parentNode
中移除指定的childNode
。被移除的节点仍然存在于内存中,可以被重新添加到文档中。const list = document.getElementById('myList'); const itemToRemove = document.getElementById('item-2'); if (list && itemToRemove && itemToRemove.parentNode === list) { // 确保是其子节点 list.removeChild(itemToRemove); }
- 现代方法:
element.remove()
: 直接从其父节点中移除该元素自身。更简洁。const oldAd = document.getElementById('oldAdBanner'); if (oldAd) { oldAd.remove(); }
6. 替换 DOM 元素 ("改")
parentNode.replaceChild(newNode, oldNode)
: 用newNode
替换parentNode
中的子节点oldNode
。const container = document.getElementById('contentArea'); const oldParagraph = container.querySelector('p.old-text'); const newParagraph = document.createElement('p'); newParagraph.textContent = '这是更新后的内容。'; newParagraph.className = 'new-text'; if (container && oldParagraph) { container.replaceChild(newParagraph, oldParagraph); }
Part 3: JavaScript 事件处理机制
事件是用户与网页交互(如点击按钮、输入文本)或浏览器自身状态改变(如页面加载完成)时发出的信号。JavaScript 允许我们“监听”这些事件,并在事件发生时执行特定的代码。
1. 什么是事件?
- 用户行为:
click
(点击),keydown
(按下键盘),mousemove
(鼠标移动),submit
(表单提交) 等。 - 浏览器行为:
load
(页面或资源加载完成),DOMContentLoaded
(DOM树构建完成),error
(资源加载错误) 等。 - 事件驱动编程: JavaScript 在浏览器中的很多行为都是基于事件驱动的,即代码的执行是由特定事件的发生来触发的。
2. 事件监听器
我们通过事件监听器来响应事件。
-
HTML 内联属性 (不推荐)
<button onclick="alert('按钮被点击了!')">点我 (内联)</button>
这种方式将 JavaScript 代码直接写在 HTML 中,不利于代码分离和维护。
-
DOM0 级事件处理:
将一个函数赋值给元素的事件处理属性 (如 onclick, onmouseover)
const button0 = document.getElementById('btn0'); button0.onclick = function() { console.log('DOM0 级事件:按钮被点击!'); }; // 再次赋值会覆盖之前的 // button0.onclick = function() { console.log('新的点击处理'); };
缺点是每个事件类型只能绑定一个处理函数。
-
DOM2 级事件处理 (addEventListener - 推荐):
这是现代 Web 开发中最常用和推荐的方式。
- 语法:
element.addEventListener('eventType', handlerFunction, useCaptureOrOptionsObject)
eventType
: 事件类型字符串,如'click'
,'mouseover'
,'keydown'
(不需要on
前缀)。handlerFunction
: 事件发生时要执行的函数。useCaptureOrOptionsObject
:- 布尔值:
true
表示在捕获阶段处理事件,false
(默认) 表示在冒泡阶段处理。 - 对象:可以传递更详细的选项,如
{ capture: true, once: true, passive: true }
。once: true
: 处理函数最多执行一次后自动移除。passive: true
: 向浏览器表明处理函数不会调用event.preventDefault()
,有助于优化滚动等性能。
- 布尔值:
- 移除监听器:
element.removeEventListener('eventType', handlerFunction, useCaptureOrBoolean)
- 重要: 移除时传入的
handlerFunction
必须与添加时传入的函数是同一个引用。匿名函数无法通过这种方式移除。
- 重要: 移除时传入的
const button2 = document.getElementById('btn2'); function handleClick() { console.log('DOM2 级事件:按钮被点击!'); } function anotherClickHandler() { console.log('DOM2 级事件:按钮也被另一个函数处理了!') } button2.addEventListener('click', handleClick); button2.addEventListener('click', anotherClickHandler); // 可以为同一事件添加多个处理函数 // 移除 handleClick // button2.removeEventListener('click', handleClick);
- 语法:
3. 事件对象
当事件发生时,浏览器会自动创建一个包含事件相关信息的对象,并将其作为参数传递给事件处理函数。通常我们将其命名为 event
或 e
。
-
常用属性:
event.type
: (字符串) 事件的类型,如'click'
。event.target
: (元素节点) 实际触发事件的那个元素(事件源)。event.currentTarget
: (元素节点) 事件监听器当前附加到的那个元素。在事件冒泡过程中,target
是不变的,而currentTarget
会是路径上每个正在处理事件的元素。event.bubbles
: (布尔值) 事件是否冒泡。event.cancelable
: (布尔值) 事件是否可以被取消默认行为。
-
常用方法:
event.preventDefault()
: 阻止事件的默认浏览器行为。例如,阻止链接的跳转,阻止表单的提交。event.stopPropagation()
: 停止事件在 DOM 树中的进一步传播(冒泡或捕获)。
-
特定事件类型的属性:
- 鼠标事件 :
event.clientX
,event.clientY
: 鼠标指针相对于浏览器窗口可视区域的坐标。event.pageX
,event.pageY
: 鼠标指针相对于整个文档的坐标。event.button
: 按下的鼠标按钮 (0: 左键, 1: 中键, 2: 右键)。event.altKey
,event.ctrlKey
,event.shiftKey
,event.metaKey
(Mac上的Command键): 布尔值,表示相应的功能键是否被按下。
- 键盘事件 :
event.key
: 按下的键的字符值 (如'a'
,'Enter'
,'ArrowUp'
)。推荐使用。event.code
: 按下的物理键的代码 (如'KeyA'
,'Enter'
,'ArrowUp'
),不受键盘布局影响。event.altKey
,event.ctrlKey
,event.shiftKey
,event.metaKey
: 同上。
const myLink = document.getElementById('myLink'); myLink.addEventListener('click', function(event) { event.preventDefault(); // 阻止链接默认的跳转行为 console.log('链接被点击,但默认行为已阻止。'); console.log('事件类型:', event.type); // "click" console.log('目标元素:', event.target); // <a> 元素 console.log('当前目标:', event.currentTarget); // <a> 元素 }); const inputField = document.getElementById('textInput'); inputField.addEventListener('keydown', function(e) { console.log(`按下的键: ${e.key}, 物理键码: ${e.code}`); if (e.key === 'Enter') { console.log('回车键被按下!'); // e.preventDefault(); // 如果在表单中,可能需要阻止默认提交 } });
- 鼠标事件 :
4. 事件流:捕获与冒泡的奇妙旅程
想象一下,当你在网页上的一个按钮上点击时,这个“点击”的信号并不仅仅是按钮自己知道,它其实经历了一场从“天”到“地”,再从“地”到“天”的旅程。这个旅程就是事件流,它描述了事件在 DOM 树中传播的顺序。
事件流主要包含三个阶段:
-
捕获阶段 :圣旨下达
- 当事件发生时,它首先像一道“圣旨”从最高层(
window
对象)开始,沿着 DOM 树的层级关系,由外向内,一层层向下传播。它会经过目标元素的祖先元素(比如包含按钮的div
,再往上的body
等),一直“下达”到实际触发事件的那个元素(我们称之为“目标元素”,比如你点击的那个按钮event.target
)。 - 在这个阶段,如果你给沿途的父元素设置了“在捕获阶段就处理”的监听器,它们就能提前“截获”并处理这个事件,就像大臣们在圣旨到达最终目的地前就能阅览一样。
- 当事件发生时,它首先像一道“圣旨”从最高层(
-
目标阶段 :正主响应
- “圣旨”(事件)终于到达了它的最终目的地——目标元素(比如那个被点击的按钮)。
- 浏览器会检查这个目标元素本身是否注册了针对该事件的监听器。如果注册了,就会执行相应的处理函数。此时,无论是设置为捕获阶段还是冒泡阶段(默认)的监听器,只要是绑定在目标元素上的,都会在这个阶段被触发(具体顺序取决于绑定时的设置和浏览器实现,但通常捕获优先于冒泡)。
-
冒泡阶段:消息上报
- 在目标元素处理完事件(或者即使没有处理)之后,这个事件通常不会就此消失。它会像水中的气泡一样,从目标元素开始,沿着 DOM 树的层级关系,由内向外,一层层向上“冒泡”传播。它会再次经过目标元素的父元素、祖父元素,一直到文档的根节点,最终回到
window
对象。 - 这是大多数事件(如
click
,mouseover
等)的默认行为。如果你给沿途的父元素设置了“在冒泡阶段处理”(这是addEventListener
的默认情况)的监听器,它们就能在这个事件“回程”的阶段响应事件,就像地方官员逐级向上传达消息一样。
- 在目标元素处理完事件(或者即使没有处理)之后,这个事件通常不会就此消失。它会像水中的气泡一样,从目标元素开始,沿着 DOM 树的层级关系,由内向外,一层层向上“冒泡”传播。它会再次经过目标元素的父元素、祖父元素,一直到文档的根节点,最终回到
如何在代码中控制?
addEventListener
方法的第三个参数用来精确控制监听器在哪个阶段触发:
element.addEventListener('click', myFunction, true);
将第三个参数设置为true
,则myFunction
会在捕获阶段执行。element.addEventListener('click', myFunction, false);
(或者不写第三个参数,因为默认值就是false
),则myFunction
会在冒泡阶段执行。
代码示例:
<div id="grandparent" style="padding: 30px; background-color: lightblue;">
祖父
<div id="parent" style="padding: 20px; background-color: lightgreen;">
父亲
<button id="child" style="padding: 10px; background-color: yellow;">孩子 </button>
</div>
</div>
const grandparent = document.getElementById('grandparent');
const parent = document.getElementById('parent');
const child = document.getElementById('child');
// --- 捕获阶段监听 ---
grandparent.addEventListener('click', function(event) {
console.log(`捕获阶段:监听器在 ${this.id},事件目标是 ${event.target.id}`);
}, true);
parent.addEventListener('click', function(event) {
console.log(`捕获阶段:监听器在 ${this.id},事件目标是 ${event.target.id}`);
}, true);
child.addEventListener('click', function(event) {
// 对于目标元素,捕获阶段的监听器会在目标阶段早期被触发
console.log(`捕获/目标阶段:监听器在 ${this.id},事件目标是 ${event.target.id}`);
}, true);
// --- 冒泡阶段监听 (默认) ---
grandparent.addEventListener('click', function(event) {
console.log(`冒泡阶段:监听器在 ${this.id},事件目标是 ${event.target.id}`);
});
parent.addEventListener('click', function(event) {
console.log(`冒泡阶段:监听器在 ${this.id},事件目标是 ${event.target.id}`);
// 如果在这里调用 event.stopPropagation(); 下面的 grandparent 的冒泡监听就不会执行了
// event.stopPropagation();
});
child.addEventListener('click', function(event) {
// 对于目标元素,冒泡阶段的监听器会在目标阶段晚期(或紧随其后)被触发
console.log(`冒泡/目标阶段:监听器在 ${this.id},事件目标是 ${event.target.id}`);
});
console.log("--- 请点击 '孩子' 按钮 ---");
当你点击 "孩子 " 按钮后,控制台大致的输出顺序会清晰地展示事件流的路径——先从外向内(捕获),到达目标,再从内向外(冒泡):
捕获阶段:监听器在 grandparent,事件目标是 child
捕获阶段:监听器在 parent,事件目标是 child
捕获/目标阶段:监听器在 child,事件目标是 child
冒泡/目标阶段:监听器在 child,事件目标是 child
冒泡阶段:监听器在 parent,事件目标是 child
冒泡阶段:监听器在 grandparent,事件目标是 child
阻止事件传播:event.stopPropagaton()
如果你不希望事件在当前元素处理后,继续向上冒泡(或者在捕获阶段继续向下传播),可以在事件处理函数中调用 event.stopPropagation()
。这就好比在消息传递的某个环节说:“消息到此为止,不用再往其他地方传了!”这在某些情况下可以避免父元素上不必要的事件处理。
5. 事件委托/代理:聪明的事件管理员
想象一个场景:你有一个很长的待办事项列表 (<ul>
),列表中的每一项 (<li>
) 点击后都需要标记为完成。如果列表有100项,难道我们要给这100个 <li>
元素都单独绑定一个点击事件监听器吗?
这样做会有两个主要问题:
- 性能开销: 绑定大量的事件监听器会占用更多的内存和CPU资源,尤其是在元素非常多的时候。
- 动态元素的烦恼: 如果你通过JavaScript动态地向列表中添加了新的待办事项,新添加的这些
<li>
元素是不会自动拥有之前绑定的点击事件的,你还得手动为它们再绑定一次,非常麻烦。
事件委托 就是来解决这些问题的“聪明管理员”!
核心思想:
与其给每个子元素都派一个“警卫”(事件监听器),不如只在它们的共同父元素(比如 <ul>
)上派一个“总警卫”。利用事件冒泡的原理(还记得吗?事件会从目标元素向上传播),当任何一个子元素(比如某个 <li>
)被点击时,这个点击事件会“冒泡”到父元素 <ul>
上。这时,父元素上的“总警卫”就会被触发。
工作原理:
- 将事件监听器绑定在这些子元素的共同父元素上。
- 当子元素上的事件(如
click
)发生并冒泡到父元素时,父元素的监听器被触发。 - 在父元素的监听器函数内部,我们可以通过事件对象
event.target
来判断究竟是哪个子元素真正触发了这个事件。event.target
指向的是实际被点击的那个最具体的元素(也就是我们的“消息来源”)。
优点:
- 高效: 只需要一个事件监听器,大大减少了内存占用和初始设置的开销。
- 灵活: 对于动态添加的子元素(比如用户新加的待办事项),无需重新绑定事件,它们产生的事件同样会冒泡到父元素,被统一处理,非常方便。
- 简洁: 代码管理更方便,逻辑更集中。
代码示例:
<ul id="todoList" style="border:1px solid #ccc; padding: 10px;">
<li>学习 JavaScript</li>
<li>做个练习项目</li>
<li>喝杯水</li>
</ul>
<button id="addTodoBtn">添加待办事项</button>
<style>
.completed {
text-decoration: line-through;
color: grey;
}
</style>
const todoList = document.getElementById('todoList');
const addTodoBtn = document.getElementById('addTodoBtn');
// 在父元素 ul 上设置事件监听器
todoList.addEventListener('click', function(event) {
// event.target 是用户实际点击的那个元素
// 我们只关心被点击的是否是 <li> 元素
if (event.target && event.target.tagName === 'LI') {
// event.target.tagName 返回的是大写的标签名,如 'LI'
// 也可以用更现代的方式 event.target.matches('li')
const listItemText = event.target.textContent;
console.log(`你点击了待办事项: "${listItemText}"`);
// 给被点击的 <li> 添加/移除一个 "completed" 样式类
event.target.classList.toggle('completed'); // toggle 会在有和无之间切换
// 你也可以在这里做其他操作,比如删除、编辑等
}
});
// 动态添加新的待办事项
let todoCounter = 3;
addTodoBtn.addEventListener('click', function() {
todoCounter++;
const newTodo = document.createElement('li');
newTodo.textContent = `新的待办 ${todoCounter}`;
todoList.appendChild(newTodo);
// 注意:新添加的 <li> 无需单独绑定事件,点击它时,父元素 ul 的监听器依然会处理!
});
在这个例子中,无论列表有多少项,或者之后动态添加了多少项,我们都只需要 todoList
(父元素 <ul>
) 上的那一个点击事件监听器。它就像一个聪明的管理员,有效地管理着所有子项的点击事件。
6. 常见事件类型
- 鼠标事件:
click
: 单击。dblclick
: 双击。mousedown
: 鼠标按钮按下。mouseup
: 鼠标按钮释放。mousemove
: 鼠标在元素上移动。mouseover
: 鼠标指针进入元素或其子元素。mouseout
: 鼠标指针离开元素或进入其子元素。mouseenter
: 鼠标指针进入元素 (不冒泡,不关心子元素)。mouseleave
: 鼠标指针离开元素 (不冒泡,不关心子元素)。
- 键盘事件:
keydown
: 按下键盘键。keyup
: 释放键盘键。keypress
: (已不推荐使用) 按下产生字符的键时触发。
- 表单事件:
submit
: 表单提交时 (通常在<form>
元素上监听)。change
: 表单元素内容改变且失去焦点后 (如<input type="text">
,<select>
,<input type="checkbox">
)。input
: 表单元素内容实时改变时 (如<input type="text">
,<textarea>
)。focus
: 元素获得焦点。blur
: 元素失去焦点。
- 窗口/文档事件:
load
: 整个页面及所有资源 (图片、CSS等) 加载完成后在window
上触发。也用于图片等单个资源的加载完成。DOMContentLoaded
: HTML文档被完全加载和解析完成之后触发,无需等待样式表、图像和子框架的完成加载 (在document
上触发)。通常比load
事件更早触发,是执行初始化脚本的好时机。resize
: 浏览器窗口大小改变时在window
上触发。scroll
: 页面或元素滚动时触发。
- 触摸事件 (主要用于移动设备):
touchstart
,touchmove
,touchend
,touchcancel
。
Part 4: 实践与最佳做法:写出高效、优雅的 DOM 与事件代码
掌握了 DOM 操作和事件处理的基础后,如何写出更专业、性能更好、更易于维护的代码呢?以下是一些重要的实践和最佳做法,并配有代码示例帮助理解。
1. 提升性能的考量
频繁或不当的 DOM 操作是导致页面性能下降的常见原因。
-
a. 减少 DOM 操作次数:批量处理是王道
不良示例: 在循环中逐个添加元素到 DOM。
// 假设有一个数组 itemsData = ['苹果', '香蕉', '橙子']; // const myList = document.getElementById('myList'); // itemsData.forEach(itemText => { // const listItem = document.createElement('li'); // listItem.textContent = itemText; // myList.appendChild(listItem); // 每次循环都操作DOM,导致多次重绘/回流 // }); // console.log("不推荐:逐个添加列表项");
推荐示例: 使用
DocumentFragment
进行批量添加。DocumentFragment
是一个轻量级的 DOM 容器,你可以先把所有新元素添加到它里面,最后再一次性把DocumentFragment
添加到主 DOM 树,这样只会触发一次重绘/回流。const itemsData = ['苹果', '香蕉', '橙子']; const myList = document.getElementById('myList'); // 假设页面有 <ul id="myList"></ul> if (myList) { const fragment = document.createDocumentFragment(); // 创建文档片段 itemsData.forEach(itemText => { const listItem = document.createElement('li'); listItem.textContent = itemText; fragment.appendChild(listItem); // 先添加到 fragment }); myList.appendChild(fragment); // 一次性添加到真实DOM console.log("推荐:使用 DocumentFragment 批量添加列表项"); }
-
b. 避免布局抖动
当你在 JavaScript 中连续地读取元素的布局属性(如
offsetHeight
,offsetTop
,clientWidth
等),然后又立即修改这些可能影响布局的属性,接着又去读取,浏览器可能需要在每次读/写之间强制重新计算布局,这非常耗性能。不良示例: 循环中交替读写布局属性。
// const elements = document.querySelectorAll('.box-item'); // // 假设我们想让每个盒子的宽度等于其高度 // elements.forEach(el => { // // 读取 offsetHeight (导致回流以获取精确值) // const h = el.offsetHeight; // // 修改 width (可能导致重绘/回流) // el.style.width = h + 'px'; // 每次循环都可能触发强制同步布局 // }); // console.log("不推荐:循环中交替读写布局属性");
推荐示例: 先集中读取,再集中写入。
const elements = document.querySelectorAll('.box-item'); // 假设页面有一些 .box-item 元素 const heights = []; // 步骤1: 集中读取所有需要的值 elements.forEach(el => { heights.push(el.offsetHeight); }); // 步骤2: 集中写入(修改DOM) elements.forEach((el, index) => { el.style.width = heights[index] + 'px'; }); console.log("推荐:分离读写操作,避免布局抖动");
-
c. 优先使用事件委托
当有大量相似元素需要相同的事件处理时(比如一个长列表的每一项),为每个元素都绑定监听器不如在它们的共同父元素上设置一个监听器。这在 Part 3 的“事件委托”部分已有详细代码演示。
核心优势回顾: 减少内存占用,动态添加的子元素也能自动应用事件处理。
-
d. 合理使用节流与防抖
对于高频触发的事件,如
scroll
(滚动)、resize
(窗口大小调整)、mousemove
(鼠标移动) 以及输入框的input
事件,如果不加控制,事件处理函数会被过度执行,严重影响性能。- 防抖 : 任务频繁触发的情况下,只有当任务触发的间隔超过指定时间时,任务才会执行一次。适用于用户停止操作后才需要响应的场景(如输入框搜索建议)。
- 节流: 规定在一个单位时间内,只能触发一次函数。如果这个单位时间内触发多次函数,只有一次生效。适用于需要规律性响应的场景(如滚动加载)。
代码演示(概念性用法,假设
debounce
和throttle
函数已定义):// 假设已在别处定义了 debounce 和 throttle 函数 // function debounce(func, delay) { /* ... */ } // function throttle(func, limit) { /* ... */ } // const searchInput = document.getElementById('searchInput'); // if (searchInput && typeof debounce === 'function') { // searchInput.addEventListener('input', debounce(function(event) { // console.log('防抖搜索:', event.target.value); // // 实际的搜索逻辑 // }, 500)); // 用户停止输入500ms后执行 // } // if (typeof throttle === 'function') { // window.addEventListener('scroll', throttle(function() { // console.log('节流处理滚动事件'); // // 实际的滚动处理逻辑,比如判断是否加载更多 // }, 200)); // 每200ms最多执行一次 // } console.log("提示:对于高频事件,务必使用节流或防抖优化。");
(注:完整的 节流和防抖实现较为复杂,这里仅演示调用方式。)
2. 提升代码组织与可读性
-
a. 关注点分离:结构、样式、行为各司其职
尽量将 HTML (结构)、CSS (样式) 和 JavaScript (行为) 分离开。
不良示例 (混合):
-
<!DOCTYPE html> <html> <head> <title>混合示例</title> </head> <body> <button id="mixedBtn" style="color: red; font-weight: bold; padding: 10px;" onclick="this.textContent = '我被点击了!'; console.log('混合按钮被点击'); alert('弹窗!');"> 点我看看 (样式和行为都在HTML里) </button> </body> </html>
推荐示例 (分离):
<button id="separatedBtn">点我</button> <style> /* 通常CSS在外部文件 */ #separatedBtn { color: blue; } #separatedBtn.clicked { font-weight: bold; } </style>
const separatedBtn = document.getElementById('separatedBtn'); if (separatedBtn) { separatedBtn.addEventListener('click', function() { console.log('按钮被点击!'); this.textContent = '已点击'; this.classList.add('clicked'); }); console.log("推荐:HTML, CSS, JS 分离,关注点清晰。"); }
-
b. 清晰、有意义的命名
为变量、函数(尤其是事件处理函数)使用能够清晰表达其用途的名称。
不够清晰的命名:
// const b = document.getElementById('btn1'); // function doIt(e) { // // ... // } // b.addEventListener('click', doIt);
更清晰的命名:
const submitFormButton = document.getElementById('userRegistrationSubmitBtn'); function handleUserRegistrationSubmit(event) { event.preventDefault(); // 假设是表单提交 console.log('处理用户注册表单提交...'); // ... 表单验证和提交逻辑 ... } // if (submitFormButton) { // submitFormButton.addEventListener('click', handleUserRegistrationSubmit); // } console.log("推荐:使用清晰的变量和函数命名。");
-
c. 及时移除不再需要的事件监听器
当元素从 DOM 中被移除,或者在单页应用 (SPA) 中组件被销毁时,如果其上绑定的事件监听器没有被移除,可能会导致内存泄漏或意外行为。
代码演示(移除自身监听器并移除元素):
const ephemeralButton = document.createElement('button'); ephemeralButton.textContent = '点我后消失 (并移除监听器)'; document.body.appendChild(ephemeralButton); // 先添加到body才能看到 function handleClickAndSelfDestruct() { console.log('临时按钮被点击!'); // 关键:在元素移除前,先移除其上的事件监听器 ephemeralButton.removeEventListener('click', handleClickAndSelfDestruct); ephemeralButton.remove(); // 从DOM中移除按钮 console.log('临时按钮及其监听器已移除。'); } ephemeralButton.addEventListener('click', handleClickAndSelfDestruct);
在复杂的SPA框架中,通常框架本身会提供组件生命周期钩子函数,让你在组件卸载时执行清理操作,包括移除事件监听器。
3. 关注可访问性 (Accessibility - a11y)
确保你通过 JavaScript 创建和控制的交互对所有用户(包括使用辅助技术的用户)都是可访问的。
-
a. 确保键盘可操作性
如果使用非标准 HTML 元素(如
<div>
)来模拟按钮或其他交互控件,需要确保它们可以通过键盘聚焦和操作。代码演示(使
<div>
像按钮一样可操作):const customControl = document.createElement('div'); customControl.textContent = '自定义可操作控件'; customControl.setAttribute('role', 'button'); // 1. 语义化:告诉辅助技术这是一个按钮 customControl.setAttribute('tabindex', '0'); // 2. 使其可通过 Tab键 聚焦 customControl.style.cssText = 'padding: 8px; border: 1px solid grey; display: inline-block; cursor: pointer;'; // 简单样式 function handleCustomControlAction() { console.log('自定义控件被激活!'); alert('自定义控件激活成功!'); } customControl.addEventListener('click', handleCustomControlAction); customControl.addEventListener('keydown', function(event) { // 3. 允许通过 Enter 或 Space 键激活 if (event.key === 'Enter' || event.key === ' ') { // 或者 event.code === 'Space' event.preventDefault(); // 防止空格键滚动页面等默认行为 handleCustomControlAction(); } }); document.body.appendChild(customControl); console.log("提示:自定义交互元素需确保键盘可访问性。");
-
b. 使用 ARIA 属性增强语义
对于通过 DOM 操作创建的复杂或非标准交互组件(如自定义下拉菜单、模态框、选项卡面板等),HTML 原生标签可能无法完全表达其角色、状态和属性。此时,应使用 ARIA (Accessible Rich Internet Applications) 属性来补充这些语义信息。
代码演示(简单的自定义开关状态):
const myToggleSwitch = document.createElement('div'); myToggleSwitch.setAttribute('role', 'switch'); // 角色:这是一个开关 myToggleSwitch.setAttribute('aria-checked', 'false'); // 初始状态:未选中 myToggleSwitch.setAttribute('tabindex', '0'); // 可聚焦 myToggleSwitch.textContent = '关'; myToggleSwitch.style.cssText = 'padding: 5px; border: 1px solid black; display: inline-block; cursor: pointer; user-select: none;'; let isSwitchOn = false; function toggleTheSwitch() { isSwitchOn = !isSwitchOn; myToggleSwitch.setAttribute('aria-checked', isSwitchOn.toString()); myToggleSwitch.textContent = isSwitchOn ? '开' : '关'; myToggleSwitch.style.backgroundColor = isSwitchOn ? 'lightgreen' : 'lightcoral'; console.log(`开关状态: ${isSwitchOn ? '开' : '关'}`); } myToggleSwitch.addEventListener('click', toggleTheSwitch); myToggleSwitch.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleTheSwitch(); } }); document.body.appendChild(myToggleSwitch); console.log("提示:复杂自定义组件应使用ARIA属性增强语义。");
ARIA 是一个相对深入的主题,但了解其基本作用和在必要时查阅相关文档非常重要。