ARTICLE DETAIL

资讯详情

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

原生DOM操作详解:从元素查找到事件委托与性能优化

原生DOM操作详解:从元素查找到事件委托与性能优化 做前端这些年如果要我选一个“天天都用、又最容易被人忽略基础”的东西我会毫不犹豫选 DOM 操作。现在 Vue、React 这些框架把视图层封装得越来越舒服很多人写业务已经很少直接碰document.getElementById了但框架底层依然是那套东西创建节点、更新节点、删除节点、绑定事件、处理属性。也就是说你可以不直接写 DOM 操作但你不可能不懂它真遇到性能瓶颈、诡异 Bug 的时候兜底的永远是原生 DOM 基本功。这篇内容主要围绕三件事展开第一怎么又快又准地把页面元素找出来第二找到之后怎么安全高效地增删改查第三事件绑定、性能优化里那些明显的坑怎么避开。所有结论都会给出选择原因不是只丢结论。新手可以把它当成一份原生 JS 动手手册做了几年开发准备面试或查漏补缺的朋友也能在里面找到一些值得对比的细节。1. 先把 DOM 的底层逻辑聊透1.1 DOM 不是 HTML也不是 CSS很多初学者会有一个误解DOM 就是 HTML 源码。其实不是。HTML 只是浏览器拿到的“原材料”浏览器会把它解析成一颗节点树这棵树才叫 DOM 树。你写下面的 HTML:div idapp p classtitle你好/p /div浏览器内存里对应的是类似这样的层级结构Document └─ html └─ body └─ div#app └─ p.title └─ 文本节点: 你好我们通过 JavaScript 操作的不是那串.html字符串而是这棵已经解析好的树。所以你会看到document、element、node这些概念。理解了这一点很多问题就顺了比如为什么document.getElementById能找到元素为什么刷新页面后 JS 动态加的元素会消失——因为只要页面重新加载整棵树就会按 HTML 重新构建一次。在 DOM 体系里还有几个容易混的概念我习惯把它们的边界理得很清楚document整个文档的入口对象可以理解成“树根”。ElementHTML 标签对应的对象比如div、p。Node比 Element 更大的概念。文本、注释、属性都属于 Node但不都是 Element。EventTarget再往上一层所有能绑定事件的对象。这三者的关系是EventTarget包含NodeNode包含Element。你用element.parentNode可能拿到一个文本节点用element.parentElement则只能拿到元素节点这是实战里很有用的一个区分点。1.2 理解“引用”和“实时结构”我们常听到“JS 操作 DOM”这句话但很少有人强调一个DOM 对象在变量里保存的本质是“对树中某个节点的引用”而不是复制品。举个例子const title document.querySelector(.title); document.body.innerHTML ; console.log(title.textContent); // 如果你先存了引用刷新或替换后 title 还在内存里这里面的变化非常微妙。如果某个节点已经被从 DOM 树中移除但你仍持有它的引用那么对象本身没有被销毁我们能读取它的属性、操作它的内容。这在框架“diff 之后的旧节点回收”里很常见。如果不理解“引用”的概念排查“为什么变量还有值但页面上没了”这种问题时会绕很久。同样的道理某些“获取元素”的方法返回的是实时集合。这个特性带来便利的同时也会制造一个经典大坑后面第 3 部分我会展开聊。2. 元素选择不同 API 的适用场景与底层差异2.1 从 getElementById 到 querySelector原生 JS 获取元素最传统的方式是这一套document.getElementById(app); document.getElementsByClassName(item); document.getElementsByTagName(div); document.getElementsByName(username);getElementById的底层查找速度依然是最快的因为它本质上走的是浏览器内部维护的 id 映射表。不过它有个限制只能挂在document上调用不能像div.getElementById(...)这样用。getElementsByClassName和getElementsByTagName返回的是HTMLCollection一个实时集合。写着简单但在遍历删除这种场景下容易踩坑。比如我想清空所有.item节点如果写成const items document.getElementsByClassName(item); for (let i 0; i items.length; i) { items[i].remove(); }你会看到一部分.item没被删掉。原因很简单items是实时集合删掉一个元素后集合长度和索引会立刻变化i还在递增于是隔一个删一个。正确做法是反向遍历或者干脆用while (items[0])一直删第一个。后来项目里我越来越倾向于用querySelector和querySelectorAll原因是选择器写法统一和 CSS 完全一致团队协作时心智负担更小。返回结果相对可控querySelectorAll返回的是NodeList静态快照不会在遍历时突然变长度。可以非常方便地实现嵌套查找比如container.querySelector(.active)不需要一层层getElementById去拼。不过querySelectorAll也有一个容易被人忽略的点它返回的不是HTMLCollection而是NodeList。虽然都长得像数组但前者通常实时后者是静态的。也就是说你用document.querySelectorAll(div)拿到一批节点后后续再往页面里加新的div这个集合不会自动变长。2.2 返回的数据结构HTMLCollection 与 NodeList 的差异把这两种集合列的对比放在这里实战时一眼能查对比项HTMLCollectionNodeList常见来源getElementsByTagName、getElementsByClassNamequerySelectorAll、childNodes是否实时通常是实时集合querySelectorAll 是静态childNodes 是实时是否包含文本节点不包含只包含元素childNodes 会包含文本节点是否可用 forEach不可直接使用querySelectorAll 返回的可直接使用转换为数组Array.from(collection)大多数场景可直接迭代我自己在写代码时为了统一凡是需要把集合当作纯数组处理的都会先做一次转换const nodes Array.from(document.querySelectorAll(.item)); nodes.filter(Boolean).forEach(...);当然现代浏览器里querySelectorAll返回的NodeList已经实现了forEach但filter、map这些还是没有的。当需要更复杂的数组操作时Array.from是最稳的。2.3 手动缓存 DOM 查找结果刚学 JS 的人最容易忽略的一件事每执行一次document.querySelector都是一次真实查找它不是免费的。尤其在事件回调、循环体、高频滚动函数里反复查找同一个元素会让性能白白损耗。我的个人习惯是如果一个元素会被多次使用先把它存进变量// 不推荐 function updateTitle(text) { document.querySelector(.title).textContent text; document.querySelector(.title).classList.add(updated); } // 推荐 const titleEl document.querySelector(.title); function updateTitle(text) { titleEl.textContent text; titleEl.classList.add(updated); }即使是querySelector一旦脱离document而是在一个很大容器里查询也会去递归遍历子树。如果在循环前先缓存容器引用再循环内container.querySelector去查节省的开销会很明显。尤其是列表页几千行数据的场景优化前后差异肉眼可见。3. 节点增删改查所有视图操作的原型3.1 创建元素和文本的几种姿势动态创建节点最基础的方式const div document.createElement(div); div.className card; div.textContent 这是内容; document.body.appendChild(div);使用textContent而不是innerHTML来插入纯文本这是我在代码审查时最常提醒的一点。因为前者只把内容当作文本处理即便内容里有img onerror...也会被安全地展示成字符串不会变成真实标签后者则会直接解析 HTML 并执行潜在的脚本逻辑。所以凡是内容可能来自输入的优先textContent。还有一个很多人会碰到却不知道怎么解决的场景我想创建大量节点并且一次性插入到页面。如果每创建一个就appendChild一次浏览器会反复触发布局计算非常卡。这时候用DocumentFragmentconst fragment document.createDocumentFragment(); for (let i 0; i 1000; i) { const li document.createElement(li); li.textContent 第 i 项; fragment.appendChild(li); } listEl.appendChild(fragment);DocumentFragment可以理解成一个临时的“虚拟容器”。它本身不在 DOM 树里但你可以像操作真实节点一样往里面塞子节点。最后一次性把整个 fragment 真正插入文档这个插入动作只会触发一次渲染更新性能提升非常明显。3.2 插入位置appendChild、insertBefore、insertAdjacentHTML很多人只知道appendChild等到需要在某个节点前面插入时就卡住了。原生 API 里对应的方法是insertBeforeparent.insertBefore(newNode, referenceNode);注意第二参不能省略。如果传null效果等同于 append 到末尾。这个 API 名字起得有点绕但用顺手后就还好。项目里常见的“列表首项插入”写成const list document.getElementById(list); const firstItem list.firstElementChild; list.insertBefore(newNode, firstItem);insertAdjacentHTML是我个人特别喜欢的一个 API。它能直接把 HTML 字符串插到指定位置且位置控制得非常灵活element.insertAdjacentHTML(beforeend, div新的内容/div); element.insertAdjacentHTML(afterbegin, div插到内部最前面/div);四个位置分别是beforebegin元素前、afterbegin元素内部第一个子节点前、beforeend元素内部最后一个子节点后、afterend元素后。做局部刷新、列表尾部加载更多时这种写法比手动createElement再插入简洁很多。但它也有代价传入的字符串会被当作 HTML 解析如果有用户输入拼接进去存在注入风险。另外如果元素原来有事件监听通过innerHTML或insertAdjacentHTML重写内部结构老节点上的监听器会全部丢失这在第 4 部分讲事件委托时会更突出。3.3 修改属性、样式以及 classList 的细节修改属性常用setAttribute、getAttribute、removeAttribute。但很多属性 DOM 对象上已经映射成了属性比如input.value、img.src。要区分“HTML attribute”和“DOM property”是两套体系。举一个很经典的坑input.getAttribute(value)拿到的是初始值而input.value拿到的是当前输入值。如果你在受控输入框场景里把这两者混用很容易出现“明明 setAttribute 了但 input 不更新”的困惑。样式处理上我的建议是能用classList就别用style.xxx。为啥因为样式集中管理更符合可维护性而且classList的性能也要比逐项改style好。还有个特别实用的方法el.classList.toggle(active, forceCondition);第二个参数可以强制让类名处于添加或移除状态。这个用法在“多项选中状态和当前状态联动”的时候特别有用比如只允许列表里一项处于激活态可以这样写items.forEach(item { item.classList.toggle(active, item currentItem); });动态修改内联样式的场景如果确实要一次设置多个样式优先用cssText合并减少多次触发样式计算el.style.cssText color: red; font-size: 14px;;3.4 删除节点和清理引用删除节点简单粗暴element.remove()。这个 API 几乎覆盖了以前先找父节点再removeChild的繁琐步骤。使用时要意识到一个问题remove()只是把节点从 DOM 树里移除并不代表所有相关的对象都会被立刻回收。如果别的变量还引着这个节点它依然待在内存里。在写单页应用或者做长列表清理时我习惯把这种场景做成“清理三步”从容器中移除或清空。删除绑定在节点上的事件监听。把外部引用变量置为null。否则页面上看不到泄漏但内存会慢慢涨上去尤其移动端长列表滚动后卡顿有一部分原因就在这里。4. 事件绑定与事件委托4.1 三种绑定方式以及为什么推荐 addEventListener事件绑定有几种写法button onclickhandleClick()按钮/buttonbtn.onclick handleClick;btn.addEventListener(click, handleClick);第一种混在 HTML 里调试和复用都很麻烦第二种只能绑定一个处理函数后绑的会覆盖先绑的第三种最灵活同一元素的同一事件可以叠加多个处理函数还支持通过第三个参数控制捕获阶段或自动移除。addEventListener的第三参可以是布尔值也可以是对象我个人最常用的是对象写法el.addEventListener(click, handler, { capture: false, // 默认 false表示在冒泡阶段处理 once: true, // 只触发一次后自动移除 passive: true // 告诉浏览器不会调用 preventDefault滚动场景下有意义 });once在做一次性引导弹窗、首次曝光埋点场景下很有用。passive用在touchmove、scroll这类事件上可以让浏览器不等待 JS 事件函数执行完就继续滚动流畅度能明显改善。4.2 事件传播机制先捕获再到达目标再冒泡要理解委托必须先了解事件传播的三步。一个事件从window开始往下找目标节点这个阶段叫捕获到达实际触发事件的元素叫目标阶段之后再从目标元素往上返回window叫冒泡。默认下addEventListener监听的是冒泡阶段。所以当某个元素内部有很多子节点需要绑定同一事件时更好的办法是只在外层容器监听一次利用事件往上冒泡的特性统一处理。这就是事件委托。经典场景一个商品列表里面有无数个“加入购物车”按钮其中包括将来动态加载的新商品。如果每次创建按钮都单独绑定代码繁琐且容易漏用事件委托后只需要绑定一次容器listEl.addEventListener(click, (e) { const target e.target.closest(.add-to-cart); if (!target) return; const goodsId target.dataset.id; addToCart(goodsId); });这里用e.target.closest(.add-to-cart)是为了处理一种边界情况用户可能点中的不是按钮本身而是按钮内部的图标或文字。closest会从当前节点一直向上找看是否匹配选择器比判断tagName再手动找父节点稳多了。事件委托最大的优势是它对动态节点天然有效。哪怕页面后续通过 AJAX 加载了新节点只要它们依然在这个容器内部事件就能被捕获到。同时也意味着内存里只需要一个监听函数尤其适合表格、列表这种节点特别多的场景。4.3 自定义事件、阻止默认行为与解绑除了浏览器原生事件我们还可以派发自定义事件让业务模块之间解耦const event new CustomEvent(user-login, { detail: { name: 张三 } }); window.dispatchEvent(event);然后在另一个模块监听window.addEventListener(user-login, (e) { console.log(e.detail.name); });这套机制在做无关组件通信时比直接互相调用方法更不容易产生耦合。阻止默认行为用e.preventDefault()比如阻止表单提交、阻止链接跳转。阻止事件继续传播用e.stopPropagation()。要注意的是stopPropagation拦不住同元素上其他监听器如果想全拦需要stopImmediatePropagation()。这几个方法区别很细微但调试“明明阻止冒泡了为什么其他监听还触发”时特别关键。解绑用removeEventListener。注意一个点removeEventListener传入的函数必须和绑定时是同一个引用匿名函数绑定了就没办法单独解绑。所以如果某个监听器之后可能需要移除记得先把函数定义为具名函数function onScroll() { ... } window.addEventListener(scroll, onScroll); window.removeEventListener(scroll, onScroll);5. 操作性能与安全边界5.1 重排和重绘别让 DOM 操作拖垮页面浏览器渲染页面有一套流程。当我们修改元素的尺寸、位置、增删节点时浏览器需要重新计算几何位置这叫重排reflow。只是修改颜色、背景这类不影响布局的属性时只会触发重绘repaint。重排的代价远大于重绘。减少重排的常见做法尽量避免在循环里逐项修改样式。能用 CSS 类名切换的不要逐条改style。批量增删节点时先操作DocumentFragment最后一次性插入。如果确实需要循环操作真实 DOM可以先隐藏元素display: none操作完再显示出来这样能把多次重排合并成一次。还有一个容易被忽略的坑读取布局属性会强制刷新渲染队列。比如你在一个循环里先写el.offsetWidth又改元素样式再读一次offsetWidth每次读取都可能强制同步布局。实测下来一个需要同时“读取多个元素宽高”再“统一批量更新样式”的需求里先集中取宽高存进普通变量再集中修改样式比边读边改快很多。5.2 防止注入不要盲目用 innerHTML在动态渲染用户生成内容时用innerHTML插入是不可取的。比如用户输入了img srcx onerroralert(1)如果直接拼进innerHTML图片加载失败会触发onerror里的脚本。这不只是理论风险是真实可以被利用的 XSS 攻击路径也就是经常听到的 DOM XSS。我的处理原则很简单内容是纯文本用textContent。内容包含可信的、非用户输入的 HTML 模板可以用innerHTML或insertAdjacentHTML。用户输入需要格式化展示先转义特殊字符或使用 DOM 节点逐个构建不要强行拼 HTML 字符串。5.3 列表渲染性能优化分片、虚拟列表、事件委托结合真实项目里“一次性渲染几千个 DOM 节点”是很常见的需求比如下拉候选、数据表格。很多人一上来就 for 循环创建几千个节点页面会卡。最简单的两层优化第一层用DocumentFragment合并插入避免反复触发重排。第二层如果节点实在太多甚至一次性插入都会卡那么考虑分片渲染一次只渲染 50 或 100 条用requestAnimationFrame或setTimeout把剩余数据的渲染切到下一帧。这样首屏能迅速打开UI 线程不会长时间被占用。到更大规模时就要上虚拟列表的思路页面只渲染可视区域内的那一小部分节点其它用空白占位。原生的“懒加载配合滚动渲染”也是从这一思路衍生出来的。但决定动手做虚拟列表之前先想清楚数据量到底有多大。几千条用DocumentFragment 事件委托就能流畅真正需要虚拟化的通常是上万条且每行结构很复杂。6. 综合实战一个动态列表的增删改查6.1 需求场景理论说再多不如用一个实际功能串一遍。假设要做一个任务管理面板包含以下能力输入框输入任务点击添加后新任务出现在列表顶部。支持点击“完成”按钮切换完成状态。支持点击“删除”按钮移除任务。任务数量可以增长必须用事件委托保证动态元素的事件响应。HTML 结构如下div idapp input idtaskInput typetext placeholder输入任务 / button idaddBtn添加/button ul idtaskList/ul /div6.2 实现思路与代码解析JS 核心部分如下const taskInput document.getElementById(taskInput); const taskList document.getElementById(taskList); function addTask(text) { if (!text.trim()) return; const li document.createElement(li); li.className task-item; li.dataset.text text; const span document.createElement(span); span.className task-text; span.textContent text; li.appendChild(span); taskList.prepend(li); // 插到列表最前面 } document.getElementById(addBtn).addEventListener(click, () { addTask(taskInput.value); taskInput.value ; }); taskInput.addEventListener(keydown, (e) { if (e.key Enter) { addTask(taskInput.value); taskInput.value ; } });这里有几个细节值得说明prepend是 Node 接口的新方法能直接把元素插入到容器的第一个子元素位置代码意图非常直观。任务文本用createElementtextContent而不用innerHTML保证输入内容不会被当成 HTML 解析。添加按钮和回车触发的逻辑重复度不高可以抽成一个函数复用。接下来把事件处理逻辑委托到taskList上列表里只有一个监听器却能覆盖未来所有新增任务taskList.addEventListener(click, (e) { const li e.target.closest(.task-item); if (!li) return; if (e.target.classList.contains(delete-btn)) { li.remove(); } else if (e.target.classList.contains(complete-btn)) { li.querySelector(.task-text).classList.toggle(done); li.classList.toggle(completed); } });在完成任务切换时我选择通过给li加一个completedclass 来控制样式。这样 CSS 里可以直接写.completed .task-text { text-decoration: line-through; }不需要在 JS 里同时改颜色和线条。能用 CSS 处理状态展示就不要用 JS 一条条写样式这是降低代码复杂度的核心原则。如果后面还要加“清空全部任务”功能先清空数组或直接taskList.innerHTML 都行。但是要注意那会对列表所有子节点进行一次性清空如果在别的地方还保存着子节点的引用需要同步把引用置空避免旧节点无法被回收。7. 常见问题速查与排错思路7.1 取不到元素控制台报 null 或 undefined这是初学者遇到最多的问题。常见原因是脚本在 DOM 树构建之前执行了。比如把script放在head里脚本直接去查document.getElementById此时 body 里的目标元素还没被解析结果自然是空。解决办法有三种把script标签放到/body前。用DOMContentLoaded事件包住逻辑。加defer属性让脚本等 DOM 解析完再执行。方案对比如下方案适用场景注意script 放 body 末尾传统多页面开发简单直接DOMContentLoaded需要在 early 阶段执行事件内逻辑不会太早执行script defer外部 JS脚本保持顺序执行7.2 动态生成的元素点击事件不生效很多人在循环绑定事件时直接写document.querySelectorAll(.btn).forEach(btn { btn.addEventListener(click, handler); });问题在于如果.btn是异步加载后生成的执行绑定那一刻这些元素还不存在循环根本不会执行。正确做法就是第 4 部分讲的事件委托把监听挂到始终存在的父容器上。理解了“事件可以冒泡到祖先”这一点你就再也不会被这个坑困住。7.3 图表组件报错Cant get DOM width or height这个错误在引入 ECharts 这类可视化库时非常常见。报错本身很直白组件初始化时它要挂载的容器宽度或高度为 0。造成这种问题的典型原因是容器使用了display: none或所在父级尚未完成布局比如在弹窗里初始化图表而弹窗初始状态是隐藏的。解决方向有三种等容器可见后再初始化给容器显式指定宽高在元素从隐藏变为显示后调用图表实例的resize()方法。不能机械地以为“元素存在就一定有宽度”布局的时机是很重要的。7.4 循环绑定闭包变量错乱经典老题for (var i 0; i buttons.length; i) { buttons[i].addEventListener(click, function () { console.log(i); }); }无论点击哪个按钮打印出来的都是buttons.length。原因是var声明的变量是函数级作用域循环结束后i已经累加到最终值所有闭包共享同一个i。最干净的解法是用let声明它每次循环都会创建一个新的绑定。如果是老代码只能用var就手动包一层立即执行函数或把索引传给函数参数复制一份。7.5 批量删除节点时删不干净我在第 2 部分提到过实时集合的问题。因为getElementsByTagName返回的集合会实时变化正向遍历删除会漏删。这里再给一个稳定的替代写法document.querySelectorAll(.item).forEach(el el.remove());querySelectorAll返回静态集合删除过程中集合不会变化所以forEach能覆盖到每一个符合条件的元素。这也是我日常写代码时偏向使用querySelectorAll的另一个原因。7.6 节点明明改了但页面没有刷新这个坑常见于对字符串、数组或一个对象副本做操作后误以为“改了这个变量就会改 DOM”。记住 DOM 只是一个对外接口不会魔法般自动观察变量。你必须通过textContent、style、class、或增删节点来通知界面变化。如果用的是框架那走的是框架的响应式系统和虚拟 DOM 机制原生环境下没有“自动更新”这回事。做了一堆基础梳理后我还想分享一个长期受用的习惯在头脑中把 DOM 操作分成“查找、读取、变更、监听”四类。遇到问题先判断当前属于哪一类再决定用哪组 API思路会很清晰。查找用选择器读取时注意区分集合实时性变更前想清楚是一次性还是批量是否涉及用户输入监听则优先考虑事件委托、留意动态节点。在这个分类下DOM 相关的大部分问题都能快速定位到原因。原生 DOM API 不是每天都会用到最冷门的那几个但只要核心链路足够清晰遇到奇怪现象时就能一步步倒推不靠猜。
返回列表