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 判断来源,兼顾动态元素。
DOM 示意:蓝色渐变背景上由发光节点连成的抽象文档树,右侧同心波纹表示事件的传播路径
节点与波纹:左侧是层层分叉的文档树,右侧是事件从目标向外扩散的传播路径——两者是同一张图上的两类信息。

选元素: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 上虽然省事,但会接住整页所有点击,判断逻辑变复杂、性能也更差,挂在最近的公共父节点最合适。

代价也要清楚:委托会把「是否命中」的判断从浏览器转移到你的回调里,事件密集且回调逻辑重时反而更慢;同时,阻止冒泡的第三方库会让委托失效。因此工具栏、列表这类结构稳定的场景适合委托,而单个独立的复杂组件,直接绑定有时更清晰。