写在前面
JS 之所以能"操作页面",靠的是 DOM(Document Object Model,文档对象模型) :浏览器把 HTML 解析成一棵节点树,JS 通过这棵树增删改查元素;而用户的每一次点击、输入、滚动,则以事件的形式进入这棵树,从根一路传播到目标元素再冒泡回去。
本篇讲三件事:
- DOM 操作:查找元素、修改内容与属性、增删节点、遍历关系;
- 事件机制 :绑定方式、事件对象、事件流(捕获 → 目标 → 冒泡);
- 事件委托:用冒泡原理把事件统一交给父级处理------动态列表与大量同类元素的标配方案。
一、认识 DOM:文档的节点树
HTML 不是一堆扁平的标签,浏览器会把它解析成树形结构:
html
<!DOCTYPE html>
<html>
<body>
<div id="app">
<h1>标题</h1>
<p>正文</p>
</div>
</body>
</html>
对应节点树:
less
document
└── html
└── body
└── div#app
├── h1("标题")
└── p("正文")
- 元素节点 :
div、h1等标签; - 文本节点:标签内的文字;
- 根对象 :
document,一切查找的入口。
DOM 是"文档的内存表示",与 HTML 源文本是两回事------JS 改的是内存中的这棵树,浏览器随后自动重绘页面。
二、查找元素
2.1 基础查找方法
js
// 按 id:返回单个元素(最常用)
document.getElementById("app");
// 按类名 / 标签名:返回 HTMLCollection(实时集合)
document.getElementsByClassName("card");
document.getElementsByTagName("div");
// 按 CSS 选择器:返回单个 / NodeList(推荐)
document.querySelector("#app .card"); // 第一个匹配
document.querySelectorAll(".card"); // 所有匹配
2.2 querySelector 与 getElementsBy 的区别
| 方法 | 返回 | 是否实时(动态) |
|---|---|---|
getElementById |
单个元素 | --- |
getElementsByClassName |
HTMLCollection | ✅ 是(DOM 变化自动更新) |
getElementsByTagName |
HTMLCollection | ✅ 是 |
querySelector |
单个元素 | --- |
querySelectorAll |
NodeList | ❌ 否(静态快照) |
js
const live = document.getElementsByClassName("item");
const static = document.querySelectorAll(".item");
// 向页面添加一个新 .item 后:
live.length; // 变多(实时集合自动更新)
static.length; // 不变(静态快照)
实践建议 :默认用 querySelector / querySelectorAll,选择器表达能力强、返回静态快照更可控。
三、操作元素
3.1 修改内容:textContent vs innerHTML
| 方法 | 特点 | 是否解析 HTML |
|---|---|---|
textContent |
纯文本,安全 | 否 |
innerHTML |
可插入 HTML 片段 | 是(有 XSS 风险) |
js
const el = document.getElementById("msg");
el.textContent = "你好"; // 插入纯文本,用户输入安全
el.innerHTML = "<b>加粗</b>"; // 解析 HTML,谨慎用于用户输入
把用户输入拼进
innerHTML会产生 XSS 注入 风险。渲染用户数据用textContent。
3.2 修改属性与类名
js
// 属性
el.getAttribute("data-id"); // 读取
el.setAttribute("data-id", "10");// 设置
el.removeAttribute("data-id"); // 删除
// dataset:data-* 属性的快捷读写
el.dataset.id = "10"; // 等价于 setAttribute("data-id","10")
el.dataset.id; // "10"
// classList:类名操作(推荐)
el.classList.add("active");
el.classList.remove("active");
el.classList.toggle("active"); // 有则删、无则加
el.classList.contains("active"); // true / false
// 行内样式
el.style.color = "red";
el.style.fontSize = "16px"; // 驼峰命名
3.3 创建与插入
js
// 创建节点
const li = document.createElement("li");
li.textContent = "新条目";
// 插入:父级追加 / 插到某节点前
const list = document.getElementById("list");
list.appendChild(li); // 追加到末尾
list.insertBefore(li, list.firstChild); // 插到头部
// 现代简洁写法
list.append(li); // 末尾(可多个节点/字符串)
list.prepend(li); // 头部
3.4 删除与替换
js
// 删除
child.remove(); // 直接删除自身(现代写法)
parent.removeChild(child); // 传统写法
// 替换
parent.replaceChild(newNode, oldNode);
3.5 遍历节点关系
js
el.parentNode; // 父节点
el.children; // 子元素集合
el.firstElementChild; // 第一个子元素
el.lastElementChild; // 最后一个子元素
el.nextElementSibling; // 下一个兄弟元素
el.previousElementSibling; // 上一个兄弟元素
el.closest(".card"); // 向上找最近的匹配祖先(委托常用)
3.6 性能:批量插入用 DocumentFragment
逐个 appendChild 会触发多次重绘。用文档片段一次性挂载:
js
const frag = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement("li");
li.textContent = "第 " + i + " 项";
frag.appendChild(li); // 先加到片段,不触发页面重绘
}
list.appendChild(frag); // 一次性插入
四、事件基础
4.1 三种绑定方式
html
<!-- ① 内联属性:HTML 与逻辑耦合,不推荐 -->
<button onclick="handle()">按钮</button>
js
// ② 属性赋值:同一事件只能绑定一个处理函数
const btn = document.getElementById("btn");
btn.onclick = function () { console.log("点击"); };
btn.onclick = function () { console.log("覆盖"); }; // 覆盖前一个
// ③ addEventListener:可绑定多个,推荐
btn.addEventListener("click", handler1);
btn.addEventListener("click", handler2); // 两个都执行
addEventListener 还支持移除监听与捕获阶段监听:
js
function handler() { console.log("hi"); }
btn.addEventListener("click", handler);
btn.removeEventListener("click", handler); // 移除
// 第三参数 true:在捕获阶段触发(默认 false 冒泡阶段)
btn.addEventListener("click", handler, true);
4.2 事件对象 event
监听函数自动收到事件对象:
js
btn.addEventListener("click", (e) => {
e.target; // 实际触发事件的元素(可能被子元素冒泡上来)
e.currentTarget; // 当前绑定监听器的元素
e.preventDefault();// 阻止默认行为(如跳转、提交)
e.stopPropagation(); // 阻止事件继续传播
});
4.3 常用事件类型
| 类型 | 触发时机 | 常用场景 |
|---|---|---|
click |
点击 | 按钮、链接 |
input |
输入内容变化 | 实时搜索、字数统计 |
change |
失去焦点且值变化 | 表单提交前取值 |
submit |
表单提交 | 校验后提交 |
keydown |
键盘按下 | 回车搜索、快捷键 |
scroll |
滚动 | 懒加载、吸顶 |
mouseover / mouseout |
鼠标移入/移出 | 悬浮效果 |
focus / blur |
聚焦 / 失焦 | 输入框提示 |
五、事件流:捕获、目标、冒泡
5.1 三个阶段
浏览器中事件从 window 出发,传播分三个阶段:
javascript
window → document → ... → 目标元素 → ... → document → window
└── 捕获阶段 ──┘ └─目标─┘ └──── 冒泡阶段 ────┘
- 捕获阶段:事件从根部向下,逐层到达目标元素;
- 目标阶段:事件到达实际点击的元素;
- 冒泡阶段 :事件从目标元素向上,逐层冒回根部(默认监听器在此时触发)。
html
<div id="parent"><button id="child">点我</button></div>
js
parent.addEventListener("click", () => console.log("父(冒泡)"));
child.addEventListener("click", () => console.log("子(目标)"));
// 点击按钮输出顺序:
// 子(目标) → 父(冒泡)
5.2 捕获与冒泡的监听
js
// 冒泡阶段触发(默认)
parent.addEventListener("click", handler);
// 捕获阶段触发:第三参数 true
parent.addEventListener("click", handler, true);
// 点击 child 时,捕获监听先于冒泡监听执行:
// 捕获:parent → 目标:child → 冒泡:parent
5.3 阻止传播与阻止默认
js
// 阻止继续冒泡:点击子元素时父级不再收到
child.addEventListener("click", (e) => {
e.stopPropagation();
});
// 阻止默认行为:如阻止链接跳转、表单提交
link.addEventListener("click", (e) => {
e.preventDefault(); // 不跳转
});
// 冒泡阶段也阻止默认:两层都调
document.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
}, false);
区别 :
preventDefault不阻止传播,只取消浏览器默认动作;stopPropagation不取消默认动作,只停止传播。两者常配合使用。
六、事件委托:冒泡思想的实战应用
6.1 为什么要委托
给列表的每个 li 绑定事件有两个痛点:
- 动态添加的项 :新增的
li没绑定过事件,点击无效; - 大量同类元素:每个都绑一个监听器,占用内存且代码冗余。
事件委托 利用冒泡:只给父容器绑定一次事件,通过 e.target 判断实际点击的元素。
6.2 基础实现
html
<ul id="list">
<li>条目 1</li>
<li>条目 2</li>
</ul>
js
document.getElementById("list").addEventListener("click", (e) => {
// 只处理 li 的点击(过滤掉点击 ul 空白处)
if (e.target.tagName === "LI") {
console.log("点击了:" + e.target.textContent);
}
});
这样新添加的 li 无需单独绑定,天然生效。
6.3 动态添加项:委托优势的完整演示
js
const list = document.getElementById("list");
const input = document.getElementById("input");
const btn = document.getElementById("add");
// 添加项:只操作 DOM,不绑事件
btn.addEventListener("click", () => {
const li = document.createElement("li");
li.textContent = input.value || "新条目";
list.appendChild(li);
input.value = "";
});
// 委托:一个监听器处理所有 li(含新增的)
list.addEventListener("click", (e) => {
const li = e.target.closest("li"); // 向上找最近的 li(容错点击 li 内部元素)
if (li) {
li.remove(); // 点击删除该行
}
});
e.target.closest("li") 是关键技巧:即使用户点击了 li 内部的 <span>,也能向上找到所属的 li。
6.4 委托的取舍
| 优点 | 注意点 |
|---|---|
| 动态元素自动生效 | 需手动判断 e.target 是否命中目标 |
| 大量元素只绑一个监听 | 事件源不清晰时排查成本略高 |
| 代码简洁、内存友好 | 个别事件(如 focus 不冒泡)不适合委托 |
focus、blur等事件不冒泡 ,无法直接委托;可用捕获阶段监听或focusin/focusout(它们支持冒泡)。
七、实战案例
实战 1:Todo 待办列表(增删 + 事件委托)
html
<div>
<input id="todo-input" placeholder="输入待办,回车添加">
<ul id="todo-list">
<li>学习 DOM <button class="del">删除</button></li>
<li>学习事件委托 <button class="del">删除</button></li>
</ul>
</div>
js
const list = document.getElementById("todo-list");
const input = document.getElementById("todo-input");
// 回车添加
input.addEventListener("keydown", (e) => {
if (e.key !== "Enter" || !input.value.trim()) return;
const li = document.createElement("li");
li.textContent = input.value.trim();
li.innerHTML += '<button class="del">删除</button>';
list.appendChild(li);
input.value = "";
});
// 事件委托:删除按钮与勾选都由 list 统一处理
list.addEventListener("click", (e) => {
const btn = e.target.closest(".del");
if (btn) {
btn.closest("li").remove();
return;
}
const li = e.target.closest("li");
if (li) li.classList.toggle("done");
});
实战 2:选项卡切换(事件委托)
html
<div id="tabs">
<button data-tab="0">首页</button>
<button data-tab="1">文档</button>
<button data-tab="2">关于</button>
</div>
<div id="panels">
<div class="panel">首页内容</div>
<div class="panel" hidden>文档内容</div>
<div class="panel" hidden>关于内容</div>
</div>
js
document.getElementById("tabs").addEventListener("click", (e) => {
const btn = e.target.closest("button");
if (!btn) return;
// 切换高亮
document.querySelectorAll("#tabs button").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
// 切换面板
const index = btn.dataset.tab;
document.querySelectorAll("#panels .panel").forEach((p, i) => {
p.hidden = i !== Number(index);
});
});
实战 3:输入防抖(input 事件)
搜索框每输入一个字都请求接口太浪费。防抖:停止输入 N 毫秒后才执行。
js
const search = document.getElementById("search");
let timer = null;
search.addEventListener("input", () => {
clearTimeout(timer); // 清除上一次定时
timer = setTimeout(() => {
console.log("发起搜索:" + search.value);
// fetch("/api/search?q=" + search.value)
}, 300); // 300ms 内没再输入才触发
});
实战 4:动态表格增删行(委托)
js
const table = document.getElementById("table");
// 添加行
function addRow(name, age) {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${name}</td><td>${age}</td><td><button class="del">删除</button></td>`;
table.querySelector("tbody").appendChild(tr);
}
// 委托:任何一行的删除按钮都由 tbody 处理
table.querySelector("tbody").addEventListener("click", (e) => {
if (e.target.classList.contains("del")) {
e.target.closest("tr").remove();
}
});
实战 5:表单提交校验(submit + preventDefault)
js
const form = document.getElementById("login-form");
form.addEventListener("submit", (e) => {
e.preventDefault(); // 阻止真正提交,先校验
const username = form.username.value.trim();
const password = form.password.value;
if (!username || !password) {
alert("用户名和密码不能为空");
return;
}
if (password.length < 6) {
alert("密码至少 6 位");
return;
}
console.log("校验通过,提交数据", { username, password });
// form.submit(); // 通过后再提交
});
八、高频误区清单
| 误区 | 正解 |
|---|---|
用 innerHTML 拼用户输入 |
有 XSS 风险,用户数据用 textContent |
给动态元素直接 addEventListener |
动态元素要在创建时绑定,或用事件委托 |
认为 querySelectorAll 返回的是实时集合 |
是静态 NodeList,getElementsBy* 才是实时的 |
混淆 e.target 与 e.currentTarget |
target 是实际触发元素,currentTarget 是绑定监听器的元素 |
分不清 preventDefault 与 stopPropagation |
前者取消默认行为,后者停止传播,不互相替代 |
给不冒泡的事件(focus/blur)做委托 |
用捕获阶段或 focusin/focusout |
大量插入逐个 appendChild |
用 DocumentFragment 一次性挂载 |
用 innerHTML 拼接时覆盖已有内容 |
el.innerHTML += ... 会重解析整个节点,小心性能与状态丢失 |
九、总结
用一张表收束本篇核心:
| 主题 | 一句话记住 |
|---|---|
| 查找 | 默认 querySelector / querySelectorAll |
| 内容 | 用户数据用 textContent,禁止拼 innerHTML |
| 类名 | 用 classList(add/remove/toggle) |
| 插入 | 批量插入用 DocumentFragment |
| 绑定 | 用 addEventListener,可多监听、可移除 |
| 事件流 | 捕获(根部→目标)→ 目标 → 冒泡(目标→根部) |
| 阻止 | preventDefault 止默认,stopPropagation 止传播 |
| 委托 | 父级绑一次,用 e.target.closest() 定位实际元素 |
三条实践心法收尾:
- 操作 DOM 前先找对节点 :优先
querySelector,遍历用children/parentNode; - 事件优先委托:同一类元素的交互,统一交给最近的共同祖先处理,动态元素自动生效;
- 注意安全与性能 :用户输入不拼
innerHTML,批量插入用DocumentFragment。