ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

DOM操作性能优化:重排重绘、DocumentFragment与事件委托实践

2026/9/29 15:50:22 拓冰建站 浏览量
DOM操作性能优化:重排重绘、DocumentFragment与事件委托实践 我做了这么多年前端JavaScript DOM 相关的坑几乎是每个项目都会踩一遍。从刚开始用document.getElementById一个个去捞元素到后来用 querySelector 一把梭再到被重排重绘折腾到秃头这条路我太熟了。这篇文章我想把手上的实操经验完整过一遍核心是“DOM 操作怎么做到既快又稳”里面包含了我踩过的坑、对比过的方案以及能直接抄走的代码和思路适合刚入门想系统打底子的人也适合写了几年业务代码但没认真抠过性能细节的同行。1. 从源头理解 DOM它到底是个什么“树”1.1 文档对象模型的本质很多人一上来就背“DOM 是文档对象模型”但真问起“为什么要这样设计”反而答不上来。我们换个角度理解你在浏览器里打开一个 HTML 页面浏览器会先解析字符串把它变成一棵树树上的每个节点对应标签、文本、注释、属性。这棵树就是 DOM。它跟你写代码操作的那个对象模型是同一棵区别在于平时我们在控制台打印document.body看到的是活生生的对象而浏览器内部维护的是渲染引擎吃透的节点树。这棵树的最大意义在于它把“文档”变成了“可编程的结构”。所以你才能通过document.createElement凭空造节点通过node.appendChild改变树的形状通过element.style.color red去影响渲染结果。如果没有这一层抽象JS 和浏览器渲染就没有沟通的桥梁Vue、React 这些框架也无从谈起。初学者最容易忽略的一点是DOM 不是 HTML 字符串本身而是 HTML 解析后的内存结构。所以你在控制台改了 DOM页面源码文件并没变只是浏览器内存中的那棵树变了这也是“动态修改”能成立的前提。1.2 节点、元素、接口之间的关系刚开始接触 DOM 时有一个极其容易混淆的概念Element、Node、HTMLElement到底什么关系。我简单梳理一下你只要记住一条继承链EventTarget→Node→Element→HTMLElement。Node是所有节点的统称包括元素节点、文本节点、注释节点、文档节点。Element是元素节点是Node的子类只有元素才有getAttribute、classList这些操作。HTMLElement是 HTML 元素的统称比Element更具体增加了style、dataset、hidden等属性。在实际开发中我们大部分操作的对象都是HTMLElement但有些时候拿到的节点其实是Text或者Comment。比如childNodes返回的是所有子节点包括文本节点和注释节点而children返回的才全是元素节点。这个区分在你遍历节点、做 DOM 操作的时候特别重要很多人删节点删不干净就是因为把childNodes和children搞混了。我建议前端岗位面试时不要只背“DOM 是树形结构”最好能画一下节点分类和继承关系把“为什么querySelectorAll返回的是NodeList而不是Array”也一并讲清楚这比背概念有用得多。2. 元素获取的完整武器库选对方法省一半事2.1 常用查询 API 盘点与性能对比元素获取是 DOM 操作的第一步也是性能最容易埋雷的地方。我列几个日常最高频的 API并给出我实测下来的性能感受方法返回类型特点性能表现getElementByIdElement按 ID 精确查找速度最快极快getElementsByClassNameHTMLCollection动态集合能拿到实时更新的列表快getElementsByTagNameHTMLCollection按标签名查找快querySelectorElement用 CSS 选择器查找第一个匹配项中querySelectorAllNodeList用 CSS 选择器查找所有匹配项中closestElement从当前元素向上找最近的匹配祖先中很多同学以为querySelectorAll是全能的做什么都用它。实际上如果只是找一个 IDgetElementById的速度远快于querySelector(#xxx)。因为getElementById可以直接走浏览器的哈希表而querySelector需要走一遍选择器解析引擎。在性能敏感的大循环里这个差异会被放大。getElementsByClassName返回的HTMLCollection是动态的意思是它时刻和 DOM 保持同步。比如你let list document.getElementsByClassName(item)然后往 DOM 里新增一个带item类的元素list的长度会自动更新。这个特性在某些场景很有用但如果你在遍历的同时又去改动类名很容易因为集合长度变化导致死循环需要注意。querySelectorAll返回的NodeList是静态的不会自动感知 DOM 变化。如果你需要“当前页面所有满足条件的元素”但后续又要删除其中一部分建议重新查询不要依赖旧的NodeList。2.2 选择策略与实用技巧在真实项目里选择器不是越简洁越好而是要综合考虑语义、可读性和性能。我一般按这个优先级来选优先用getElementById精准且快。需要用 CSS 选择器做复杂匹配时用querySelector/querySelectorAll。如果关心“动态性”需要用getElementsByClassName。处理嵌套结构想找某个元素的最近父容器用closest。还有一个非常实用的技巧在事件处理函数里用e.target.closest(selector)判断点击目标是否落在某个区域内。这比在冒泡阶段一层层判断classList.contains要优雅得多代码短且逻辑清晰。另外NodeList虽然长得像数组但不是数组直接调用map、filter会报错。有人会用Array.prototype.slice.call(nodeList)转数组现在更方便的是Array.from(nodeList)。这个细节很基础但确实每天都有不少人因为这个问题去百度报错信息。2.3 动态集合与静态集合的坑动态集合和静态集合的区别是很多前端老手也会疏忽的。我给你讲一个真实案例我之前接手过一个表格导出功能页面里有 1000 行数据每行一个 checkbox。最开始我用querySelectorAll获取所有 checkbox然后循环遍历勾选状态。逻辑看着没问题但后来需求改成了“导出前自动把所有行标记为导出中”我需要在列表里动态插入一个“导出中”状态行。因为querySelectorAll是静态的旧的NodeList拿不到新插入的行我一度以为是自己插入代码写错了排查了很久才发现是集合类型的问题。换成getElementsByClassName之后因为它是动态的新插入的行立刻就会出现在集合里遍历逻辑直接就能覆盖新元素。反过来也有坑如果你用动态集合做遍历遍历过程中又删除匹配元素集合长度会变遍历次数也跟着变很容易漏掉元素或越界。所以这类场景建议先把集合转成静态数组再去做增删操作。3. 节点操作的细节与坑别让增删改查变事故3.1 创建、插入、删除的正确姿势与常见错误节点操作的核心就四个字增、删、改、查。先说说创建与插入。创建元素最常用的是document.createElement创建后它只是一个游离在 DOM 树之外的对象需要手动插入才能显示。插入方式有多种appendChild会把节点追加到父元素末尾insertBefore可以插到某个子节点之前append是较新的 API用起来更灵活可以直接传多个节点和字符串。这里有个高频错误同一个节点不能同时出现在两个地方。如果你把一个已经插入到 DOM 的节点再用appendChild插到另一个父节点里它不会复制一份而是先从原来的位置移除再插入新位置。很多初学者以为这是在克隆结果父元素里的内容莫名消失就是这个原因。如果想复制要用cloneNode注意深拷贝要传true。删除节点也有讲究老方法是parentNode.removeChild(child)新 API 是child.remove()。我建议优先用remove()代码更简洁语义也更清楚。另外删除节点后如果你还有变量引用着它它的引用依然存在只是不在文档里了这叫“脱离文档”后面如果想重新插入是完全可以的。替换节点用replaceChild(newNode, oldNode)这个方法的参数顺序很容易记反我每次都是“新的放前面旧的在后面”你可以理解为“用谁谁谁去替换谁谁谁”。不要去直接修改innerHTML来做局部替换一旦涉及用户输入会带来 XSS 风险后面我会专门讲。3.2 innerHTML、textContent 与 DocumentFragmentinnerHTML用起来确实方便可以一次性把一大段 HTML 塞进去。但它有两个核心问题第一会触发解析器重新解析字符串性能开销比单纯的textContent大得多。 第二安全性差如果字符串里有用户输入的内容很容易造成 XSS 注入。textContent是纯文本操作会帮你把内容里的script等标签自动转义不会解析成 DOM。所以“插入纯文本一律用textContent”这条建议是我在项目里反复强调的。还有一点textContent是获取所有文本节点拼接后的内容和innerText不一样innerText还受样式影响比如隐藏元素的文本是取不到的。需要获取完整的、不关心样式的文本时用textContent更靠谱。DocumentFragment是一个特别适合批量插入的容器。它就像一个“虚拟父节点”你先创建DocumentFragment把要插入的子节点往里塞最后一次性把fragment插到 DOM 里。这个操作会把这批节点当作一组整体插入不会因为每插入一个就引发一次重排性能提升非常明显。我在后面性能优化章节会再展开讲。3.3 属性操作的现代写法classList 与 dataset早年操作 class 都是className字符串拼接写起来又长又容易出错。现在基本都用classList提供了add、remove、toggle、contains和replace五个常用方法语义清晰代码可读性高很多。toggle还支持第二个布尔参数classList.toggle(active, condition)能根据条件决定加还是不加这个在写复杂交互的时候很实用。dataset是处理自定义数据的利器。HTML 里写>// 创建一个测试容器 const container document.createElement(div); document.body.appendChild(container); console.time(直接循环插入); for (let i 0; i 5000; i) { const item document.createElement(div); item.textContent 第 i 行; container.appendChild(item); } console.timeEnd(直接循环插入); // 清空容器准备测试 fragment container.innerHTML ; console.time(使用 Fragment 插入); const fragment document.createDocumentFragment(); for (let i 0; i 5000; i) { const item document.createElement(div); item.textContent 第 i 行; fragment.appendChild(item); } container.appendChild(fragment); console.timeEnd(使用 Fragment 插入);我在 Chrome 里跑下来的结果大概是直接循环插入需要 50ms 到 70ms使用 Fragment 后只需要 5ms 到 8ms差距接近十倍。这个对比很直观也很有说服力。如果你在面试中被问到“如何优化大量 DOM 操作”能把这一段代码讲清楚比背概念有力得多。5.5 事件性能、防抖节流与渲染时机事件性能优化的第一原则是减少监听器数量使用事件委托。第二原则是在监听器里不要做重活。重活包括大量 DOM 读写、复杂计算、网络请求。如果必须在滚动或 resize 这类高频事件里做操作就用防抖或节流来控制频率。防抖和节流的区别我在这里说一句人话防抖是“你折腾完了我才动手”适合输入框连续打字后等 300ms 再去搜索节流是“每隔一段时间我只执行一次”适合滚动、拖拽这类持续触发但需要保持响应性的场景。还有一个非常实用的渲染时机工具requestAnimationFrame。它能把你的代码调度到浏览器即将绘制下一帧之前执行保证 DOM 变更不会跟浏览器的绘制节奏错位。如果你在滚动回调里做动画没有它就会卡得厉害。5.6 日常开发里最实用的几条优化习惯前面讲的都是原理这条我给一套能立刻上手的操作规范流程都是我项目里的内部约定新增大量元素时一律用DocumentFragment不要循环appendChild。修改样式时优先切换 class不要逐条改style。必须用style时用cssText或setProperty批量处理。读取布局属性时先统一读取需要的值再统一修改避免读写交错。动画尽量用transform和opacity不要用top、left、width这些触发重排的属性。高频事件的回调里用防抖或节流配合requestAnimationFrame做渲染。事件监听统一管理能用委托就用委托能解绑就解绑。这些习惯养成了很多页面性能问题根本不会出现也不用等到线上告警才去排查。6. 常见问题与排查技巧实录6.1 典型问题速查表现象常见原因正确做法null上调用addEventListener报错DOM 还没加载完成或 ID 拼写错误把脚本放在DOMContentLoaded或 body 末尾执行节点插到页面里消失了同一个节点被插入到两个父节点发生了移动用cloneNode复制后再插入querySelectorAll后续节点不在里面返回的是静态 NodeList不会自动更新重新查询或改用动态集合循环里删除子元素出现“只删了一半”动态集合长度在遍历中被改变先转成静态数组再倒序遍历删除页面滚动特别卡高频事件回调里触发了布局抖动改用节流或requestAnimationFrame批量读写布局属性绑定的事件怎么也解不掉绑定时用了匿名函数把函数提取为命名函数或使用AbortControlleroffsetWidth获取的值是旧值样式修改还没来得及应用到布局需要清楚读取布局属性会触发强制同步布局用innerHTML渲染用户输入后页面被“搞坏”用户输入被当作 HTML 解析了用textContent或严格的转义方案6.2 排查思路我一般从哪里下手遇到 DOM 相关的问题我的排查顺序通常是先看控制台有没有报错报错信息会直接告诉我调用对象是不是null再看 HTML 结构是不是和预期一致很多时候是脚本执行时机提前了元素还没渲染出来然后看事件是否绑定成功用getEventListeners或者打断点看e.target、e.currentTarget最后才考虑性能用 Performance 面板录制一段看是脚本执行时间太长还是渲染时间太长。举一个我印象深刻的例子有个页面在点击“新增行”时表格总是一次性插入很多行导致点击后界面卡死。我一开始以为是我的循环逻辑写重了翻代码半天没找到问题。后来用 Performance 面板录制才发现不是循环次数的问题而是每次插入行都触发了布局计算几千行叠加下来就把主线程拖死了。最后改成DocumentFragment一次性插入问题直接消失。这类问题在设计阶段很难预判只有经过性能工具分析后才会意识到“性能瓶颈不一定是逻辑复杂度而是浏览器内核的渲染机制”。6.3 安全底线DOM 型 XSS 是红线在 DOM 操作里最不能放松的一件事就是处理用户输入。DOM 型 XSS 的触发方式是攻击者构造一段字符串如果代码把它当作 HTML 插入到页面里这段字符串里夹带的script或者某些事件属性就会被执行。而且这种攻击完全发生在浏览器端不经过服务端很多人在后端做了过滤却忘了前端用innerHTML渲染了用户的输入。我处理这类问题的铁律是默认所有用户输入都是不可信的。需要展示纯文本时只用textContent确有必要渲染富文本时要么走白名单过滤、要么用成熟的富文本渲染库不要自己用innerHTML去拼接字符串。要从上传到展示的全链路都做转义和过滤而不是只看某一个环节。这里还有一个细节不要只过滤script还要注意img标签的onerror属性、a标签的javascript:协议等。字符串拼接 HTML 的方式风险最高能避免就避免能用结构化创建 DOM 的就优先用createElementtextContent。6.4 我的排查工具组合我平时排查 DOM 问题最常用三样DevTools 的 Elements 面板观察节点树和事件监听Console 里直接做实验性操作快速确认 API 行为Performance 面板录制关键交互定位耗时函数。如果能配合 React DevTools 或 Vue DevTools还能看到框架层面的组件更新排查路径会更短。很多时候与其反复猜测不如先写一段最小可复现的代码跑一遍看结果和预期差在哪这比在巨大的业务代码里翻招要高效得多。经验越足越会发现前端的大部分 DOM 问题本质上都是“对浏览器渲染机制的理解不足”。7. 最后的个人经验先画树再写代码写到这里我想把一条我自己一直在用的经验放在最后。每次要开始一段复杂的 DOM 操作不管项目多急我都会先在纸上画一下节点树结构标清楚谁是谁的父节点、谁是谁的兄弟节点、事件绑定在哪里、数据从哪里来。哪怕只是简单画几笔也好过直接上手写代码。画完树之后写代码会变得特别顺因为所有操作都变成了“在树的哪个位置加节点、在哪个节点上监听事件、把哪个子树替换掉”。这个习惯陪我解决了无数个“改了 A 页面 B 也跟着变”的诡异 bug也让我在接手别人的项目时能很快理清 DOM 结构。另外一个小技巧在项目里定一个“DOM 操作红线清单”把前面提到的原则写进去新同学来了先读一遍代码 review 的时候也对照着查能省下大量解释时间。无论如何DOM 都是前端的地基地基处理好了上面不管建什么框架都不会轻易塌。