一文搞懂更新DOM:基础API、性能陷阱与框架原理

发布时间:2026/10/18 7:05:46
一文搞懂更新DOM:基础API、性能陷阱与框架原理 有一次在答疑群里看到有人问“更新DOM是什么操作”我第一反应是这个问题问得特别好。很多新人在浏览器控制台里写过document.querySelector()用过innerHTML拼过列表也见过框架项目里那句“不要直接操作 DOM”但真要说清楚“更新DOM”这四个字到底指什么、有哪些方式、为什么会影响性能能讲明白的人其实不多。这篇内容写给三类人刚学会 JavaScript、想在页面里动态改东西的初学者写过 Vue 或 React、但不太清楚框架背后替你做了什么的前端开发者以及想在原生环境里处理列表、表单、弹窗又想避开常见性能坑的人。我会从“更新DOM到底更新了什么”讲起再梳理常见的操作方式然后用一个真实场景解释为什么频繁更新会卡最后聊聊现代框架的处理思路。1. 先搞清楚“更新DOM”这句话到底在更新什么1.1 DOM不是HTMLHTML只是一串文本很多初学者把 DOM 和 HTML 当成一回事这是第一个要纠正的认知。HTML 文件在服务器上是一段字符串浏览器收到后要经过解析才会在内存里生成一棵树这棵树才是 DOM。DOM 的全称是 Document Object Model文档对象模型。它把页面里的每个标签、文本、属性都转成一个个节点对象再用父子、兄弟关系把它们组织成一棵结构树。你在控制台输入document.body能拿到一个对象输入document.body.children能拿到一组子节点。这些操作之所以成立就是因为浏览器已经创建好了 DOM 树。我常用一个类比HTML 是建筑图纸DOM 是根据图纸盖出来的实际房间。你想在图纸上改格局改的是字符串你想动现实里的房间就必须通过门进去操作实际空间。对于页面来说DOM 就是那个“实际空间”JavaScript 能直接接触、修改、增删的对象集合而不是标签文本本身。1.2 更新DOM就是改这棵树上的节点搞清了 DOM 是什么“更新DOM”这句话就很容易理解了你通过 JavaScript 对这棵节点树做增、删、改、查只要最终改变了树的结构或节点状态都是在更新 DOM。举个例子// 修改文本内容 document.getElementById(title).textContent 新的标题; // 新增一个节点 const li document.createElement(li); li.textContent 新增条目; document.getElementById(list).appendChild(li); // 删除一个节点 document.getElementById(list).removeChild(li);这几段代码看起来很简单但背后涉及一个关键机制DOM 是浏览器暴露给 JavaScript 的一扇门。脚本执行在 JS 引擎里页面显示由渲染引擎负责两者通过 DOM 接口协作。当你调用appendChild并不是“画一笔”那么简单而是改变了浏览器内部渲染管线的输入数据。浏览器渲染一个页面大体要经过这么几步解析 HTML 生成 DOM 树、解析 CSS 生成样式规则树、合并成渲染树、计算布局位置、最后绘制到屏幕。更新 DOM 相当于修改了渲染树的源头数据浏览器为了反映变化可能要从布局甚至渲染树合并那一步重新走一遍。这就是为什么“更新DOM”从来不只是改一行代码的事它牵扯到一整套渲染流程。2. 真正动手更新DOM常用的动作和它们的差别2.1 改内容textContent、innerHTML、innerText不是同一个东西平时最多见的更新场景是改文字和改一块区域的HTML。三个 API 经常被混用但它们的行为差别很大。const box document.querySelector(.box); box.textContent b粗体/b; // 显示成普通文本 box.innerHTML b粗体/b; // 显示成加粗文字 box.innerText 普通文本; // 只影响可见文本textContent把整段内容当作纯文本处理不会解析标签所以安全性好、性能也不错。innerHTML会解析 HTML 片段能做到一次插入一大段结构但如果你把用户输入直接拼进去很容易产生隐患。innerText更特殊它会考虑“渲染后实际能看到什么”比如读到display: none节点时结果是空字符串而且会触发重排去计算样式性能一般。API是否解析HTML是否受CSS显示影响典型使用场景textContent否否更新纯文本、避免解析意外标签innerHTML是否批量插入已知结构、拼接模板innerText否是读取/设置用户可见文本不常用我自己的习惯是能不用innerHTML就不用。需要插入复杂列表时优先考虑createElement配合appendChild或者先拼好 HTML 再一次性赋值。原因后面讲到性能时会展开。2.2 改结构添加、插入、替换、删除节点结构更新是另一大类操作。你可能要往列表里加一项、把某个模块移到另一个容器、或者清空某个区域。常用 API 有这些// 添加默认放到子节点末尾 parent.appendChild(newNode); // 插入放在指定位置之前 parent.insertBefore(newNode, referenceNode); // 替换用新节点替换旧节点 parent.replaceChild(newNode, oldNode); // 删除移除某个子节点 parent.removeChild(childNode);这些操作里容易忽略的一点是同一个节点只能有一个父级。如果你把 A 容器里的某个节点appendChild到 B 容器它不会在 A 里复制一份而是会被“移动”过去。比如做个待办列表想把一项从“未完成”切到“已完成”直接把节点挪过去就行不需要先创建新节点再删除旧的。2.3 改样式和属性不只 style 一种方式改样式也属于“更新DOM”而且方式很多适用的场景各不相同。// 直接改行内样式 element.style.color #333; element.style.display flex; // 用 class 切换样式 element.classList.add(active); element.classList.remove(active); element.classList.toggle(done); // 修改普通属性 element.setAttribute(disabled, ); element.removeAttribute(disabled); element.setAttribute(data-id, 1001);改style属性最直接但行内样式优先级高后期维护容易混乱项目里更多是用classList切换类名把具体样式交给 CSS 去处理。改属性时要注意布尔属性的坑比如disabled只要存在就生效用setAttribute(disabled, )和setAttribute(disabled, false)效果其实一样正确移除用removeAttribute。2.4 先“查”到节点才能“改”很多人写代码卡在更新之前不知道怎么拿到那个节点。更新 DOM 的前置步骤永远是查询节点常用方法可以搭配使用// 最常用返回第一个匹配项 document.querySelector(.card-title); // 返回一组静态节点集合 document.querySelectorAll(.item); // 按 id 获取原生方法里最快 document.getElementById(app);querySelectorAll返回的NodeList是静态快照你后续改了 DOM它不会自动更新需要重新查一次。而像document.getElementById这种查询在大型页面里性能不错没必要为了“看起来底层”故意写成querySelector(#id)。我见过有人纠结这两种写法实测下来多数页面里差距可以忽略重要的是别在循环里反复查询同一个节点后面会继续讲。3. 为什么都说“频繁更新DOM”是性能杀手3.1 从回流和重绘说起每一处 DOM 更新都可能触发浏览器的渲染工作但不同类型的变化代价差别很大。这里有两个核心概念回流也叫重排和重绘。回流是浏览器重新计算页面元素的位置和几何尺寸的过程。你改了宽度、改了字体大小、增删了节点、改变了窗口大小都可能让一部分元素的位置和大小重新计算。重绘则是把更新后的样式重新画到屏幕上比如改了背景色、文字颜色。回流的代价通常比重绘高得多因为一旦布局变了浏览器可能要重新计算这个元素之后所有兄弟节点、父节点的几何信息。夸张点说改颜色像是给衣服换了个颜色改布局像是把房间里所有家具的位置重新摆一遍。3.2 一个反面案例循环里拼命 appendChild我接过一个很典型的问题某开发者在列表页一次性渲染 2000 条数据用for循环一条条appendChild结果页面滚动明显卡顿。代码大约是const list document.getElementById(list); for (let i 0; i 2000; i) { const li document.createElement(li); li.textContent 数据条目 i; list.appendChild(li); }看上去很常规的写法为什么慢因为每执行一次appendChildDOM 节点就真实存在了浏览器很可能马上要重新计算容器的高度、所有子项的位置。循环 2000 次相当于把“摆家具”这个动作重复了 2000 次而且后面的计算还要考虑前面刚新增的节点。3.3 优化思路先离线再一次性提交改进思路也很明确不要“改一下、算一下”而是先在内存里把整段结构准备好最后只触发一次真实更新。DocumentFragment就是为这种场景准备的。它是一个轻量的文档片段可以像普通父节点一样挂子节点但它不在真实 DOM 树里不会触发渲染。把所有节点都塞进片段最后再把整个片段挂到列表里const list document.getElementById(list); const fragment document.createDocumentFragment(); for (let i 0; i 2000; i) { const li document.createElement(li); li.textContent 数据条目 i; fragment.appendChild(li); } list.appendChild(fragment);这里的关键是循环里的appendChild操作的是内存中的片段没有进入真实文档所以浏览器不会反复计算布局。最后一次插入片段时片段的所有子节点“整体搬家”到了列表里真实 DOM 只被修改了一次渲染负担小得多。除了片段还有两个常用优化手段。一是临时设置容器display: none操作完再显示回来彻底脱离渲染流程二是用innerHTML一次性拼接列表再用textContent思路避免重复解析但要注意字符串拼接的安全和开销权衡。3.4 另外一个隐藏性能点强制同步布局真正写过复杂页面的人还会遇到一个更隐蔽的问题读和写交错进行会触发“强制同步布局”。比如你在循环里反复做这两件事item.style.left item.offsetLeft 10 px;先写了样式马上又读了布局位置。浏览器为了返回准确值只能被迫立刻做一次布局计算哪怕你原本打算稍后再统一重排。这个反模式会让本来能合并的渲染工作被拆成许多次小任务。正确做法是先把要读的值一次性读完再批量写样式即“读写分离”。前端项目里的性能优化很多时候并不是用了什么黑科技而是把渲染管线的工作从“我逼你做很多次”变成“我让你一次做完”。4. 现代框架为什么让你“别手动更新DOM”4.1 命令式和声明式的区别原生方式更新 DOM本质是命令式你得一步步告诉浏览器从哪里取节点、改什么属性、插到哪个位置。这套逻辑写多了会发现最容易出错的就是“状态一变忘了同步界面”。现代框架换了个思路声明式。你只需要声明“当前数据长什么样”框架负责把界面同步到数据描述的状态。写 Vue 组件时你会写data里的字段写 React 组件时你会返回 UI 描述这些都不是直接操作 DOM 的指令而是描述最终结果。4.2 框架替你做了“计算最小改动”的活以常见的响应式框架为例组件里数据变化后内部会重新生成一棵新的虚拟节点树然后和旧的虚拟节点树做对比找出哪些地方真正变了最后只把变化的部分落到真实 DOM 上。这个过程叫 diff。虚拟 DOM 本身不是“一定比原生 DOM 快”它的价值在于把“人工维护哪些节点需要更新”这件事自动化了。你不需要记住自己改了哪几项数据也不需要担心漏改某一块视图框架通过对比能定位到具体节点再把差异提交给浏览器。这也是为什么框架文档经常提到“不要手动操作 DOM”因为你手动改了真实 DOM框架维护的虚拟树并不知道下次数据更新时它可能用旧树去做 diff把你刚改掉的地方又重置回去反而制造出奇怪的 bug。4.3 框架不会让 DOM 更新消失只是让它变得可控不少初学者误以为用了框架就“没有 DOM 更新”了其实不是。所有界面变化最终仍然要落到真实 DOM 上只是这些操作由框架代码统一调度批量合并、异步微任务处理、尽可能减少对渲染管线的干扰。比如 Vue 的更新是异步批量执行的你连续修改多个数据它会在同一个调度周期里合并成一次视图更新。React 的函数式组件更新发生在渲染流程中由调度器决定什么时候提交到 DOM。这里面的核心思路都是“攒一批统一改”和前面 DocumentFragment 的思想一脉相承。4.4 遇到这些场景你还是得亲手摸真实 DOM框架也不是万能遮罩。下面这些情况几乎绕不开手动操作真实 DOM接入第三方图表库通常需要把图表实例挂到一个普通 div 上初始化、销毁都要直接操作节点。管理输入框焦点焦点比如点按钮后让搜索框自动聚焦需要调用input.focus()。复杂动画的逐帧控制尤其是结合 Web Animations API 或 Canvas 的场景需要读取前一帧节点状态。处理页面级滚动位置、元素尺寸测量比如弹窗居中需要知道offsetWidth和offsetHeight。Vue 里可以借助ref拿到真实元素React 里用useRef绑定到节点这些能力就是为了在声明式世界里打开一扇“必要时直接操作”的窗。关键是别把它们用在常规数据渲染上而是用在框架管不到的边缘场景。5. 不同场景下我实际会怎么做选择5.1 原生 DOM 依然有它的主战场虽然框架普及程度很高但原生 DOM 操作远没有过时。浏览器里的用户脚本、简单交互页、或者一个表单校验的小工具完全可以用几行原生代码解决。我曾经做一个演示页面只有三个按钮控制一段提示文案的显隐引入整套框架反而多余直接用classList.toggle最省事。判断标准其实就一句话需要维护的界面状态多不多。如果只有一个按钮、一个列表、一个人机交互流程状态不跨模块共享原生方法完全够用如果界面状态分散在几十个组件里你还手动去同步每个按钮的禁用态、每段文字的内容维护成本很快就会失控。5.2 选框架还是选原生先算清这些账做技术方案时我一般会拿三个问题来问自己这部分界面的数据来源是否跨模块、跨页面共享是交给框架否原生考虑。后续是否有大量结构增删和状态联动结构复杂、联动频繁框架的 diff 机制帮我避免漏改。团队里所有人对原生 DOM 细节是否都熟练如果新人需要快速接手声明式的模板比一连串appendChild更直观。这不是说原生不好而是说原生把“如何更新”的控制权完全交给你这也意味着你要自己承担所有细节管理。框架把“如何更新”托管起来但你要遵守它的数据规则。两者没有绝对优劣只有适用边界。5.3 我习惯遵循的小原则具体到每次更新 DOM 时我脑子里其实有一套固定流程先判断这次更新是“内容变化”还是“结构变化”。内容变化优先用textContent更新单个文本节点避免整块区域被innerHTML全部重建否则输入框里的值、滚动位置、聚焦状态都可能被无辜重置。结构变化先把新节点构建好再一次性插入不要用循环一点点往真实文档里塞。多节点更新前先想想“是否需要先离线”。能用DocumentFragment就先在内存里搭好能合并成一次appendChild就不要拆成十次。每减少一次真实 DOM 的改动都是给浏览器渲染管线减负。这让我想起一个很实际的项目模拟消息流用户点一次加载 20 条消息。第一版是每次点按钮后用innerHTML把整个列表重拼一遍消息多了之后键盘焦点会丢、输入框内容被重刷。改成只把新增的 20 条节点用片段追加到底部后问题直接消失。更新 DOM 不是不能碰而是要想清楚每一次改动触碰的是整块区域还是最小的一块节点。回到最初那个问题更新DOM是什么操作它就是通过 JavaScript 修改网页这棵树。理解它最好的方式不是背 API而是分清“改内容、改结构、改样式”三件事再知道每一类改动背后浏览器要付出多少成本。做到这两点无论以后写原生脚本还是用框架你都不会被“要不要操作DOM”这个问题困住。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询