news 2026/9/9 8:26:25

深入理解DOM节点:从获取、遍历到性能优化与安全防护

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入理解DOM节点:从获取、遍历到性能优化与安全防护

写DOM节点,基本是前端工程师天天都要碰的东西。很多新手一开始就栽在“这个元素怎么找不到”“为什么改了内容页面不更新”“数据一多页面就卡死”这些问题上,归根结底,都是对DOM节点的底层逻辑没吃透。这篇文章我不讲枯燥的规范文档,而是从一棵树讲起,把节点的获取、创建、遍历、性能优化、安全防护这些事串起来,顺便把我这些年踩过的坑和总结的骚操作一并分享出来。内容适合刚接触前端的小白,也适合写了两三年业务代码但没系统梳理过DOM知识的同学,看完之后再去处理表格渲染、组件封装、DOM diff这类需求,会顺手很多。

1. 从一棵树说起:先把DOM节点的底层逻辑盘明白

1.1 把HTML想象成一棵倒挂的树

我第一次听说“DOM是一棵树”的时候,脑子里浮现的是家谱图。其实DOM和家谱的区别不大,只是它是倒着长的:最顶上的根节点是document,然后一层一层往下长出<html><body><div><p>这些节点。每个节点除了自己本身,还带着一堆关系——父节点(parent)、子节点(child)、兄弟节点(sibling),这些关系就是后面所有查找、遍历操作的基础。

为什么要强调“树”这个结构?因为树的遍历路径非常明确,从根到叶子只有一条路径,你找任何一个节点都可以通过某种固定规则走到它。所以浏览器才能用那么快的速度通过选择器锁定元素。如果你把DOM当成一个“扁平的数组”,用的时候就会很别扭,因为你总会漏掉层级关系,比如<ul>里到底是直接放<li>还是中间隔了一层<div>,这直接决定了你用children还是childNodes能不能正确拿到东西。

在线上的HTML文档中,几乎所有可见内容都是元素节点,但还有大量看不见的节点在默默影响你的判断。比如一段文字里的换行符和空格,在childNodes里会以文本节点形式存在;<!-- 注释 -->则是注释节点。这些看不见的“幽灵节点”,曾经让我在遍历时莫名其妙拿到一堆undefined或空白内容,所以现在只要排错,我都会先看nodeType

1.2 十二种节点类型里的三种常客

规范的DOM Level 2定义了12种nodeType,但实际上前端日常能碰到的只有三种:

nodeType常量名中文含义示例
1ELEMENT_NODE元素节点<div><span><li>
3TEXT_NODE文本节点“这是一段文字”
8COMMENT_NODE注释节点<!-- 注释 -->

很多新手会在取到的节点上直接调用querySelector或者classList,结果报错说ele.querySelector is not a function,一看原因,这是文本节点啊。文本节点是没有这些API的,它只有nodeValuetextContent可用。所以我在写遍历代码的时候,第一件事永远是靠nodeType过滤一遍,确保自己拿到的确实是元素节点。

还有一个容易忽略的点:document本身也是一个节点,它的nodeType是9,nodeName#documentdocument.documentElement才是<html>元素,很多人在控制台输入document.parentNode拿到null,因为Document已经是根了,再往上就没有父节点了。

2. 节点获取的几种姿势,别再只会用getElementById了

2.1 从document一路查下去

拿到一个节点的最原始方式,就是从document这个根开始,一级一级往下找。document.getElementByIddocument.getElementsByClassNamedocument.getElementsByTagName,这是老牌三件套。它们的共同点是返回的不是“单个元素”就是“实时集合”(HTMLCollection/Live NodeList)。

这里有个隐藏陷阱:getElementsByClassName返回的是动态集合,也就是说DOM结构只要一变,这个集合跟着就变了。如果我在循环里一边用getElementsByTagName('li')一边往ul里追加li,理论上会陷入无限循环,因为每次追加,集合长度就变长,计数器永远追不上。实际场景中,动态集合也经常导致“删了却还在”的诡异bug。所以我现在的基本态度是:动态集合适合一次性读取,不适合在循环中反复操作。

2.2 为什么我把querySelector系方法当首选

querySelectorquerySelectorAll是后来才普及的,但它俩现在已经被我用成了一种信仰。原因很简单:你只需要写一行CSS选择器,就能找到目标节点,还能顺便利用CSS选择器的组合逻辑。

// 直接通过选择器组合锁定节点 const btn = document.querySelector('div.modal .btn-confirm'); const items = document.querySelectorAll('.list > li:not(.disabled)');

querySelectorAll返回的是静态NodeList,这就比HTMLCollection省心得多。我前面的循环陷阱,在querySelectorAll身上不会发生:它拿到的是当时那一时刻的快照,后面DOM再变也不影响已经拿到的这份列表。此外,NodeList还有标准的forEach方法,配合解构赋值和展开运算符,想转数组也很方便。

需要注意的一点是querySelectorAll不能填伪类选择器里的:hover:focus这类状态相关的,因为那些不是标准选择器。还有性能问题:在超大文档里,复杂选择器反复查询会有损耗,不过一般业务页面根本不用在意,现代浏览器的选择器匹配已经足够快了。

2.3 用JS属性直接访问节点,比想象中更高效

除了从document开始查找,很多时候我们已经在某个节点上了,这时候直接通过节点属性拿它的父、子、兄弟节点,不仅代码短,性能也比重新querySelector一遍快得多。常见的几个属性我列一下:

  • ele.children:返回所有元素子节点的HTMLCollection。
  • ele.childNodes:返回所有子节点的NodeList,包括文本和注释。
  • ele.firstElementChild/ele.lastElementChild:只取第一个/最后一个元素子节点。
  • ele.nextElementSibling/ele.previousElementSibling:下一个/上一个元素兄弟节点
  • ele.parentElement/ele.parentNode:父节点。

这里有一个我一直强调的细节:优先用Element版本,也就是带Element的那些属性。因为如果你用firstChild,拿到的可能是换行符那个文本节点。我在解析用户上传的JSON生成列表时,经常遇到结构看起来没问题但数据死活不出来的问题,最后发现就是firstChild取到了文本节点,后来又改成了firstElementChild才正常。

3. 动态创建与挂载:让页面真正“活”起来

3.1 createElement、createTextNode与cloneNode的角色分工

动态创建节点是前端最日常的操作之一。document.createElement('div')只是创建了一个孤立的内存节点,它还没有进入页面,所以页面不会有任何反应。紧接着你需要给它设置属性、添加内容,然后手动把它挂到某个父节点下面。

我自己在实际编码中,很少直接用createTextNode,因为大部分情况我都可以用textContent属性来给元素设置文本:

const li = document.createElement('li'); li.textContent = '第一项';

textContent在赋值时会把内容当作纯文本处理,而不是HTML,这样既安全又方便。createTextNode的价值在于你需要单独创建一个文本节点作为其他容器的一部分时,例如需要精确控制文本节点在子节点列表里的位置。

cloneNode是个冷门但救命的方法。它接收一个布尔参数,true表示深克隆(连子节点一起复制),false表示浅克隆(只复制当前节点本身)。我在做列表排序、表格数据刷新时,经常先克隆一个临时的template里的节点,改完内容后整块替换原来的节点,这样比挨个改子节点性能好得多。有个细节:cloneNode不会复制用addEventListener绑定的事件,所以克隆完要重新绑事件,这也是很多新手踩坑的地方。

3.2 appendChild、insertBefore、replaceChild的正确打开方式

三个经典方法里,appendChild最常用,它把节点追加到父节点的末尾。也许你已经发现了,它有个特性:如果传入的节点已经在页面中存在,它会先把该节点从原来的位置移除,再插入新位置,这就实现了“移动节点”的效果。但反过来也容易造成问题——你只是想把某个节点备份一下,结果它从页面上消失了。

insertBefore(newNode, referenceNode)则是把新节点插到参考节点前面。比如我在做一个动态页签时,想在一个特定的“更多”按钮前面插入新的页签,必须用它。注意第二个参数如果传null,就相当于appendChild

replaceChild(newNode, oldNode)用于替换节点,这个在虚拟DOM框架出来前是组件更新的主力。使用时要小心:一旦替换,旧节点上绑的事件统统没了。所以如果你没有依赖框架,自己写替换逻辑,务必在替换前解绑事件,或者使用事件委托,否则轻则内存泄漏,重则点击逻辑错乱。

3.3 用DocumentFragment做批量插入,性能直接起飞

这里分享一个我特别喜欢的性能技巧:DocumentFragment。它是虚拟的“文档片段”,可以像普通父节点一样往里面塞子节点,但它本身不会出现在DOM树里。你可以把它理解成一个临时中转站。

我们的常规做法是:循环创建一大堆<li>,每创建一个就appendChild<ul>里,这样会导致浏览器多次触发布局和绘制,性能很差。但如果你把它们先全部塞进DocumentFragment,再把整个fragment一次性挂到真实的ul上,那么浏览器只需要一次重排重绘,性能可以说是质的飞跃。

const fragment = document.createDocumentFragment(); for (let i = 0; i < 10000; i++) { const li = document.createElement('li'); li.textContent = '第' + i + '项'; fragment.appendChild(li); } list.appendChild(fragment);

我拿一个页面试过,正常逐个插入一万条li时页面明显卡顿,换成fragment后几乎没有感知。而且这个方案比字符串拼HTML再用innerHTML赋值更安全、更灵活,因为你还可以在循环里设置属性、绑定事件。

4. 节点遍历与关系判定:处理复杂布局的必修课

4.1 parentNode和parentElement看起来一样,但坑在类型

有人问我这两个到底啥区别,我直接给结论:parentNode返回当前节点的父节点,它可以是任何类型的节点(包括Document和DocumentFragment);parentElement返回当前节点的父元素节点,必须是元素(Element),否则返回null

这个区别在自己构造的节点树里特别明显。如果我在document.documentElement上调用parentNode,拿到的是document,但parentElement就是null。如果你代码里写死了用parentElement去取父节点,而它自己已经是根元素了,就会出错。还有一个常见场景是,我们经常用while (node.parentElement)向上找祖先,找到null就停,这其实是一种非常方便的祖先遍历。

用哪个其实取决于你是否关心“父节点要不是元素”。通常我写业务代码都用parentElement,因为如果不是元素,后面想调用classListstyle这些属性基本没意义,提前返回null反而能避免报错。

4.2 nextSibling和nextElementSibling,我踩过的空白兄弟坑

曾经有个需求,要在表格里点击一行时高亮,并且把“删除”按钮移到这一行的上一行和下一行。我一开始用的是row.nextSibling,结果运行时发现按钮总是差一行,而且有时候插到奇怪的位置。后来在控制台打印,才发现HTML里每个<tr>之间是有换行和缩进的,这些空白被浏览器解析成了文本节点,占据了nextSibling的位置,所以我拿到的根本不是下一行tr元素。

从那以后,所有处理兄弟节点的代码,我一律使用nextElementSiblingpreviousElementSibling,从源头上避开空白文本节点。如果你的HTML是压缩过、没有空白的,那用nextSibling可能碰巧没问题,但代码的可读性和健壮性都差。这里也提醒大家:写DOM遍历逻辑时,永远假设HTML源码中存在“不可见节点”,然后主动过滤掉。

4.3 判断包含关系,contains可能是最体面的方案

判断某个节点是否在另一个节点内部,是一个很常见的需求,比如点击某个按钮显示下拉菜单,点击菜单外部就关闭。很多人第一反应是遍历parentElement,一层层比id或者class,其实Node.contains方法一行就能搞定。

const menu = document.getElementById('dropdown'); const isInside = menu.contains(e.target); if (!isInside) { closeMenu(); }

它的返回值:如果调用者是目标节点的祖先,甚至就是它自身,都会返回true;否则返回false。我用这个方法处理全局点击关闭弹窗的场景,代码非常优雅,不用管嵌套多少层。还有compareDocumentPosition可以判断更复杂的关系,但实践中我很少用它,因为可读性差点。

4.4 用TreeWalker遍历整棵子树,功能比想象中强

NodeIteratorTreeWalker这两个API可能很多前端一直没用过,但它们处理“遍历整棵DOM子树”的需求时非常顺手。比如我想遍历一个容器里所有文本节点,把某个关键词高亮,如果用querySelectorAll只能选元素节点,这时候TreeWalker就厉害了。

const walker = document.createTreeWalker( container, NodeFilter.SHOW_TEXT, { acceptNode(node) { return node.textContent.includes('target') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } } ); const textNodes = []; while (walker.nextNode()) { textNodes.push(walker.currentNode); }

代码里NodeFilter.SHOW_TEXT是只关心文本节点,你也可以用SHOW_ELEMENTSHOW_COMMENT,甚至用SHOW_ALL做全量遍历。注意createTreeWalker第一个参数是根节点,遍历范围是它下面的所有后代。这个API的兼容性在现代浏览器上完全没问题,但在老版本IE上就别想了。

5. 节点操作的性能杀手与优化策略

5.1 为什么频繁操作DOM会让页面卡成PPT

浏览器渲染页面的流程大致是:解析HTML生成DOM树,接着生成CSSOM树,然后合成渲染树,再计算布局,最后绘制像素。任何一个节点的位置、尺寸、内容发生变化,浏览器都可能从“布局计算”这步重新走一遍,这就是重排(reflow);如果只是颜色、背景这类不影响布局的属性变化,浏览器可能只做重绘(repaint)。频繁的节点插入、删除、属性修改,就像你在一个本来整洁的办公室里隔几分钟就重新摆放一次家具,谁都扛不住。

有一个我常用的类比:把浏览器想象成一个厨子,你每次操作DOM就像给厨子递一个需求,他得停下来重新构思整个菜谱。而DocumentFragment批量插入,相当于你把所有的改动一次性告诉厨子,他只需要一次性搞定,自然快得多。

5.2 减少重排重绘的几条黄金法则

这几条原则我至少用过三年,效果稳定:

  • 合并读写:把多次读操作放在一起,多次写操作放在一起,避免“读-写-读-写”交叉触发多次重排。比如用循环去读一批元素的宽度,再统一改它们的高度,比边读边改快得多。
  • 离线操作:需要频繁变化的节点,可以先display: none,操作完再显示。display: none后的节点不在渲染树中,改再多也不会触发重排重绘。
  • 批量替换:实在不想用DocumentFragment,那就用innerHTML一次拼接完整个结构再赋值。虽然innerHTML有安全和性能争议,但如果你自己能保证内容不含不可信数据,它依然是简单粗暴的方案。
  • 缓存节点引用:不要在循环里反复document.getElementById,先在循环外把节点存到变量里,再在循环中复用。

另有一个小技巧:用requestAnimationFrame把节点插入操作放在下一帧前统一执行,也能让交互更流畅。这虽然不能减少重排次数,但是能避免在同一帧里堆积太多任务导致的掉帧。

5.3 事件委托:利用节点冒泡机制,一发入魂

事件委托的核心原理是:事件在DOM节点之间会从目标向上冒泡,所以我可以把多个子节点的监听器绑定在它们共同的祖先上,让祖先统一处理。这样一来,即使后续动态添加的子节点,都不用重新绑定事件,因为事件最终会冒泡到祖先那里。

最常见的例子就是列表点击:

list.addEventListener('click', function (e) { const li = e.target.closest('li'); if (li && list.contains(li)) { handleItemClick(li.dataset.id); } });

这里closest扮演了“向上查找最近匹配节点”的角色,再配合contains确认当前节点确实在目标列表内,能避免误判。事件委托不仅减少了内存占用,还让动态生成的节点天然支持事件,省略了一堆onoff的烦恼。

6. DOM节点安全:别让XSS在你的页面上撒野

6.1 DOM型XSS到底是个什么东西

大家在安全报告里经常看到“DOM XSS”这个词,它和传统的反射型、存储型XSS有个重要区别:它的攻击载荷根本不会经过服务器,完全在浏览器端的DOM操作中发生。攻击者通过控制URL的hash、location.search或者postMessage注入恶意字符串,然后你的代码把这个字符串通过innerHTMLdocument.write之类的方式插入了DOM,导致脚本被执行。

这个场景在单页应用里特别常见。比如我把某个参数从URL里读出来,然后直接设置成某个节点内容。攻击者只要把这个参数改成一段包含<img onerror=...>之类的字符串,你的页面就会中招。我见过很多自认为用了前端框架就万无一失的人,结果在某个需要“渲染富文本”或“动态模板”的角落,还是用innerHTML裸接了外部数据。

6.2 最容易让DOM节点暴露风险的三个入口

  • innerHTML:把不可信字符串塞进HTML,是最常见的DOM XSS入口。
  • document.write:页面加载期间如果用它写了一段带脚本的内容,基本直接执行。这也是为什么很多站点严禁使用document.write
  • eval/Function:虽然不算DOM节点操作,但经常和动态字符串拼接一起出现,一旦内容可控,就是任意代码执行。

除了这三个,insertAdjacentHTMLouterHTMLsetAttribute('onclick', ...)setAttribute('src', 'javascript:...')也都是危险操作。我的建议是,所有进入这些“HTML解析型API”的字符串,都要默认不可信,除非有完整的白名单过滤。

6.3 用textContent代替innerHTML,从源头掐死XSS

如果你只是想插入一行纯文本,首选永远是textContent(或者createTextNode)。它的语义就是把内容当作纯文本,任何<script><img onerror>都只是普通的字符串,不会被解析成HTML。看我实际项目里的例子:

// 危险写法 userInfoEl.innerHTML = '欢迎,' + userInput; // 安全写法 userInfoEl.textContent = '欢迎,' + userInput;

两者视觉效果几乎一样,但安全性天差地别。如果非要展示富文本,请使用成熟的安全过滤库,比如DOMPurify,先清洗再插入。注意DOMPurify本身也是基于DOM节点操作的,它的原理是把HTML字符串解析成DOM,再递归遍历节点,把危险节点和属性全部移除,再把安全的节点重新构建成HTML。这个思路本身就说明:理解DOM节点遍历是防御XSS的重要基础。

6.4 稳妥的节点清空方式也有讲究

清理一个节点的所有子节点,很多人会写el.innerHTML = '',这在大多数场景下没问题。但如果这些子节点上有事件监听器、带引用数据的Web组件,直接innerHTML清空可能无法正确触发析构逻辑,甚至导致内存泄漏。更稳妥的做法是先遍历子节点,把每个节点的事件移除,再清空:

while (el.firstChild) { el.removeChild(el.firstChild); }

这个过程一旦遇到动态绑定的组件,需要逐个调用组件的销毁方法。实际项目里,框架的虚拟DOM卸载阶段做的事情本质上就是这个——从父节点移除前,先处理掉监听器和相关引用。

我个人在实际操作中的体会是:DOM节点的处理远远不止“找到元素改一下内容”这么简单。你把节点关系搞清楚了,许多“莫名其妙”的bug都能一眼看破。比如渲染列表突然多出空白、点击事件没有响应、页面卡顿,很多问题往根节点、空白节点、事件委托、批量插入这几个方向一查,基本就能定位。最后再分享一个小技巧:在控制台调试时,多使用dir()而不是log(),它能把节点对象的所有属性完整展开,排查节点类型和父子关系时会清晰得多。这个习惯救过我无数次,也希望能帮你在DOM的世界里少踩一些暗坑。

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

CMOS传感器动态范围工程极限测量方法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 8:25:33

Python协程实战:将IO密集型爬虫采集从49秒优化到2.5秒

同一个爬虫脚本&#xff0c;同步版跑 20 个接口要 49 秒&#xff0c;用 Python 协程改成异步并发后只用了 2.5 秒。这不是网络变快了&#xff0c;也不是换了一台性能更强的机器&#xff0c;只是把代码里那些"傻等"的时间重新用了起来。如果你平时用 Python 写爬虫、批…

作者头像 李华
网站建设 2026/9/9 8:22:00

OpenClaw升级排障:飞书插件失联与Control UI白屏修复指南

升级 OpenClaw 这种事&#xff0c;正常来说应该是平滑的&#xff1a;拉新版本、重启服务、继续用。但 v2026.3.22 这次升级&#xff0c;我前后折腾了两个晚上&#xff0c;核心问题就两个——飞书插件静默失联&#xff0c;Control UI 直接白屏连不上。更烦的是&#xff0c;升级过…

作者头像 李华
网站建设 2026/9/9 8:18:02

Ansible自动化运维实战:从安装到Playbook编写与排错

1. Ansible到底是什么东西&#xff1f;先把它讲明白 干运维这么多年&#xff0c;我一直有个很深的体会&#xff1a;日常最耗时间的并不是那些高难度的故障排查&#xff0c;恰恰是“重复劳动”——几十台服务器挨个做同样的事情。以前我管理几十台机器的时候&#xff0c;写一堆s…

作者头像 李华
网站建设 2026/9/9 8:17:44

React Native for OpenHarmony实战:狗狗品种测试模块开发全记录

《狗狗之家》这个项目&#xff0c;本质上是个宠物内容社区&#xff0c;我负责的其中一个模块叫“品种测试”——通过一组交互式题目&#xff0c;帮用户找到最适合自己养的狗狗品种。这个功能本身不算复杂&#xff0c;但难就难在它跑在 React Native for OpenHarmony 这条新出的…

作者头像 李华
网站建设 2026/9/9 8:16:54

holaOS:Agent开发者的本地优先调试工作台

1. 项目背景&#xff1a;Agent开发者的工具链之痛 上周一个做Agent开发的朋友跟我吐槽&#xff0c;说他现在调试一个带工具调用的Agent流程&#xff0c;要同时开着终端、浏览器、Postman、还有三个不同的聊天窗口&#xff0c;来回拷贝JSON上下文&#xff0c;一个参数传错就得从…

作者头像 李华