掌握HTML DOM元素操作:从节点查找到样式控制的完整指南
很多人在学习JavaScript时都有过这种体验变量、函数、数组、对象这些基础语法都看明白了逻辑也能写通可真到了浏览器里想“动”页面元素的时候卡住了。想在点击按钮后改掉另一处文字想给某一列标签动态加上高亮样式甚至只是想在控制台里查一下当前页面有哪几张图片——这些需求靠之前学的JS知识解决不了。它们全都要从一个叫DOM的东西入手。这一篇是针对HTML DOM中“文档元素操作”的第一阶段内容我会把最核心的查找元素、遍历节点、读写内容、操作属性与样式的方法全部过一遍。默认你已经掌握了这个系列前面的JS基础功底因为接下来所有示例都会大量使用变量、函数、循环、数组这些基础特性去操作页面。这几块内容是你日后做交互、做动态渲染、做表单验证的底盘也是所有前端框架底层绕不开的原始能力。把这部分吃透你之后看任何框架的源码都不会觉得那是天书。1. 先建立DOM的思维模型浏览器把HTML变成了一棵“活”树1.1 document是总入口节点是基本单位在JavaScript里document是浏览器内置的对象它代表整个HTML文档。你可以把document想象成一个超大号的门卫室所有关于页面的查询、修改、新增、删除操作都要先敲它的门拿权限。它身上挂着一大堆方法和属性专门用来返回文档里的元素对象。当我第一次把这个概念讲给身边的初学者听时我用的类比是HTML源码是建筑蓝图DOM树是盖好的大楼document就是大楼管理处。蓝图上的每一根线条对应大楼里真实的墙壁、门窗但你要在大楼里干活不能直接改蓝图你得通过管理处去找到那面墙然后用工具去拆改它。浏览器加载页面时会先把HTML字符串解析成内存里的一棵树这棵树就是DOM树。你的JavaScript代码操作的永远是这个内存中的树对象而不是硬盘上的HTML文件。树的结构很好理解最顶端是document节点往下是html元素节点html下面分出头head和身体body两个子节点继续向下展开每一个标签就是一棵子树的根。这时有个更容易忽略但又特别重要的点标签之间的换行和空格在DOM树里也会被当作“文本节点”保存下来。这就引出了一个非常核心的概念节点node是总称元素element是子集。节点包含元素节点、文本节点、注释节点、属性节点等。你平时说的“操作那个div”指的是元素节点但你遍历某个容器的所有子节点时冒出来的很可能是text节点它们只是换行符而已。1.2 nodeType和nodeName是你的“验血工具”面对一个不确定的节点怎么快速判断它是元素还是文本每个节点对象都有nodeType属性它是数字编号元素节点是1文本节点是3注释节点是8document本身是9。nodeName则返回节点名称元素节点的nodeName是大写的标签名比如DIV、P文本节点的nodeName永远是#text。我在实际调试中经常用这段代码快速摸底const container document.querySelector(.container); Array.from(container.childNodes).forEach(node { console.log(node.nodeType, node.nodeName, node.nodeValue); });nodeValue这个属性也很关键对文本节点来说它就是文本内容对元素节点来说它永远是null。很多人一开始以为用node.nodeValue能读到div里的文字结果踩了空原因就在这儿。把这个模型搞清楚后面遍历DOM结构时才不会懵。很多所谓的“疑难杂症”比如为什么children拿到的只有4个元素而childNodes有9个根源都是没分清节点和元素。2. 定位元素的主要手段getElement系列与querySelector家族的取舍2.1 最经典的三件套getElementById、getElementsByClassName、getElementsByTagNameDOM操作的第一步永远是“找到目标”。老牌方法里最常用的是getElementById它接收一个不带#的id字符串返回匹配的元素找不到就返回null。const header document.getElementById(header);这个方法快得离谱因为浏览器会为id建立索引不需要遍历整个文档。但这也意味着id在整个页面里应该是唯一的如果你违反规范塞了好几个同id元素它只会返回第一个。getElementsByClassName和getElementsByTagName看起来很相似都返回一个HTMLCollection但需要注意三点细节。第一方法名里是“Elements”复数因为返回的是一组元素。第二返回的HTMLCollection不是数组它是类数组对象有length属性也能用索引访问但你不能直接调用forEach。想用数组方法先转一下就行const buttons document.getElementsByClassName(btn); const buttonArr Array.from(buttons); buttonArr.forEach(btn btn.classList.add(clicked));第三也是最重要的一点HTMLCollection是实时集合。什么意思就是说这个集合不是快照它像一个“活引用”文档里一旦新增或删除了匹配这个class或标签名的元素集合内容会立刻自动更新。这个特性用好了很方便用不好就是灾难。我会在后面专门讲它带来的坑。2.2 querySelector和querySelectorAll用CSS选择器的语法拿元素如果只能让我选两个DOM方法长期使用那一定是querySelector和querySelectorAll。它们接收任何合法的CSS选择器字符串返回匹配结果。前者返回第一个匹配项后者返回所有匹配项的NodeList。const firstCard document.querySelector(.card); const allCards document.querySelectorAll(.card);这套API最大的优势是灵活复杂层级关系、伪类、属性选择器都能直接用在字符串里。比如要选中class为active的li下的直接子链接一行代码搞定const activeLinks document.querySelectorAll(li.active a);如果用老牌getElementsByClassName你得先拿到所有li再逐个判断有没有active类再往下找子元素代码量至少翻两倍。所以实战项目里querySelectorAll是绝对的主力。不过querySelector有个限制它只支持“选择器”不能匹配伪元素比如::before、::after这种东西是拿不到的因为伪元素不是真实DOM节点。2.3 “实时集合”与“静态快照”的隐藏差异一个能自动更新一个不会这是文档操作中最容易出问题的隐藏差异值得单独拎出来讲。getElementsByClassName和getElementsByTagName返回的HTMLCollection是实时的而querySelectorAll返回的NodeList是静态快照。我用一个例子来说明const liveCollection document.getElementsByClassName(item); const staticList document.querySelectorAll(.item); console.log(liveCollection.length); // 2 console.log(staticList.length); // 2 // 动态往文档里新增一个 .item const div document.createElement(div); div.className item; document.body.appendChild(div); console.log(liveCollection.length); // 3实时集合自动追踪到了新元素 console.log(staticList.length); // 2静态快照保持原样这个特性在遍历中特别容易引发bug。最经典的一个场景你用一个实时集合去做循环循环体内又往文档里插入匹配元素。因为集合是实时更新的length一直在变大循环就变成了无底洞。// 危险代码可能造成死循环 const items document.getElementsByClassName(item); for (let i 0; i items.length; i) { const newItem document.createElement(div); newItem.className item; document.body.appendChild(newItem); }解决办法很简单提前把长度存下来或者用Array.from转成静态数组再遍历。碰到这个问题的同学第一反应往往是自己选择器写错了调试半天其实真正的原因是集合的动态性。3. 节点家谱parentNode、children、firstElementChild和兄弟节点遍历3.1 向上走parentNode与parentElement到底用哪个好拿到一个元素后向上找父级最常用的两个属性是parentNode和parentElement。它们95%的情况返回同一个值唯一的边界差异出现在顶级节点上document节点的parentNode是null而parentElement也是null。但对于document.documentElement也就是html元素它的parentNode是documentparentElement却是null因为document不是元素节点。这个差异平时几乎碰不到但我在写一些通用的DOM工具函数时习惯用parentElement因为它的语义更精确我只关心元素父级不关心document这类节点。而如果你某个场景确实需要判断是否存在document父节点再用parentNode。两条腿走路不冲突关键是你心里得有数。3.2 向下走children与childNodes的巨大差别想拿子元素children和childNodes是两个完全不同的属性。children只返回元素节点组成的HTMLCollection不包含文本节点、注释节点。childNodes返回所有节点组成的NodeList包括文本、注释。我在刚开始接触DOM时犯过一个错误想获取某个容器的全部子元素用了childNodes结果拿到一个超长列表打印出来全是#text节点一度怀疑是浏览器bug。后来才明白HTML里标签之间的换行缩进在解析时都变成了文本节点。所以只要你的HTML写了换行childNodes.length必然大于你肉眼看到的子标签数量。实战建议很明确默认优先用children。如果你只是想遍历子元素干活children是最直接、最符合直觉的选择。那childNodes还有用吗当然有处理textarea的内容、解析富文本、处理包含文本节点的复杂内容时它才登场。3.3 兄弟之间nextElementSibling比nextSibling更“省心”遍历兄弟节点的操作里nextSibling和previousSibling返回下一个/上一个兄弟节点注意它们不分元素和文本。而nextElementSibling和previousElementSibling只返回兄弟中的元素节点。还是那个问题HTML中的换行和空格会产生文本节点所以nextSibling经常拿到一个#text节点。比如你做了个tab切换组件每个tab是li两个li之间有换行你用currentLi.nextSibling想拿下一个tab结果拿到一个文本节点然后调用.classList就直接报错了。这类问题在没经验的人眼里像玄学其实就是没分清节点类型。我的习惯是任何“找兄弟元素”的需求一律用Element版本的方法省心省力不出幺蛾子。3.4 一个完整的遍历案例结合起来看一个实际场景假设页面里有个表格点击任何一个单元格时我想知道它所在行的所有数据。const cells document.querySelectorAll(td); cells.forEach(td { td.addEventListener(click, function() { const row this.parentElement; // 先拿到当前单元格的父级行 const rowCells row.children; // 该行的所有单元格 const data Array.from(rowCells).map(cell cell.textContent.trim()); console.log(data); }); });这里用parentElement是因为我知道td的父级一定是行元素不会是文本节点用children是因为我只想拿这一行里的所有td不关心换行符。一个干净利落的组合就把问题解决了。4. 修改元素内容textContent、innerHTML、innerText的差异与安全边界4.1 三个API三种语义别混着用修改元素内容最常见的三个API是textContent、innerHTML、innerText。它们的区别如果没搞清楚写出来的代码会有一堆隐藏问题。textContent获取或设置元素的纯文本内容不解析HTML标签。你把b加粗/b赋给它页面上会直接显示这段字符串本身而不是渲染出一个加粗文字。它是性能最好的选择也最安全。innerHTML则会解析字符串里的HTML标签并生成真实节点。赋值b加粗/b页面上会渲染出一个加粗文字。它功能强大但要付出解析HTML的代价。innerText和textContent很像但它会考虑CSS渲染结果。比如元素设置了display: noneinnerText读到的是空字符串而textContent仍然能读到文字。此外innerText的读取会触发一次重排因为浏览器需要计算样式来知道哪些文本是“可见”的。性能上不如textContent。什么时候用哪个我的规矩是这样只想读/写纯文本不需要解析标签用textContent操作快、语义清楚。确实要插入一段带格式的HTML片段并且这段HTML来源可信用innerHTML。想获得用户“肉眼看得到的文字”例如复制到剪贴板那种效果用innerText。4.2 innerHTML与XSS用户输入是个危险品如果你做了Web开发却不知道XSS跨站脚本攻击这个词那很危险。innerHTML接收的字符串一旦包含用户输入就存在脚本注入风险。举一个最常见的场景留言板功能用户输入了img srcx onerroralert(攻击)你直接把输入塞进innerHTML浏览器就会解析这个标签图片加载失败触发onerror脚本就执行了。换成script标签虽然现代浏览器不允许innerHTML里插入的script执行但通过onerror这类事件属性一样能达成攻击效果。这就是为什么现代前端框架几乎不让你直接用innerHTML拼接用户内容。你要处理用户输入就要么用textContent要么先做转义。我最常用的转义方式是替换关键字符function escapeHTML(str) { const div document.createElement(div); div.textContent str; return div.innerHTML; }利用textContent自动转义HTML实体的特性把危险字符变成lt;、gt;这种安全形式再交给innerHTML也不会被解析为标签。这个方法简单但非常实用面试里也经常被问到。4.3 性能差异为什么频繁修改DOM时textContent更占优势浏览器操作DOM不是免费的每一次修改都可能引发布局重新计算。innerHTML赋值时需要把字符串整个解析成节点树再替换掉原有内容而textContent只需要改动文本节点的内容成本低很多。我之前写过一个列表筛选功能每秒钟要根据数据变化更新几百条文本。一开始用innerHTML拼接整个列表的HTML字符串页面明显掉帧后来改成用textContent精准更新每条数据的文本节点流畅度立刻恢复正常。原因就是前者每次都要销毁并重建一大堆DOM节点而后者只是在已有节点上改文本内容。这个经验在大列表、高频更新的场景非常重要。能用textContent解决的需求坚决不用innerHTML。反过来说如果你要频繁往容器里“整体替换一大块结构”innerHTML也在可接受范围内毕竟它内部做了优化。关键要分清场景。5. 属性操作setAttribute、getAttribute与直接属性访问的微妙差别5.1 什么时候用setAttribute什么时候直接用点语法修改元素属性有两种方式一种是使用setAttribute(attr, value)和getAttribute(attr)另一种是直接访问元素的属性属性比如el.id newId、el.href https://xxx.com。大多数情况下我推荐直接属性访问因为它是JavaScript原生对象属性的标准操作性能也更好。但有个重要例外标准属性之外的自定义属性直接点语法访问不到。比如你给一个div加了一个>el.className el.className active;classList的出现彻底改善了这种体验。它是DOMTokenList类型的对象专门管理元素的类列表提供了add、remove、toggle、contains这些方法。日常用得最多的是这几个el.classList.add(active); // 追加一个类 el.classList.remove(active); // 删除一个类 el.classList.toggle(active); // 有就删没有就加完美适配开关场景 el.classList.contains(active); // 返回是否包含这个类还有个非常好用的技巧是toggle接收第二个参数作为强制开关条件。比如根据一个布尔值决定是否高亮el.classList.toggle(highlight, score 60);这行代码在score 60时添加类否则移除类省去了手动写if/else的代码。类似地add可以接收多个类名一次传几个都行。5.3>div>const userCard document.querySelector(div[data-user-id123]); console.log(userCard.dataset.userId); // 123 console.log(userCard.dataset.role); // admin用dataset存数据特别适合在事件处理中传递与元素绑定的业务信息。比如列表项都绑了点击事件想分辨点击的是哪一项把id存进>const box document.getElementById(box); box.style.backgroundColor #f00; box.style.width 200px; box.style.transform translateX(20px);注意样式属性名使用驼峰式background-color要写成backgroundColormargin-left要写成marginLeft。当需要根据JavaScript里的变量实时设置具体数值时比如拖拽进度条、动态定位弹窗、随着滚动改透明度直接操作style是必要的。此时样式值是一个计算后的动态结果没法提前写死在CSS类里。style对象还有两个用得相对少、但很好用的附属能力setProperty允许设置带优先级的属性removeProperty删除内联样式。前者在需要覆盖第三方库样式时偶尔用得上。6.2 切换类的正确姿势业务状态交给class具体外观交给CSS开发实践中我最推荐的样式控制方式是把“业务状态”映射到“CSS类”而不是直接修改一大堆具体样式。举个例子做一个下拉菜单展开效果你可以这样写menuButton.addEventListener(click, () { dropdown.classList.toggle(open); });然后CSS里定义.dropdown { opacity: 0; visibility: hidden; transform: translateY(-10px); transition: all 0.3s ease; } .dropdown.open { opacity: 1; visibility: visible; transform: translateY(0); }切换类的好处非常明显。第一所有与展开相关的样式集中放在CSS规则里维护方便第二可以充分利用CSS的transition过渡动画而直接改style.display往往生硬跳跃第三类的命名本身具有语义代码可读性大幅提升。你把一段逻辑和样式关联起来比在JavaScript里拼各种CSS属性要可维护得多。6.3 样式操作实战动态换肤的小案例我做过一个最简单的换肤功能用classList加>body>const themeBtn document.getElementById(themeBtn); themeBtn.addEventListener(click, () { const body document.body; const currentTheme body.dataset.theme; body.dataset.theme currentTheme light ? dark : light; });CSS里再对body[data-themedark]的变量做覆写。整个过程JavaScript只负责维护一个状态值样式层的切换全部交给CSS分工清晰后续加再多主题也只需要扩展CSS。这套思路很值得沿用到更多场景。7. 操作元素时的常见报错与排查思路从“为什么拿到null”说起7.1 最常见的报错at HTMLxxx.innerHTML or Cannot read properties of null查DOM相关的报错排第一名的一定是Cannot read properties of null。这个报错的含义是你在一个null值上读属性了。最典型的情况是使用getElementById或querySelector没找到元素返回了null你却接着对它调方法。比如这样的代码const modal document.getElementById(modal); modal.classList.add(show);一旦页面里没有id为modal的元素modal就是null第二行直接报错。造成这个问题的原因通常有三个。第一是选择器写错了id、class大小写不匹配第二是元素在代码执行时才刚被动态创建还没有插入到文档中第三个原因最常见也最坑脚本在DOM就绪之前就执行了。如果你将script标签放在head里浏览器解析到脚本时body还没加载自然找不到后面的元素。所以一定要记住放置脚本的顺序要么把script标签放在body末尾要么监听DOMContentLoaded事件确保DOM已经解析完成再执行脚本。7.2 排查思路不是代码逻辑问题是时机与结构问题遇到“元素没找到”的情况我建议先按这三个步骤排查。第一步在浏览器控制台手动画输入document.querySelector(你的选择器)看返回的是元素还是null。如果控制台返回null那核心问题就是选择器有问题或者元素真的不存在不用怀疑代码逻辑。第二步检查脚本加载的时机。在script标签前加一个console.log(脚本执行了)同时刷新页面看这个日志是不是出现在DOM渲染完成之前。如果打开控制台看到日志顺序是脚本先执行、元素后渲染那就要调整脚本位置或加事件监听。第三步注意动态渲染场景。如果你的元素是后端接口数据返回后由JavaScript生成的而你直接在页面加载时就去查这个元素当然查不到。这时候你需要在接口回调里、生成元素之后再去绑定操作。这种排查思路比直接搜报错信息有效得多。因为知道“为什么报错”比“怎么消掉报错”重要只有理解了null的来源才能真正避开这个坑。7.3 与上一篇内容的衔接你现在可以做什么到这里你已经掌握了查找元素、遍历节点、读写内容、操作属性和控制样式的全部基本功。现在可以做的事情很多给页面的一组按钮批量绑定事件点击时切换类名做一个轮播图的切换逻辑写一个TODO列表的新增与渲染。这些都可以用本文的API组合出来。下一篇我会接着讲文档元素操作的更进阶部分创建新元素、删除元素、替换节点、插入到指定位置以及事件绑定与冒泡机制。到时候你会发现这一篇建立的“节点与元素”概念是理解后来所有内容的基础。按照我自己带人的经验这一篇里的API用不着刻意背。多写几天代码这些东西会自然变成肌肉记忆。但有几个概念值得反复在心里默念实时集合与静态快照的区别、childNodes包含文本节点、textContent和innerHTML的安全边界、classList优先于直接改style。这四个点你记住了就比很多写了两年代码的人还清楚DOM操作的要点。