ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

透彻理解DOM操作与事件机制:从冒泡到委托的实践指南

透彻理解DOM操作与事件机制:从冒泡到委托的实践指南 如果你已经能写一些页面效果但对 DOM 和事件的理解还停留在“会用”的阶段——能从文档里复制代码一旦报错就抓瞎——那这篇内容就是写给你的。我在带团队和面试时发现很多人写了好几年前端问起事件冒泡和事件委托只能背出定义和流程图真到了让他在动态列表里用事件委托实现增删改查反而会绕回“每个子元素绑一个监听器”的老路。DOM 操作和事件机制是整个前端交互的地基这两块没真正吃透后面学什么框架都是空中楼阁。这篇文章我会从一次真实的改样式失败讲起把页面元素操作的常规方式、事件绑定的演进、冒泡与捕获的完整传播链路以及事件委托的最佳实践都过一遍。最后用一个可扩展的待办清单案例把知识串起来再分享几个我实际踩过、排查过、后来总结出规律的事件相关坑。适合刚接触 JS 不久、准备系统过一遍 DOM 和事件的初学者也适合写过一阵业务代码但总感觉哪里不扎实的开发者——你可以直接跳到你最模糊的那一节看。1. 从一次改按钮样式失败说起DOM 操作的基本盘1.1 选不中元素的三个原因和 querySelector 的正确用法先讲一个很典型的场景。新手写了一个按钮想通过 JS 改掉它的背景色代码是!DOCTYPE html html langzh-CN head meta charsetUTF-8 title示例/title style .btn { background: #eee; padding: 10px 20px; border: none; } /style /head body button classbtn点我/button script const btn document.querySelector(.btn); btn.style.background #f60; /script /body /html这段代码放在 body 末尾所以大概率能跑通。但很多人会把 script 放在 head 里或者在外部 JS 文件里写这段逻辑于是浏览器报错Cannot read property style of null。你第一时间觉得是选择器写错了检查了一遍发现没错其实真正的原因是执行的时机太早了——脚本执行时浏览器还没解析到按钮DOM 树里根本不存在这个节点。解决方式有三种把script标签移到/body前面利用浏览器从上到下的解析顺序。用defer属性外部引入 JS浏览器会在 DOM 解析完成后、DOMContentLoaded 事件触发前执行。在代码里包一层document.addEventListener(DOMContentLoaded, fn)。我个人的习惯是能用defer就尽量用defer其次才是把脚本放 body 末尾。因为脚本一旦多了你很难控制顺序defer能保证脚本按照引入顺序执行同时不阻塞 HTML 解析。至于在 head 里裸写一个 script 然后依赖 DOMContentLoaded不是不行但会让代码多一层嵌套维护起来反而累赘。选元素的 API 也值得展开说说。早期大家习惯用getElementById、getElementsByClassName、getElementsByTagName这三者返回的类型不一样getElementById返回单个元素或 null后面两个返回 HTMLCollection。HTMLCollection 是“活的”——DOM 一变集合自动更新这在某些场景下是优点但如果你习惯了数组的 forEach 方法会发现它用不了得先转成数组。现在我更推荐直接用querySelector和querySelectorAll它们接受任何合法 CSS 选择器返回的结果分别是单个元素和静态 NodeList。NodeList 在大部分浏览器里直接支持 forEach不需要转来转去。要注意querySelectorAll返回的快照是静态的如果页面上后续新增了匹配元素集合不会自动包含新节点——这个特性虽然不常出问题但在做动画或列表渲染时容易让人困惑。1.2 改内容、改属性、改样式一套够用的操作清单选中元素之后日常操作基本围绕三类改内容、改属性、改样式。改内容有两个 API 最容易混textContent和innerHTML。前者把内容当纯文本处理后者会解析 HTML 字符串。const div document.querySelector(.info); div.textContent phello/p; // 页面上显示的是 phello/p div.innerHTML phello/p; // 页面上出现一个真正的 p 标签我遇到不少同学写动态渲染时习惯用innerHTML拼一段大 HTML图省事。但这里有两个隐患一是字符串拼接容易出错二是如果拼接的内容里有用户输入的文本会直接造成 DOM 型 XSS恶意脚本可以直接塞进去执行。所以我的原则很简单能textContent就绝不innerHTML确实需要渲染结构时优先用创建节点的方式或者用insertAdjacentHTML配合“内容是可信代码”的前提而不是把不可信文本直接拼进来。改属性这个部分不同属性的操作方式不一样。标准属性像href、value、checked可以直接通过元素属性访问自定义属性或者>button>const btn document.querySelector(button); console.log(btn.dataset.id); // 123这里有个坑dataset里的值永远是字符串。如果你存的是数字取出来要做类型转换。我在业务里经常看到有人直接拿dataset.id去和数字比较等号判断失败排查了半天才发现是 string 和 number 的差异。改样式有三种方式。内联样式直接改element.style但注意属性名要转成小驼峰background-color要写成backgroundColor。批量改多个样式推荐用classList配合预设好的 CSS 类手写逻辑不用一个属性一个属性地赋。如果你确实需要一次性读取计算后的最终样式比如某个元素的实际显示宽度请用getComputedStyle直接读element.style.width只能拿到行内样式值外部样式表里设的宽度是读不出来的。1.3 创建和删除节点离开文档前先想清楚注册过的监听器动态创建节点是前端逃不掉的操作标准三部曲是const li document.createElement(li); li.textContent 新项目; list.appendChild(li);createElement之后节点还不在文档里这时你可以随便改属性、改内容、绑定事件完全不影响页面。但有一个细节经常被忽视如果你在节点上绑了监听器再把它移除监听器会一起被回收吗得分情况。如果没有任何外部引用浏览器会连同节点和监听器一起回收但如果这个节点被变量引用着又或者监听器被注册到了别的地方就可能造成内存泄漏。所以移除节点有两种方式removeChild和remove。现在推荐直接用node.remove()代码简洁而且不用先找父节点。但如果是批量删除注意遍历时的集合变化——用querySelectorAll返回的静态 NodeList 循环删除没问题用getElementsByClassName返回的 HTMLCollection 循环删除就要小心了因为删除一个元素集合长度会变索引顺序也会跟着变传统的 for 循环倒序遍历才能保证不出错。关于创建和插入的时机还有一个容易被忽略的性能点频繁操作 DOM 会引起重排和重绘所以如果一次要插入很多节点正确的做法是先创建DocumentFragment把所有子节点塞进去最后一次性把 fragment 插入文档。const fragment document.createDocumentFragment(); for (let i 0; i 1000; i) { const li document.createElement(li); li.textContent item ${i}; fragment.appendChild(li); } list.appendChild(fragment);fragment 的好处是它存在于内存里插入时不会引发多次渲染。这个技巧在老旧的兼容写法里很常见现代框架内部也还在用你手写业务代码时也可以直接用。2. 事件绑定的演进从 onclick 到 addEventListener2.1 为什么不要在一个 HTML 标签里写 onclick新手入门时经常看到的写法是这样的button onclickhandleClick()点我/button这个写法本身没有错浏览器确实会执行但它把行为层和结构层耦合在了一起维护成本高。而且这里有个安全边界的问题如果handleClick是在外部某个作用域里的函数你需要保证它在全局可见一旦打包工具做了模块化处理函数被封在模块作用域里不会暴露到 window 上点击就会报错。还有一点很关键这种写法不能动态解绑也不方便传复杂对象。即使你想传 event 对象也要在属性值里写handleClick(event)这个 event 是隐藏在属性行为里的参数新手很容易忽略。所以我建议从一开始就养成addEventListener的习惯。它解决的不只是“能不能绑定”的问题而是让你把事件处理的逻辑完全控制在 JS 代码里想加就加、想移除就移除也不会污染全局作用域。2.2 addEventListener 的第二个参数和第三参数addEventListener的标准签名是element.addEventListener(type, listener, options);options里最常用的是capture、once、passive。once表示只执行一次执行完后浏览器自动移除监听器这个在支付按钮、防重复提交的场景里很实用。passive一般用在 touchmove、scroll 这类高频事件上告诉浏览器“我不会调用 preventDefault”浏览器听到这个承诺之后就不需要等待 JS 执行完再做滚动滚动流畅度提升明显。但这个标志在旧浏览器里有兼容问题用之前最好查一下是否需要额外处理。capture就是事件捕获开关默认是 false表示监听器注册在冒泡阶段。设为 true 则注册在捕获阶段。这个参数很多人在面试时会背但实际用的少。真正用到的场景是后面要讲的“父元素想比子元素更早收到事件”的情况。比如一个表格行和单元格都绑定了点击事件你希望点击单元格时先触发行的逻辑就可以把行的监听器放在捕获阶段。另外addEventListener允许对同一个元素的同一事件类型注册多个监听器执行顺序按照注册顺序来。这个特性也是内联 onclick 做不到的。2.3 事件对象里的门道target 和 currentTarget 经常分不清监听器函数的第一个参数是事件对象这个对象里信息量很大。先说最容易搞混的一组event.target和event.currentTarget。一句话总结target是事件真正发生的那个元素可能是最深层的子节点currentTarget是当前绑定监听器的元素。两者在事件冒泡过程中很可能不是同一个。举个例子div idparent button idchild点我/button /divparent.addEventListener(click, function(e) { console.log(e.target.id); // child console.log(e.currentTarget.id); // parent });当你点击按钮时事件经过冒泡到达 parent监听器执行此时target指向实际被点击的按钮currentTarget指向 div。这个区分是后面事件委托的核心如果你用currentTarget去判断“我点了哪个”在委托场景里会永远只能拿到绑定元素拿不到真正触发事件的子元素逻辑必然出错。事件对象里还有一个容易被忽略的属性是event.defaultPrevented它标识这个事件的默认行为是否已经被阻止。比如你通过preventDefault()阻止了链接跳转之后上游的监听器可以用defaultPrevented判断是否有其他代码处理过这个事件。这种写法在处理第三方组件的事件拦截时很常见。3. 事件冒泡与事件捕获浏览器到底把事件通知给了谁3.1 先捕获再冒泡一条完整的事件传播链路很多人对事件传播的理解是两个独立阶段捕获从根往下走冒泡从目标往上走。但完整的过程其实是三个阶段捕获阶段从 window 往下经过 document、html、body一直到达目标元素的父级。目标阶段事件到达目标元素本身注册在目标元素上的监听器被触发。冒泡阶段从目标元素再往上一路回到 window沿途触发各父级元素注册在冒泡阶段的监听器。用一个图来表示会清晰很多但我更建议你直接在浏览器里验证因为光背“先捕获后冒泡”没啥用真到调试时得能串起来。验证的方式很简单在嵌套结构里同时给每一层注册捕获和冒泡监听器打印标签和阶段值。const log (label, e) { console.log(label, e.eventPhase); // 1 捕获 2 目标 3 冒泡 }; document.querySelector(div).addEventListener(click, log.bind(null, div capture), true); document.querySelector(div).addEventListener(click, log.bind(null, div bubble), false); document.querySelector(button).addEventListener(click, log.bind(null, btn capture), true); document.querySelector(button).addEventListener(click, log.bind(null, btn bubble), false);点击按钮后控制台的输出顺序是div capture捕获阶段、btn capture捕获阶段、btn bubble目标阶段、div bubble冒泡阶段。这里有个细节需要注意目标元素的监听器不管第三个参数是 true 还是 false都会在目标阶段触发顺序取决于注册顺序而不是捕获/冒泡的区分。这个规则比较绕记不住也没关系实际业务里很少在目标元素上同时挂捕获和冒泡监听器。3.2 stopPropagation 和 stopImmediatePropagation停止传播的精确操作事件传播不是必须走完全程的你可以在任何阶段的任何监听器里调用event.stopPropagation()让事件不再继续向上传播。这种需求常见于“点击模态框外关闭点击模态框内不关闭”的场景modal.addEventListener(click, function(e) { e.stopPropagation(); }); document.addEventListener(click, function() { closeModal(); });不写 stopPropagation 的话点击模态框内部时事件冒泡到 document关闭函数照样执行弹窗刚打开就关了。加了之后事件停在模态框内部外层监听器收不到通知自然就不会关闭。stopImmediatePropagation是更狠的版本。它除了阻止事件继续传播还会阻止同一元素上后续注册的其他监听器执行。这是什么场景会用到呢比如一个按钮绑定了两个监听器第一个监听器判断到某些条件下需要彻底中断后面的逻辑用stopImmediatePropagation就可以直接清场。这个 API 用的频率不高但好处是让你拥有精确控制事件流的能力不会出现“A 逻辑停了但 B 逻辑还在跑”的情况。3.3 冒泡为什么会在某些场景里反过来坑你冒泡不是总帮你有些场景下它会变成问题源。最常见的例子是下拉菜单。你在一个按钮上点击展开下拉面板点击面板内部的其他按钮时事件先经过面板内部的按钮然后冒泡到最上层如果不做任何拦截外层的 click 监听器也会触发导致菜单刚要操作就关了。另一个典型场景是表格行点击和单元格按钮点击并存。你给整个 tbody 绑了点击事件点击表格里某一行的单元格时这个事件会经过 td、tr、tbody你的行点击逻辑会执行同时单元格内部如果有删除按钮按钮的点击事件也会触发。这时候你的删除逻辑和行点击逻辑会一起跑典型的互相干扰。解决这类问题不一定要满世界加 stopPropagation有时反而是约束好“谁该处理什么”。我会在事件委托那一节展开说因为当你把所有监听器归拢到父节点时第一个判断条件往往就是target是不是你关心的元素如果是再看要不要继续处理。这种“过滤式”思路比到处拦截冒泡更干净。4. 事件委托用一个监听器搞定所有子元素4.1 事件委托的核心原理和收益事件委托利用的就是冒泡机制。正常情况下你要给 100 个子元素绑事件就得循环 100 次注册监听器事件委托的做法是只给它们的公共父元素绑一次监听器点击事件冒泡上来时通过e.target判断当前被点的是哪个子元素再执行对应的逻辑。这样做的好处有三个一是注册的监听器数量大幅减少内存占用下降二是新增的子元素不需要额外绑定事件因为监听器在父级已经存在事件冒上来自然会被抓到三是代码更集中不用分散在渲染逻辑里到处写事件绑定。我见过一个真实案例某个后台管理系统里有一个动态生成的权限列表列表项可以增删改。开发同学在每次渲染后都做一次“给所有新增项绑定事件”的操作结果列表中项目一多删除某个中间项之后后面的项会神秘地消失又出现排查下来是绑定事件和 DOM 更新顺序不一致导致的索引错位。后来我让她改成事件委托一个父级监听器解决整个逻辑清晰了性能也上去了。这个例子很典型它能说明事件委托不只是性能优化更是解决动态元素事件失效的正路。4.2 实战动态列表的删除按钮我们直接写一个动态列表的删除功能。列表的结构是ul idlist li>const list document.querySelector(#list); list.addEventListener(click, function(e) { const delBtn e.target.closest(.delete); if (!delBtn) return; const li delBtn.closest(li); const id li.dataset.id; // 执行删除逻辑 li.remove(); });这里用了两个新 APIclosest和dataset。closest从当前元素向上查找匹配选择器的祖先元素直到文档根节点找到就返回该元素找不到返回 null。为什么不能直接判断e.target的 class因为用户点击的可能是按钮里的文字节点e.target会落到文本节点对应的元素上甚至可能是按钮内部嵌套的 span。直接用closest从e.target往上找无论点的是按钮、按钮里的文字还是按钮包裹的图标都能准确回到.delete按钮。这个写法最妙的地方是之后无论列表项怎么动态增加删除功能始终有效。你永远不需要再为新增的按钮单独绑定事件因为监听器在父级的 ul 上任何点击都会冒泡上来。4.3 哪些事件不适合事件委托不是所有事件都能委托。原因很简单委托依赖冒泡有些事件根本不冒泡有些事件虽然能冒泡但频率太高冒泡到父级时信息已经不够用了。不冒泡或不完全冒泡的常见事件有focus、blur、mouseenter、mouseleave、scroll部分浏览器支持冒泡部分不冒泡、resize。focus和blur不冒泡这会导致你试图在父容器上委托 focus 时永远不生效。不过浏览器提供了focusin和focusout这两个事件可以冒泡但注意它们在 IE 里反而是原生事件现代浏览器也普遍支持。如果你的场景是在一个表单容器上统一处理输入框失焦验证写法可以这样form.addEventListener(focusout, function(e) { if (e.target.matches(.form-input)) { validate(e.target); } });mouseenter和mouseleave在设计上就不冒泡但mouseover和mouseout可以冒泡只是mouseover在移动到子元素时也会触发判断起来比较麻烦。如果你想实现“鼠标移入容器”的效果直接在容器上绑mouseover然后用e.target判断是否在容器内部即可但如果只是想简单地在子元素上做 hover 处理委托的成本高于收益直接用mouseenter更省心。另外像input事件本身会冒泡但你如果想做输入防抖委托到父级后还要先定位到输入框再做防抖逻辑代码会多一层。这种场景我反而不推荐委托直接在具体输入框上绑事件反而更直观。5. 综合实战一个可扩展的待办清单是怎么写出来的5.1 先规划数据结构再决定 DOM 结构很多新手写页面功能时习惯直接开写 HTML 和 JS边写边改最后代码乱成一团。我的建议是先想清楚数据长什么样。比如待办清单的核心数据就是一个数组每一项有 id、text、completed 三个字段。const todos [ { id: 1, text: 学习 DOM 操作, completed: false }, { id: 2, text: 理解事件冒泡, completed: true }, { id: 3, text: 掌握事件委托, completed: false } ];数据的结构决定了渲染的复杂度。id 用于唯一定位新增时生成唯一 id 可以借助Date.now()加随机数简单场景够用。completed 控制样式和复选框状态。DOM 方面只需要一个输入框、一个添加按钮、一个 ul 列表容器。这里不打算套任何框架原生实现目标是让你看清楚 DOM 操作和事件委托各自承担什么角色。5.2 渲染函数把数据变成界面渲染函数接收整个 todos 数组返回拼接好的 HTML 字符串然后一次性插入列表容器。考虑到动态新增项以后事件委托能覆盖这里不需要在渲染后重新注册任何监听器。function render() { list.innerHTML todos.map(item li>function escapeHtml(str) { const div document.createElement(div); div.textContent str; return div.innerHTML; }利用 textContent 的自动转义特性把字符串变成 HTML 实体比手写 replace 正则更可靠。5.3 事件委托增删改查都交给一个监听器在列表容器上挂一个点击监听器统一处理复选框切换、删除按钮和可能的“整行删除”操作。list.addEventListener(click, function(e) { const li e.target.closest(li); if (!li) return; const id Number(li.dataset.id); if (e.target.classList.contains(delete)) { removeTodo(id); return; } if (e.target.type checkbox) { toggleTodo(id); } });这样无论列表渲染多少次新增多少待办项都不需要再为每一项单独绑定事件。删除逻辑只需要在数据层过滤数组然后调用 render 重新渲染。添加待办的流程也简单读取输入框的值清洗一下构造一个新对象 push 进数组然后重渲染。addBtn.addEventListener(click, function() { const text input.value.trim(); if (!text) return; todos.push({ id: Date.now(), text: text, completed: false }); input.value ; render(); });这个流程的关键是所有状态变化都通过“更新数据-重渲染”的路径完成而不是在事件处理函数里直接操作 DOM 修改某一项。这样代码逻辑容易推理也方便扩展新功能。5.4 扩展性想加“编辑”“筛选”“统计”时怎么做如果数据结构设计得好后续加功能只需要改数据层逻辑然后重新渲染就行。比如编辑功能双击某项时把 text 变成 input编辑完成后更新数组对应项。筛选功能维护一个 filter 状态all/active/completed渲染前对数组做一次过滤。统计功能渲染后更新一个显示未完成数量的 span 文本。这些功能加进去都不会破坏原有的事件委托结构因为列表的监听器始终只有一个。这也是事件委托在动态渲染场景下最大的优势渲染函数可以任意重建内部结构事件绑定的逻辑完全不用跟着改。6. 我踩过的坑事件相关的排查思路6.1 事件重复注册同一个监听器绑了两遍怎么排查一个常见的问题是同一个元素的监听器被注册了多次点击一次触发两次逻辑。遇到这种问题我一般先用浏览器的开发者工具直接看元素的 Event Listeners 面板Chrome 的 Elements 面板里选中元素右侧 Event Listeners 标签会列出所有已注册的监听器还能看到注册在哪个节点上。这一步能快速确认到底绑了几次。重复注册一般有两个来源一是事件绑定代码放在了一个会被重复调用的函数里比如初始化逻辑在某个全局函数里执行了多次二是使用了具名函数还是匿名函数的差异——如果绑定的是匿名函数你没有办法用removeEventListener移除它因为它没有引用。解决方案很明确事件绑定要在初始化阶段一次性完成不要放在会被频繁调用的渲染逻辑里。如果必须动态绑定用具名函数并在下次绑定前先移除旧监听器。6.2 委托中 e.target 是文本节点的问题事件委托最常踩的坑之一就是e.target落到了文本节点上导致你读取它的 classList 或 dataset 时报错。解决思路从根上改变不要对e.target做过于细致的假设而是用closest向上查找“最近的目标元素”查不到就说明这次点击不是你要处理的点击。const item e.target.closest(.item); if (!item) return;这样做的好处是无论用户点击的是文字、图标、内嵌 span还是按钮本身都能正确归因到这个 li 的.item层。早期代码里常见的另一套写法是if (e.target.tagName LI) { ... }这套写法遇到上面说的文字节点和二三层嵌套就失效建议统一改成closest。6.3 大数据量下的卡顿排查当列表项特别多时DOM 操作本身的成本会上升。事件委托已经帮你降低了监听器数量但如果每次更新都全量重渲染还是会有性能问题。此时可以考虑按需更新数据变化后只更新变化的项而不是从头渲染整个列表。比如render时把数组每一项的 id 作为 key渲染前先和上一次渲染的 key 列表做 diff只更新有变化的节点。还有一个很容易忽略的性能陷阱高频事件不设节流或防抖。像 scroll、input、mousemove 这类事件即便用事件委托回调也可能每秒执行几十次回调里如果做了比较重的 DOM 操作页面必然卡。合理的做法是给回调包一层 throttle 或 debounce把回调的实际执行频率限制下来。这个在前面提到过但值得再强调一次。从更长远的角度看当你的项目足够大你可能会发现手写事件处理逻辑逐渐走到了极限。但 DOM 与事件机制这套基本功即使在 React、Vue 这类框架时代也依然没有失去价值框架帮你屏蔽了底层细节却替代不了你对“事件到底是怎么传播的”“为什么这里用委托更合理”这些底层判断的理解。写业务时遇到动态列表、弹窗、全局键盘事件处理你最终会发现早年间在原生 DOM 上踩过的坑全都变成了你对框架方案为什么这么设计的第一手理解。
返回列表