前阵子帮一个老项目做性能排查页面列表每次刷新都要卡上好一阵打开Performance面板一看重排和重绘的时间占比高得吓人。问题的根源并不复杂密密麻麻的DOM操作散落在循环里读一下布局属性又写一下样式来来回回把浏览器折腾得够呛。后来我把这些DOM操作整理成批页面马上就顺了。这让我又想提起那句话DOM操作本身不难难的是知道每次操作背后浏览器到底替你扛了多少活。所以这篇想跟你把DOM操作这件事从头到尾捋一遍。不管你是刚接触前端没多久还是已经在项目里写了很久的业务代码这篇文章都按基础到进阶的顺序来先从浏览器视角认识DOM再讲选择器、节点遍历、属性样式、动态创建、事件委托最后我会带你看一个简化版虚拟DOM的渲染思路再用调试工具把性能问题摊开来看。这些东西放在一起就是一套比较完整的DOM操作知识体系。1. 从浏览器视角重新认识DOM1.1 DOM不是一棵静止的树很多人学DOM的第一课是“DOM就是文档对象模型浏览器把HTML转成一棵树”。这个说法没错但它容易给人一个误解好像DOM是一份静态图纸你查一下、改一下就行。实际完全不是这样。浏览器在解析HTML时确实会生成一棵节点树但这棵树跟渲染进程里的布局树、绘制层是实时联动的。你每动一次DOM浏览器都可能重新走一遍样式计算、布局、绘制、合成的流程。换句话说DOM更像一个施工现场你改的是图纸但每次改动都会让现场工人重新忙活一阵。所以我在排查性能问题时第一步不是看代码逻辑而是先分清哪些操作在“读”这棵树哪些操作在“写”这棵树。1.2 重排、重绘、合成慢在哪里要理解DOM操作的成本必须先分清三个概念重排Reflow改变了元素的几何属性比如宽高、位置、display浏览器要重新计算整棵布局树。重绘Repaint改变了元素的视觉属性比如颜色、背景、visibility浏览器要重新绘制受影响区域。合成Composite使用transform、opacity这类属性元素可以直接交给合成线程处理不用重新布局和绘制。这里有个很常见的误区大家总觉得“操作DOM慢”但慢的其实不是JS调用本身而是你触发的重排和重绘太频繁。举一个我踩过的坑。渲染一个表格的时候我写了类似下面这样的循环const list document.querySelectorAll(.row); for (const item of list) { item.style.height 40px; const offset item.offsetTop; // 强制同步布局 item.style.top offset px; }每次写入高度之后又立刻读取offsetTop。浏览器没办法批量优化只能同步重新布局一次。这个循环跑几十行之后页面就已经肉眼可见地卡顿。正确的做法是“先读后写”先统一读取需要的数据再统一写入样式。浏览器拿到一批连续的写操作以后可以把多次重排合并成一次。提示任何在写入样式之后立刻读取offsetTop、offsetHeight、clientWidth、scrollTop、getComputedStyle这类属性都会触发强制同步布局。这是前端性能排查里最高频的坑之一。1.3 选型老接口还是新接口操作DOM的API很多老接口和新接口各有适用场景。我个人在项目里的选型标准很简单如果你只需要通过id拿一个元素getElementById仍然最快语义也清晰。如果你需要按选择器匹配querySelector和querySelectorAll是最稳妥的选择。如果你要遍历某个类名下的所有元素并且希望集合能动态反映DOM变化可以考虑getElementsByClassName因为它返回的是一个live集合。live集合和静态集合是个容易忽略的细节。querySelectorAll返回的是静态NodeList你拿到手之后哪怕DOM后来又新增了匹配元素集合也不会变。而getElementsBy*返回的HTMLCollection是动态的你每次访问它的length都可能拿到新的结果。这里有个实际教训之前有个同事通过循环removeChild清理某个容器下的子节点用的是live集合的length做判断结果删着删着length一直在变循环就乱了。后来改成先复制一份元素数组再统一删除问题才解决。2. 节点查询与遍历别再只会getElementById了2.1 querySelector系和getElement系怎么选先给结论新代码里优先使用querySelector和querySelectorAll。原因有两个一是灵活所有CSS选择器都能用包括后代选择器、伪类、属性选择器二是返回结果直观不会出现live集合带来的隐式变化。但性能上getElementById确实比querySelector快这点在极高频操作里能看出差别。之前在某后台项目的表格行渲染里核心循环每秒要执行几百次id查找用getElementById能省下不少时间。不过日常业务代码里这个差距可以忽略。我更在意的是querySelectorAll返回的静态集合配合扩展运算符后非常好用const items [...document.querySelectorAll(.list-item)]; items.filter(item item.dataset.active true).forEach(doSomething);至于伪类选择器比如:not()和:checked对于筛选状态也很有用。但要注意不要写太复杂的选择器尤其在循环里重复执行。之前一个项目里出现过类似这样嵌套过深的选择器直接在解析阶段就拖慢了首屏渲染。2.2 children与childNodes的坑节点遍历里最常见的坑就是漏了childNodes和children的区别。简单说childNodes包含所有子节点包括文本节点、注释节点、元素节点。children只包含元素节点。看一个很典型的问题ul idlist li1/li li2/li /ul如果你用document.getElementById(list).childNodes.length来数子项得到的结果大概率是5而不是2。因为换行符和缩进都会被认为是文本节点。之前我在写一个目录高亮功能时遍历目录下的所有子节点结果总是比预期多出几个空项。排查了半天最后发现是childNodes把文本节点也算进去了。正确的做法是一律用children除非你有明确的理由需要处理文本节点。同理相邻节点也是nextSibling可能拿到文本节点。nextElementSibling只会拿到元素节点。下面是我比较常用的几个辅助判断// 获取元素节点子项 el.children; // 获取元素节点兄弟 el.nextElementSibling; el.previousElementSibling; // 获取最近的符合条件的祖辈 el.closest(.parent-selector);2.3 实战封装一个轻量的查找工具虽然浏览器已经提供了querySelectorAll但实际开发中还是会遇到一些定制需求。比如你想支持在某个上下文里找元素还想对结果做一下缓存。我一般会写一个很薄的小工具const $ (selector, context document) { const list context.querySelectorAll(selector); return list.length 1 ? list[0] : Array.from(list); };这个工具本身没什么黑科技但它解决了一个很实际的痛点不用每次都在代码里写document.querySelectorAll然后转数组。在一些老项目里直接用这种方式替换散落各处的DOM查询也能减少不少重复代码。注意不要在一个高频函数里反复执行同样的DOM查询。可以把结果存在变量或Map里做缓存。比如在滚动事件里获取某个容器的位置每次都立即读取offsetTop和getBoundingClientRect会导致频繁的同步布局。3. 属性、样式与内容操作高频但容易翻车的细节3.1 className和classList的底层区别操作类名可能是DOM操作里最基础的一环但也是翻车重灾区。早期写法是这样的el.className active; el.className active;问题很明显直接赋值会覆盖原有类名用字符串拼接又容易出错比如中间缺个空格。而现在推荐的写法是el.classList.add(active); el.classList.remove(active); el.classList.toggle(active, condition); el.classList.contains(active);classList的好处不只是看起来清爽它还能天然避免重复添加同一个类名也能支持同时操作多个类名el.classList.add(a, b, c);之前在一个项目里为了切换一个按钮的loading状态不断用字符串拼接类名结果出现了一个非常隐蔽的bug某个场景下类名重复导致样式优先级错乱按钮样式一直不对。换成classList之后问题一次到位。3.2 style操作与CSS变量很多人觉得操作内联样式很简单其实这里也有很多细节。最常见的问题是频繁单属性赋值el.style.width 100px; el.style.height 200px; el.style.marginTop 10px;这本身没毛病但如果要同时设置多个样式直接用cssText更高效el.style.cssText width:100px;height:200px;margin-top:10px;;cssText会整体替换内联样式好处是减少多次写入坏处是覆盖之前所有内联样式所以要用在一个你确定不会保留旧样式的场景里。读取样式则是另一个容易踩坑的地方。el.style.width只能拿到内联样式里的值拿不到最终计算出来的值。要拿实际生效的样式必须用getComputedStyle。const style window.getComputedStyle(el); const width style.width;但注意getComputedStyle会触发样式计算不宜在循环里反复调用。我的习惯是先把这个值读到变量里然后再做后续判断。CSS变量方面DOM操作也能直接读写el.style.setProperty(--theme-color, #409eff); const value getComputedStyle(el).getPropertyValue(--theme-color);这在做主题切换时很常用不用反复改具体样式只改CSS变量就能全局生效。3.3 innerHTML、textContent、dataset的使用边界这三个API看起来简单但用错一个都可能造成性能或安全问题。innerHTML很好用尤其在大块渲染时它能直接把HTML字符串塞进容器里。但有几个前提字符串里不能包含不可信的用户输入。频繁更新大区块时会反复销毁和重建DOM节点性能不一定比细粒度操作好。赋值前要先清空容器或确认旧内容可以被替换。textContent则完全相反它只做纯文本操作不会解析HTML。如果你要做的是把一段用户输入显示出来textContent是安全的选项。dataset用于读写data-*属性el.dataset.id 1; const id el.dataset.id;注意大小写转换规则HTML里写>const container document.getElementById(list); const fragment document.createDocumentFragment(); for (const item of data) { const li document.createElement(li); li.textContent item.name; fragment.appendChild(li); } container.appendChild(fragment);这个方案的核心是减少回流次数。因为浏览器只需要在container.appendChild(fragment)这一步真正更新一次DOM。顺便说一个细节DocumentFragment插入真实DOM之后fragment本身会被清空。如果你后续还需要它的子节点记得先复制一份再插入。4.3 事件委托从50个按钮到1个监听器动态生成的元素每次都要绑事件会非常痛苦而且容易造成内存泄漏。事件委托是用事件冒泡的原理把监听器挂在父级上统一处理所有子元素的事件。比如一个商品列表每行都有“删除”按钮。与其给每个按钮分别addEventListener不如在列表容器上挂一个监听器listContainer.addEventListener(click, (event) { const target event.target; if (!target.closest(.delete-btn)) return; const row target.closest(.item-row); if (!row) return; // 执行删除逻辑 });这里用closest做判断一是能处理点击事件冒泡到容器再传递过来的场景二是能支持多层嵌套结构下的按钮定位。这个模式在React和Vue时代也没有过时。框架内部虽然帮你做了事件代理但原生场景、低代码平台、组件封装里事件委托仍然是减少监听器数量、提升性能的可靠手段。另一个好处是新增的元素不需要再单独绑定事件只要它满足选择器条件事件就能被父级捕获。注意事件委托也不是万能的。比如某些特殊事件不支持冒泡比如focus、blur但focusin、focusout可以冒泡。scroll也不是冒泡事件不过可以通过捕获阶段监听。5. 高级实战实现一个简化版虚拟DOM渲染器5.1 虚拟DOM解决了什么问题虚拟DOM是很多框架的底层理念但它并不是什么高深技术。说到底就是用普通的JS对象来描述DOM结构再通过对比新旧对象的差异来最小化真实DOM操作。它的价值在于把“频繁直接操作DOM”变成“先在内存里对比再一次性提交最少改动”。前面我们一直在强调减少重排重绘虚拟DOM就是在应用层帮你把这块做了。不过虚拟DOM不是免费的“性能银弹”。如果页面本身没多少变化它的diff开销可能比直接操作DOM还大。它真正的优势是让开发者不用手动跟踪每次变化的细节尤其在SPA应用里可维护性的收益远大于性能维度的收益。5.2 核心数据结构和createElement我做一个简化版时虚拟节点的结构一般长这样function h(tag, props, children) { return { tag, props, children: Array.isArray(children) ? children : [children] }; }这里h函数就是框架里createElement的简化版本。它返回一个普通的JS对象描述一个元素应该长什么样。渲染成真实节点时需要递归创建DOMfunction renderer(vnode) { if (typeof vnode string || typeof vnode number) { return document.createTextNode(vnode); } const el document.createElement(vnode.tag); if (vnode.props) { for (const key in vnode.props) { el.setAttribute(key, vnode.props[key]); } } if (vnode.children vnode.children.length) { for (const child of vnode.children) { el.appendChild(renderer(child)); } } return el; }这样一棵虚拟节点树就能完整地渲染成真实DOM。到这里虚拟DOM最基础的概念已经通了。5.3 一个缩略的diff和patch思路diff的完整实现比想象中要复杂但核心思想不复杂对比新旧虚拟DOM找出差异只更新差异点。我在简版实现里只做三件事属性变化、节点新增、节点删除。function patch(oldVnode, newVnode, parent) { if (oldVnode.tag ! newVnode.tag) { const newEl renderer(newVnode); parent.replaceChild(newEl, oldVnode._el || parent.childNodes[0]); return; } const el oldVnode._el; if (oldVnode.props newVnode.props) { for (const key in newVnode.props) { if (oldVnode.props[key] ! newVnode.props[key]) { el.setAttribute(key, newVnode.props[key]); } } for (const key in oldVnode.props) { if (!(key in newVnode.props)) { el.removeAttribute(key); } } } // 子节点递归patch const oldChildren oldVnode.children || []; const newChildren newVnode.children || []; const maxLen Math.max(oldChildren.length, newChildren.length); for (let i 0; i maxLen; i) { if (!oldChildren[i]) { el.appendChild(renderer(newChildren[i])); } else if (!newChildren[i]) { el.removeChild(oldChildren[i]._el); } else { patch(oldChildren[i], newChildren[i], el); } } }真正的框架不会用这么粗暴的索引对比它们会引入key来识别移动和复用的节点。但对我们理解原理来说这个简化版已经足够说明虚拟DOM是如何通过对象对比把原本复杂的DOM操作封装成一套可预测的流程。如果你想深入研究可以把key的思路加进去当旧子节点和新子节点都带key时先按key建Map再逐个匹配能复用的节点就不销毁重建只更新差异属性。6. 调试与性能把DOM操作的成本可视化6.1 强制同步布局的诊断与修复前面已经提到过强制同步布局这个现象。在开发者工具里它会有很明显的表现。用Chrome的Performance面板记录操作点开“Summary”如果看到大量紫色或红色的“Rendering”块且伴随Scripting就说明JS执行触发了多次布局。再点开具体任务能看到“Layout Forced”或“Recalc Style”事件。排查的步骤通常是找到循环中交替读写DOM属性/样式的代码。把读操作全部提前写操作全部延后。如果还能优化就把多个写操作合并到requestAnimationFrame里执行。一个比较常用的修复手法是const items document.querySelectorAll(.row); const values []; // 先读 for (const item of items) { values.push(item.offsetHeight); } // 再写 items.forEach((item, index) { item.style.height (values[index] * 2) px; });这个分层逻辑看起来简单但如果能贯彻下去很多滚动卡顿、列表刷新卡顿都能解决。6.2 MutationObserver监听DOM变化除了自己主动操作DOM有时候你还想观察某个容器里发生了什么变化比如第三方脚本或者某个未知模块偷偷加了很多节点。这时候就可以用MutationObserver。使用方式const observer new MutationObserver((mutations) { for (const mutation of mutations) { if (mutation.type childList) { console.log(新增节点或删除节点了); } if (mutation.type attributes) { console.log(属性变化了, mutation.attributeName); } } }); observer.observe(document.getElementById(container), { childList: true, subtree: true, attributes: true }); // 停止观察 observer.disconnect();我之前在一个嵌入式页面里需要统计某区块被外部脚本改了多少次用MutationObserver记录后排查效率直接翻倍。注意几个细节观察者自身触发的DOM变化也会进入回调。如果回调里又改了DOM可能造成死循环所以需要加一个状态标记来区分“内部修改”和“外部修改”。subtree配合childList时会监听所有子孙节点的变化性能开销更大。能被精确一点观察的容器就不要把观察范围扩大。提示如果只是监听某个固定元素是否进入视口用IntersectionObserver别用scroll事件加offsetTop判断性能差距非常大。这两者经常被混为一谈但它们解决的问题完全不同。7. 一些实战习惯和收尾建议说了这么多原理和代码最后再分享几个我平时在工作中一直坚持的DOM操作习惯。第一尽量用类名而不是频繁修改内联样式。内联样式的优先级处理起来很复杂而且每次修改都可能触发重算。用classList切换预定义好的CSS类语义清晰也便于维护。第二能合并到一次DOM操作里的就不要拆成多次。比如需要改多个属性先用cssText或者直接批量改需要插入多个子节点用DocumentFragment或先拼好字符串再一次性插入。第三不管在哪个框架里写代码都要保持“操作DOM是一种开销”的意识。框架帮你抽象了很大一部分DOM操作但如果你想做性能优化最后还是要落到原生DOM层面来看问题。第四读操作和写操作要分类。读一堆offsetWidth、getBoundingClientRect、scrollTop的时候尽量一次拿出来用完写操作集中处理减少中间被打断。我自己最近在做的一个优化就是把一个老项目里的DOM查询全部抽出到一个独立模块里统一做缓存和数据管理。这样页面逻辑里几乎看不到原生的querySelector调用代码可读性也提升了不少。DOM操作这个主题说大不大说小不小。真正掌握了它你对浏览器工作机制的理解会上一个台阶写出来的代码也更经得住性能考验。希望这篇文章能帮你把平时用到的API重新串起来后面再遇到类似的性能问题自己就能顺手排查出方向。“DOM操作本身不难难的是知道每次操作背后浏览器都替你干了多少活”——这是我一直挂在嘴边的话也当作今天的收尾。附录常见DOM操作速查表需求推荐写法慎用/不推荐写法获取元素querySelector(selector)在循环里重复querySelector批量获取元素querySelectorAll返回后转数组依赖live集合的长度做循环修改类名classList.add/remove/toggleclassName字符串拼接读取最终样式getComputedStyle(el)后缓存在循环中反复调用插入多个子节点createDocumentFragment循环appendChild渲染大段HTMLinsertAdjacentHTML配合模板字符串用innerHTML反复覆盖监听动态元素事件委托closest每创建节点就addEventListener数据属性dataset.xxx频繁读写dataset观察DOM变化MutationObserver轮询setInterval监听元素可见IntersectionObserverscroll事件里计算offsetTop场景建议说明循环里读写样式先读后写避免强制同步布局批量插入节点DocumentFragment只触发一次布局动态列表事件事件委托减少监听器数量虚拟DOM渲染对比新旧vnode再patch最小化真实DOM变更
阅读完成 · 觉得有帮助?