资讯动态

JavaScript DOM操作从基础到实战:元素获取、修改与事件绑定全指南

发布时间:2026/9/15 1:38:40 来源:尧图企业网站定制
做前端这几年我有个特别深的体会很多人写JS卡住的往往不是那些花哨的框架、复杂的算法反而是最基础的一环——操作元素。你去看那些面试题、那些线上bug翻来覆去都是getElementById拿不到节点、样式改了不生效、动态生成的按钮点了没反应。JS操作元素说到底是前端一切交互的根基DOM操作不扎实后面学React、Vue也是空中楼阁。这篇博文我打算从实际开发的角度把JS操作元素这条线完整捋一遍从DOM结构认知、元素获取、内容样式属性修改到事件绑定、动态节点、常见坑点最后用一个三级联动下拉菜单的案例把整个流程串起来。不管你是刚入门的前端小白还是写了一阵子但总感觉DOM这部分不够系统的开发者这篇内容都值得花十几分钟读一遍。1. 操作元素之前先搞懂DOM到底是什么结构很多人一上来就背APIgetElementById怎么用、querySelectorAll怎么用背得滚瓜烂熟但真到了页面上元素一多还是不知道从哪儿下手。核心原因就一个没搞懂DOM是个什么东西。1.1 DOM树与元素之间的关系DOM的全称是Document Object Model文档对象模型。你写了一个HTML页面浏览器解析的时候不会把它当成一串纯文本而是会构建成一棵倒着长的树。树的根是document往下是html标签再往下是head和bodybody里面又挂着div、p、ul、li这些节点。这棵树里面每个标签都是一个元素节点每个标签里面的文字是文本节点标签上的class、id这些是属性节点。我们说的“操作元素”本质就是在这棵树上做三件事找到某个节点、修改这个节点的状态、让节点之间产生联动。为什么要理解树这个结构因为你在页面上做的任何操作都逃不开节点之间的关系。比如你想给某个ul的第一个li加样式你有两种思路一是直接从document往下找走一遍父节点到子节点的路径二是先拿到ul再用ul.querySelector(li:first-child)顺着就近的节点找。第二种方式往往更稳因为它的搜索范围缩小了不会受到页面其他部分的影响。1.2 为什么“拿到元素”是第一步也是最重要的一步所有的元素操作第一步永远是“拿到这个元素”。你拿不到元素后面改内容、绑事件全都是空谈。这就好比你想给一个朋友打电话得先有他的号码号码都拨错了后面说什么都没用。我见过不少新手踩过这种坑JS代码写在head里面然后直接document.getElementById(box)结果拿回来一个null折腾半天找不到原因。这就是典型的还没搞懂DOM树的加载顺序。浏览器解析HTML是从上往下一行一行来的你的script在head里先执行了可body里的div还没解析到树上压根没有这个节点你当然拿不到。理解这一点之后很多问题就豁然开朗了。为什么业界普遍推荐把script标签放在body结尾为什么会有DOMContentLoaded这个事件一切都是为了确保一个前提你要操作的元素得先存在于DOM树上。2. 核心实操六种获取元素的方式与选型获取元素的方法说多不多说少不少但每个API背后对应的是不同的使用场景。用对了事半功倍用错了轻则性能受影响重则直接拿不到元素。2.1 从getElementById到querySelectorAll老一批的DOM获取API有这几个getElementById、getElementsByClassName、getElementsByTagName。其中getElementById用得最多因为id在页面里是唯一的拿到就是那一个元素不会有歧义。// 经典方式 var box document.getElementById(box); var items document.getElementsByClassName(item); var divs document.getElementsByTagName(div);这里要特别提醒一个容易忽略的点getElementsByClassName和getElementsByTagName拿到的是HTMLCollection这是一个“活集合”。什么意思就是它引用的不是某一时刻的快照而是一个动态的引用。你拿到这个集合之后哪怕后面往页面里新加了一个匹配的节点这个集合会自动把新节点加进去。var divs document.getElementsByTagName(div); console.log(divs.length); // 假设现在是 3 // 往页面里新增一个 div var newDiv document.createElement(div); document.body.appendChild(newDiv); console.log(divs.length); // 变成 4集合是动态的这个特性在某些场景下很有用但如果你不小心在循环里依赖这个length做判断很容易写出死循环来。我建议新手在一开始就养成习惯如果只是临时遍历一下用完之后不需要再关注就尽量用querySelectorAll它返回的是NodeList静态快照不会被后续的DOM变化影响。2.2 现代推荐的querySelector系列querySelector和querySelectorAll是后来出的API最大的优势是参数直接写CSS选择器怎么给元素写样式就怎么选元素没有学习成本。// CSS 选择器方式 var box document.querySelector(#box); var firstItem document.querySelector(.item); var allItems document.querySelectorAll(.list .item); var checkedInput document.querySelector(input[namegender]:checked); var directChild document.querySelector(.container ul li);querySelectorAll返回的是NodeList虽然它也有forEach方法可以直接遍历但它里面的元素不是动态的。如果你先拿到了一个NodeList然后往DOM里追加了匹配元素这个NodeList不会跟着变。这一点和HTMLCollection正好相反实际开发中要注意区分。选型上的建议是这样的要拿单个元素、而且明确知道id直接用getElementById它是所有方法里性能最好的要拿一批符合复杂条件的元素用querySelectorAll要实时跟踪某个集合的变化用getElementsByClassName这类活集合。大部分业务场景下getElementById加querySelectorAll已经能覆盖九成以上的需求。2.3 几种特殊节点的获取方式除了常规的标签节点DOM里还有一些特殊的节点获取方式也值得记一下。// 表单相关 var form document.getElementById(myForm); var usernameInput form.elements[username]; // 按name属性直接取表单控件 var firstInput form.elements[0]; // 文档级快捷方式 var body document.body; var head document.head; var title document.title; // HTMLCollection 快捷集合 var allLinks document.links; // 页面上所有带 href 的 a 标签 var allImages document.images; // 页面上所有 img 标签 var allForms document.forms; // 页面上所有 form 标签form.elements这个API特别实用你不用写一长串的选择器只要表单里的控件有name属性就能直接通过名字取到。在写表单验证或者收集提交数据的时候这个特性可以省不少事。3. 核心实操动手改元素的内容、样式、属性与结构拿到元素之后接下来的重头戏就是改了。这一步是JS操作元素里花样最多、也最容易踩坑的地方。我按内容、样式、属性、结构四个方面逐个拆解。3.1 修改内容textContent、innerHTML、innerText怎么选改元素内容主要有三个属性可以用textContent、innerText、innerHTML。很多新手搞不清它们的区别其实从名字就能猜个大概。var box document.getElementById(box); // 纯文本方式把里面的内容当作纯文本处理 box.textContent 你好 b世界/b; // 页面上显示的是你好 b世界/b尖括号会被转义 // innerHTML方式把字符串当作HTML解析 box.innerHTML 你好 b世界/b; // 页面上显示的是你好 世界其中“世界”两个字是加粗的 // innerText方式和textContent类似但会触发重排 box.innerText 你好 世界;textContent和innerText都可以设置纯文本不同的是读取的时候textContent会返回所有子节点的文本包括script和style里面的内容innerText只返回“渲染出来”的可见文本受CSS影响比如display:none的元素里的文字textContent能读到innerText读不到。从安全角度考虑如果内容是用户输入的或者来自接口、不确定来源一律用textContent不要用innerHTML。innerHTML注入会导致XSS攻击这是前端安全里非常基础但极其重要的一条红线。我在实际开发中的习惯是能不用innerHTML就不用遇到复杂结构宁可动态创建节点。3.2 修改样式style、className、classList的边界样式操作有三种手段行内样式、切换类名、操作classList。它们的使用场景完全不同。var box document.getElementById(box); // 方式一直接修改行内样式 // 注意CSS属性名要转成驼峰式margin-top写成marginTop box.style.display none; box.style.marginTop 20px; box.style.backgroundColor #f5f5f5; box.style.setProperty(--main-color, #333); // 自定义属性用setProperty // 方式二切className本质是切换整个类名 box.className box box-active box-disabled; // 方式三classList推荐粒度更细 box.classList.add(box-active); box.classList.remove(box-disabled); box.classList.toggle(box-active); box.classList.contains(box-active); // 判断是否包含某个类返回布尔值直接操作style适合动态计算的情况比如跟随鼠标移动、拖拽时的实时定位这种场景下每次都要算具体数值用style是最合理的。但如果是固定的一组样式变化比如按钮的选中态、弹窗的显隐优先用classList切类名。为什么推荐类名方案因为样式应该归CSS管行为归JS管各司其职。你把所有样式写在CSS文件里要改样式的时候只动CSS不用碰JS维护成本低得多。而且classList的add、remove、toggle语义清晰不会出现误覆盖的问题。另外提醒一个高频坑通过style读取样式时只能读到自己通过JS设置的行内样式读不到CSS样式表里定义的值。想知道元素最终的渲染样式要用getComputedStyle。var box document.getElementById(box); var styles getComputedStyle(box); console.log(styles.height); // 返回解析后的实际高度 console.log(styles.backgroundColor); // 里面是rgb格式3.3 修改属性getAttribute、setAttribute与dataset一个元素的属性分两类一类是标准HTML属性比如id、class、href、src、value一类是自定义属性比如data-id、data-type。对这两种属性的操作方式不太一样。var link document.getElementById(myLink); // 标准属性可以直接用点语法 link.href https://example.com; link.target _blank; link.id newId; // 更通用的方式getAttribute / setAttribute / removeAttribute link.setAttribute(data-id, 12345); var id link.getAttribute(data-id); link.removeAttribute(data-id); // 自定义>// 创建一个新的 div var newDiv document.createElement(div); newDiv.className card; newDiv.textContent 这是一张新卡片; // 把新元素挂到指定父节点下 var container document.getElementById(container); container.appendChild(newDiv); // 在指定位置插入把newDiv插到refNode前面 var refNode document.getElementById(ref); container.insertBefore(newDiv, refNode); // 更现代的写法insertAdjacentHTML / insertAdjacentElement // 位置参数支持 beforebegin、afterbegin、beforeend、afterend container.insertAdjacentHTML(beforeend, div classcard新卡片/div); // 删除节点 container.removeChild(newDiv); // 或者现代一点的写法 newDiv.remove();创建节点有一个性能大坑新手一定要知道如果循环里每次创建节点都立刻插入DOM浏览器就要反复重排重绘页面会卡得不行。正确的做法是先用文档片段document.createDocumentFragment()攒一批节点最后一次性插入。var fragment document.createDocumentFragment(); for (var i 0; i 1000; i) { var item document.createElement(li); item.textContent 第 i 项; fragment.appendChild(item); } document.getElementById(myList).appendChild(fragment);这个片段不是真实的DOM节点往里面添加再多的子节点也不会触发页面更新。等到全部组装完再一次性塞进真实DOM浏览器只需要重排一次性能差距肉眼可见。4. 从零做一个案例省市区三级联动下拉菜单理论说了这么多我带你完整实现一个非常经典的需求省市区三级联动下拉菜单。这个案例几乎覆盖了JS操作元素里所有核心操作获取元素、修改内容、创建节点、事件绑定、遍历数据做完这个你对DOM操作的掌握会上一个台阶。4.1 HTML骨架与数据设计先搭页面的骨架。三个下拉框分别放省、市、区。用最简单的方式!DOCTYPE html html langzh-CN head meta charsetUTF-8 title省市区三级联动/title style select { width: 180px; height: 36px; font-size: 14px; margin-right: 12px; } /style /head body div classarea-selector select idprovince option value请选择省份/option /select select idcity option value请选择城市/option /select select iddistrict option value请选择区县/option /select /div script src./area-data.js/script script src./main.js/script /body /html省市区数据在真实项目里一般来自后端接口这里为了演示我用一个简化版的数据结构放在单独的area-data.js文件里// area-data.js var areaData [ { name: 浙江省, cities: [ { name: 杭州市, districts: [西湖区, 滨江区, 余杭区, 拱墅区] }, { name: 宁波市, districts: [海曙区, 江北区, 镇海区, 鄞州区] } ] }, { name: 江苏省, cities: [ { name: 南京市, districts: [玄武区, 秦淮区, 鼓楼区, 栖霞区] }, { name: 苏州市, districts: [姑苏区, 吴中区, 相城区, 工业园区] } ] } ];数据结构设计是联动的核心。我采用嵌套结构省下面挂城市数组城市下面挂区县数组。这样每次选择变化只需要到对应层级去取数据不需要额外匹配查找。4.2 渲染函数封装让逻辑可复用写三个渲染函数分别负责填充省份、城市、区县下拉框。这里的关键思路是“数据驱动视图”——先准备好数据再根据数据创建option节点塞进select元素里。// main.js var provinceSelect document.getElementById(province); var citySelect document.getElementById(city); var districtSelect document.getElementById(district); // 渲染省份列表 function renderProvinces() { provinceSelect.innerHTML ; // 清空 provinceSelect.appendChild(createOption(, 请选择省份)); areaData.forEach(function (item) { provinceSelect.appendChild(createOption(item.name, item.name)); }); } // 根据选中的省份渲染城市列表 function renderCities(provinceName) { citySelect.innerHTML ; citySelect.appendChild(createOption(, 请选择城市)); var province areaData.find(function (item) { return item.name provinceName; }); if (!province) return; province.cities.forEach(function (city) { citySelect.appendChild(createOption(city.name, city.name)); }); } // 根据选中的城市渲染区县列表 function renderDistricts(cityName) { districtSelect.innerHTML ; districtSelect.appendChild(createOption(, 请选择区县)); var province areaData.find(function (item) { return item.name provinceSelect.value; }); if (!province) return; var city province.cities.find(function (item) { return item.name cityName; }); if (!city) return; city.districts.forEach(function (district) { districtSelect.appendChild(createOption(district, district)); }); } // 工具函数创建 option 元素 function createOption(value, text) { var option document.createElement(option); option.value value; option.textContent text; return option; }createOption这个工具函数值得多说一句。你会发现后面每个渲染函数里都需要创建option与其重复写createElement、赋值、appendChild这三行不如抽出来复用。这也是JS操作元素的一个通用思路凡是重复出现的DOM创建逻辑都可以封装成函数。这样做的好处不仅仅是少写几行代码更在于后续如果要调整option的结构只需要改一个地方。4.3 事件绑定与联动逻辑视图渲染函数写好了接下来就是把它们串起来。核心联动逻辑是省份变化时重新渲染城市同时清空区县城市变化时重新渲染区县。// 绑定省份变更事件 provinceSelect.addEventListener(change, function () { var provinceName this.value; renderCities(provinceName); // 省份变化后清空区县选择 districtSelect.innerHTML ; districtSelect.appendChild(createOption(, 请选择区县)); }); // 绑定城市变更事件 citySelect.addEventListener(change, function () { var cityName this.value; renderDistricts(cityName); }); // 初始化 renderProvinces();这段代码有一个细节省份变化的时候除了要渲染新的城市列表还必须手动清空区县下拉框。为什么因为省份都变了之前选中的区县就已经失效了。如果你只更新城市列表、不动区县就会出现“省份选浙江、城市选江苏南京、区县还停留在西湖区”这种数据错乱。事件绑定这里我用了addEventListener而不是直接在HTML里写οnchangexxx()。两者都能实现效果但addEventListener的优点是同一个元素的同一个事件可以绑定多个处理函数而且把逻辑写在JS文件里HTML保持干净符合“结构与行为分离”的原则。到这里一个完整的三级联动就做好了。这个案例虽小但实实在在地用上了前面讲的所有知识点获取元素、遍历数据、创建节点、清空列表、事件绑定。每一步都是JS操作元素的基础功。5. 进阶技巧事件委托、this指向与常见的坑案例做完了但实际开发中你还会遇到一些更棘手的情况比如动态生成的元素绑定不上事件、函数里this指向莫名其妙变了、循环里绑事件变量错乱。这些是JS操作元素里最容易劝退新手的几个坎我按实战经验一个个说清楚。5.1 动态创建的元素怎么绑定事件比如你有一个列表用户点“加载更多”按钮从接口拿到新数据渲染出一批新的li每个li点击都能弹出自己的内容。如果你在页面初始化时给所有li绑了事件那新加的li一定绑不上因为它们渲染的时候绑定事件的那段代码早就执行完了。这时候有两条路一是渲染完之后重新遍历新节点再绑一遍事件二是用事件委托把事件绑定在父容器上利用事件冒泡机制让父容器统一处理。事件委托是更优雅的方案var list document.getElementById(myList); list.addEventListener(click, function (event) { var target event.target; // 用 closest 向上查找最近的 li兼容你点击的是li内部的span或a标签的情况 var li target.closest(li); if (!li) return; var index Array.prototype.indexOf.call(list.children, li); console.log(点击了第, index, 个列表项); });event.target是你真正点击的那个元素closest方法会从它开始往上找一直找到匹配选择器的祖先元素。这样无论点击的是li本身还是它里面的文字、图片、子标签都能准确找到对应的li。而且这种方式天然支持动态元素——只要父容器没有消失任何新加的子元素点击事件都会被捕获到。事件委托的核心优势是少绑事件、内存占用低、支持动态节点。但要注意如果事件发生在子元素上stopPropagation会导致冒泡中断委托就失效了需要单独评估每个场景是否适合用委托。5.2 循环里绑定事件的闭包陷阱经典的坑长这样var btns document.querySelectorAll(.btn); for (var i 0; i btns.length; i) { btns[i].addEventListener(click, function () { console.log(i); // 你以为输出的是 0、1、2实际全是 3 }); }这段代码在ES5时代几乎是必踩的坑。原因是var声明的i是函数级作用域循环结束后i已经变成了3你点击的时候读到的自然是3。解决办法有两个方向一是用let声明i让它每次循环都有独立的块级作用域二是用闭包把i的值“锁住”。// 方式一let现在的主流做法 for (let i 0; i btns.length; i) { btns[i].addEventListener(click, function () { console.log(i); }); } // 方式二自定义函数传参相当于复制了一份i for (var i 0; i btns.length; i) { (function (index) { btns[index].addEventListener(click, function () { console.log(index); }); })(i); }我在实际项目中从ES6普及之后就统一用let。这个坑主要出现在旧代码改造或者维护老项目的时候。不管怎样理解了变量作用域和闭包机制这类问题就能举一反三地解决。5.3 this指向在事件回调里的变化还有一个让新手头疼的问题事件回调函数里的this到底指谁addEventListener绑定的事件回调this默认指向被绑定的那个元素这没问题。但如果回调函数里面还套了一层普通函数、或者你把方法作为回调传出去this就变了。var obj { name: 测试, init: function () { document.getElementById(btn).addEventListener(click, function () { // 这里 this 指向按钮元素不是 obj console.log(this.name); // undefined }); } }; // 解决办法箭头函数不绑定this往上一层层找找到obj var obj2 { name: 测试, init: function () { document.getElementById(btn).addEventListener(click, (event) { console.log(this.name); // 测试 console.log(event.target); // 通过event.target访问按钮 }); } };箭头函数在事件回调里的使用要注意它的this是词法作用域决定的用起来非常方便但代价是你没法再用this拿到当前绑定元素了。如果既要箭头函数、又要当前元素可以通过event.currentTarget或者event.target来获取不要混用。我一般推荐事件回调里统一通过event对象来拿目标元素这样不管this怎么变代码行为稳定。6. 常见问题与排查技巧实录最后这部分我把这些年做JS操作元素时踩过的坑、以及排查问题的方法总结一下。好记性不如烂笔头这些问题几乎每天都在各种技术群里反复出现。6.1 典型问题速查表问题现象可能原因解决方式getElementById返回nullscript在DOM解析前执行了把script放到body末尾或者监听DOMContentLoaded样式修改了但不生效属性名写错或类名优先级不够检查驼峰命名backgroundColor不是background-color用浏览器F12看样式层的优先级动态添加的元素点击无响应事件绑定发生在元素创建之前改为事件委托绑到共同的父级上或者创建后再绑定innerHTML设置了一段含特殊字符的内容页面乱了特殊字符被当作HTML解析了改为textContentsetAttribute(value,)设置输入框的值页面上数字还在混淆了HTML属性和DOM属性直接用element.value赋值循环里绑事件点击全都输出同一个值闭包变量共享用let声明循环变量或用IIFE传参textContent能读到的内容innerText读不到元素或子元素是隐藏状态用textContent或变换思路不要依赖innerText读隐藏内容NodeList.forEach不能用某些老旧环境不支持先Array.from(nodeList)转成真数组或用for循环修改了style.background但不生效没有指定background-color简写属性在某些浏览器解析有差异用style.backgroundColor或style.cssText6.2 我的调试心法遇到元素操作不生效的情况我一般按这套流程排查基本能解决绝大多数问题。第一步确认有没有拿到元素。在操作之前console.log一下变量看是null还是元素对象。如果是null大概率是执行时机问题元素还没渲染。如果拿到了元素继续下一步。第二步确认操作符属性名有没有写错。style.backgroundColor写对了没驼峰有没有漏className合在一起了没这些低级错误占了一半以上。第三步确认事件有没有绑上。在事件回调里第一行console.log(xxx被触发了)点击看控制台有没有输出。没输出就是事件没绑上或者选择器选错了元素输出了但逻辑不对就按代码一行行排查。第四步确认被其他样式覆盖了。很多“操作了但看不到效果”的情况其实是CSS优先级在捣乱。你设置了display:block但某个高优先级的类写了display:none。用F12看计算样式一切明了。这套排查思路我在工作里总结出来之后几乎没有遇到过解决不了的元素操作问题。关键是别瞎猜用console.log把每一步的执行情况打出来问题很快就水落石出。最后再分享一个小体会。我见过太多人把时间花在背各种API上总觉得记不全就没底。实际上JS操作元素这块API翻来覆去就那么十几个真正决定水平的是你怎么组织代码、怎么避免踩坑。把上面这些坑都趟过一遍之后你再去学框架、写组件会发现很多东西的原理都是相通的。基础打牢了后面会越走越快。

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

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

免费获取报价