DOM 与 JS 事件:页面元素操作、事件冒泡、事件委托实战

写在前面

JS 之所以能"操作页面",靠的是 DOM(Document Object Model,文档对象模型) :浏览器把 HTML 解析成一棵节点树,JS 通过这棵树增删改查元素;而用户的每一次点击、输入、滚动,则以事件的形式进入这棵树,从根一路传播到目标元素再冒泡回去。

本篇讲三件事:

  1. DOM 操作:查找元素、修改内容与属性、增删节点、遍历关系;
  2. 事件机制 :绑定方式、事件对象、事件流(捕获 → 目标 → 冒泡)
  3. 事件委托:用冒泡原理把事件统一交给父级处理------动态列表与大量同类元素的标配方案。

一、认识 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("正文")
  • 元素节点divh1 等标签;
  • 文本节点:标签内的文字;
  • 根对象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
         └── 捕获阶段 ──┘  └─目标─┘  └──── 冒泡阶段 ────┘
  1. 捕获阶段:事件从根部向下,逐层到达目标元素;
  2. 目标阶段:事件到达实际点击的元素;
  3. 冒泡阶段 :事件从目标元素向上,逐层冒回根部(默认监听器在此时触发)。
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 绑定事件有两个痛点:

  1. 动态添加的项 :新增的 li 没绑定过事件,点击无效;
  2. 大量同类元素:每个都绑一个监听器,占用内存且代码冗余。

事件委托 利用冒泡:只给父容器绑定一次事件,通过 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 不冒泡)不适合委托

focusblur 等事件不冒泡 ,无法直接委托;可用捕获阶段监听或 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.targete.currentTarget target 是实际触发元素,currentTarget 是绑定监听器的元素
分不清 preventDefaultstopPropagation 前者取消默认行为,后者停止传播,不互相替代
给不冒泡的事件(focus/blur)做委托 用捕获阶段或 focusin/focusout
大量插入逐个 appendChild DocumentFragment 一次性挂载
innerHTML 拼接时覆盖已有内容 el.innerHTML += ... 会重解析整个节点,小心性能与状态丢失

九、总结

用一张表收束本篇核心:

主题 一句话记住
查找 默认 querySelector / querySelectorAll
内容 用户数据用 textContent,禁止拼 innerHTML
类名 classList(add/remove/toggle)
插入 批量插入用 DocumentFragment
绑定 addEventListener,可多监听、可移除
事件流 捕获(根部→目标)→ 目标 → 冒泡(目标→根部)
阻止 preventDefault 止默认,stopPropagation 止传播
委托 父级绑一次,用 e.target.closest() 定位实际元素

三条实践心法收尾:

  1. 操作 DOM 前先找对节点 :优先 querySelector,遍历用 children/parentNode
  2. 事件优先委托:同一类元素的交互,统一交给最近的共同祖先处理,动态元素自动生效;
  3. 注意安全与性能 :用户输入不拼 innerHTML,批量插入用 DocumentFragment
相关推荐
Dreams°1231 小时前
【React+TS+ECharts可视化避坑:图表空白、不实时更新、数据池堆积卡顿完整复盘(附完整源码)】
前端·react.js·echarts
计算机魔术师1 小时前
三年前估值45亿,现在129亿——Hugging Face凭什么被老黄盯上?
前端
一个游离的指针1 小时前
浏览器的渲染原理
前端·javascript
zttbee1 小时前
vue2的API大白话讲解
前端·vue.js·前端框架
几何心凉1 小时前
ToDesk/向日葵/UU远程,谁才是真正的“口袋里的办公室”
前端
亿元程序员1 小时前
一个人,4个岗位,20天:我用Cursor+Codex上线了一款微信小游戏
前端
LabVIEW开发2 小时前
将自定义 DLL 与 INI 文件部署到 LabVIEW 实时目标
前端·labview·labview知识·labview功能·labview程序
IT_陈寒2 小时前
Redis Pipeline用错竟比不用还慢,这个坑我帮你踩过了
前端·人工智能·后端
ynchyong2 小时前
JavaScript Promise 实战:.then() 与 .catch() 的区别及回调函数封装指南
开发语言·javascript·ecmascript·promise·then