A08 DOM 操作与事件

2026-10-31 17:220 阅读约 13 分钟JavaScript
AI 摘要30 秒读完

讲解DOM节点查询修改、事件绑定解绑与委托、批量更新避免布局抖动的完整写法

  • 动态列表用事件委托,父容器挂一个监听器配合closest即可覆盖新增节点
  • 写文本用textContent自动转义防注入,避免拼接innerHTML
  • 批量更新用DocumentFragment或replaceChildren,只触发一次布局
  • 需要解绑时保存命名函数引用,匿名箭头函数无法比对
  • 频繁读写布局属性触发布局同步,是交互卡顿常见原因
AI 生成 · 仅供参考

A08 DOM 操作与事件

摘要:DOM 是浏览器把 HTML 解析成的对象树,脚本通过查询、创建、修改节点驱动页面变化。事件默认冒泡,在父元素统一监听(事件委托)能避免为动态节点重复绑定。频繁读写布局属性会触发布局同步,是交互卡顿的常见原因。本文给出节点操作、事件绑定与解绑、委托与批量更新的完整写法。
关键词:DOM,节点,事件冒泡,事件委托,批量更新

所属:frontend/js / A-入门 | 上一篇:A07-字符串与数字处理 | 下一篇:A09-实战待办清单

1 引言

页面交互只有一条路径:事件进入,状态改变,DOM 更新。三者任一处理不当都会出现特定缺陷:事件绑在会被替换的节点上导致失效,直接拼接 HTML 带来注入风险,循环里读布局属性导致卡顿。

本文解决三个问题:怎么找与改节点、怎么绑定与解绑事件、怎么避免重复绑定与反复重排。范围见表 1-1。

项 本文覆盖 本文不覆盖
查询 querySelector、closest、children 选择器语法(HTML A03)
修改 textContent、classList、dataset 样式属性体系(CSS)
事件 绑定、解绑、冒泡、委托、表单提交 合成事件与框架(D01)
性能 批量更新、文档片段、避免布局抖动 渲染流水线(C03)

2 预备知识

项 要求 核对方式
环境 Chrome 130+,本地服务器可用 页面经 http 打开
前置 A05、HTML A08 理解函数与语义化结构
文件 dom-practice/index.html、app.js 编码 UTF-8

3 原理与实现

3.1 事件从目标冒泡到根

点击发生在最内层元素,事件沿祖先链向上传播,父级可以在冒泡阶段拦截,如图 3-1 所示。

flowchart TD
    A["用户点击 li > button"] --> B["目标阶段:button"]
    B --> C["冒泡:li"]
    C --> D["冒泡:ul(监听器在此)"]
    D --> E["冒泡:body → html → document → window"]
    D --> F{"event.target.closest('li')"}
    F -->|命中| G["处理该行逻辑"]
    F -->|未命中| H["忽略"]

图 3-1 事件冒泡与委托处理的位置

图上可见:只在 ul 上挂一个监听器即可覆盖全部行,新增行无需重新绑定;处理时用 closest 从 target 找回行元素。

3.2 完整实现

<!-- dom-practice/index.html;运行环境:Chrome 130+ -->
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>DOM 操作</title>
  </head>
  <body>
    <form id="add-form">
      <input id="title" name="title" required maxlength="40" />
      <button type="submit">添加</button>
    </form>
    <ul id="list" aria-live="polite"></ul>
    <p id="empty">暂无记录</p>
    <script type="module" src="./app.js"></script>
  </body>
</html>
// dom-practice/app.js;运行环境:Chrome 130+
const form = document.querySelector('#add-form');
const input = document.querySelector('#title');
const list = document.querySelector('#list');
const empty = document.querySelector('#empty');

const state = [{ id: crypto.randomUUID(), title: '读 A08' }];

// 1 用 createElement 构建节点,不拼 HTML 字符串
function render() {
  const fragment = document.createDocumentFragment();
  for (const item of state) {
    const li = document.createElement('li');
    li.dataset.id = item.id;

    const span = document.createElement('span');
    span.textContent = item.title;          // 文本节点,天然转义

    const btn = document.createElement('button');
    btn.type = 'button';
    btn.dataset.action = 'remove';
    btn.textContent = '删除';

    li.append(span, btn);
    fragment.append(li);
  }
  list.replaceChildren(fragment);           // 一次性替换,只触发一次布局
  empty.hidden = state.length > 0;
}

// 2 事件委托:容器挂一个监听器
list.addEventListener('click', (event) => {
  const btn = event.target.closest('button[data-action="remove"]');
  if (!btn) return;
  const id = btn.closest('li').dataset.id;
  const index = state.findIndex((i) => i.id === id);
  if (index !== -1) state.splice(index, 1);
  render();
});

// 3 表单提交:阻止默认刷新,校验交给浏览器
form.addEventListener('submit', (event) => {
  event.preventDefault();
  const title = input.value.trim();
  if (!title) return;
  state.push({ id: crypto.randomUUID(), title });
  form.reset();
  render();
});

// 4 需要解绑时保存同一函数引用
function onResize() {
  console.log('resize', window.innerWidth);
}
window.addEventListener('resize', onResize);
// window.removeEventListener('resize', onResize);

render();

常用节点操作见表 3-1。

目的 写法 说明
查询单个 document.querySelector('.item') 返回首个匹配或 null
向上找祖先 el.closest('li') 含自身,事件委托必备
写纯文本 el.textContent = str 自动转义,防注入
切换类名 el.classList.toggle('is-done') 优于拼接 className
存自定义数据 el.dataset.id = id 对应 data-id 属性
批量替换子节点 el.replaceChildren(fragment) 只触发一次布局

事件相关规则见表 3-2。

场景 做法 原因
动态列表 监听父容器 + closest 新增节点无需绑定
需要取消默认行为 event.preventDefault() 表单提交、链接跳转
阻止继续冒泡 event.stopPropagation() 少用,会切断父级逻辑
需要解绑 传命名函数,不用匿名箭头 匿名函数无法比对引用
只触发一次 { once: true } 自动解绑,无需手动清理

4 验证与讨论

  1. 打开页面,确认有一条初始记录,添加新记录后列表刷新且输入框清空。
  2. 点击任意「删除」,确认对应行消失,其余行保持不变。
  3. 在控制台执行 list.querySelectorAll('li').length,与 state.length 对照。
  4. 把 replaceChildren 改为在循环内 list.append(li),用 Performance 面板比较布局次数。
检查项 预期结果 说明
委托生效 新增行可点击删除 监听器挂在父容器
文本安全 输入 <b>x</b> 原样显示 textContent 不解析标签
表单不刷新 提交后页面停留 preventDefault
空态提示 全部删除后显示「暂无记录」 同步更新 hidden

讨论与局限。 replaceChildren 会重建所有行,行内输入焦点与滚动位置随之丢失;保留焦点需要做差量更新。另,crypto.randomUUID 需要安全上下文,file:// 下不可用,这也是必须用本地服务器的原因之一。

5 常见问题

现象 原因 处理
新增行点击无反应 监听器绑在具体行上 改为父容器委托
Cannot read properties of null 脚本早于节点执行 用 defer 或模块脚本
输入 <script> 被执行 用了 innerHTML 拼字符串 改用 textContent 或 createElement
解绑无效 绑定与解绑用了不同函数引用 提取命名函数复用
列表更新后闪烁 每次操作都重建整棵子树 做差量更新或复用节点

6 结论

序号 结论 依据
1 动态列表用事件委托,只挂一个监听器 图 3-1
2 写文本用 textContent,不拼 innerHTML 表 3-1
3 批量更新用 DocumentFragment 或 replaceChildren 表 3-1
4 需要解绑时保存函数引用 表 3-2

参考文献

[1] MDN Web Docs · 事件参考,https://developer.mozilla.org/zh-CN/docs/Web/Events(访问于 2026-10-31)
[2] DOM Standard,WHATWG,https://dom.spec.whatwg.org/(访问于 2026-10-31)

返回文章列表

评论 0

0/2000

还没有评论,快来抢沙发吧~