更新DOM到底有多贵?一文搞懂重排重绘与批量更新优化

发布时间:2026/10/17 8:43:08
更新DOM到底有多贵?一文搞懂重排重绘与批量更新优化 更新DOM这个词前端圈子里几乎天天能听到。老程序员在代码评审里念叨它面试官拿它当基础题考我自己刚工作时也因为它吃过亏。当时以为更新DOM就是把页面上的字改一下、把列表重新渲染一下直到有一天线上项目出现明显卡顿才被迫把整个渲染流程从头啃了一遍。这篇文章不绕弯子直接讲清楚更新DOM到底是个什么操作、背后发生了什么、怎么做才不容易出问题适合刚入门前端想补基础的同学也适合写了几年代码但对性能优化还停留在用框架就能快的兄弟。1. 先搞清楚更新DOM到底在更新什么1.1 DOM不是HTML它是一棵能互动的树很多人的误解在于把DOM和HTML当成一回事。其实浏览器拿到HTML源码后先要把这些字符串解析成一棵结构化的节点树这棵树才是DOM。HTML只是源码DOM是浏览器内存里真实存在的一棵可操作的对象树。你可以这么理解HTML像施工图纸DOM像已经建造出来的毛坯房JavaScript手里的API则是水电工的扳手和螺丝刀想改哪里就要通过工具去动这栋真实存在的房子。这棵树的节点有很多类型最常见的元素节点div、li、span这些、文本节点你好这种纯文字、属性节点class、id等。它们按照父子、兄弟关系排列比如body下面挂着一个ulul下面挂着若干个li每个li又挂着一个文本节点。浏览器在屏幕上的像素就是从这棵树加样式信息换算出来的。所以更新DOM翻译成人话就是用JavaScript改变这棵树的某个节点、某条文本、某个属性比如把某个li的文本从运行中改成已停止或者往ul里插入一个新li或者给某个div换个class。每次这么改完浏览器不能假装没看见它必须重算一遍这棵树对屏幕像素的影响然后把变化画出来用户才会看到界面的更新。1.2 更新DOM具体包含哪几类动作实际操作里更新DOM基本逃不开下面这几类改文本内容用textContent、innerText、innerHTML去替换一段文字或一段HTML片段。改属性用setAttribute、removeAttribute直接操作属性或者直接改className、id、href这些对象属性。改样式通过style.color、style.width这类行内样式或者切换class来影响最终计算样式。增删节点创建节点用createElement插到树上用appendChild、insertBefore移除节点用removeChild、remove。移动和排序把已有节点从A父节点挪到B父节点或者调整兄弟节点的顺序。前面四类都很直观第五类容易被忽略。移动节点并不是复制一份过去而是把原来的节点摘下来换个位置这是DOM的独特性。很多人用appendChild把一个已存在于文档里的节点加到另一个容器里结构发生变化的同时原本的位置也会自动空缺这不是bug是规则。另外一个重要认知是读操作不会触发更新写操作才可能触发。但如果你在写之后立刻去读浏览器为了给你准确的返回值会被迫先完成一次内部计算。这个细节后面会展开讲它是很多性能问题的来源。2. 一次DOM更新浏览器在背后干了什么2.1 渲染管线从JavaScript到屏幕像素的流水线你说这个div的宽度改成300像素这行代码执行完毕后屏幕并不会瞬间变化。浏览器要走一条流水线我把它类比成咖啡店做一杯手冲JavaScript你下单修改DOM属性。Style计算样式确定这杯咖啡的配方也就是把CSS规则应用到元素上算出最终颜色、尺寸、字体。Layout布局确定杯子放在哪个桌面、占多大空间对应元素在页面里的几何位置和尺寸。Paint绘制把咖啡液倒进杯子里对应把元素的可见部分文本、颜色、背景填充成一个个像素。Composite合成把所有杯子按正确顺序端到顾客面前对应把多个图层合成最终画面。大多数DOM更新至少要命中Style和Paint这两步。改文本会影响到布局和绘制改颜色可能只需要绘制和合成改transform这类合成属性则可能只走合成这一步所以它特别快。理解这条流水线是理解后面所有优化手段的基础。2.2 重排、重绘、合成三类代价天差地别围绕这条流水线前端圈把代价分成了三档概念触发场景是否影响几何成本典型例子重排Reflow / Layout改宽高、改位置、增删节点、改字体大小是最高把div宽度从100px改成200px重绘Repaint改颜色、改背景、改可见性否中等把文字颜色从黑改成红合成Composite改transform、opacity否最低用transform移动一张卡片重排为什么贵因为一个元素的尺寸一变它的父元素、兄弟元素、子元素可能全都要跟着重新算位置。极端情况下浏览器得把整棵渲染树推倒重算。这就像你把餐桌上的一道菜挪了个位置服务员为了把整张桌子摆整齐又把所有盘子重新摆了一遍。重绘稍微便宜一些因为不需要重新算几何只把对应区域的像素重新画一遍。合成最便宜因为GPU可以直接处理不需要主线程介入太多。日常开发里真正的性能杀手不是单次重排而是频繁重排。一次两次没什么一个循环里成百上千次重排帧率就会立刻掉下来。2.3 真正拖慢页面的隐形杀手强制同步布局这是我特别想强调的一个坑因为它隐蔽很多人写了好几年代码都没意识到。浏览器有个内部优化机制它会尽量把多次DOM改动攒起来在合适的时机一次性算布局。但如果你在改完DOM后立刻读取一个依赖布局的属性比如offsetWidth、offsetHeight、clientWidth、getBoundingClientRect()浏览器就没办法了它必须当场停止手头的活儿先把布局算出来好给你返回一个当前实际值。举个最典型的场景el.style.width 100px; const w el.offsetWidth; // 强制同步布局 el.style.height 200px; const h el.offsetHeight; // 再一次强制同步布局这段代码会让浏览器连续排两次队。假如你把它放进一个循环每次迭代都写一次读一次那就是布局抖动Layout Thrashing性能直接崩盘。这就像你去食堂打饭每次打一勺菜就重新排一次队而不是把菜都打完再一起结账。排队成本本身不高架不住来回折腾。真要避免核心原则就是读操作和写操作要分开。先把所有要读的布局数据一次性读出来再做所有写操作让浏览器自己决定何时统一计算。3. 直接用原生API更新DOM怎么用才对3.1 该选哪个API增删改查全家桶原生DOM API多到让人眼花但常用的就是那么几个。我按场景列一下创建节点document.createElement(li)创建文本节点可以用document.createTextNode(document.createTextNode(内容))。插入节点parent.appendChild(child)插到末尾parent.insertBefore(child, referenceNode)插到某个节点前面。删除节点parent.removeChild(child)或者直接用child.remove()后者不需要找父级。替换节点parent.replaceChild(newChild, oldChild)。改内容element.textContent改纯文本element.innerHTML改HTML片段。改样式element.style.color这类属性或者用element.classList.add/remove/toggle切换类名。改属性element.setAttribute(data-id, 42)配套的还有getAttribute和removeAttribute。这里有一个很多人踩过的坑innerHTML虽然写着方便但它的开销比textContent大很多因为浏览器要把字符串重新解析成一系列节点再挂上去。尤其要注意innerHTML会把原来的子节点整个替换掉如果这些子节点上绑了事件监听器监听器也会跟着一起被销毁。所以清空列表、渲染动态HTML这种操作要慎重用innerHTML该解绑的事件要提前解绑否则会出现内存泄漏。还有一个小建议如果一段代码里要多次查找同一个元素最好用变量把它缓存起来而不是每次都document.getElementById。虽然现代浏览器查DOM很快但函数调用也有成本性能分析的时候这些零碎调用会累积成可观的时间。3.2 批量更新的三种实操方案原生操作里最常用的优化手段是批量更新。核心思路很简单让浏览器的排队机制发挥最大作用减少重排次数。我平时实践下来有三招最实用。第一招是DocumentFragment。它是一个轻量的、不在文档树里的容器节点你可以把它理解成一个待打包的箱子。先创建Fragment把新节点全部塞进箱子最后把整个箱子一次性挂到目标节点下。这样一来真实DOM只被改动了一次重排也只需要一次。const list document.getElementById(list); 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);这段代码跑起来比循环里逐个appendChild要快得多差距在列表达到几千条、上万条时尤其明显。我做过一次不太严谨的本地测试往一个空列表里加一万个li直接用循环appendChild耗时大概是Fragment方案的3到5倍。数据量越大差距越恐怖。第二招是先隐藏再更新。把一个容器先设置成display: none更新完再让它显示。隐藏状态下对它的布局计算成本会大幅降低更新完成后恢复显示浏览器只需要计算一次最终布局。适合那种一次要改动一大片内容、而且这个容器本身很复杂的场景。第三招是合并样式读写。把代码里交错出现的读操作和写操作拆开先统一读需要的数据再统一修改样式。比如前面那个改宽高的例子可以把宽度和高度的值先算好再一次赋值避免一次循环里多次触发强制同步布局。3.3 列表场景下的性能悬崖与解法列表渲染是前端业务里最高频的场景也是最容易写出性能问题的场景。最常见的坑就是在循环里反复操作真实DOM每一次循环都触发重排。这个问题我在上个项目里见过不止一次代码风格通常是这样的const list document.getElementById(list); for (const item of items) { const li document.createElement(li); li.innerHTML span${item.name}/span span${item.status}/span; list.appendChild(li); }这写法在数据少的时候没感觉一旦数据量上到几百条页面就会明显顿一下。每循环一次浏览器都要重新计算一次布局。解决办法就是把所有新节点打包进Fragment最后一次性提交我在前面已经给过示例了。另一种场景是更新已有列表。比如数据从接口回来了要整列表重画老代码往往先list.innerHTML 清空再重新渲染。这么做的开销更大因为清空和重新填充都涉及解析和布局。更精细的做法是让新旧数据对比相同key的节点复用只更新发生变化的文本或类名新增的才插入删除的才移除。原生代码手写这个对比逻辑很累所以这也是为什么很多人会引入框架——框架的虚拟DOM就是专门做这件事的。另外如果列表真是大到几千条甚至上万条还有终极方案虚拟滚动只渲染视口附近能看到的几十条其余的全不渲染。这个思路已经超过了更新DOM的范围但值得了解一下它能彻底消灭超大列表的性能问题。4. 框架层面如何接管DOM更新4.1 命令式维护的成本为什么代码写多了会乱先聊一个哲学问题为什么我们会需要框架来管DOM更新。不用框架的时候你的代码是命令式的状态变了你得自己找到对应的DOM节点执行对应的更新操作。听起来也没多难但一个真实项目里有几十个列表、几十个弹窗、无数个联动关系状态一多你就得在各种地方手动维护状态和视图的对应关系。改了一处状态漏掉更新另一处视图页面就会出现数据对不上的诡异bug。我见过最崩溃的维护现场是这样的一个页面里三个按钮控制同一个数据的展示状态分别是加、减、重置。每个按钮的处理函数里都写了一遍更新DOM的逻辑后来需求要求展示格式变化改代码的时候漏了一个按钮结果只有点那个按钮的时候格式是旧的。这就是命令式更新代码维护成本的真实写照。框架的答案是声明式你只需要声明状态是什么界面就应该长什么样框架负责在状态变化后自动找出需要更新的DOM并帮你更新。程序员从操作工变成了设计师这是解放也是一种把复杂度转移给框架的取舍代价是引入运行时开销和额外的学习成本。4.2 虚拟DOM与diff更新变成一场有计划的比对现在很多前端框架以虚拟DOM为核心机制。虚拟DOM并不是什么高深的东西它就是用一个普通的JavaScript对象去描述一个节点长什么样包含标签名、属性、子节点这些信息。比如一个div可以表示成类似这样的对象结构{ type: div, props: { class: box }, children: [...] }。更新的时候大致的流程是状态变了重新生成一棵新的虚拟DOM树把这棵树和上一次生成的旧虚拟DOM树做一次逐层diff对比找出差异点只针对这个差异点去操作真实DOM。为什么要这么绕因为直接比较、操作真实DOM成本太高真实DOM对象里塞满了浏览器内部的状态和方法非常重。而虚拟DOM只是普通JS对象轻量得多比较起来快得多。当然这不代表虚拟DOM一定比手写原生操作更快把所有节点换成虚拟DOM再diff一遍本身也有开销只是它在程序的逻辑复杂度和运行性能之间找到了一个不错的平衡。diff的时候key这个属性起着关键作用。它就像货物上的标签让框架知道这个节点是谁。列表里如果不给key框架只能用位置来猜节点对应关系一旦数据顺序变了它就分不清哪个节点该更新文本、哪个节点该保留只能把整段子节点全部重建。给了key它才知道哪个节点可以复用只更新文本避免大量DOM重建。所以以后看到代码审查里有人要求列表循环必须传唯一key不要去嫌他烦这是有实打实的性能考量的。4.3 批量更新与异步渲染把多次修改揉成一次提交框架还有一个隐藏性能优势批量更新。在原生代码里你改一次DOM浏览器可能攒着等下一帧一起算这是浏览器默认的异步批处理。而框架把这个机制用得更彻底如果你在一个函数里连续修改了10次状态框架不会傻傻地跑10次渲染流程它会在当前同步代码执行完之后只跑一次统一的更新流程。这里面用到的技术点是微任务调度。简单理解就是框架把本次需要更新这个信号放到一个队列里等当前这段JS同步代码全部执行完、浏览器即将进入渲染阶段的时候再统一处理。这样做直接减少了无效的重排次数用户体验就是界面更流畅。这个思路其实可以迁移到原生编程里。我写原生操作的时候也习惯把所有需要改的状态先收集起来最后一次性操作DOM而不是每改一个状态就去碰一次DOM。批量永远是更新DOM的第一性原则。5. 实战把一次粗暴更新改造成精细更新5.1 一个典型列表更新场景的改造前与改造后光讲理论不过瘾我拿一个真实业务里很常见的场景来完整演示某后台管理系统的在线设备列表数据每隔两秒从接口拉一次拉回来就要更新页面。第一次接手这种需求的时候新手常见的粗暴写法是这样的function renderDeviceList(data) { const list document.getElementById(device-list); list.innerHTML ; for (const item of data) { const li document.createElement(li); li.innerHTML span classname${item.name}/span span classstatus${item.status}/span; list.appendChild(li); } }这个写法的问题一眼就能看出来每次渲染先清空再逐个插入列表有100条就要执行100次appendChild还要加上一次innerHTML清空触发的重排重排次数几乎是数据量的两倍。如果设备数量上千这个函数一执行页面就要卡一秒而它每两秒就执行一次。改造思路分三步。第一步用Fragment一次性提交新增节点避免循环里的多次重排第二步引入按key复用的思路拉新数据前先看看现有列表里有没有相同>function renderDeviceList(data) { const fragment document.createDocumentFragment(); data.forEach(item { let li list.querySelector(li[data-id${item.id}]); if (!li) { li document.createElement(li); li.dataset.id item.id; fragment.appendChild(li); } li.querySelector(.name).textContent item.name; li.querySelector(.status).textContent item.status; }); list.appendChild(fragment); }第一次执行时Fragment里装满了新节点一次性挂上去。之后每次执行已有节点不会重复创建只是被挪到Fragment里再挂回去文本更新走textContent几乎没有创建和布局开销看起来也完全正常。我在实际项目里做过对比数据量在500条时优化前的渲染函数平均耗时接近80毫秒优化后降到10毫秒以内效果非常直观。5.2 用Performance面板验证性能提升代码改完了怎么证明它真的变快了不能光凭感觉浏览器开发者工具里那个Performance面板就是干这个的。操作步骤很简单打开DevTools切到Performance点击左上角的Record录制按钮然后在页面上触发一次列表刷新再点Stop。录制结束后面板里会按时间轴列出每一帧里浏览器都在干什么ScriptingJS执行、Recalc Style样式重算、Layout布局、Paint绘制。你会看到两件事。一是长任务Long Tasks如果某一段JS执行时间特别长时间轴上会出现红色的长条这就是导致卡顿的主嫌。优化前的版本里renderDeviceList这个函数往往就会占一个长任务时间大几十毫秒。二是Layout和Recalc Style的密集程度优化前时间轴上Layout出现得非常频繁几乎每个帧都有优化后Layout变成一两帧才出现一次而且持续时间大幅下降。还有一个指标叫FPS就是每秒帧数稳定在60就是流畅。Performance面板录制过程中如果FPS持续掉到30以下基本可以断定有性能瓶颈。把这些数据截个图写代码评审的时候摆在同事面前比说一百句我觉得会卡都管用。5.3 常见问题速查表更新DOM时的典型故障最后把我在项目里碰过的典型问题整理成一张表遇到类似症状直接对照处理就行现象常见原因处理办法更新后页面出现白屏闪烁多次清空又重建DOM中间过程暴露了空状态用Fragment一次提交避免中间空白阶段列表渲染卡顿循环里逐个appendChild触发大量重排批量插入统一提交页面滚动时掉帧滚动事件里频繁读写布局属性用requestAnimationFrame节流读与写分离内存持续增长节点被移除但事件监听器未解绑移除前用removeEventListener或组件卸载钩子清理频繁出现强制同步布局改一个读一个布局抖动先统一读缓存再统一写同类节点状态互相串列表更新没按唯一key区分节点给每项加稳定id更新时按key复用节点6. 我在实际项目里积累的几条硬经验写到这里理论说完了最后分享几条从踩坑中总结的个人体会不算规范文档里会写的东西但我觉得挺有用。第一条是不要一开始就急着上虚拟DOM、上框架。如果你的列表只有几十条更新频率也不高原生写法加一个Fragment就够了引入框架反而增加包体积和运行开销。框架是复杂度到了一定程度才划算的解决方案小场景里杀鸡用牛刀收益很低。第二条是学会用Performance面板定位问题比背一堆性能优化口诀重要。我在团队里见过不少同学性能问题一出现就凭感觉猜猜不中就乱加缓存、乱加节流。其实只要录一段Performance哪些函数占了时间、重排是不是集中出现一眼就能看懂。先定位再动手永远是排查问题的正确顺序。第三条是关于框架的框架帮你省掉了DOM操作的心智负担但没省掉理解数据变化的心智负担。我见过最隐蔽的bug是在一个大对象上直接修改某个属性期望页面刷新结果框架的依赖追踪没有监听到页面纹丝不动。最后改用不可变数据的方式每次返回新对象更新才正常。用框架不等于可以完全不理解更新机制反而更需要理解数据一变视图才变这条链路。更新DOM这件事说到底是理解浏览器如何处理变化的能力。你掌握了重排重绘的区别理解了强制同步布局的原理学会了批量更新和按key复用就等于把性能的主动权握回了自己手里。工具会变框架会换但这条底层逻辑在未来很长一段时间里都不会过时。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询