5.2 事件传播与委托的调度现场


5.2 事件传播与委托的调度现场

本节摘要:一次点击不是「直达按钮」,而是一场从文档根出发、途经目标、再折返的巡逻——捕获阶段向下、目标阶段触发、冒泡阶段向上,沿途每个节点的监听器按注册顺序进调用栈执行。理解巡逻路线,事件委托(把监听器挂在祖先节点、用 event.target 定位真实来源)就成为自然推论。本节拆传播三段、监听器入栈顺序、阻止传播与阻止默认行为的区别,给出委托模板与动态列表、组件通信、埋点三个实战场景。

一次点击的三段旅程

<div id="outer"> <button id="btn">点我</button> </div>
const phases = { 1: '捕获', 2: '目标', 3: '冒泡' }; function log(tag) { return function (e) { console.log(tag, '在', e.currentTarget.id, '阶段', phases[e.eventPhase]); }; } document.addEventListener('click', log('文档'), true); // 捕获开关 document.body.addEventListener('click', log('body'), false); const outer = document.getElementById('outer'); const btn = document.getElementById('btn'); outer.addEventListener('click', log('outer捕获'), true); outer.addEventListener('click', log('outer冒泡'), false); btn.addEventListener('click', log('btn')); btn.click(); // 实测输出: // 文档 在 html 阶段 捕获 // outer捕获 在 outer 阶段 捕获 // btn 在 btn 阶段 目标 // outer冒泡 在 outer 阶段 冒泡 // body 在 body 阶段 冒泡

回放旅程:引擎从 window(演示从 document 起)向下巡逻到按钮,途中捕获型监听器(第三个参数 true)触发;到达按钮进入目标阶段,此节点上的监听器按注册顺序执行(目标阶段不区分捕获冒泡位次,现代浏览器目标节点上也按注册序);随后沿原路向上返回,沿途冒泡型监听器触发。每个被触发的监听器都是一个普通的函数调用——依次压入第 2 章的调用栈,同步执行完才轮到下一个。

两个精细规则值得背下来。同一节点同类型多个监听器按注册顺序执行(removeEventListener 之后重新注册会排到队尾)。目标阶段的 eventPhase 是 2,currentTarget 与 target 在目标阶段相同,在传播途中不同——这是委托定位的根基。

图 5.2-1 事件传播三段与监听器入栈顺序

图 5.2-1 事件传播三段与监听器入栈顺序

一、event 对象的关键字段

监听器收到的 event 是引擎在事件发生时现造的对象,字段按用途分三组。

定位组:target(最初触发的最深节点,传播中不变)、currentTarget(当前正在巡逻的节点,随传播变化,监听器外访问为 null)。

控制组:preventDefault(取消默认行为,前提 cancelable 为 true)、stopPropagation(截断继续巡逻)、stopImmediatePropagation(连当前节点的后续监听器一起截断)、event.type(事件名)。

输入组:键盘事件的 key 与 code、指针事件的坐标与 button、输入事件的 data。

高频误区是把「阻止冒泡」当「阻止默认」用:e.stopPropagation() 后链接照样跳转,表单照样提交——默认行为与传播是两条独立通道。反过来,submit 监听里 e.preventDefault() 阻止了提交,但事件还会继续冒泡,外层再挂的监听照样触发。passive 开关是第三条通道的近亲:addEventListener('touchmove', fn, { passive: true }) 承诺回调不调 preventDefault,浏览器据此可以不等回调跑完就开始滚动——滚动流畅度的关键开关,触摸与滚轮监听默认已是 passive(Chrome),回调里调 preventDefault 只会收到控制台警告。

二、委托模板与三个场景

委托的标准形态:

const list = document.querySelector('ul'); list.addEventListener('click', (e) => { const li = e.target.closest('li'); // 从点击处向上找目标行 if (!li || !list.contains(li)) return; // 点击落在行外(间隙、边框)直接忽略 const action = li.dataset.action; if (action) console.log('执行动作', action, '行内容', li.textContent); });

closest 的向上搜索替代了老代码里「逐层判断 tagName」的循环;contains 的防御性检查处理「点到容器自身」的边界。模板里两次判断各有含义:closest 定位「是哪个后代」,dataset.action 决定「要不要响应」——把「命中判定」与「行为分发」分开,委托代码就能长期维护。

场景一:动态列表。 行由数据渲染、随时增删,逐行绑定意味着每次渲染都要重新绑。委托挂一次,行怎么换都有效(第 5.1 节提过:克隆与重建的节点不带旧监听器,委托不受影响)。表格式界面、聊天消息流、商品网格全是这个形态。

场景二:组件通信。 自定义组件用 CustomEvent 冒泡上报:子组件 dispatchEvent(new CustomEvent('item-selected', { detail, bubbles: true })),页面层在容器上监听。bubbles 不开就不会巡逻到上层,这是自造事件必须显式声明的开关。相比回调注入,事件通道路径松耦合,组件可独立测试。

场景三:埋点与全局行为。 全站点击埋点挂 document 一次,用 target 的 data- 属性读业务标识;全局快捷键挂 keydown 一次分发。注意埋点监听器要挂捕获位并尽量不调 stopPropagation——你只是观察者,不该改变别人的传播现场。

⚠️ 常见坑一:委托里用 e.target 直接当目标行,点击行内子元素(图标、加粗文字)时 target 是子元素,动作判定失灵——closest 向上找才是正解。坑二:focus 与 blur 不冒泡,委托要用 focusin 与 focusout(它们冒泡);或者用捕获位挂 document 兜住。坑三:stopPropagation 滥用会让上层的全局行为(快捷键、埋点、弹层关闭)静默失效,截断前先确认自己有权关闸。

  • 传播是三段巡逻:捕获向下、目标触发、冒泡向上,监听器按注册顺序入栈;
  • target 是最深触发者且不变,currentTarget 随巡逻变化,二者差异即委托根基;
  • preventDefault 管默认行为,stopPropagation 管传播,两条独立通道;
  • 委托三步:容器挂一次、closest 定位来源、data 属性分发动作;
  • 不冒泡的事件(focus blur)用 focusin focusout 或捕获位,passive 换滚动流畅。

综合演练:给表格做一套行内编辑委托

一个真实到可以直接抄走的案例:数据表格支持「点编辑改状态、点删除出确认、双击收藏」,全部用一个委托监听器承载:

const table = document.querySelector('table tbody'); table.addEventListener('click', (e) => { const btn = e.target.closest('button'); if (!btn || !table.contains(btn)) return; // 点的不是按钮:忽略 const row = btn.closest('tr'); if (!row) return; switch (btn.dataset.action) { case 'edit': row.classList.toggle('editing'); break; case 'remove': if (row.dataset.confirmable === 'yes') { // 二次点击才真删 row.remove(); } else { row.dataset.confirmable = 'yes'; btn.textContent = '确认删除'; } break; default: } }); table.addEventListener('dblclick', (e) => { const cell = e.target.closest('td[data-field]'); if (!cell || !table.contains(cell)) return; console.log('编辑字段', cell.dataset.field, '当前值', cell.textContent); });

结构里有四层防御值得点名:closest(button) 先判定「这是个可点的东西」;contains 确认命中者属于本表格(委托挂在高容器上时,页面里别的按钮不该误伤);data-action 分发动作而不是猜类名;删除的两段式确认把危险操作放进「第二次点击」。行怎么增删、顺序怎么换,监听器一行不用改——委托的全部红利就浓缩在这句里。

配套的收尾知识:给这个表格接自定义事件向外汇报,「编辑完成」用 CustomEvent 带 detail 与 bubbles 抛出,页面层在 table 上监听即可拿到行数据;表格销毁(SPA 切换路由)时记得 removeEventListener——不然它就是第 6 章泄漏清单的第一型现场。

常见问答

捕获和冒泡,监听器挂哪个阶段更合适?
默认冒泡(绝大多数交互语义是「最具体的先响应」)。三个例外配捕获:全局快捷键(要在目标处理器之前拦截);弹层外点击关闭(document 层捕获判断 target 是否在弹层内);埋点观察(只看不干预,抢在别人 stopPropagation 之前拿到事件)。拿不准就用默认,需要「抢先」或「兜底」时再切捕获位。

事件委托能完全代替逐行绑定吗?
九成场景可以,剩下的例外是「监听器需要行的私有数据闭包」与「极高频事件配每行不同节流参数」这类情况。前者其实也该改成 data 属性传数据(更可序列化、更好调试),后者罕见。另一个边界是移动端某些手势库要求绑定目标元素本身——那是库的实现约束,不是委托的缺陷。

preventDefault 之后事件还会传播吗?
会。默认行为与传播是两条独立通道(本节图右下已画):阻止了链接跳转,点击照样冒泡到外层监听器。反过来 stopPropagation 也不影响默认行为——想「既不传播也不跳转」两个都要调。常见错误是在捕获层 stopPropagation 以为拦住了跳转,结果链接照跳。

事件对象在监听器执行完之后还能用吗?
不能依赖它——事件池机制下,监听器返回后事件对象可能被引擎回收复用,异步上下文(setTimeout、Promise 回调)里再访问它的字段,拿到的可能是下一场事件的值或全零的空壳。确实要留存信息,在监听器同步段内把需要的字段拷出来(const x 与 y 等基本量、target 的引用按需保留)。React 的旧版合成事件把这条坑做成了显式警告,原生开发的纪律就靠自己记。

自定义事件:组件间的广播线路

自造事件走 CustomEvent,三个参数决定它能「传多远、带多少」:

// 子组件:抛出带数据的自定义事件 function emitSelected(list, detail) { list.dispatchEvent(new CustomEvent('item-selected', { detail, // 事件负载:任意结构化数据 bubbles: true, // 允许冒泡,否则巡逻不出本节点 cancelable: false // 不需要外界能 preventDefault })); } // 页面层:在容器上监听,拿 detail 干活 container.addEventListener('item-selected', (e) => { console.log('选中了', e.detail.id, e.detail.label); render(e.detail); });

三个字段的语义要分清:detail 是唯一的数据通道(不要往 event 对象上随手挂属性,那是非标准做法);bubbles 不开就出不了当前节点——「为什么外层监听不到我的自定义事件」九成是忘了它;cancelable 配合 preventDefault 做「外界可否否决」的协议。组合起来,自定义事件就是一条低耦合的广播线路:子组件不认识任何父级,父级按需订阅,测试时用 dispatchEvent 模拟交互即可,无需真实点击。

两条使用边界:同一 DOM 里用这套(原生组件、无框架页面、或框架内的局部通信);跨组件树的全局状态共享,事件线路容易变成「谁都能发、谁都在听」的蜘蛛网,规模上来后换成显式的状态管理更可维护。

事件监听的生命周期管理模板

「注册了没注销」是泄漏清单第一型(第 6 章),用一个登记配对的小工具把纪律固化下来:

function createListeners(target) { const cleaners = []; return { on(type, handler, options) { target.addEventListener(type, handler, options); cleaners.push(() => target.removeEventListener(type, handler, options)); return handler; // 方便内联箭头函数的场景 }, off() { while (cleaners.length) cleaners.pop()(); // 逆序注销,全部清空 } }; } // 使用:组件挂载时登记,卸载钩子里一行清空 function mountPanel(root) { const bus = createListeners(root); bus.on('click', handleClick); bus.on('keydown', handleKey, { capture: true }); return () => bus.off(); // 返回清理函数,卸载时调用 } const unmount = mountPanel(container); // unmount(); ← 组件销毁时调用,全部监听一并注销

模板的三个设计点:清理函数数组把「谁注册了」集中记账,注销逆序执行避免索引错位;on 返回原函数,配合「同一引用才能注销」的 API 规则(本节规则区提过)刚刚好;挂载函数返回清理函数的形态,与主流框架「 effect 返回清理」的约定同构,迁移成本低。把这套结构放到公共工具层,团队里「忘了注销」的事故就有了结构性的解药——比口头约定「记得清理」可靠一个量级。

事件相关的性能问题,最常见的三种是什么?
榜首是滚动与触摸监听里做重活——高频事件配高成本回调,标配解法是被动监听加 rAF 节流(本节与第 4、6 章都点过名);其次是监听器数量失控——万级列表逐行绑定,内存与绑定耗时双输,委托一步到位;第三是「每次触发都查 DOM」——监听器里反复查询选择器而不是用事件对象自带的信息(target 与 dataset),把调度问题又变回查询问题。三种的共同处方都指向同一原则:监听器只做「响应决策」,重活与查询搬出回调

交互的现场讲完了:三段巡逻的路径、两个「阻止」的分界、委托模板与生命周期管理,加上自定义事件的广播线路与高频性能问题的处方,页面的输入侧至此完整。下一节看页面生命周期的起点——脚本从哪里进入、为什么会让解析器停摆,以及 defer 与 async 如何把「必须停」与「不必停」分开。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U