news 2026/10/18 7:05:45

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
一文搞懂更新DOM:基础API、性能陷阱与框架原理

有一次在答疑群里看到有人问“更新DOM是什么操作”,我第一反应是:这个问题问得特别好。很多新人在浏览器控制台里写过document.querySelector(),用过innerHTML拼过列表,也见过框架项目里那句“不要直接操作 DOM”,但真要说清楚“更新DOM”这四个字到底指什么、有哪些方式、为什么会影响性能,能讲明白的人其实不多。

这篇内容写给三类人:刚学会 JavaScript、想在页面里动态改东西的初学者;写过 Vue 或 React、但不太清楚框架背后替你做了什么的前端开发者;以及想在原生环境里处理列表、表单、弹窗,又想避开常见性能坑的人。我会从“更新DOM到底更新了什么”讲起,再梳理常见的操作方式,然后用一个真实场景解释为什么频繁更新会卡,最后聊聊现代框架的处理思路。

1. 先搞清楚“更新DOM”这句话到底在更新什么

1.1 DOM不是HTML,HTML只是一串文本

很多初学者把 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); }

看上去很常规的写法,为什么慢?因为每执行一次appendChild,DOM 节点就真实存在了,浏览器很可能马上要重新计算容器的高度、所有子项的位置。循环 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 选框架还是选原生,先算清这些账

做技术方案时,我一般会拿三个问题来问自己:

  1. 这部分界面的数据来源是否跨模块、跨页面共享?是,交给框架;否,原生考虑。
  2. 后续是否有大量结构增删和状态联动?结构复杂、联动频繁,框架的 diff 机制帮我避免漏改。
  3. 团队里所有人对原生 DOM 细节是否都熟练?如果新人需要快速接手,声明式的模板比一连串appendChild更直观。

这不是说原生不好,而是说原生把“如何更新”的控制权完全交给你,这也意味着你要自己承担所有细节管理。框架把“如何更新”托管起来,但你要遵守它的数据规则。两者没有绝对优劣,只有适用边界。

5.3 我习惯遵循的小原则

具体到每次更新 DOM 时,我脑子里其实有一套固定流程:

先判断这次更新是“内容变化”还是“结构变化”。内容变化,优先用textContent更新单个文本节点,避免整块区域被innerHTML全部重建,否则输入框里的值、滚动位置、聚焦状态都可能被无辜重置。结构变化,先把新节点构建好,再一次性插入,不要用循环一点点往真实文档里塞。

多节点更新前先想想“是否需要先离线”。能用DocumentFragment就先在内存里搭好,能合并成一次appendChild就不要拆成十次。每减少一次真实 DOM 的改动,都是给浏览器渲染管线减负。

这让我想起一个很实际的项目:模拟消息流,用户点一次加载 20 条消息。第一版是每次点按钮后用innerHTML把整个列表重拼一遍,消息多了之后,键盘焦点会丢、输入框内容被重刷。改成只把新增的 20 条节点用片段追加到底部后,问题直接消失。更新 DOM 不是不能碰,而是要想清楚每一次改动触碰的是整块区域,还是最小的一块节点。

回到最初那个问题:更新DOM是什么操作?它就是通过 JavaScript 修改网页这棵树。理解它最好的方式,不是背 API,而是分清“改内容、改结构、改样式”三件事,再知道每一类改动背后浏览器要付出多少成本。做到这两点,无论以后写原生脚本还是用框架,你都不会被“要不要操作DOM”这个问题困住。

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

用Dart统一Flutter与鸿蒙构建:GitHub Actions跨端流水线治理实践

如果你的 Flutter 工程和鸿蒙应用工程挤在同一个仓库里&#xff0c;发版前要在两三套 CI 之间来回切换&#xff0c;脚本一会儿 Python 一会儿 Shell 一会儿又是 Node Action&#xff0c;那这篇内容大概率对你有用。核心是 actions_toolkit_dart 这层工具库。这段时间我一直在折…

作者头像 李华
网站建设 2026/10/18 7:03:13

跨架构InlineHook:x86/x64双平台机器码级函数劫持方案

简介&#xff1a;本资源是一套完整支持32位与64位平台的Inline Hook技术实现方案&#xff0c;面向Windows底层开发、安全研究及逆向工程学习者&#xff0c;解决跨架构函数劫持与行为注入的核心难题。包内共6个源码文件&#xff08;2个C文件实现轻量级C语言Hook逻辑&#xff0c;…

作者头像 李华
网站建设 2026/10/18 7:02:39

用声卡当数据采集卡:从信号调理到Python实现的全链路指南

用过声卡做过采集的人&#xff0c;十有八九是因为手头没有专业数据采集卡&#xff0c;或者只是临时想看一眼某个低频信号长什么样。“基于声卡的数据采集”这个说法听起来像是一种DIY玩法&#xff0c;但它背后实际上是套非常成熟的思路&#xff1a;把电脑上现成的音频输入通道当…

作者头像 李华
网站建设 2026/10/18 7:00:30

REA建模思想实战:资源-事件-代理模型重构订单库存系统

REA 这个词&#xff0c;我第一次看到时心里想的是“这又是哪个新中间件”&#xff0c;后来一查才发现&#xff0c;它是一套在业务系统里非常值得认真研究的建模思想&#xff0c;全称 Resource-Event-Agent&#xff0c;翻译过来就是资源-事件-代理。这篇笔记就来聊聊我完整落地过…

作者头像 李华
网站建设 2026/10/18 6:58:58

科研效率翻倍:6个网站打通文献检索到选刊投稿全流程

1. 先说说我自己的效率翻倍经历&#xff1a;从收藏夹吃灰开始研一上学期&#xff0c;我干过一件特别蠢的事&#xff1a;花了一整个周末&#xff0c;把各种"学术神器""科研必备网站"的帖子翻了个遍&#xff0c;然后把这些网站统统塞进浏览器收藏夹。收藏夹整…

作者头像 李华
网站建设 2026/10/18 6:54:51

信息学奥赛(GESP、CSP-J/S、NOIP、省选、NOI)七大书系之梅拉智算系列书籍

梅拉智算 欢迎来到梅拉算智 我的第一本算法竞赛启蒙书 欢迎来到梅拉算智 中学生计算机程序设计基础&#xff1a;例题精析与训练 欢迎来到梅拉算智 中学生计算机程序算法入门&#xff1a;例题精析与训练 欢迎来到梅拉算智 GESP 大通关 欢迎来到梅拉算智 算法竞赛黄金法则 欢迎来…

作者头像 李华