资讯动态

HTML DOM方法详解:从元素查找到事件委托与性能优化实战

发布时间:2026/9/10 10:09:53 来源:尧图企业网站定制
1. 先拆掉DOM 很高深的滤镜它不过是一棵能改的树刚学前端那会儿我对 HTML DOM 方法一直有种说不出的别扭文档翻了一遍每个方法名字都认识一写页面就全忘光。getElementById、createElement、appendChild 这些单独拎出来都好理解可真要在页面上加个弹窗、做个动态列表常常不知道从哪个方法下手。后来写多了才慢慢意识到问题不在记性而是我一直没把 DOM 方法的体系串成一张网。先说一个最基础、但很多人没真正想透的问题DOM 到底是什么它不是你在编辑器里写的那堆 HTML 标签而是浏览器把你的 HTML 解析完之后在内存里生成的一棵对象树。HTML 文本只是原料DOM 树才是浏览器实际用来理解、渲染和交互的真身。你的 CSS 选择器能生效、JavaScript 能操作页面本质都是在跟这棵树打交道。方法在这里扮演什么角色如果 DOM 是一棵长在浏览器里的树那 HTML DOM 方法就是你手里的剪刀、铲子和嫁接刀。想剪掉某个节点用 removeChild想嫁接一个新的子树用 appendChild想找到某根枝条用 querySelector。浏览器对这棵树的每一次改动几乎都必须通过方法完成直接赋值改属性只是其中一小类例外。我在带新人时最喜欢打一个比方HTML 是房子的设计图纸DOM 是盖好的毛坯房而 DOM 方法就是你的装修工具。图纸你可以反复改但想动毛坯房的墙体、电路和家具没有工具就只能干瞪眼。理解了这层关系你就明白为什么所有前端框架React、Vue不管包了多少层底层最后还是要回到这些原生方法上——因为桌面只有一个工具始终出自同一套体系。1.1 为什么你总记不住 DOM 方法我观察过很多初学者记不住 DOM 方法的根本原因不是笨而是把方法当成了独立的单词去背。实际上这些方法背后只有几条主线找查找、建创建、插插入、改修改、删删除、听事件监听。你看任何页面操作都逃不出这六个动作你不需要背几十个孤立的名字你只需要记住这六个动词然后把每个动作下的 2~3 个常用方法记牢就已经覆盖了日常开发 90% 以上的场景。另一个记不住的原因是没有意识到方法之间经常配合使用。比如新增一个列表项标准链路是 createElement 创建 - 设置内容 - appendChild 插入三步缺一不可。很多人只背了 createElement不知道后面还要插到树里结果节点建了但页面上看不到。这就好比你加工了一个零件但没装到机器上机器当然不会转。所以我在后文会把方法按操作链来讲而不是按字母表排列。1.2 DOM 方法全景图在深入每个方法之前先用一张表把整个体系铺开。这张表是我自己给团队培训时用的覆盖了日常开发最常见的入口动作类型核心方法典型场景查找元素getElementById / getElementsByClassName / querySelector / querySelectorAll / closest拿到要操作的目标节点创建节点createElement / createTextNode / cloneNode / createDocumentFragment生成新的元素、文本或批量容器插入节点appendChild / insertBefore / insertAdjacentHTML / prepend / after把节点放进指定位置修改内容textContent / innerHTML / setAttribute / style / classList改文字、改结构、改样式、改属性删除节点removeChild / remove从树中移除节点事件相关addEventListener / removeEventListener / dispatchEvent绑定、解绑、触发事件遍历关系parentNode / children / firstElementChild / nextElementSibling在节点之间移动看这张表你会发现方法就那么几十个真正高频的就上面这些。后文我会逐个展开讲清楚为什么这么做和坑在哪里而不是简单罗列 API 文档。2. 找元素的方法getElementById、querySelector 到底该用谁查找元素是所有 DOM 操作的第一步就像你拿钥匙开门——钥匙找错了后面全是白费。很多新人问的第一个问题就是这么多查找方法我到底该用哪个老实说这个问题的答案随着前端发展一直在变。2.1 老派三兄弟getElementById、getElementsByClassName、getElementsByTagName这三个方法非常典型它们从前端早期一直活到今天但现在很多新手对它们的脾气已经不熟了。getElementById 最简单参数是 ID 字符串返回匹配的元素对象如果找不到返回 null。它的特点是一个 ID 只能对应一个元素所以语义上天生就是独立节点。过去我们用document.getElementById(app)拿到根节点再往里面塞东西现在虽然也可以用但写法上逐渐被 querySelector 替代。不过有一个细节值得记住getElementById 只能挂在 document 上调用不能通过某个父元素去调比如parent.getElementById(...)会报错这是它的限制。getElementsByClassName 和 getElementsByTagName 就有点脾气了它们返回的不是单个元素而是一个 HTMLCollection——一个活的集合。这个集合不是静态快照而是跟 DOM 树实时联动。什么意思你先把集合存进变量再往页面里新增一个匹配元素回头再看这个集合变量长度竟然变了。这在某些场景下是好事但在循环里就是灾难——边遍历边增删会导致索引错乱我在第 8 章会详细讲这个坑。2.2 万能查询的 querySelector 与 querySelectorAllquerySelector 和 querySelectorAll 是后来浏览器原生支持的选择器查询方法它们接受任何 CSS 选择器字符串比如#app .item、ul li:first-child、[data-typebutton]。这是它们最大的优势你在 CSS 里怎么写选择器在 JS 里就能怎么写心智模型完全统一。两个方法的区别在于返回值querySelector 返回第一个匹配的元素找不到返回 nullquerySelectorAll 返回所有匹配元素的 NodeList静态集合。注意这里我特意写了静态querySelectorAll 返回的 NodeList 是快照后续 DOM 变化不会影响它。这一点跟 getElementsByClassName 的 HTMLCollection 正好相反用的时候要分清楚——如果你需要实时反映页面状态用 Collection如果你只是想拿当前这一批用静态的 NodeList 更可控不容易在循环里出bug。有个实际经验在组件开发里我几乎只用 querySelector / querySelectorAll 和 getElementById很少用 getElementsByClassName。原因一个是 CSS 选择器表达能力强另一个就是静态集合在复杂逻辑里更好预测不会出现明明删了怎么还有的灵异现象。2.3 查找方法的实时性差异这里值得用一张表把实时性差异钉死因为我发现这是面试常问、实战常错的地方方法返回类型是否实时找不到时getElementByIdElement-nullgetElementsByClassNameHTMLCollection是空集合getElementsByTagNameHTMLCollection是空集合querySelectorElement-nullquerySelectorAllNodeList否空集合实时集合听起来很智能实际上在项目里容易埋雷。我记得有一次做无限滚动列表循环里用 getElementsByClassName 实时集合判断是否还有更多节点因为每次 append 后集合长度自动变导致判断条件永远不为假最后内存直接爆掉。换成 querySelectorAll 静态快照后问题立刻消失。不是说实时集合没用但你至少要知道它在你代码里会自己长大。3. 造元素与插元素让新节点在正确位置落地找到目标元素之后下一步通常是生成新的节点并把它放进页面。这个环节的每一步我都会拆开讲因为它是 DOM 操作里最容易做成能用但很糙的部分。3.1 createElement createTextNode 的黄金搭档createElement 的作用是创建元素节点但它创建出来的只是一个孤儿节点——存在于内存里但不在 DOM 树上页面不会显示。想让它显示必须找到父节点用 appendChild 或 insertBefore 插入。createTextNode 则用于创建文本节点。很多人写动态内容时图省事直接给元素的 innerHTML 赋字符串这在处理纯文本时其实藏着 XSS 风险。更好的做法是用 createTextNode 创建文本节点浏览器会把它内容里的、等字符正当地当成文本处理不会解释成 HTML 标签。这句话我建议所有做前端的人都认真读一遍textContent/createTextNode处理的是文本innerHTML处理的是HTML两者处理的数据类型根本不同。用户输入的数据永远当作文本处理永远不要直接拼进 innerHTML这是我做前端以来守住的最重要的一条安全底线。3.2 appendChild、insertBefore 与节点位置控制appendChild 把一个节点追加到父元素的最后一个子节点位置这是最常见的操作。但有时候你想插到指定位置比如列表第二项后面这时候 appendChild 就不够了要动用 insertBefore。insertBefore 接收两个参数语法是parent.insertBefore(newNode, referenceNode)意思是把 newNode 插到 referenceNode 的前面。如果第二个参数传 null它等价于 appendChild把节点追加到末尾。这里有个小记忆技巧insertBefore 是插到某节点前面所以第二个参数是参照物是后面那个节点不是插到第几个位置。很多人第一次用会把参数顺序搞反我建议默认先写好参照节点再决定前面还是后面。还有一个必须知道的行为如果你把一个已经存在于 DOM 中的节点传给 appendChild 或 insertBefore浏览器不会复制它而是把它从原位置移动过来。这个特性有时候会吓到人——明明只是想把某元素挪个位置结果它从原来的地方消失了。其实这不是 bug而是 DOM 的移动语义。如果真想复制得先用 cloneNode(true) 克隆一份再插。3.3 DocumentFragment批量插入的性能关键DocumentFragment 是一个专门用来当临时容器的节点。它有个特性你在它上面 append 子节点页面不会立即更新等它整体被插入 DOM 后它自己不会出现在树里只有它包含的子节点会落地。换句话说它是一个中转站。为什么需要中转站因为每往 DOM 树里插一次节点浏览器都可能触发回流或重绘频繁操作会卡顿。通过 DocumentFragment 先把所有要插入的节点组装好再一次插进真实 DOM就把 N 次页面更新压缩成了 1 次。我在渲染长列表时几乎必用这个模式比如一百条数据的列表直接循环 appendChild 100 次和用 fragment 组装后插一次肉眼都能感觉到流畅度差异。3.4 insertAdjacentHTML另一种思路除了 appendChild 系列还有一个方法被严重低估insertAdjacentHTML。它接受两个参数第一个是插入位置beforebegin、afterbegin、beforeend、afterend第二个是 HTML 字符串。比如el.insertAdjacentHTML(beforeend, li新项/li)等价于在 el 内部的末尾追加一段 HTML。它的优点是写法直观适合插入一段由字符串拼好的结构返回值还不是节点而是 undefined所以别指望它返回插入的元素。要注意它的定位是基于某一个元素的外部或内部beforebegin 是元素外面之前afterend 是元素外面之后afterbegin 是元素内部第一个子节点之前beforeend 是元素内部最后一个子节点之后。这四个方位在写复杂布局时相当好用尤其是做内容流插入的场景。但安全提醒依然适用不要把用户输入直接塞进 HTML 字符串。4. 改内容与改属性innerHTML、textContent、classList 的边界感节点插进去之后接下来要做的通常就是改——改文字、改结构、改样式、改属性。这些操作看着简单方法之间的边界如果拿不准很容易出现文本丢了样式样式溢出到无关元素等离奇问题。4.1 innerHTML 与 textContent 的安全分界innerHTML 属性会读取或设置元素内部的 HTML 结构你给它赋一段包含标签的字符串它会解析成真实节点textContent / innerText 则只处理纯文本内容任何 HTML 标签都会被当成普通字符显示。这两个属性最大的区别就是数据类型一个是结构一个是文本。很多动态渲染需求用 innerHTML 确实快、直观但安全上必须保持警惕。比如评论功能如果把用户输入的img srcx onerroralert(1)直接拼进 innerHTML浏览器一旦解析它就是一次可被利用的机会。防御方式不复杂——要么用 textContent 赋值要么先做转义把换成lt;。我个人的习惯是项目里凡是跟用户输入相关的一律用 textContent / createTextNode只有业务确信内容是可信的静态模板时才允许 innerHTML。另一个容易被忽略的点是性能差异。赋值给 innerHTML浏览器需要重新解析整个子树的 HTML 字符串而 textContent 只是替换文本节点开销小得多。如果你只是改一个按钮的文字完全没必要用 innerHTML。这种地方省一点页面在高频更新时差距就出来了。4.2 属性操作的三种姿势属性操作有几种亲属关系不太一样的方式用的时候要分清直接通过点语法访问比如el.id xx、el.href ...。优点是简洁但不是所有属性都能用点语法直接映射比如自定义属性>button idbackTop classback-top hidden返回顶部/button.back-top { position: fixed; right: 30px; bottom: 60px; padding: 10px 16px; border: none; border-radius: 6px; background: #1677ff; color: #fff; cursor: pointer; transition: opacity 0.3s; } .back-top.hidden { opacity: 0; pointer-events: none; }const backTopBtn document.getElementById(backTop); // 1. 监听滚动控制按钮显隐 window.addEventListener(scroll, () { if (window.scrollY 300) { backTopBtn.classList.remove(hidden); } else { backTopBtn.classList.add(hidden); } }); // 2. 点击返回顶部 backTopBtn.addEventListener(click, () { window.scrollTo({ top: 0, behavior: smooth }); });这套代码用到了这一章的核心方法getElementById 查找按钮classList.add / remove 控制显隐addEventListener 监听滚动和点击window.scrollTo 完成滚动。整套逻辑没有任何多余的库纯粹的原生 DOM 方法就能跑通。7.3 性能与体验优化基础版能工作但生产环境我会再加两个优化。第一个优化是滚动事件的节流。scroll 事件触发频率极高每次滚动几十像素就能触发十几次如果回调里有复杂操作页面就卡。简单节流用 requestAnimationFrame 就能做它会把多次回调合并到每一帧只执行一次let ticking false; window.addEventListener(scroll, () { if (ticking) return; ticking true; requestAnimationFrame(() { if (window.scrollY 300) { backTopBtn.classList.remove(hidden); } else { backTopBtn.classList.add(hidden); } ticking false; }); });第二个优化是支持用户中途打断。如果点击返回顶部后用户立刻滚动基础版的 smooth 行为会跟用户的操作打架。完善的做法是记录一个标志位滚动事件触发时把标志位清零并停止动画。一般我会用更可控的方式实现不用 window.scrollTo 的 behavior而是手动用 requestAnimationFrame 做逐帧滚动每帧移动固定距离滚动事件打断时取消动画帧let scrollAnimationId null; function scrollToTop() { if (scrollAnimationId) { cancelAnimationFrame(scrollAnimationId); } const startY window.scrollY; const distance -startY; const duration 400; const startTime performance.now(); function step(currentTime) { const progress Math.min((currentTime - startTime) / duration, 1); const eased 1 - Math.pow(1 - progress, 3); // easeOutCubic window.scrollTo(0, startY distance * eased); if (progress 1) { scrollAnimationId requestAnimationFrame(step); } } scrollAnimationId requestAnimationFrame(step); } window.addEventListener(scroll, () { if (scrollAnimationId) { cancelAnimationFrame(scrollAnimationId); scrollAnimationId null; } // ...显隐控制 });这段代码里用到了 cancelAnimationFrame 清理动画帧核心思路是视觉上用户一动程序自动让位。这也是整套 DOM 方法配合中最值得体会的部分方法本身不复杂复杂的是方法之间的节奏控制。8. 返回值细节与性能陷阱这些年我踩过的 DOM 方法坑最后这一章聊聊实际开发中因为 DOM 方法用得不当而踩过的坑。每个坑背后都是返回值类型、实时性或者事件绑定细节在作祟很值得花时间沉淀总结。8.1 高频 DOM 操作的布局抖动布局抖动Layout Thrashing是读和写交替太多导致的性能问题。比如在一个循环里先读 offsetHeight再修改 style再读 offsetWidth再修改 style……每次读到布局相关属性时浏览器为了保证结果是最新的会被迫重新执行一次布局计算而这种计算如果刷得比渲染还快性能就崩了。解决思路是读写分离先把所有需要的布局数据读出来存好再统一修改。这个原则听着简单但一旦代码量大很容易在不经意间又混着写。我对新人的建议是凡是高频操作 DOM 的地方优先考虑用 DocumentFragment 批量创建插入业务里循环改样式时也尽量把修改集中到一个函数里执行。通过方法组合把操作批次化性能提升是最直接的。8.2 动态渲染节点的绑定失效我给每个 li 都绑了 click为什么新加的 li 没反应——这是我在论坛里看到频率最高的问题之一。本质原因很简单你给 li 绑事件是在页面初始渲染时执行的后来动态新增的 li 根本没有经过那次绑定自然没有监听器。解决办法前面已经讲了用事件委托。把监听器挂在一个始终保持不变的父容器上通过 closest 判断目标是不是想要的元素。我经历过的很多莫名其妙的 bug 最后都是这么解决的。这不仅是一个技术选型更是一种面向动态页面的思维模式不要给未来的节点绑定任何东西你要绑定的是那个永远存在的容器。8.3 常见方法返回 null/undefined的排查链路最后一个高频困惑为什么 getElementById 返回 null为什么 querySelector 返回 null我总结了一套排查链路按顺序走基本都能定位先确认脚本执行时机是不是在 DOM 加载之前。如果你把 script 放在 head 里且没有 defer这时候 DOM 树还没构建完查找当然是空。解决方法是把 script 放到 body 底部或者用 DOMContentLoaded 事件包一层。检查选择器有没有打错。ID 大小写敏感class 要带点号属性选择器要核对引号。浏览器控制台里可以直接document.querySelector(...)试一下立刻能验证。检查是不是拼写和命名空间的问题。比如自定义元素在某些环境里渲染前查不到需要稍微等一下或使用 MutationObserver 监听。最后别忘了看动态组件如果元素由前端框架渲染原生查找方法直接执行时框架还没来得及生成 DOM应该等生命周期钩子或 nextTick 再查。这套链路我建议直接截图保存排查时一步步对照比凭空猜测快得多。毕竟 DOM 方法本身不会骗你返回 null 的时候它已经诚实地告诉你此刻这棵树里没有你要找的那根枝条。写到这里我已经把 HTML DOM 方法从查找、创建、修改、事件到遍历、实战和踩坑完整讲了一遍。就我的体会而言DOM 方法不难难的是养成树结构思维——每行代码都要想清楚操作的是哪个层级、什么节点类型、会不会影响实时集合。把这些边界理顺之后再写页面交互你会明显感觉到不再是一个方法一个方法地查字典而是整个操作链路在脑子里自动跑通。后面你在项目里遇到更复杂的场景比如虚拟滚动、表格编辑、图表联动回头再来对照这篇文章提到的套路和原则会发现底层逻辑始终是同一套。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价