"更新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复用,就等于把性能的主动权握回了自己手里。工具会变,框架会换,但这条底层逻辑,在未来很长一段时间里都不会过时。