☰
深入理解HTML DOM:节点操作、事件机制与性能优化实战
2026/10/21 7:34:16 网站建设 项目流程

做前端这些年,要说哪个概念最基础也最容易被忽略,我会第一个提名HTML DOM。很多新人写页面,能熟练操作div和class,但一旦被问到“浏览器把HTML变成什么了?”“为什么改一个样式会那么卡?”就答不上来。这其实不怪大家,DOM这东西平时就在浏览器背后默默工作,我们天天在用,却很少系统梳理过它。我有段时间因为项目重构,需要把老代码里大量直接操作DOM的逻辑迁移到框架里,被迫把DOM相关的底层机制重新过了一遍,才发现很多以前“能用”的写法,其实是踩了性能和安全的大坑。这篇文章就把我总结的HTML DOM核心知识、实操套路还有踩坑记录整理出来,给还在跟DOM较劲的朋友一份参考。

DOM(Document Object Model)说白了就是浏览器把HTML文档解析成的一棵对象树,每个标签、属性、文本都会变成树上的节点。这棵树不仅描述页面的结构,还提供了增删改查的接口,让脚本能动态改变页面。它的价值在于:没有DOM,JS就只是运行在真空里的逻辑片段,页面也永远只能是静态的。无论你现在是用原生JS、jQuery还是Vue/React,最终操作的都是这棵树上的节点,区别只是谁在帮你操作、用什么策略操作而已。

适合看这篇内容的,主要是两类人:一类是刚接触前端、听说DOM但还没有系统概念的初学者,可以把它当作一份带实操视角的入门提纲;另一类是写了一阵业务代码、想提升页面性能和代码规范的朋友,我总结的很多坑都是业务代码里真实遇到过的。至于已经熟练操作DOM的老手,这篇也可以当查漏补缺用。

1. DOM是什么:从HTML到对象树的完整链路

1.1 浏览器解析HTML的产物不是字符串,而是树

很多人以为浏览器拿到HTML后,直接就是一堆标签可以操作。实际过程是这样的:浏览器收到HTML文本后,会经过词法解析和语法解析两步,把标签、属性、文本内容转换成内部的节点对象,然后根据标签的嵌套关系组织成一棵多叉树结构。这个树就是DOM树,也就是document对象背后的数据模型。

举一个很简单的例子:

<html> <head></head> <body> <div id="app"> <p>hello</p> </div> </body> </html>

这段代码会被解析成一个层级结构。document是树的根,html节点是根的孩子,head和body是html的孩子,div是body的孩子,p是div的孩子,文本“hello”是p的子节点。注意,文本本身也是节点,类型是TextNode,这一点初学者很容易忽略,以为只有标签才算节点。实际上DOM规范里节点类型有十几种,常用的有Element(元素节点)、Text(文本节点)、Comment(注释节点)、Document(文档节点)等。

理解这个树形结构最大的意义在于:你操作DOM时,本质上是在遍历和修改这棵树。document.getElementById()是从树里精确找一个节点,parentNode是从当前节点往上爬一层,children则是往下取子节点列表。脑子里有这棵树,代码逻辑才不会写乱。

1.2 DOM树的节点关系与导航属

在树结构里,节点之间有几种关系:父子、兄弟、祖先与后代。DOM提供了一组导航属性,方便你在节点之间来回移动:

  • parentNode:父节点。
  • childNodes:所有子节点的集合,包括文本、注释等。
  • firstChild/lastChild:第一个和最后一个子节点。
  • nextSibling/previousSibling:下一个和上一个兄弟节点。
  • children:只包含元素类型的子节点,不含文本和注释,这个在实际开发里比childNodes更常用。

这里有个很容易踩的坑:childNodes包含文本节点,所以如果你写了空格、换行,它们也会算作子节点。比如<div>\n <span>x</span>\n</div>,div.childNodes里至少有3个节点:一个文本节点(换行空格)、一个span元素节点、又一个文本节点。而div.children只有一个span。所以当你遍历子节点时,要确认自己是想处理“所有节点”还是“元素节点”。绝大多数业务场景只需要元素节点,用children更安全。

另一个容易混淆的是parentNode与parentElement。绝大多数情况下两者结果一致,因为document节点没有元素父节点,所以document.documentElement.parentElement是null,而parentNode返回#document。处理极端情况时要注意。

2. 节点操作实战:查、增、删、改的惯用写法

2.1 查询节点的几种方式与性能差异

操作DOM第一步永远是找节点。原生API里,getElementById、getElementsByClassName、getElementsByTagName、querySelector、querySelectorAll这几种用得最多。

从性能角度排序,最快的是getElementById,因为它借助了浏览器内部对ID的哈希索引,几乎是常数级的。其次是getElementsByClassName和getElementsByTagName,它们返回的是动态集合(live collection),也就是说,后续只要DOM结构变了,这个集合会自动更新。而querySelectorAll返回的是静态快照,查询那一刻的结果固定下来,后续DOM变化不会影响它。

这个动态与静态的差异,在写循环的时候尤其需要警惕。我见过有人写这样的代码:

let items = document.getElementsByClassName('item'); for (let i = 0; i < items.length; i++) { document.body.appendChild(items[i].cloneNode(true)); }

因为items是动态集合,每追加一个克隆节点,集合长度不变(克隆的节点不在原集合里),所以这个循环会一直执行下去,直到卡死。如果你本意是克隆原始列表然后在原列表基础上翻倍,就应该先把集合转成静态数组:

let items = Array.from(document.getElementsByClassName('item'));

这样循环次数固定,不会出问题。同样的逻辑,换成querySelectorAll就不会死循环,因为它静态。知道这个区别,能帮你在未来排查很多“页面卡死”的诡异问题。

querySelectorAll虽然返回静态集合,但它的使用成本比前面几个高一点,因为需要解析CSS选择器。不过对现代浏览器来说差别已经很小了。它的优势是选择器语法强大,能像写CSS一样定位多层嵌套的节点。我的习惯是:需要精确ID用getElementById,需要批量操作同一类节点且要保留实时性用getElementsByClassName,需要复杂选择时用querySelectorAll。

2.2 创建和插入节点的正确打开方式

创建节点有不少坑。新手常常喜欢用innerHTML直接拼一段HTML,这是把双刃剑。用innerHTML拼字符串简单直接,但有两个问题:一是性能,浏览器要把字符串重新解析成节点再替换原有内容,如果目标容器内部原本有事件绑定,会被全部抹掉;二是安全性,字符串里如果含有用户输入的内容,极容易造成XSS注入。

比如评论功能,用户输入一段<img src=x onerror=alert(1)>,如果你直接把字符串塞进innerHTML,那段脚本就会执行。这就是经典XSS。正确的姿势是用document.createElement创建节点,再通过textContent设置文本内容,textContent会把所有内容当作纯文本处理,不会解析HTML标签。

let userComment = '<img src=x onerror=alert(1)>'; let commentBox = document.createElement('div'); commentBox.textContent = userComment; // 安全,显示的就是那段字符串

创建节点之后,插入方式也很有讲究。appendChild可以把一个节点追加到父元素的末尾;insertBefore(newNode, referenceNode)可以把新节点插到某个已有节点前面;replaceChild(newNode, oldNode)则是替换。如果你想把一个节点插到第一个位置,常用insertBefore(newNode, parent.firstChild)。

有一个很多文档没强调的点:一个节点同时只能有一个父节点。当你把一个已有节点appendChild到另一个父元素时,它会先从原父节点中移除,再插入新位置。这个特性可以巧妙利用来做元素移动,不用先removeChild再append。

2.3 删除和替换节点时的边界问题

删除节点最朴素的做法是parent.removeChild(child)。但如果你用child.parentNode.removeChild(child),就不需要先抓住父节点变量,在只知道子节点的情况下就能删,代码更健壮。后来浏览器新增了child.remove()方法,简洁很多,兼容性在现代浏览器里都没问题,可以直接用。

替换节点时有个坑:如果用replaceChild,新节点会替代旧节点的位置,但旧节点上的事件监听器和属性会一并消失。如果你希望保留事件,得考虑把事件绑定迁移到新节点上,或在新节点上重新绑定。我遇到过一个场景:点击列表里的“编辑”,把原来的span替换成input,但替换之后按回车应该触发的逻辑没生效,排查半天发现是事件绑在旧span上,替换后自然没了。后来改成事件委托到列表容器,才从根上解决这个问题。这个话题后面事件委托部分会展开。

删除节点后,如果被删除的节点及其子树还保留了JS变量引用,它在内存里并不会被立刻回收。在单页应用里,这个现象积累多了就成了内存泄漏。后面专门有章节讨论这个。

2.4 修改属性与样式时隐藏的陷阱

设置元素的属性有两种口子:一种是setAttribute('class', 'box'),另一种是直接操作element.className = 'box'。区别在于,setAttribute会把字符串作为属性值赋给虚拟属性,而element.className映射的是class属性的实时值。对于style属性,element.style.color只能读取或设置内联样式,你写在CSS文件里的样式它是读不到的。

想读取最终生效的样式,必须用getComputedStyle(element)。这个方法返回一个实时的计算样式对象,包含所有CSS属性计算后的最终值。注意它的属性名是驼峰式,比如background-color要写成backgroundColor。另外getComputedStyle的读取会强制浏览器进行样式计算,频繁调用会有性能压力,后面重排重绘部分会细说。

修改样式的时候,建议别一条条地改style.xxx = ...,每改一条都可能触发一次样式更新。更高效的做法是:

element.style.cssText = 'color:red; font-size:16px;';

或者更优雅一点,提前定义好CSS类,然后把类的名字加到元素上:

element.classList.add('active'); element.classList.remove('hidden');

用classList的好处不仅是性能,还能避免在JS里维护一堆样式细节。这也是现代开发推荐的方式——把样式决策放回CSS,JS只负责切换状态。

3. 事件机制:冒泡、捕获、委托与异步执行

3.1 事件流的三阶段:从点击到处理的完整路径

当你点击页面上的一个按钮时,事件并不是直接触发按钮的监听器,而是经过一个完整的传播过程。DOM事件规范定义了三个阶段:捕获阶段(capturing)、目标阶段(target)、冒泡阶段(bubbling)。

  • 捕获阶段:事件从根节点document开始,沿着DOM树向下传播,经过每一级祖先节点,直到达到目标节点。
  • 目标阶段:事件到达目标节点本身。
  • 冒泡阶段:事件从目标节点开始,向上往回传播,经过每一级祖先节点,直到根节点。

默认情况下,addEventListener设置的监听器是在冒泡阶段触发的,除非你显式传入第三个参数为true,才会在捕获阶段触发。这个参数就是一个布尔值或配置对象。

理解这个机制后,有两件事变得特别有价值。一是你可以利用捕获阶段做“提前拦截”,比如在全局捕获阶段判断点击是否落在某个区域内,如果不合法就取消。二是可以利用冒泡阶段做事件委托,这是业务中最实用的技巧。

3.2 事件委托:列表页面性能救星

事件委托的原理很简单:不把监听器挂在每个子元素上,而是挂在它们的共同父元素上,利用事件冒泡的特性,通过event.target判断实际触发事件的元素,再分发处理。

假设有一个<ul>列表,里面可能有几十上百个<li>,每个li里有一个“删除”按钮。如果用循环给每个按钮绑click,麻烦有三:一是绑定本身就耗费内存和时间,二是在动态新增li时新按钮没绑事件,三是删除某个按钮后监听器还附着在元素上容易泄漏。事件委托一次解决这三个问题:

document.querySelector('ul').addEventListener('click', function(event) { let target = event.target.closest('button.delete'); if (target) { // 执行删除逻辑 target.closest('li').remove(); } });

这里用了closest方法,它会从当前元素向上找匹配选择器的祖先元素,比手动判断event.target.tagName好使,因为包裹层或者文字节点的情况都能兼容。而且新增的li不需要额外处理,因为监听器只在父容器上。

事件委托虽然好用,也不能滥用。如果你需要区分很细的按键组合、键盘事件,或者某个区域频繁stopPropagation,那还是直接绑目标元素更省心。实践中,一个页面建议把委托挂到离目标尽量近的公共容器上,避免挂在document根上造成太多无关元素的事件判断。

3.3 addEventListener与onclick的差距

很多人图省事直接写element.onclick = fn,这样写有明显的缺点:同一元素的同一个事件只能绑一个处理函数,后绑的会把前绑的覆盖。而addEventListener可以绑多个,互不干扰,而且还能指定捕获阶段执行,能传配置项。

addEventListener的第三个参数如果传对象,还可以加once: true实现只执行一次,passive: true告诉浏览器不调用preventDefault,提高滚动性能。这些能力onclick都不具备。

还有一点是关于removeEventListener的。移除监听器时需要传入同一个函数引用,如果你绑定时用的是匿名函数,是永远移除不掉的。所以当需要移除时,要么保存函数引用,要么把函数声明成具名函数再绑定。这是个很常见的小坑,很多新手会在“移除事件不起作用”的问题上卡很久。

3.4 事件循环影响下的DOM渲染时机

事件监听器执行时,如果里面有同步的复杂计算,会阻塞浏览器渲染。原因是JS引擎和渲染引擎共享主线程。当你执行一段长循环或大量同步DOM操作时,动画、滚动、输入响应都会卡顿。

但是,DOM的修改并不会立刻生效到屏幕上。浏览器会把多个DOM修改合并到一次渲染里,在每次事件循环结束后统一进行重排和重绘。这也就是为什么在一个事件处理函数里多次修改样式,性能往往比在多个setTimeout里分散修改要好——后者会触发多次渲染。

JavaScript里还有一个API关注渲染时机:requestAnimationFrame。它会告诉浏览器在下次渲染之前调用你传入的函数,适合做动画或需要在帧更新前同步数据的场景。而setTimeout是到时间就执行,不一定跟渲染节奏对齐,所以动画用requestAnimationFrame更顺滑。

4. 性能优化:减少重排重绘的实用手段

4.1 重排与重绘:页面卡顿的元凶

DOM的每个元素都有几何属性(位置、尺寸),当这些属性变化触发布局重新计算时,叫reflow(重排);当元素外观变化但布局不变(比如颜色、背景),叫repaint(重绘)。重排的开销比重绘大得多,因为浏览器要重新计算受影响区域里所有节点的几何信息,有时候还会扩散到整棵子树甚至整页。

触发重排的常见操作包括:读写offsetWidth、offsetHeight、clientWidth、clientHeight、getBoundingClientRect()、scrollTop、scrollLeft这些几何属性,修改元素的大小、位置、内容,以及插入或删除节点。读取这些属性时,浏览器为了保证结果最新,会强制执行一次布局计算,哪怕你之前已经改过并尚未渲染的样式。

这就导致一个经典的性能陷阱——读写交错:

for (let i = 0; i < 1000; i++) { el.style.left = el.offsetTop + i + 'px'; }

这段代码每次循环先写样式,又读offsetTop,浏览器被强制反复进行布局计算,性能极差。正确姿势是把需要读的值先缓存起来,写完再统一读:

let top = el.offsetTop; for (let i = 0; i < 1000; i++) { el.style.left = top + i + 'px'; }

4.2 批量修改DOM的三种实用姿势

想要减少重排次数,核心思路是把多次零散的修改合并起来。常用的做法有三种:

第一种,隐藏元素后修改再显示。把要操作的容器设成display: none,修改完内部结构再设回display: block,因为隐藏状态下不会触发重排。适合大范围的修改。

第二种,使用DocumentFragment。它是一个轻量的文档片段,不属于主DOM树。你可以把新节点都塞进fragment里,最后一次性把fragment插入目标元素。插入时,fragment本身不会被插入,而是它的子节点被移动进去,并且只触发一次重排。这是个很经典的高性能批量插入方案。

let fragment = document.createDocumentFragment(); for (let i = 0; i < 1000; i++) { let li = document.createElement('li'); li.textContent = 'Item ' + i; fragment.appendChild(li); } list.appendChild(fragment);

第三种,用cloneNode配合替换。把要修改的节点克隆一份,在克隆体上做所有改动,再用replaceChild把原节点换成克隆体。这样页面只有一次替换操作,重排也只在替换那一刻发生。这个方法适合修改一个比较复杂但不想惊动其他节点的场景。

4.3 虚拟DOM与真实DOM的关联

现在前端框架流行,很多新人以为不需要再关心重排重绘了。其实框架只是把DOM操作封装成了更高效的模式。像Vue和React,它们在内存里维护一份虚拟DOM树,当数据变化时,框架先计算虚拟DOM的差异,再把差异批量应用到真实DOM上。这样做确实能减少不必要的直接操作,但最终还是要操作真实DOM,重排重绘依然存在。

所以即使你用了框架,了解真实DOM的性能特征还是有用。比如在Vue里,v-for渲染长列表时,如果给每一项都动态绑定了大量事件,性能依旧可能有问题,这时候事件委托或虚拟滚动还是有效的优化手段。换个角度说,框架不能替代基础能力,它只是帮你把DOM操作做得更聪明。

5. 常见问题与排查实录

5.1 NodeList与HTMLCollection,你真的分得清吗

HTMLCollection是getElementsByClassName、getElementsByTagName等API返回的集合,它是动态的,且只能包含元素节点。NodeList是querySelectorAll、childNodes返回的集合,它可以是静态的(querySelectorAll),也可以包含文本节点和注释节点(childNodes返回的NodeList是动态的,但childNodes的变动行为与元素节点集合不同)。

两者的遍历方式也有差异。HTMLCollection有namedItem方法,可以通过id或name直接取元素。NodeList则有forEach方法,而HTMLCollection没有,不过可以Array.from转换后用数组方法。日常开发中,当你需要对集合批量操作时,统一「转数组」是个安全习惯:

let items = document.querySelectorAll('.item'); items.forEach(item => item.classList.add('foo'));

5.2 强制同步布局:为什么读取样式也会卡

前面提过,读取几何属性会触发强制布局(forced reflow)。在实测中,如果循环读取大量元素的offsetTop,页面帧率会明显下降。但有些场景必须读取,比如做平滑滚动、动画拖拽、元素吸顶。这时候可以把读取集中到一个阶段,写入集中到另一个阶段,避免读-写-读-写交错,也就是“批处理”思想。

另一种做法是使用requestAnimationFrame把写入操作延到下一次渲染前,保证读取时拿到的是上一次渲染的结果,不会被迫重新计算布局。

代码里常见的性能检测方法是用Performance面板录制,放大时间轴,看主线程里蓝色、紫色、绿色区块的分布。如果能看到大量连续的样式计算和布局事件,那基本就是读写交错导致的。

5.3 动态HTML字符串的安全边界

有些人会想,innerHTML似乎很危险,那我用insertAdjacentHTML是不是就好一些?实际上insertAdjacentHTML同样会解析HTML字符串,也有XSS风险。安全的关键不在于API,而在于数据来源。只要字符串里包含未经转义的用户输入,任何解析HTML的API都有风险。

替代方案里,最安全的是使用textContent或者createTextNode生成文本节点。如果你的确需要拼一段含少量动态字段的HTML,建议把动态部分用encodeURIComponent或者专门的HTML转义函数处理。一个简单的转义函数至少要把&、<、>、"、'替换成实体字符。

我在一个后台管理系统里就遇到过类似问题:用户输入的备注里放了<script>标签,后端没有过滤,前端直接innerHTML渲染,结果页面脚本报错。从那以后,凡是用户输入渲染,一律走文本节点或者转义流程,没有例外。

5.4 内存泄漏的常见制造方式

DOM相关的内存泄漏,最常见的就是监听器泄漏和孤立节点引用。代码里经常能看到这个模式:

let btn = document.getElementById('btn'); let wrapper = document.getElementById('wrapper'); btn.onclick = function() { wrapper.innerHTML = ''; };

如果你在某个地方持有wrapper下某个子元素的引用,比如为了缓存而const x = wrapper.querySelector('.x'),即使innerHTML清空了,那个子元素仍然被引用着,浏览器不会回收。类似的,事件监听器挂在一个对象上但忘了移除,也会连同对象一起被保留。

解决思路是:在元素被销毁前移除事件监听器,并清空对外部引用;如果用了addEventListener,就记得removeEventListener;如果用onclick,就置为null。现在很多框架在组件卸载时会自动清理监听器,但原生逻辑还是要自己细心。

另外,WeakMap或WeakSet可以用来缓存节点引用而不会阻止垃圾回收,适合需要缓存DOM节点但又不希望造成泄漏的场景。这也是个进阶小技巧。

6. 我总结的几条实操心得

做了这么多DOM相关的开发,我最大的体会是,DOM操作本身不难,难的是在正确的时间做正确的事。查节点、改样式这些谁都会,但能意识到“这个改动会不会触发重排”“这个集合是不是动态的”“用户输入会不会注入”,才是拉开水平差距的关键。

如果让我给新手一条建议,我会说:不要急着上框架,先用原生JS写几个页面,把getElementById、querySelector、addEventListener、createElement这些基础API用熟。写的过程中多留意DevTools的面板,亲手制造一次性能问题再解决它,比背十篇优化文章都管用。

还有一个特别实用的经验,就是给DOM操作做“分层”。把核心逻辑封装成小的纯函数,比如createListItem(data)、renderList(list, container),不要在业务代码里到处散落DOM操作。这样不仅好维护,出问题时也能快速定位是数据问题还是渲染问题。

最后再分享一个小技巧:用>

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

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

立即咨询