大禹记。

首页 / 笔记 / 正文

DOM 事件的几个易混点

改页面脚本时我反复踩到的七个点,每条先给结论,再配一段代码或一张表。

一、捕获与冒泡的顺序

结论:事件先从 window 向下捕获到目标,再向上冒泡回来,所以外层捕获先跑、内层冒泡先跑。第三个参数写 true 走捕获,省略或写 false 走冒泡,现在一般写成对象。

outer.addEventListener("click", onOuter, { capture: true }); // 先
inner.addEventListener("click", onInner);                    // 中
outer.addEventListener("click", onOuter2);                   // 后

// 目标元素自己的监听器不分阶段,只按注册顺序执行。
// 同阶段、同函数、同选项重复注册,第二次会被静默忽略。

二、target 与 currentTarget 不是一回事

event.targetevent.currentTarget
指谁被点到的那个最深层元素正在执行监听器的元素
委托时可能是 li,也可能是里面的 span始终是挂监听的 ul
生命周期全程不变只在监听器执行期间有值
list.addEventListener("click", function (event) {
  var item = event.target.closest("li");     // 从被点的元素往上找
  if (item === null || list.contains(item) === false) {
    return;                                   // 点在列表外面,不管
  }
  console.log(event.currentTarget, item.dataset.id);
});

三、事件委托什么时候不该用

结论:子项多、会动态增删、处理器只做分发时适合委托;事件本身不冒泡、或要精细拦传播的不适用。

  • focus、blur 不冒泡,委托要换成 focusin、focusout,这条我踩得最久。
  • scroll 在元素上不冒泡,想在容器上委托滚动得开捕获。
  • input、change、submit 冒泡,能委托给表单。
form.addEventListener("focusin", function (event) {
  if (event.target.matches("input, textarea")) {
    showTip(event.target.name);   // 换成 focus 这里永远收不到
  }
});

四、passive 与 preventDefault 冲突

结论:标了 passive: true 就是承诺不阻止默认行为,再调 event.preventDefault() 不起作用,控制台还会报错;浏览器拿这个承诺去提前开始滚动。window、document、body 上的 wheel、touchstart、touchmove 默认就是被动的,我要拦滚动得显式关掉。

panel.addEventListener("wheel", onWheel, { passive: true });  // 只统计,不拦截
sheet.addEventListener("touchmove", onMove, { passive: false }); // 要 preventDefault 就写 false

五、preventDefault 与 stopPropagation 各拦什么

调用拦住的东西不拦的东西
preventDefault()默认动作:跳转、提交、滚轮滚动照样冒泡,祖先的委托照样收到
stopPropagation()继续向外层传播默认动作照常,链接照样跳
stopImmediatePropagation()同元素后面的监听器也不执行无

我在 li 里点删除,外层 a 跟着跳转,就是只写了 stopPropagation,忘了拦跳转的是 preventDefault。

六、click 与 pointerdown 的时序差

结论:一次鼠标操作的顺序是 pointerover、pointerdown、pointerup、click;click 要等按下和抬起都完成,触屏还要多等一次手势判定。所以拖拽和即时反馈绑 pointerdown,确认意图绑 click,同一个动作别两个都绑,会执行两遍。

handle.addEventListener("pointerdown", startDrag); // 接触屏幕的瞬间
ok.addEventListener("click", commit);              // 抬起之后才到这里

七、once 与解绑要盯住函数引用

结论:{ once: true } 只在事件真正触发时自动解绑,一直没触发它就还挂在原地;而 removeEventListener 靠「类型 + 函数引用 + 捕获选项」三者匹配,匿名函数永远删不掉,passive 不参与匹配。

function onScroll(event) { /* 收起顶部条 */ }

window.addEventListener("scroll", onScroll, { passive: true });
window.removeEventListener("scroll", onScroll);           // 同一个引用,删得掉
window.removeEventListener("scroll", onScroll, true);     // 捕获选项对不上,删不掉

一次性的交互我就写 { once: true },但函数照旧提到外面命名,防止它被注册第二遍。

备忘:整页只有一个脚本时这些都还不明显;等同一批元素上出现多个来源的监听器,差一层捕获就会变成 bug。