JavaScript DOM 与事件

AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。

Node.js 22 LTS · Chrome 130

不引入框架时,页面交互全靠 DOM API。用对了很直接,用错了就是「点了没反应」「列表越点越多」这类问题。

环境准备

示例在 Chrome 130 下验证,写法对其他现代浏览器一致。把脚本放在 <body> 末尾,或用 <script type="module">(默认延迟执行),避免拿不到节点。

选择节点

document.querySelector(".card");            // 第一个匹配
document.querySelectorAll(".card li");      // NodeList(静态快照)
document.getElementById("app");             // 单个,最快

const form = document.forms.login;          // 具名表单
const input = form.elements.email;          // 表单控件

querySelectorAll 返回的 NodeList 是静态的:后续新增的节点不会出现在里面;而 getElementsByClassName 返回 HTMLCollection,是实时的,循环时增删节点容易死循环。优先用 querySelectorAll 再 for...of。

创建与插入

const li = document.createElement("li");
li.textContent = "第一项";
li.classList.add("item");
li.dataset.id = "42";

list.append(li);       // 末尾
list.prepend(li);      // 开头
li.before(other);      // 兄弟位置
li.replaceWith(next);
li.remove();

textContent 插入纯文本,安全;innerHTML 会解析 HTML,拼接用户输入就是 XSS 漏洞:

// 危险
li.innerHTML = `<b>${userInput}</b>`;

// 安全
li.textContent = userInput;

确实需要插入 HTML 结构时,用 <template> + cloneNode,或 el.querySelector() 后再用 textContent 填值。

批量更新

每次改样式或插入节点都可能触发重排。先脱离文档构建,再一次性挂上:

const frag = document.createDocumentFragment();
for (const item of items) {
  const li = document.createElement("li");
  li.textContent = item.title;
  frag.append(li);
}
list.append(frag);

读布局属性(offsetWidth、getBoundingClientRect())会强制同步重排,别在大循环里交替读和写:

// 差:每轮读写交替
for (const el of els) {
  el.style.width = el.offsetWidth + 10 + "px";
}

// 好:先读完再写
const widths = els.map((el) => el.offsetWidth);
els.forEach((el, i) => (el.style.width = widths[i] + 10 + "px"));

事件流:捕获、目标、冒泡

点击一个 <button>,事件会从 document 一路捕获下来,到达目标,再冒泡回去。默认监听在冒泡阶段触发:

document.addEventListener("click", () => console.log("document"));
button.addEventListener("click", () => console.log("button"));
// 点击按钮:先 button,后 document

在第三参数打开捕获:

container.addEventListener("click", handler, { capture: true });

常用控制:

event.preventDefault();   // 阻止默认行为(提交、跳转)
event.stopPropagation();  // 阻止继续冒泡(慎用,会破坏委托)
event.target;             // 实际触发的元素
event.currentTarget;      // 绑定监听的元素

事件委托

列表项是动态生成的,别给每个都绑监听——挂在父容器上,用 target.closest() 判断来源:

list.addEventListener("click", (event) => {
  const btn = event.target.closest("button[data-action]");
  if (!btn || !list.contains(btn)) return;

  const { action } = btn.dataset;
  if (action === "delete") btn.closest("li").remove();
});

好处:只一个监听器、动态节点自动生效、内存占用低。注意 closest 要检查祖先边界(上例的 list.contains),否则可能匹配到容器外的同名按钮。

清理监听器

单页应用里组件被替换后监听器仍挂在旧节点上,会泄漏内存,也可能对已销毁状态做出反应。

const controller = new AbortController();
button.addEventListener("click", onClick, { signal: controller.signal });
window.addEventListener("resize", onResize, { signal: controller.signal });

// 卸载时一次性移除
controller.abort();

比记住每个 removeEventListener 的引用更省心;注意移除时不能带 { once: true } 之类的额外选项不一致问题。

表单处理

<form id="login">
  <input name="email" type="email" required />
  <button type="submit">登录</button>
</form>
form.addEventListener("submit", async (event) => {
  event.preventDefault();                 // 阻止页面刷新
  const data = new FormData(form);
  const payload = Object.fromEntries(data);
  await fetch("/api/login", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(payload),
  });
});

浏览器自带校验(required、type="email"、pattern)先用起来,别全部手写。提交后给按钮加 disabled 防止重复提交。

事件性能

高频事件(scroll、resize、input)不要直接跑重逻辑,节流或防抖:

function debounce(fn, ms = 200) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

searchInput.addEventListener("input", debounce((e) => search(e.target.value), 250));

input 用防抖(等用户停手),scroll 里的视觉更新用 requestAnimationFrame 合并到每帧一次。

常见错误与注意事项

1. 脚本执行时节点还不存在

document.querySelector("#app") 返回 null,后续 addEventListener 报错。用 type="module"、defer,或把脚本放到 </body> 前。

2. 用 innerHTML 拼用户数据

XSS 的第一入口。文本一律 textContent。

3. event.target 和 this 混用

箭头函数没有自己的 this;用 event.currentTarget 最明确。

4. 循环里绑定闭包变量

// 老问题:全部输出最后一个 i
for (var i = 0; i < 3; i++) {
  btn[i].addEventListener("click", () => console.log(i));
}

// 用 let,或事件委托
for (let i = 0; i < 3; i++) {
  btn[i].addEventListener("click", () => console.log(i));
}

5. removeEventListener 移除失败

必须传同一个函数引用,匿名函数移不掉。用 AbortController 或具名函数。

6. 直接改 style.cssText 覆盖一切

会清掉之前的内联样式。用 classList 或逐个属性。

7. 忘记 preventDefault 导致页面刷新

表单提交、<a href="#"> 点击都会跳转,调试时先看地址栏是不是变了。

小结

  • 查询用 querySelector(All),插入用 textContent + DocumentFragment。
  • 事件捕获 → 目标 → 冒泡;target 是触发者,currentTarget 是监听者。
  • 动态列表用事件委托;卸载用 AbortController 统一清理。
  • 表单先吃浏览器内置校验,提交时 preventDefault 并防重复提交。
  • 高频事件防抖/节流,读写分离避免强制重排。

下一步