news 2026/10/17 8:43:06

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
更新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下面挂着一个ul,ul下面挂着若干个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 class="name">${item.name}</span> <span class="status">${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。录制结束后,面板里会按时间轴列出每一帧里浏览器都在干什么:Scripting(JS执行)、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复用,就等于把性能的主动权握回了自己手里。工具会变,框架会换,但这条底层逻辑,在未来很长一段时间里都不会过时。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 8:43:05

diagram-design:构建可维护的图表设计体系,告别团队图表混乱

1. 从一张草图到一套系统&#xff1a;diagram-design 到底在解决什么问题第一次看到 diagram-design 这个词&#xff0c;很多人会以为它只是“画图”的另一种说法。但真正在项目里摸爬滚打过几年的人会明白&#xff0c;图这件事从来不是“画得好看”那么简单。它背后牵扯的是信…

作者头像 李华
网站建设 2026/10/17 8:42:56

多态 Java

我对多态的理解就是&#xff1a;完成某个任务&#xff0c;不同对象去做出现不同状态。如何去实现这个效果&#xff0c;利用动态绑定加重写方法。什么是动态绑定呢&#xff1f;与其相对应的是静态绑定。静态绑定的意思是编译时就已经知道要执行哪一个方法了&#xff0c;十分确定…

作者头像 李华
网站建设 2026/10/17 8:42:05

UniApp中rpx与rem深度对比:原理、换算与迁移实战

做UniApp开发的朋友&#xff0c;十有八九都会在官方文档或者同事的代码里碰到 rpx 这个东西。尤其当你之前做Web开发&#xff0c;习惯用 rem 控制字号和间距&#xff0c;第一次看到 rpx 的时候很难不想&#xff1a;这俩是不是一回事&#xff1f;能不能直接替换&#xff1f;哪个…

作者头像 李华
网站建设 2026/10/17 8:42:03

diagram-design:一种嵌入研发流程的视觉建模方法论

1. “diagram-design”不是画图软件&#xff0c;而是一套系统性视觉表达方法论很多人第一次看到“diagram-design”这个词&#xff0c;下意识会联想到Visio、draw.io或者Figma里的拖拽连线功能——这恰恰是最大的认知偏差。它根本不是一个工具名称&#xff0c;也不是某个开源库…

作者头像 李华
网站建设 2026/10/17 8:41:56

diagram-design实战:用diagrams.net画好架构图与流程图的完整方法论

写图这件事&#xff0c;看似简单&#xff0c;实则翻车率极高。我刚接触 diagram-design 这个项目时&#xff0c;想的只是找个工具把架构图画出来&#xff0c;结果发现真正难的不是工具操作&#xff0c;而是如何让一张图在表达复杂关系时依然清晰、可维护、让人一眼看懂。用过一…

作者头像 李华
网站建设 2026/10/17 8:41:32

基于YOLO11的飞鸟识别与图像分割系统实战:从训练到告警联动

1. 飞鸟检测这件事&#xff0c;为什么值得认真做先说个很多人都忽略的事实&#xff1a;机场净空区、光伏电场、风力发电场、甚至大型赛事场馆&#xff0c;最头疼的“空中威胁”往往不是无人机&#xff0c;而是鸟。鸟击事件一旦发生&#xff0c;轻则设备损坏、航班复飞&#xff…

作者头像 李华