很多人在学习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,注释节点是8,document本身是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、getElementsByTagName
DOM操作的第一步永远是“找到目标”。老牌方法里,最常用的是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是document,parentElement却是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: none,innerText读到的是空字符串,而textContent仍然能读到文字。此外innerText的读取会触发一次重排,因为浏览器需要计算样式来知道哪些文本是“可见”的。性能上不如textContent。
什么时候用哪个?我的规矩是这样:
- 只想读/写纯文本,不需要解析标签:用
textContent,操作快、语义清楚。 - 确实要插入一段带格式的HTML片段,并且这段HTML来源可信:用
innerHTML。 - 想获得用户“肉眼看得到的文字”,例如复制到剪贴板那种效果:用
innerText。
4.2 innerHTML与XSS:用户输入是个危险品
如果你做了Web开发却不知道XSS(跨站脚本攻击)这个词,那很危险。innerHTML接收的字符串一旦包含用户输入,就存在脚本注入风险。
举一个最常见的场景:留言板功能,用户输入了<img src=x onerror="alert('攻击')">,你直接把输入塞进innerHTML,浏览器就会解析这个标签,图片加载失败触发onerror,脚本就执行了。换成<script>标签虽然现代浏览器不允许innerHTML里插入的script执行,但通过onerror这类事件属性一样能达成攻击效果。
这就是为什么现代前端框架几乎不让你直接用innerHTML拼接用户内容。你要处理用户输入,就要么用textContent,要么先做转义。我最常用的转义方式是替换关键字符:
function escapeHTML(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }利用textContent自动转义HTML实体的特性,把危险字符变成<、>这种安全形式,再交给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-id="123"]'); 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要写成backgroundColor,margin-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-theme="dark"]的变量做覆写。整个过程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操作的要点。