☰
更新DOM完全指南:从原生操作到虚拟DOM渲染机制
2026/10/17 11:34:16 网站建设 项目流程

有人问更新DOM是什么操作,我认真想了想这个问题

有人问“更新DOM是什么操作”的时候,往往是遇到了这样一种情况:代码里明明把数据改了,页面上却纹丝不动。或者更糟,页面动了,但输入框的焦点丢了、滚动条跳了,整个界面肉眼可见地卡了一下。

更新DOM,听起来就是把页面内容换一换。但真正操作过的人都知道,这四个字背后是一条完整的渲染流水线:从JavaScript到样式计算、再到布局、绘制、合成,任何一环没处理好,都会变成卡顿和Bug。这篇文章就来把这套流水线拆开,从最基础的DOM操作方式讲起,再到浏览器底层渲染机制,最后聊聊框架为什么要用虚拟DOM来接管更新这件事。不管你是写原生JavaScript做交互的新手,还是刚接触框架、想看明白virtual dom原理的开发者,这篇文章都值得花十分钟读完。

1. 先理清楚:更新DOM到底在更新什么

1.1 DOM是浏览器的“内部台账”,不是页面本身

很多人有个误解,觉得DOM就是网页上能看到的东西。严格来说,DOM是一份存放在内存里的对象模型,是浏览器把HTML文档解析之后生成的一棵节点树。你在页面上看到的文字、图片、按钮,实际上是浏览器根据这棵节点树“画”出来的结果。

可以这样类比:DOM像是建筑公司手里那份详细的施工图纸,页面上渲染出来的内容是已经盖好的楼。你更新DOM,就相当于修改施工图纸。图纸改了,楼当然要重新修一轮,但修楼这个动作本身是有成本的,而且不是所有修改都会影响整栋楼的结构。修改一份图纸上的某个房间名,和修改承重墙的位置,施工范围和成本完全不一样。

这也就解释了为什么“更新DOM”值得被单独拎出来讨论。它不只是改数据这么简单,每一步改动都可能触发浏览器重新计算样式、重新排版、重新绘制,这些操作的链路很长,成本很高。

1.2 “更新”这件事为什么有那么多坑

直接在浏览器控制台跑document.getElementById('app').innerHTML = 'hello',看起来就更新了。但放到真实项目里,问题会一层层冒出来:

  • 更新之后页面没反应,可能是事件监听器被覆盖了。
  • 更新频率太高,性能面板上全是密密麻麻的渲染任务。
  • 更新到一半,用户正在输入,结果整个列表被重建,输入框当场失焦。
  • 多个地方同时更新同一个节点,顺序错了,数据互相覆盖。

这些问题背后的根源,在于DOM操作是“命令式”的。你得手动精确地告诉浏览器:你要改哪个节点、改成什么样、在什么时机改。一步没想清楚,后面就会出状况。所以理解更新DOM,本质上是在理解“浏览器如何响应你的修改指令”。

2. 五种直接更新方式怎么选:从innerHTML到textContent

2.1 动手前先看清手边的工具

原生API提供了好几种直接操作DOM的方式,它们看起来都能达到“更新页面”的效果,但底层机制完全不同。下面这张表整理了我平时最常用的几种操作方式,关键差异都在里面:

操作方式底层机制是否解析HTML是否重建子节点典型场景
innerHTML赋值序列化并解析HTML字符串是会,旧节点全部销毁重建整块区域大换血
insertAdjacentHTML解析HTML并插入指定位置是仅新增在某元素前后插入一段结构
textContent赋值把节点内容当作纯文本替换否子节点会被移除只改一段文字
appendChild/insertBefore把已有节点移动到新位置否不影响其他子节点列表追加项
replaceChild替换单个子节点否仅替换目标节点局部替换一项

注意看最后一列,使用场景其实有很强的倾向性。我见过不少同学把innerHTML当万能工具,任何更新都先拼个HTML字符串再说,这往往是很多性能问题和交互Bug的起点。

2.2 选择更新方式的三个判断依据

每次动手更新DOM之前,我会在脑子里过三个问题:

第一,更新范围是局部还是整体?只改列表里某一项的文本,那就定位到那个节点用textContent,千万别去重建整个列表。改动范围越小,浏览器后续需要做的计算就越少。

第二,数据是纯文本还是结构化内容?如果是用户输入的内容,任何情况下都要用textContent,不要用innerHTML。拿innerHTML插入用户数据,等于把XSS攻击的大门敞开,这个习惯要从一开始就养成。

第三,原有的事件监听和表单状态还要不要保留?innerHTML会销毁节点内部的一切,包括绑定在子元素上的监听器。如果你替换的是一个输入框所在区域的容器,用户正在输入的内容和焦点都会瞬间丢失。

2.3 一个高频误区:随手就用innerHTML

我一度也是innerHTML重度用户,直到有一次维护一个聊天窗口,需要每秒刷新消息列表。最开始图省事,直接读数据、拼字符串、塞进容器。结果发现两个问题:第一,用户在输入框打字,消息一刷新,输入框就失焦了;第二,列表项多的时候,滚动位置会突然回到顶部。

原因很简单——innerHTML是把整个列表的内容全部销毁重新生成,输入框被重建,焦点自然就没了。后来我改成先对比新旧消息,只把新增的消息appendChild进去,再把过期消息移除,问题立刻消失。这个案例说明了一个核心原则:能用局部操作解决问题,就不要整块替换。

3. 浏览器渲染路径:一次DOM更新的完整旅程

3.1 从改DOM到屏幕画出来:完整链路拆解

当你修改了一个DOM节点的文本或者样式,浏览器并不会立刻重画屏幕。它走的是下面这条流水线:

  1. JavaScript:你的代码执行,DOM节点被修改,浏览器标记该节点为“脏”状态。
  2. Style Calculation:浏览器重新计算这个节点及其子节点的样式。注意,不只是被改的那个节点,所有受选择器影响的节点都可能被重新计算。
  3. Layout:计算几何信息。这个节点尺寸多大、位置在哪、有没有挤压到周围元素。这一步在业界也叫reflow(重排)。
  4. Paint:把节点绘制成屏幕上的像素,比如背景色、文字颜色、边框。这一步叫repaint(重绘)。
  5. Composite:把多个图层合成最终画面,在GPU上完成。

每一步都是有代价的。其中Layout的代价最高,因为一个节点的尺寸位置变化,可能牵连它的父节点、兄弟节点、后代节点全部重新计算。Paint次之,但大面积重绘同样会造成帧率下降。

3.2 读写交错:强制同步布局为什么会卡

Layout本身是异步的,浏览器会在合适的时机统一批量计算。但这个优化有一个前提:你不在布局信息被读取的时候打破它。

举个反面例子:

const list = document.getElementById('list'); for (let i = 0; i < list.children.length; i++) { const item = list.children[i]; const height = item.offsetHeight; // 读取布局信息 item.style.height = (height / 2) + 'px'; // 写入样式 }

这段代码看起来没毛病,实际上每次执行offsetHeight之前,浏览器都被迫先同步做一次布局计算,因为你要读的是“最新的”布局数据。循环里一次读、一次写、再读、再写,浏览器就被迫反复执行Layout,这就是所谓的“强制同步布局”。

用生活场景来类比:仓库管理员本来打算下班前统一理一次货,结果你每隔五分钟就问他“现在某个箱子在哪个位置”,他只能每次放下手里的活先去数一遍箱子。理货本身就慢,再被反复打断,整体效率自然一塌糊涂。

3.3 更新操作的代价,往往是由“连锁反应”放大

我常见到有人只改了一个节点的背景色,整个页面都跟着闪一下。这就是因为修改样式时,浏览器无法精确判断这个改动的波及范围,它会保守地扩大重绘区域。

具体来说,改color只会触发重绘,改width会触发重排且必然伴随重绘,改transform则只触发合成,不触发重排和重绘。知道这些区分以后,能帮你在设计更新策略时做出更聪明的选择。比如,想让元素移动位置,用transform: translate()而不是top/left,前者完全走GPU合成,性能高得不是一个量级。

4. 性能优化核心:把多次改动合并成一次渲染

4.1 浏览器帮你合并,但有个前提

同一个JavaScript任务里连续改了三次DOM,浏览器通常会合并成一次渲染。这意味着,定义一个函数,在里面做完所有DOM改动,优于在循环体外东一榔头西一棒子地改。

但前提是改动过程中不穿插读取布局信息的操作。如果你先写一个样式,立刻又读offsetWidth,浏览器就被迫中止合并,先执行一次布局。我踩过最深的坑就是在监听滚动事件的函数里,既读scrollTop又去修改其他元素位置,结果每次滚动都触发多次强制布局,页面直接掉到十几帧。

4.2 三个合并技巧,抄完就能用

第一个技巧是用DocumentFragment离线组装节点:

const fragment = document.createDocumentFragment(); for (let i = 0; i < 1000; i++) { const item = document.createElement('div'); item.textContent = '第' + i + '项'; item.className = 'list-item'; fragment.appendChild(item); } // 一次性挂载,只触发一次回流 list.appendChild(fragment);

DocumentFragment是一个虚拟容器,所有节点在它里面组装不会触发任何渲染,挂载到真实DOM时只产生一次插入操作。实测生成千级节点时,这种方式比在循环中直接appendChild快一个数量级。

第二个技巧是批量写入样式时,优先切换className而不是逐条修改style属性:

// 不推荐:每改一个样式属性都可能触发一次样式计算 el.style.padding = '20px'; el.style.fontSize = '18px'; el.style.color = '#333'; // 推荐:用类选择器统一处理,浏览器只需要做一次样式计算 el.className = 'card card--expanded';

第三条是业务层的技巧,本质上是“缓存查询结果”。循环里如果要反复读取某个节点的几何信息,提前在循环外算好存进变量,不要在每次迭代中重新查询。

4.3 requestAnimationFrame:把DOM改动调度到正确的时机

如果你需要在浏览器下一帧绘制之前做DOM更新,requestAnimationFrame是一个可靠的选择。它保证回调执行在样式计算和布局之前,所有改动能在同一帧内统一完成,避免跨帧的不一致。

注意和setTimeout的区别。setTimeout不保证跟显示器刷新率同步,它可能被其他任务挤到奇怪的时机执行,导致这一帧改了一点,下一帧又改了一点,看起来就是抖动。我在做动画相关的更新时,一律用requestAnimationFrame,实测帧率稳定很多。

5. 框架的答案:虚拟DOM和diff如何接管更新

5.1 手动更新DOM的终极难题:很难不出错

手动更新DOM有两条绕不开的痛点:一是逻辑分散,数据变化的源头可能分布在整个代码库的各个角落,每处都得手动同步视图;二是性能边界模糊,很容易写出能跑但低效的代码。

这就是为什么现代框架要提出“状态驱动视图”的思路。你只管状态,框架负责把状态的映射变成界面变化,这种方式在工程上减少了大量心智负担。

5.2 虚拟DOM加diff:先算后做,降低更新成本

虚拟DOM的思路可以概括为三个步骤:

第一步,状态变化后,框架执行渲染函数,生成一棵全新的对象树(虚拟DOM树)。这棵树是在JavaScript内存中构建的,开销很小。

第二步,对比新旧两棵对象树,找出差异。这一步叫 diff。diff 的结果是一个“最小变更集合”:哪些节点需要新增、删除、修改。

第三步,根据变更集合,定向执行真实DOM操作。这一步叫 patch。真实DOM只被精确地修改一次,不会出现大规模的冗余重建。

这就像一个记账员:手头有一本大账本(真实DOM),每次数据变化时,先在小抄上(虚拟DOM)快速算清楚要改动哪几行,再一次性誊抄到大账本上。誊抄大账本的过程很贵,所以尽量减少誊抄的次数和行数。

5.3 key 的作用:给diff一个“锚点”

diff 算法里有一个关键参数key,它决定了两个节点在对比时是否被认为是“同一个节点”。为什么列表渲染一定要传key?因为没有 key 的时候,框架只能靠位置来对应新旧节点。如果列表顺序变了,它可能把旧的第三个节点当作新的第三个节点处理,从而执行错误的更新。

举个例子,一个待办列表,原来有三项,后来第一项被删除了。没有 key 时,框架对比规则是“位置相同即复用同一个节点”,那么它把旧的第二项当作新的第一项、旧的第三项当作新的第二项去更新,每一项的内容都改了,白白多做了两次DOM更新。有了稳定唯一的 key 之后,框架可以直接识别出新的第三项其实对应旧的第三项,只需要删除第一项,真正的改动量缩减到最小。

从这层意义上看,key 本质上是给虚拟DOM节点提供“身份标识”,好让 diff 算法做出更精准的判断。这也是为什么框架官方文档反复强调:不要用 index 当 key。

5.4 框架不等于万能药:该手动时还得手动

框架把90%的DOM更新自动化了,但剩下10%的边界场景,你仍然需要手动更新DOM。比如第三方图表库初始化了实例,数据变化后需要调用实例的update方法;比如要在某个DOM节点上做自定义绘制,框架根本不知道这个节点的内部状态。这些时候就得用框架暴露的接口去操作真实DOM,比如ref、useRef、watchEffect等。理解这些工具的本质,有助于判断“该不该手动更新”。

5.5 无框架场景:一个轻量级更新函数

function renderList(data) { const container = document.getElementById('root'); const newVNodes = data.map(item => ({ id: item.id, text: item.text })); // 对比容器当前子节点和期望节点集合 const existingIds = [...container.children].map(child => child.dataset.id); const removed = existingIds.filter(id => !newVNodes.some(v => v.id === id)); removed.forEach(id => { container.querySelector(`[data-id="${id}"]`).remove(); }); newVNodes.forEach(vnode => { const el = container.querySelector(`[data-id="${vnode.id}"]`); if (el) { if (el.textContent !== vnode.text) el.textContent = vnode.text; } else { const child = document.createElement('div'); child.dataset.id = vnode.id; child.textContent = vnode.text; container.appendChild(child); } }); }

这段代码的精髓是:先算出差集,再执行最小操作。没有引入任何框架,但遵循的是和虚拟DOM一样的思路。这也是框架带给我们的最有价值的东西——不是某个具体的API,而是一种“更新前先算清楚”的思维模式。

6. 高频故障与排查思路实录:为什么页面没变、卡顿、失焦

6.1 页面为什么没更新:四个常见原因

页面在数据变化之后没有更新,是“更新DOM”最常见的一组问题。我在排障时通常会按下面这个顺序逐层排查:

现象可能原因排查方法
数据改了,视图没变操作的是旧DOM引用,或更新代码未执行在更新语句处打断点,确认代码真的走到了
视图变了,但内容不对异步数据返回时序错乱打印数据更新顺序,核对请求并发场景
输入框光标丢失容器被innerHTML重建,状态被重置检查更新方式,局部操作替换全量替换
动画抖动、掉帧强制同步布局或频繁触发重排性能面板录制,查找紫色布局任务

特别说一下异步时序错乱的问题。多个接口同时发起,返回顺序不是你能保证的。如果两个请求先后到达,但先发出去的请求最后返回,它就会覆盖后返回的数据。解决方案是给数据加版本号,或者用可取消请求的方式避免过期数据参与渲染。

6.2 性能问题定位三板斧

如果页面卡顿,大概率不是JavaScript计算慢,而是渲染链路被拖垮了。打开浏览器的性能面板,录制十几秒操作,重点看三样东西:

一是长任务标记。主线程被一段超过50ms的JavaScript占住,会导致掉帧,此时需要优化数据处理逻辑。

二是强制同步布局警告。性能面板会明确标出“Forced reflow”字样,提醒你改了样式后立刻读取布局属性。找到对应代码,拆开读写。

三是Layout Shifts。这个指标看似是视觉稳定性问题,但根源往往就是更新DOM时干扰了其他元素的尺寸位置。

记住一个委托原则:能在CSS层解决的问题,永远不要到JavaScript层解决。比如展开收起动画,完全可以靠加一个类名让CSS去过渡,不需要在脚本里一步一走地改样式。

6.3 动手更新前,先问自己五个问题

我习惯把更新DOM当成一次“施工”,施工之前先做评估:

  • 这次更新改动的是内容还是结构?纯文字内容用textContent,结构新增用insertAdjacentHTML或appendChild。
  • 能不能只更新“变化的那一部分”?比较新旧数据,只增删改差异项,以最小化的方式执行DOM操作。
  • 循环里有没有重复查询?有就提出去,缓存结果。
  • 有没有更上层的手段?能用className搞定就不要逐条改样式。
  • 我是否理解了这次更新会触发的渲染管线阶段?如果答案是否,先查文档,别急着写代码。

7. 一个对比实验:三种更新策略的实测差距

7.1 实验场景和三种方案

为了直观展示“更新DOM的打开方式”对性能的影响,我写了一个模拟页面:一个列表,初始渲染500个列表项,然后每两秒用新数据刷新一次,所有列表项的内容都会变化。

方案A:整块重构。拿到新数据后,直接container.innerHTML = 拼接好的全部HTML。

方案B:按文本节点更新。遍历现有子节点,逐个用新的文本内容替换。

方案C:差分更新。先算出新旧数据的差异,只更新变化的项,新增的插入,删除的移除。

用performance.now()记录每次更新的耗时,每个方案跑20次取中位数。

结果非常直观:

方案平均耗时(ms)触发的渲染次数焦点/滚动影响
方案A:innerHTML全量约8.5ms1次较大的重建高风险,输入框焦点必丢
方案B:遍历更新文本约1.8ms500次局部更新低风险,但高频时掉帧
方案C:差分更新约0.6ms仅差异节点更新最低风险,影响面最小

方案A虽然视觉上代码最短,但耗时是方案C的十倍以上。原因是它每轮都要序列化整个字符串、解析整个HTML、销毁全部旧节点、创建全部新节点;方案B看似更轻,但它在循环里逐节点修改,触发的局部更新数量太多;方案C既减少了节点操作次数,又把每次更新的成本压到最低。

7.2 方案C具体怎么实现

方案C可以用前面写的renderList函数为核心思路。关键在于拿旧数据和新数据做对比,生成三个集合:新增列表(需要appendChild)、删除列表(需要remove)、更新列表(需要改textContent)。只有更新列表里那些内容真正变化了的项才需要写入真实DOM。

这个实验给我的最大启发是:性能瓶颈很多时候不是工具的问题,而是策略的问题。把更新范围限制到最小,比用任何高性能API都更重要。

7.3 从实验看更新DOM的优化层级

通过这个实验,可以总结出一个清晰的优化路径,我一般称为“三层递进”:第一层,减少DOM操作次数,能用一次搞定就不用一百次;第二层,降低每次DOM操作的成本,用textContent而不触发HTML解析,用class切换代替样式逐条改写;第三层,对渲染管线进行调优,把改动集中到同一帧,利用requestAnimationFrame等机制归并渲染任务。

这三层不是独立的,而是递进的。先控制数量,再控制单价,最后控制时机,所有关于更新DOM的性能问题都能从这三层的角度找到答案。

一个收尾的实操建议

我个人在实际排错中最大的体会是:遇到和更新DOM相关的问题,千万不要急着写代码,先搞清楚“这一步操作会触发哪一级渲染管线”。把更新DOM看作一次调度任务,而不是单纯的改数据,很多焦点丢失、页面卡顿、数据不显示的Bug都能从源头避开。

最后再分享一个小技巧,如果你的项目还在手动拼字符串更新页面,立刻停下来,先把“所有更新入口”罗列出来,统一收敛到一个渲染函数里。哪怕不用框架,也能清晰地控制更新边界。这个改造动作的长期收益,远大于你第一眼看到它时预估的工程量。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询