先记住这个答案
动态列表若给每个删除按钮单独绑定监听器,新插入按钮还要再绑,移除节点时也容易遗漏清理。事件委托把一条 click 监听放在稳定父容器上,按钮点击会冒泡到这里;处理器用 event.target.closest() 找到对应按钮和列表项。只要新项仍是该容器后代,它也能沿用这条监听器。
- 父级监听覆盖未来子项
- 依赖事件沿路径向上传播
closest()找匹配的业务节点- 确认节点仍属于当前容器
待办列表删除当前项
列表项从接口实时追加。父级始终存在,因此只需在父级注册一次事件。点击图标时,实际目标可能是按钮里的 <svg>,直接判断 event.target.matches("button") 会漏掉;向上找按钮更稳。
const list = document.querySelector('#todos')
list.addEventListener('click', event => {
const button = event.target.closest('[data-remove]')
if (!button || !list.contains(button)) return
const item = button.closest('[data-todo-id]')
if (item) item.remove() // 动态插入的待办项同样生效
})contains() 确认找到的按钮属于当前列表,避免在复杂嵌套中错把外部节点当作目标。新插入的列表项只要带同样属性,便可复用这段处理器。
若点击会触发别的组件的停止传播,父级可能收不到;若列表处于 Shadow DOM 内,还要考虑事件是否跨越边界和目标重定向。
委托不是无条件省内存
委托确实减少大量重复的监听器,也简化动态节点清理。但父级每收到一次 click 都要判断目标,复杂页面若把所有事件挂到 document,逻辑会难以维护。
选择足够稳定且范围尽量小的共同祖先。委托适合大量同类子项的统一动作;只有少数固定控件时,直接监听往往更清楚。
示例使用 closest('[data-remove]'),是为了允许用户点到按钮内的图标;再找 closest('[data-todo-id]'),是为了把按钮映射回所属待办。若按钮被放到列表项外的浮动操作栏,这层关系就不存在,委托逻辑也得改。它省的是重复注册,不是省掉对 DOM 结构的约定。
从服务器追加节点时,父容器的监听器不必重新绑定;但如果整个列表容器被框架重新渲染替换,旧监听器会留在旧节点上。要么在稳定的更外层注册,要么随组件生命周期重新注册并清理。排查“新按钮没反应”时,先确认换的是列表项还是父容器。
容易答错的地方
- 直接把
event.target当成按钮 - 用户可能点到按钮内部图标或文字,应向上找到业务节点。
- 以为委托能处理任何事件
- 不冒泡事件、跨 Shadow DOM 的事件和被阻断传播的路径都需要另看。
面试官还会怎么问?
target 与 currentTarget 在例子中各是什么?
target 是实际点击的内部节点,currentTarget 是绑定监听器的列表父元素。
为什么还要检查 contains()?
防止 closest() 找到不属于该业务容器的匹配节点。
父元素也动态替换怎么办?
监听器会跟旧父节点一起离开;应在稳定祖先上委托,或重新绑定新父元素。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。