A08 DOM 操作与事件
讲解DOM节点查询修改、事件绑定解绑与委托、批量更新避免布局抖动的完整写法
- 动态列表用事件委托,父容器挂一个监听器配合closest即可覆盖新增节点
- 写文本用textContent自动转义防注入,避免拼接innerHTML
- 批量更新用DocumentFragment或replaceChildren,只触发一次布局
- 需要解绑时保存命名函数引用,匿名箭头函数无法比对
- 频繁读写布局属性触发布局同步,是交互卡顿常见原因
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 验证与讨论
- 打开页面,确认有一条初始记录,添加新记录后列表刷新且输入框清空。
- 点击任意「删除」,确认对应行消失,其余行保持不变。
- 在控制台执行
list.querySelectorAll('li').length,与state.length对照。 - 把
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 |
- 上一篇:A07-字符串与数字处理
- 下一篇:A09-实战待办清单
- 返回:A00-总结 | 科目 README
参考文献
[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
还没有评论,快来抢沙发吧~