DOM 是什么:浏览器里的文档树
浏览器把 HTML 文本解析成一棵文档对象模型(Document Object Model,简称 DOM)树:html 是根,下面是 head 与 body,再往下是层层嵌套的元素节点、文本节点与注释节点。JavaScript 能改变页面,靠的就是操作这棵树——找到节点、修改节点、增删节点。
理解「树」这个形态很重要,因为几乎所有 DOM 操作都能用树的语言描述:parentNode 是父节点、children 是子元素集合、nextElementSibling 是相邻的下一个兄弟元素。事件机制同样建立在树上——事件从根传播到目标再回传,路径就是这棵树上的一条链。
DOM 不是 JavaScript 的一部分,而是浏览器提供的宿主接口。同一份 HTML 在浏览器里是一棵 DOM 树,在服务端渲染时可能只是一段字符串——想清楚这一点,就能理解为什么「操作 DOM」和「生成 HTML」是两件事。
- 文档树HTML 被解析为节点树,一切 DOM 操作都是对树的操作。
- 选择器querySelector / querySelectorAll 是首选,返回静态结果、可安全遍历。
- 事件流捕获、目标、冒泡三个阶段,决定监听器的触发顺序。
- 事件委托把监听挂在共同父节点,用 e.target 判断来源,兼顾动态元素。
选元素:querySelector 与集合遍历
操作之前先要选中。现代浏览器提供了两套选择器 API:querySelector 返回第一个匹配的元素(没有则返回 null),querySelectorAll 返回全部匹配结果。它们都接受标准 CSS 选择器字符串,因此你在样式里怎么写,在脚本里就怎么选,不必再记一套新语法。
// 选一个元素
const title = document.querySelector(".hero-title");
// 选一组元素(返回 NodeList,可直接 forEach)
const chips = document.querySelectorAll(".tag-chip");
chips.forEach(function (chip) {
chip.dataset.role = "filter";
});
三个细节决定了这段代码在生产环境里是否可靠:
- 返回静态结果。querySelectorAll 得到的是一个「快照」,之后页面新增的元素不会自动出现在这个集合里,需要重新查询。
- 可能是 null。没匹配到时单元素查询返回 null,直接取属性会抛错。写一行判空,能省掉一次线上事故。
- 老 API 的差别。getElementById 只认 id,getElementsByClassName 返回动态的 HTMLCollection,遍历时要转成数组,且它不带 forEach。
性能上不用过度担心:选择器查询本身很快,真正的开销来自「在循环里反复查询同一个元素」。把结果缓存到变量里,是最简单也最有效的优化。
增删改:节点、属性与样式的操作
选中节点之后,真正的工作分三类:改内容、改属性、改结构。内容用 textContent(纯文本,安全)或 innerHTML(解析为 HTML,方便但有注入风险);属性用 setAttribute 或直接访问 element.id、element.href 这类同名属性;结构则用创建与插入 API。
const list = document.querySelector("#list");
// 创建并插入
const li = document.createElement("li");
li.className = "item";
li.textContent = "新增条目";
list.append(li); // 追加到末尾
list.prepend(li.cloneNode(true)); // 克隆一份插到开头
// 移动既有节点(append 会自动从原位置摘除)
list.append(document.querySelector(".item:first-child"));
// 移除
li.remove();
样式建议统一走 classList,而不是拼接 style 字符串:
el.classList.add("active")添加类,交给 CSS 决定长什么样。el.classList.toggle("open")在开与关之间切换,一行顶三行。el.dataset.role = "filter"写自定义数据属性,最终映射为data-role,是脚本与 DOM 之间最轻量的通信方式。
有一处安全边界必须强调:永远不要用用户输入拼 innerHTML。它会把字符串当 HTML 解析,脚本标签、事件属性都可能被注入执行。需要展示用户内容时,用 textContent,或者先做转义再插入——这是前端最常见的一类漏洞来源。
事件三要素:绑定、监听与解绑
让页面「活」起来靠事件。现代写法统一使用 addEventListener,它接收三个参数:事件类型、回调函数、以及一个选项对象或布尔值。onclick = fn 这种老写法也能用,但它只能挂一个处理函数,后写的会覆盖先写的,因此只适合演示。
选项里的三个开关各有用途:capture 决定监听器在捕获阶段还是冒泡阶段触发;once 让监听器执行一次后自动解绑,适合「只应发生一次」的初始化;passive 承诺回调里不会调用 preventDefault,浏览器因此可以不等脚本执行就滚动页面,在 scroll、touchmove 这类高频事件上能明显改善流畅度。
- 绑定的回调里,
this默认指向当前元素,而箭头函数没有自己的 this,混用时要注意。 - 解绑必须传入同一个函数引用;传匿名函数或箭头函数会解绑失败,这是最常被忽略的一条。
- 同一元素、同一类型、同一阶段、同一函数重复绑定会被自动去重,不必手动防重。
- 元素被移除后其监听器一般随之释放,但挂在 window、document 上的监听器不会,需要在组件卸载时手动清理。
事件流:捕获、目标与冒泡
一次点击事件并不是「只在被点中的元素上触发」。W3C 定义的事件流分三个阶段:事件从 window 出发,自上而下传递到目标元素,这是捕获阶段;抵达目标本身是目标阶段;随后自下而上回传到根,这是冒泡阶段。addEventListener 默认在冒泡阶段响应,把第三参数设为 true 才会在捕获阶段响应。
| 阶段 | 传递方向 | 监听器触发条件 | 典型用途 |
|---|---|---|---|
| 捕获阶段 | 从 window 向下到目标 | 第三参数为 true 的监听器 | 全局拦截、行为埋点 |
| 目标阶段 | 目标元素本身 | 注册在目标上的监听器 | 主体交互逻辑 |
| 冒泡阶段 | 从目标向上回传到 window | 默认(第三参数 false) | 事件委托、统一收口 |
const box = document.querySelector(".box");
box.addEventListener("click", function (e) {
console.log("目标元素:", e.target); // 真正被点中的元素
console.log("当前监听者:", e.currentTarget); // 绑定监听的那个元素
console.log("能否继续冒泡:", e.bubbles);
}, false);
区分 e.target 与 e.currentTarget 是理解事件流的关键:前者是事件源头,后者是监听器所在节点。事件委托正是利用这两者的差异——监听器挂在父节点(currentTarget 固定为父节点),通过 target 判断具体点到了谁。
并非所有事件都会冒泡:focus、blur、mouseenter、load 等不冒泡,所以它们不能直接用于委托。对应的可冒泡替代是 focusin、focusout、mouseover。另外,e.stopPropagation() 能阻止继续传播,e.preventDefault() 只阻止浏览器默认行为(如链接跳转、表单提交),两者互不相干,不要混用。
事件委托:把监听挂到父节点
列表里有一百行,每行一个删除按钮,如果给每个按钮单独绑定监听器,就是一百个回调;更麻烦的是列表动态新增的行需要重新绑定。事件委托把监听器统一挂到父容器上,利用冒泡机制收口,一个监听器管住所有现在与未来的行。
document.querySelector(".list").addEventListener("click", function (e) {
const btn = e.target.closest(".del"); // 从点击点向上找最近的删除按钮
if (!btn) return; // 点到的不是按钮,交给其他逻辑
const row = btn.closest("li");
row.remove(); // 一次绑定,管住动态新增的行
});
三个要点让这段代码更稳:
- 用 closest 而不是精确比较。按钮里可能还有图标或文字节点,事件目标未必是按钮本身,closest 会沿祖先链向上查找。
- 判空后 return。父容器可能接住的是别处的点击,未命中就交还控制权。
- 监听范围别升得太高。挂在 document 上虽然省事,但会接住整页所有点击,判断逻辑变复杂、性能也更差,挂在最近的公共父节点最合适。
代价也要清楚:委托会把「是否命中」的判断从浏览器转移到你的回调里,事件密集且回调逻辑重时反而更慢;同时,阻止冒泡的第三方库会让委托失效。因此工具栏、列表这类结构稳定的场景适合委托,而单个独立的复杂组件,直接绑定有时更清晰。
表单与常用事件的实战写法
表单是 DOM 操作最集中的地方:取值、校验、提交、重置,每一步都有对应的原生能力。推荐用 FormData 一次性读出全部字段,避免逐个查询输入框。下面的写法可以直接运行,提交后不会刷新页面。
const form = document.querySelector("#signup");
form.addEventListener("submit", function (e) {
e.preventDefault(); // 阻止默认提交与刷新
const data = Object.fromEntries(new FormData(form));
if (!data.email || data.email.indexOf("@") < 0) {
console.warn("邮箱格式不正确");
return;
}
console.log("提交数据:", data);
});
几个常用事件的触发时机差别,值得对照记忆:
| 事件 | 触发时机 | 常见用途 |
|---|---|---|
| input | 输入框内容每变化一次 | 实时搜索、字数统计 |
| change | 值变化且失去焦点(或选项切换) | 表单校验、联动下拉 |
| submit | 表单提交时 | 统一校验与拦截 |
| keydown | 按键按下时 | 快捷键、回车提交 |
| click | 点击完成时 | 按钮与链接交互 |
两个实战提醒:实时搜索要配合防抖,否则每敲一个字符就发一次请求;表单校验优先使用原生能力(type="email"、required、pattern),浏览器会给出可访问性更好的提示,脚本只做补充校验。
性能与内存:避免泄漏的实践
DOM 操作是前端最容易写出性能问题的地方,原因在于每次修改都可能触发浏览器的重排(重新计算几何位置)与重绘(重新描画像素)。在循环里逐个改样式,浏览器可能被迫计算很多次;把改动集中到一次,或者先离线构建再整体插入,代价会低得多。
解绑监听器是另一个高频遗漏。单页应用里组件反复挂载卸载,如果 scroll、resize 这类全局监听没有清理,回调会越积越多,内存占用持续上涨,表现为「页面用久了越来越卡」。用 AbortController 可以一次性解绑一组监听,写法比逐个记住函数引用更省心。
// 统一登记,统一解绑
const ac = new AbortController();
const opts = { signal: ac.signal, passive: true };
document.addEventListener("scroll", onPageScroll, opts);
window.addEventListener("resize", onResize, opts);
// 组件卸载时一次性清理
ac.abort();
- 批量改样式。先给容器加一个类,让浏览器一次算完,而不是连续改十条内联样式。
- 避免布局抖动。读一次
offsetHeight又写一次样式,会强制同步布局,把读写分开能显著提速。 - 高频事件做节流。scroll 与 resize 每帧都可能触发,用 requestAnimationFrame 或节流函数限制执行频率。
- 及时释放引用。脱离文档的节点若仍被闭包或全局变量持有,内存不会回收,用完把它设为 null。
DOM 与事件的功力,一半体现在「会不会用」,另一半体现在「知不知道该清理」。写完一个交互,回头问一句「它被销毁时留下了什么」,很多线上问题会提前消失。
把这八节串起来,你已经走完了原生前端最核心的一条路径:选中、修改、监听、委托、清理。下一节我们换到更高的抽象层,讨论在 Vue 与 React 之间该如何为项目做选型。