
你第一次接触“DOM”这个词大概是在某个前端教程的目录里。旁边可能还跟着“BOM”“事件”“节点”这些让人望而生畏的名词。我当时学的时候也绕了不少弯子总觉得这是一堆抽象的理论直到后来真正上手写页面、做交互才慢慢意识到DOM 其实就是浏览器给前端开发者准备的一大盒乐高积木网页上每一个能看见、能点击、能滚动的东西都是这盒积木里的一块零件。这篇博文我就用“玩乐高”的思路把 DOM 从原理到实操完整拆给你看。没有那些绕来绕去的术语轰炸只有我从项目里踩出来的经验和可以直接抄走的方法适合刚接触前端不久、想把原生 JavaScript 页面操作彻底吃透的朋友。1. 先从盒子里倒出积木理解DOM树与节点在动手拼任何东西之前你得先知道自己手里有什么。DOM 的全称是 Document Object Model中文叫“文档对象模型”。听起来很高大上但说白了它就是浏览器把一段 HTML 文本解析成一棵由节点组成的树。这棵树上的每个分支、每片叶子都是一个节点对应页面里的一个元素、一段文本、一个属性甚至一个注释。你在开发者工具里看到的 Elements 面板展示的就是这棵树的实时形态。1.1 乐高说明书DOM树到底长什么样很多人学 DOM 卡住是因为只记 API 名字却没有在脑子里建立起“树”这个结构。我习惯把 HTML 文档想象成一张乐高拼装说明书最顶层是document它是整棵树的根相当于说明书的总目录。document下面冒出html这个最大的框架它是所有页面内容的容器。html里又分出head和body两个主干。head是看不见的“信息卡”里面放着 meta、title、style、script 这类元信息body是真正摆出来给用户看的“展示区”。body里再继续嵌套div包着pp里又有span一层套一层像俄罗斯套娃又像积木塔。节点的关系也有专门叫法上下级叫父子节点同一父节点下的元素叫兄弟节点层层向上叫祖先节点。比如下面这段最简单的 HTMLdiv idparent p classchild第一段/p p classchild第二段/p /div这里div是父节点两个p是它的子节点两个p互为兄弟节点。等你用 JavaScript 操作页面时几乎做的所有事情都是在这棵树上找节点、加节点、改节点、删节点。清楚了树的结构后面所有 API 都只是“在树上走位”的技巧而已。1.2 认识几种最常用的“积木块”乐高积木有不同形状DOM 节点也一样。初学阶段你只需要重点分清这几种元素节点对应 HTML 标签比如div、p、a。这是最常操作的对象它的 nodeType 是 1。文本节点标签里的文字内容比如p你好/p里的“你好”。它的 nodeType 是 3。容易被忽略但改文字时绕不开它。属性节点像id、class、href这些标签上的属性在 DOM 树里也以节点的形式存在nodeType 是 2。不过日常开发很少直接操作属性节点都是通过元素节点的 API比如getAttribute、setAttribute来间接处理。注释节点HTML 注释!-- 注释 --nodeType 是 8基本不用管但要知道它的存在。我见过不少新手用childNodes遍历子节点时被一堆文本节点搞懵就是因为没搞懂childNodes会把文本节点、注释节点也一并返回。这个时候可以用children属性它只返回元素节点省掉很多麻烦。这是一个非常实在的使用细节后面实操环节我会反复提醒。2. 学会挑积木选择器怎么用才顺手玩积木第一步是从一堆零件里挑出你要的那块。操作 DOM 也一样不管你后续要改样式、绑事件还是删元素都得先精准拿到目标节点。原生 JavaScript 里找节点的方式五花八门我用下来最顺手、也最推荐大家掌握的主要就是下面这两类。2.1 选择器的三种基础写法第一类是“老三家”选择器虽然代码不长但各有脾气document.getElementById(id)按 id 找返回单个元素或 null。名字虽然长但它是所有查找方式里最快的一个因为 id 在页面里理论上唯一。document.getElementsByClassName(class)按 class 找返回一个 HTMLCollection也就是类数组的集合。注意它和querySelectorAll不同这个集合是“活的”页面里的元素一变化集合内容会跟着变。document.getElementsByTagName(div)按标签名找同样返回 HTMLCollection。第二类是更现代、也更通用的querySelector和querySelectorAlldocument.querySelector(选择器)传入一个 CSS 选择器字符串返回匹配的第一个元素。document.querySelectorAll(选择器)返回所有匹配元素的 NodeList可以用forEach直接遍历。从实际体验来看querySelector系列是我日常开发中使用频率最高的原因有三个参数就是 CSS 选择器比如.class、#id、[data-typelist]、div p写起来顺滑不需要记各种“老三家”的拼写差异。返回值更规范NodeList有forEach方法配合箭头函数写起来干净利落。查找范围更灵活不仅可以从document开始查还能从任意元素节点开始查比如container.querySelector(.item)就是在容器内查找。但有一说一getElementById在性能敏感的场景比如循环里反复查找依然是首选因为它足够快。实际项目里我通常是纯 id 用getElementById其他情况一律querySelector/querySelectorAll兼顾可读性和性能。2.2 为什么我推荐querySelector系列很多初学者会问既然有那么多方法为什么不只学一个我的答案是你当然可以只学querySelector系列把 CSS 选择器玩熟了90% 的查找需求都能覆盖。剩下 10% 的需求比如用 id 高频查找再用getElementById补充。这样学习成本最低代码也更统一。分享一个具体场景。我做一个多 tab 切换的页面时通常会给每个 tab 加自定义属性button>const tabButtons document.querySelectorAll([data-tab]);这样不管以后 tab 增加还是减少JavaScript 代码一行都不用改。这就是选择器抽象带来的好处。再比如表单校验时要拿到错误提示元素如果用老方法可能要写很长一串距离表达式用querySelector一个form .error-message就搞定了。这里插一个容易踩的坑querySelectorAll返回的 NodeList 是静态的和getElementsByClassName返回的动态集合不一样。也就是说如果你先存了一个const list document.querySelectorAll(.item)然后在页面里新增了一个.item元素list里并不会自动加上它。如果你需要“实时反映页面状态”的集合用getElementsByClassName如果只是拿一次快照做操作querySelectorAll更安全迭代的时候不会因为集合变化而卡死。3. 动手搭积木增删改查的实操核心挑好积木块之后真正好玩的来了把积木拼起来、拆掉、换一块。这对应到 DOM 上就是节点的新增、删除、修改和查询。这一节我会把高频操作全部过一遍然后带大家做一个完整的待办清单小案例把增删改查全部串起来。3.1 造积木createElement与innerHTML创建新节点有两种主流方式。第一种是原生 API 一步步来const div document.createElement(div); div.className card; div.textContent 我是一张卡片; container.appendChild(div);这种方式的优点是安全、可控缺点是代码啰嗦。如果结构复杂一点比如卡片里还要套一个标题、一个描述、一个按钮那createElement要写一长串。第二种方式是直接用innerHTML拼接字符串const html div classcard h3标题/h3 p描述文字/p button classbtn操作/button /div ; container.innerHTML html;innerHTML写起来非常爽尤其配合模板字符串一次性能生成一大块结构。但这里我必须敲黑板提醒永远不要用innerHTML拼接用户输入的内容。比如用户提交了一段文字你直接container.innerHTML p userInput /p用户输入里如果带了img srcx onerroralert(1)这种内容脚本就会在页面里执行这就是所谓的“DOM 型 XSS”。安全是前端开发不可逾越的红线。想用 HTML 模板又不想有安全风险可以用textContent插入文本或者对用户输入做转义。后面我会专门用一节详细讲这个问题。3.2 拼和拆appendChild、insertBefore、removeChild创建节点只是第一步把它放到正确的位置才是关键。最常用的三个方法parent.appendChild(child)把 child 追加到 parent 的最后一个子节点。parent.insertBefore(newChild, referenceChild)把 newChild 插到 referenceChild 前面。注意它没有appendAfter所以想插到某元素后面得先找它的下一个兄弟节点再插。parent.removeChild(child)从 parent 中移除 child。但更常用的是child.remove()现代浏览器都支持直接在自己身上调用即可。还有两个替换相关的方法parent.replaceChild(newChild, oldChild)以及属性层面的一对万能接口element.setAttribute(attr, value)和element.getAttribute(attr)。不过对于class、style、value、href这类常用属性原生属性操作比setAttribute更高效比如element.className、element.style.display、input.value。这里有一个我早期反复踩的坑insertBefore插入已经存在于页面里的节点时它不会复制节点而是先把原位置的节点移除再插到新位置。也就是说同一个节点不可能同时出现在页面的两个地方。如果你确实需要“复制”出多份要用node.cloneNode(true)参数true表示深克隆会连同子节点一起复制。3.3 换个颜色classList与属性操作实际操作中改动样式不推荐直接写在style属性里而是通过切换 class 来控制。这个习惯养成越早越好因为它能让你把样式和逻辑分离后面维护起来省很多心。classList提供了一组非常好用的方法element.classList.add(active)添加 class。element.classList.remove(active)移除 class。element.classList.toggle(active)有则删、无则加最常用。element.classList.contains(active)判断是否包含某个 class返回布尔值。比如点击按钮高亮我只需要写button.addEventListener(click, () { card.classList.toggle(active); });CSS 里定义好.active的样式逻辑和视觉完全分离。这也是 React、Vue 这类框架底层韵味的雏形数据变化驱动 class 变化class 变化驱动样式变化。3.4 一整个场景做一个待办清单我们把这些 API 串起来做一个极简但五脏俱全的待办清单。先写 HTML 骨架div idtodo input idinput typetext placeholder输入待办事项 / button idadd添加/button ul idlist/ul /div然后写 JS 逻辑const input document.getElementById(input); const addBtn document.getElementById(add); const list document.getElementById(list); function addItem() { const text input.value.trim(); if (!text) return; const li document.createElement(li); li.textContent text; const delBtn document.createElement(button); delBtn.textContent 删除; delBtn.className del; delBtn.addEventListener(click, () li.remove()); li.appendChild(delBtn); list.appendChild(li); input.value ; } addBtn.addEventListener(click, addItem); input.addEventListener(keydown, (e) { if (e.key Enter) addItem(); });这段代码虽然短但把创建节点、设置文本、绑定事件、追加节点、删除节点全用上了。你把它跑起来就是一个能用的待办工具。别急着上框架先把这种原生的肌肉记忆练出来后面理解 React 的虚拟 DOM、理解 Vue 的模板编译都会轻松很多。4. 让积木动起来事件绑定与事件委托页面之所以“活”是因为用户能和它交互点击、输入、滚动、拖拽。这就是事件系统要解决的问题。DOM 里的事件机制看着简单真正用好却有不少门道尤其是事件冒泡和事件委托几乎是面试必问、实战必用的核心点。4.1 事件绑定addEventListener的细节现代前端绑定事件基本只用addEventListener。它的好处是可以给同一个元素、同一个事件类型绑定多个处理函数而且可以控制是否在捕获阶段执行还能用removeEventListener精确解绑。基本写法const btn document.querySelector(#btn); btn.addEventListener(click, function (event) { console.log(event.target); });常用的事件类型很多click、dblclick、mouseenter、mouseleave、input、change、submit、keydown、keyup、scroll、resize等等。做移动端还有touchstart、touchmove、touchend。关于event对象新手最容易混淆的是target和currentTarget。我一句话给你讲明白target是用户实际点击的那个元素currentTarget是监听事件的那个元素。如果监听器和实际点击的是同一个元素两者相同如果用了事件委托两者就不同。这个区别在后续事件委托里尤其重要。还有一个解除绑定的细节addEventListener传入的 handler 必须是有名字的函数才能用removeEventListener解绑。如果传的是匿名函数你是永远解不掉它的只能把整个元素替换掉。4.2 事件委托只绑定一次搞定一堆按钮回到待办清单例子。每个删除按钮都单独绑定了事件。这个例子元素少没问题。但如果列表有一万个项每个项都绑一个事件处理器内存和性能都会受影响而且每新增一项还得重新绑定一次。事件委托就是解决这个问题的经典方案。原理很简单利用事件冒泡让子元素的事件冒泡到父元素上由父元素统一处理。代码变成这样list.addEventListener(click, (event) { if (event.target.classList.contains(del)) { event.target.closest(li).remove(); } });这里只用了一个click监听器却管住了整个列表里所有删除按钮。新加的项不需要额外绑定老项的删除逻辑也统一在一处维护。我把这种做法称为“给积木塔装了一个总开关”。实际业务里表格的操作列、列表的点赞按钮、导航菜单的点击都适合用事件委托。很多人问事件委托的性能收益到底有多大。我测试过一个 5000 行的列表逐行绑定事件在低端手机上初始化明显卡顿改成委托后初始化时间从 40ms 级别降到 2ms 级别内存占用也少很多。虽然不是每次都这样天差地别但养成委托的习惯前端工程的健壮性会有显著提升。5. DOM性能与安全别把积木撒一地拼积木的时候有个道理一个动作摆得稳胜过反复推倒重来。DOM 操作也一样频繁改写页面结构会导致浏览器反复计算布局、重新绘制这就是所谓的“重排”和“重绘”。操作不当页面会明显卡顿。同时DOM 安全也是不可回避的话题尤其是现在很多前端框架都强调“默认转义”但原生操作里一不小心就会中招。5.1 重绘重排与DocumentFragment浏览器渲染页面的过程可以简化成解析 HTML 生成 DOM 树解析 CSS 生成样式规则两者结合生成渲染树再计算每个节点的位置布局也叫重排/reflow最后绘制到屏幕上重绘/repaint。当你用 JavaScript 修改 DOM 时如果只改了颜色、背景图、可见性等不影响布局的属性浏览器只要重绘即可开销较小。如果改了宽高、位置、隐藏显示、字体大小、增删节点等影响布局的属性浏览器就要重新计算布局这就是重排开销大得多。避免频繁重排有几个实用套路。第一个套路是批量修改把多次 DOM 写操作用DocumentFragment合并。DocumentFragment是一个“离线”的容器你可以往里面添加节点它不会触发页面重排等所有节点都放好了再一次性插入真实 DOM。看代码const fragment document.createDocumentFragment(); for (let i 0; i 1000; i) { const li document.createElement(li); li.textContent 第 i 项; fragment.appendChild(li); } list.appendChild(fragment);这段代码只触发一次重排而不是一千次。第二个套路是临时隐藏元素在display: none状态下操作操作完再显示出来。这个适合要对一个复杂模块做大面积修改的场景。第三个套路是读写分离读布局属性的操作比如offsetHeight会强制刷新样式队列如果在循环里先读后写反复交替会造成布局抖动所以要先把需要读的值缓存下来。5.2 DOM型XSS千万别信用户的输入前面提过innerHTML的安全隐患这里展开讲透。DOM 型 XSS 是前端安全里最常见的一类问题。它不是后端传来的数据也不是服务器漏洞而是浏览器端 JavaScript 直接使用用户输入去操作 DOM导致恶意脚本被执行。看一个经典反例const search location.search.split()[1]; document.getElementById(result).innerHTML 你搜索的是 search;如果用户访问的链接里search的值被拼成了img src1 onerroralert(document.cookie)这段 HTML 就会被解析onerror里的脚本就会执行。轻则弹个框搞你心态重则窃取 Cookie、模拟用户操作。所以我给大家列几条铁律优先用textContent而不是innerHTML来插入纯文本。textContent会把内容当作纯文本渲染任何 HTML 标签都只是显示成字符串。如果必须渲染 HTML一定要转义。把换成lt;、换成gt;、换成amp;、引号换成实体字符。不要用eval不要用new Function不要用.innerHTML去执行用户可控的模板字符串。不要用location.href或location.search里取到的值直接拼 HTML。还有一个小众但很值得注意的点DOM 型 XSS 不只在innerHTML里有document.write、element.outerHTML、a 标签的 href属性比如javascript:协议、iframe 的 src都有可能成为注入点。写完代码多问自己一句这个变量的值是从用户那里来吗如果是我能保证它不会变成可执行代码吗这几句话比任何安全扫描工具都管用。再补充一个日常开发常见场景接口返回的 JSON 数据里有一段富文本需要渲染到页面。很多人图省事直接innerHTML data.content。这里正确的姿势是如果内容是后台编辑上传的富文本至少要让后端做 HTML 白名单过滤或者用 DOMPurify 这类库做清洗如果只是用户昵称、评论内容一律textContent处理。多年前某社区就是因为在评论里直接拼 HTML导致用户都能弹窗这个教训值得记住。这里回应一下开头提到的“dom json libxml ajax”这个组合。现在前后端分离越来越普遍接口返回 JSON 是常态前端拿到 JSON 数据后用 DOM 操作渲染页面这就是目前最主流的 Web 页面组装方式。说白了Ajax 负责把 JSON 数据从服务器拿回来DOM 负责把这些数据变成用户看得见的界面。理解好这一层关系你就能看懂为什么前端框架再花哨底层也逃不开 DOM 这棵积木树。6. 练习建议与常见坑位学 DOM 最大的误区是只看不写。API 再多不亲手拼一遍隔两天就忘得干净。我建议所有学到这里的朋友不要直接打开框架教程先给自己定三个原生 DOM 小项目做一个手风琴菜单、做一个可增删改的待办清单、做一个带数据校验的留言板。这三个做完选择器、节点操作、事件委托、表单处理基本就入门了。过程中你一定会踩到下面这些坑我提前给你打了预防针拿不到元素脚本写在 head 里还没等 body 渲染就执行查找了。解决办法是把script放到/body前或者等DOMContentLoaded事件。querySelectorAll 返回不是单个元素忘记它是类数组直接对它调.style报错。先.forEach或选中下标。删除后数据错乱用数组维护列表数据时只删了 DOM 没删数组索引刷新或重渲染时数据又回来了。记住数据驱动视图数据是唯一的真源。事件绑定在动态生成元素上失效给后添加的按钮绑定事件一定是在创建时绑定或者用事件委托统一处理后者更优。innerHTML 重复累加在循环里box.innerHTML pxx/p不仅性能差还会导致原有节点的状态丢失比如输入框焦点、已绑定的事件。用createElement或DocumentFragment更合理。还有几个“老手才知道”的细节。比如element.closest(选择器)可以从当前元素向上查找最近的匹配祖先元素写事件委托时堪称神器比手动parentNode循环可靠得多。再比如element.insertAdjacentHTML(beforeend, html)它和innerHTML类似但不会动原有兄弟节点适合在容器末尾追加一段结构性能也比innerHTML 好。我在实际带项目时发现能把 DOM 操作写得干净的人学 Vue 和 React 的速度通常也更快因为他们理解“组件最终还是要渲染成 DOM”这件事。所以说这一节内容不是过时技术而是真正的地基工程。写到这里我想说句真心话DOM 这套东西看起来琐碎但它像乐高底座一样撑起了整个前端大厦。别再被网上那些“DOM 太老、直接用框架”的观点带偏。先把积木玩明白再去看框架怎么帮你省事儿你才能真正看懂框架在做什么遇到框架解决不了的边缘情况时你也能凭原生能力全身而退。我到现在写组件封装还是会偶尔翻一下Node和Element的原生 API 文档因为最扎实的东西永远在这些看似基础的细节里。