DOM 操作与事件机制
前言
前几篇我们学了 JS 的语法核心(类型、函数、对象、数组)。但 JS 在浏览器中真正的用途是操控页面——动态修改 HTML 结构、改变样式、响应用户点击和输入。这一切都通过 DOM(Document Object Model,文档对象模型) 实现。
DOM 是浏览器将 HTML 文档解析后生成的树形 API。用嵌入式的话说:DOM 操作就像操作寄存器——读取页面状态、修改页面显示。事件监听就像中断回调——用户点击、输入时触发你预先注册的处理函数。
ℹ️ 学完本篇后请记住:后续学 React 时,你几乎不会直接操作 DOM——React 会帮你管理。但理解 DOM 原理是理解 React 工作方式的基础。
正文
1. DOM 树结构
<html>
<body>
<div id="app">
<h1>标题</h1>
<p class="text">段落 <a href="#">链接</a></p>
</div>
</body>
</html>
对应的 DOM 树:
document
└── html
└── body
└── div#app
├── h1
│ └── "标题"
└── p.text
├── "段落 "
└── a
└── "链接"
每个节点都是一个 JS 对象,可以读取属性、调用方法。
2. 查询元素
// 推荐方式(CSS 选择器语法)
document.querySelector("#app"); // 第一个匹配的元素
document.querySelector(".text"); // 第一个 class="text"
document.querySelector("div > h1"); // 组合选择器
document.querySelectorAll(".item"); // 所有匹配的元素(NodeList)
// 旧方式(仍然有效,但不如 querySelector 灵活)
document.getElementById("app");
document.getElementsByClassName("text"); // HTMLCollection(实时更新)
document.getElementsByTagName("p");
// querySelector vs getElementById
// querySelector 返回 null(找不到时)
// getElementById 也返回 null
// querySelectorAll 返回空 NodeList(找不到时)
// 遍历 NodeList
const items = document.querySelectorAll("li");
items.forEach(item => console.log(item.textContent));
// 也可以转为数组
const arr = [...document.querySelectorAll("li")];
arr.filter(li => li.classList.contains("active"));
3. 读取与修改元素
const el = document.querySelector("#app");
// 文本内容
el.textContent; // 获取纯文本
el.textContent = "新文本"; // 设置纯文本(安全,不解析 HTML)
// HTML 内容
el.innerHTML; // 获取内部 HTML
el.innerHTML = "<b>加粗</b>"; // 设置 HTML(注意 XSS 风险)
// 属性
const link = document.querySelector("a");
link.getAttribute("href"); // 获取属性
link.setAttribute("href", "/new"); // 设置属性
link.removeAttribute("target"); // 删除属性
// 特殊属性可以直接访问
link.href;
link.id;
link.className;
// data-* 自定义属性
// <div data-user-id="42" data-role="admin">
el.dataset.userId; // "42"(自动驼峰转换)
el.dataset.role; // "admin"
// 样式操作
el.style.color = "red";
el.style.fontSize = "20px"; // 驼峰命名(不是 font-size)
el.style.backgroundColor = "#f0f0f0";
// class 操作(推荐,比直接改 style 好)
el.classList.add("active");
el.classList.remove("hidden");
el.classList.toggle("open"); // 有则删,无则加
el.classList.contains("active"); // true/false
el.classList.replace("old", "new");
4. 创建与插入元素
// 创建元素
const div = document.createElement("div");
div.className = "card";
div.textContent = "新卡片";
// 插入到页面
const container = document.querySelector("#app");
container.appendChild(div); // 添加到末尾
container.insertBefore(div, container.firstChild); // 添加到开头
container.prepend(div); // 添加到开头(新 API)
container.append(div, "文本也可以"); // 末尾,支持多个
// insertAdjacentHTML(性能好,常用)
container.insertAdjacentHTML("beforeend", `
<div class="card">
<h3>标题</h3>
<p>内容</p>
</div>
`);
// 位置参数:beforebegin | afterbegin | beforeend | afterend
// 删除元素
div.remove(); // 现代方式
container.removeChild(div); // 旧方式
// 克隆
const clone = div.cloneNode(true); // true = 深克隆(含子元素)
5. 事件监听
const button = document.querySelector("#btn");
// 添加事件监听(推荐方式)
button.addEventListener("click", function(event) {
console.log("按钮被点击了");
console.log(event.target); // 触发事件的元素
console.log(this); // 绑定事件的元素(普通函数中)
});
// 箭头函数版本(注意 this 差异)
button.addEventListener("click", (e) => {
console.log("点击", e.target);
// 箭头函数中 this 不指向 button,而是外层作用域
});
// 移除事件监听(需要引用同一个函数)
function handleClick() { console.log("clicked"); }
button.addEventListener("click", handleClick);
button.removeEventListener("click", handleClick);
// 常用事件类型
// 鼠标:click, dblclick, mouseenter, mouseleave, mousemove
// 键盘:keydown, keyup, keypress(已废弃)
// 表单:submit, input, change, focus, blur
// 窗口:load, resize, scroll
// 触摸:touchstart, touchmove, touchend
6. 事件冒泡与捕获
<div id="outer">
<div id="inner">
<button id="btn">点我</button>
</div>
</div>
// 点击 button 时,事件会"冒泡"向上传播:
// button → #inner → #outer → body → html → document
document.querySelector("#outer").addEventListener("click", () => {
console.log("outer clicked");
});
document.querySelector("#inner").addEventListener("click", () => {
console.log("inner clicked");
});
document.querySelector("#btn").addEventListener("click", () => {
console.log("button clicked");
});
// 点击按钮输出:
// button clicked
// inner clicked
// outer clicked
// 阻止冒泡
document.querySelector("#btn").addEventListener("click", (e) => {
e.stopPropagation(); // 事件不再向上传播
console.log("只有我");
});
// 阻止默认行为
document.querySelector("a").addEventListener("click", (e) => {
e.preventDefault(); // 阻止链接跳转
});
7. ★ 事件委托——性能优化的关键技巧
利用冒泡机制,在父元素上监听子元素的事件:
// 不好的方式:给每个 li 都绑定事件
document.querySelectorAll("li").forEach(li => {
li.addEventListener("click", () => { /* ... */ });
});
// 如果有 1000 个 li,就创建了 1000 个事件监听器!
// 好的方式:事件委托,只在父元素上绑定一个
document.querySelector("ul").addEventListener("click", (e) => {
// e.target 是实际被点击的元素
if (e.target.tagName === "LI") {
console.log("点击了:", e.target.textContent);
}
// 也可以用 closest 查找最近的匹配祖先
const li = e.target.closest("li");
if (li) {
console.log("点击了:", li.textContent);
}
});
// 只有 1 个事件监听器,动态添加的 li 也能自动响应
事件委托在 React 中被框架自动处理,但理解原理很重要。
💡 工程师手记:事件委托让我想起嵌入式中的中断处理思路——不是给每个外设都配一个中断处理程序,而是用一个统一的中断入口,根据中断源分发到不同的处理函数。事件委托就是这个思路在前端的体现。
(建议替换为你自己理解事件委托的经历)
8. 表单处理
const form = document.querySelector("form");
const input = document.querySelector("#username");
// 获取/设置输入框的值
input.value; // 获取当前值
input.value = "新的值"; // 设置值
// 监听输入(实时响应每次按键)
input.addEventListener("input", (e) => {
console.log("当前输入:", e.target.value);
});
// 监听变化(失焦后触发)
input.addEventListener("change", (e) => {
console.log("最终值:", e.target.value);
});
// 表单提交
form.addEventListener("submit", (e) => {
e.preventDefault(); // 阻止页面刷新(默认行为)
const formData = new FormData(form);
const data = Object.fromEntries(formData);
console.log(data); // { username: "...", password: "..." }
});
// 键盘事件
document.addEventListener("keydown", (e) => {
console.log(`按下: ${e.key}, 代码: ${e.code}`);
if (e.key === "Enter") { /* 回车处理 */ }
if (e.ctrlKey && e.key === "s") {
e.preventDefault(); // 阻止浏览器保存
console.log("自定义保存");
}
});
总结
| 知识点 | 核心要点 |
|---|---|
| 查询元素 | querySelector / querySelectorAll(CSS 选择器语法) |
| 读写内容 | textContent(安全)、innerHTML(可解析 HTML) |
| 样式操作 | 优先用 classList,避免直接写 style |
| 创建插入 | createElement + append,或 insertAdjacentHTML |
| 事件监听 | addEventListener,event 对象包含 target/key 等信息 |
| 事件冒泡 | 子元素事件向上传播,stopPropagation 阻止 |
| 事件委托 | 在父元素上监听,用 e.target 判断实际元素 |
| 表单处理 | e.preventDefault() 阻止刷新,FormData 收集数据 |
常见问题
💬 你可能会问:innerHTML 和 textContent 用哪个?
默认用
textContent(安全,不解析 HTML)。只有当你确实需要插入 HTML 结构时才用innerHTML,但要注意 XSS(跨站脚本攻击) 风险——永远不要把用户输入直接拼接到innerHTML中。
💬 你可能会问:学了 React 还需要直接操作 DOM 吗?
日常开发中几乎不需要——React 会帮你管理 DOM 更新。但理解 DOM 原理能帮你理解 React 为什么这样设计(比如为什么要用虚拟 DOM),也能帮你在少数需要直接操作 DOM 的场景(如第三方库集成)中游刃有余。
💬 你可能会问:事件监听器会不会导致内存泄漏?
如果元素被移除但事件监听器没有清理,理论上会。但现代浏览器在元素被删除时会自动清理其事件监听器。使用事件委托能从根本上减少监听器数量,是更好的实践。
下一步行动:打开任意网页的 Console,用 document.querySelector 选中一个元素,试着修改它的 textContent 和 classList。然后给一个按钮加个点击事件——感受一下用 JS 操控页面的力量。
参考资料
📖 系列导航:本文是「FPGA 工程师的前端学习笔记」系列的第 10 篇 上一篇:数组与高阶函数 下一篇:ES6+ 现代语法速览