news 2026/9/9 23:18:44

从原理到实操:用乐高思维拆解DOM操作核心技巧

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从原理到实操:用乐高思维拆解DOM操作核心技巧

你第一次接触“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>包着<p><p>里又有<span>,一层套一层,像俄罗斯套娃,又像积木塔。

节点的关系也有专门叫法:上下级叫父子节点,同一父节点下的元素叫兄弟节点,层层向上叫祖先节点。比如下面这段最简单的 HTML:

<div id="parent"> <p class="child">第一段</p> <p class="child">第二段</p> </div>

这里div是父节点,两个p是它的子节点,两个p互为兄弟节点。等你用 JavaScript 操作页面时,几乎做的所有事情,都是在这棵树上找节点、加节点、改节点、删节点。清楚了树的结构,后面所有 API 都只是“在树上走位”的技巧而已。

1.2 认识几种最常用的“积木块”

乐高积木有不同形状,DOM 节点也一样。初学阶段你只需要重点分清这几种:

  • 元素节点:对应 HTML 标签,比如<div><p><a>。这是最常操作的对象,它的 nodeType 是 1。
  • 文本节点:标签里的文字内容,比如<p>你好</p>里的“你好”。它的 nodeType 是 3。容易被忽略,但改文字时绕不开它。
  • 属性节点:像idclasshref这些标签上的属性,在 DOM 树里也以节点的形式存在,nodeType 是 2。不过日常开发很少直接操作属性节点,都是通过元素节点的 API(比如getAttributesetAttribute)来间接处理。
  • 注释节点: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。

第二类是更现代、也更通用的querySelectorquerySelectorAll

  • document.querySelector('选择器'):传入一个 CSS 选择器字符串,返回匹配的第一个元素。
  • document.querySelectorAll('选择器'):返回所有匹配元素的 NodeList,可以用forEach直接遍历。

从实际体验来看,querySelector系列是我日常开发中使用频率最高的,原因有三个:

  1. 参数就是 CSS 选择器,比如.class#id[data-type="list"]div > p,写起来顺滑,不需要记各种“老三家”的拼写差异。
  2. 返回值更规范,NodeListforEach方法,配合箭头函数写起来干净利落。
  3. 查找范围更灵活,不仅可以从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 class="card"> <h3>标题</h3> <p>描述文字</p> <button class="btn">操作</button> </div> `; container.innerHTML = html;

innerHTML写起来非常爽,尤其配合模板字符串,一次性能生成一大块结构。但这里我必须敲黑板提醒:永远不要用innerHTML拼接用户输入的内容。比如用户提交了一段文字,你直接container.innerHTML = '<p>' + userInput + '</p>',用户输入里如果带了<img src=x onerror=alert(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')。不过对于classstylevaluehref这类常用属性,原生属性操作比setAttribute更高效,比如element.classNameelement.style.displayinput.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 id="todo"> <input id="input" type="text" placeholder="输入待办事项" /> <button id="add">添加</button> <ul id="list"></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); });

常用的事件类型很多:clickdblclickmouseentermouseleaveinputchangesubmitkeydownkeyupscrollresize等等。做移动端还有touchstarttouchmovetouchend

关于event对象,新手最容易混淆的是targetcurrentTarget。我一句话给你讲明白: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 src=1 onerror=alert(document.cookie)>,这段 HTML 就会被解析,onerror里的脚本就会执行。轻则弹个框搞你心态,重则窃取 Cookie、模拟用户操作。

所以我给大家列几条铁律:

  1. 优先用textContent而不是innerHTML来插入纯文本textContent会把内容当作纯文本渲染,任何 HTML 标签都只是显示成字符串。
  2. 如果必须渲染 HTML,一定要转义。把<换成&lt;>换成&gt;&换成&amp;、引号换成实体字符。
  3. 不要用eval,不要用new Function,不要用.innerHTML去执行用户可控的模板字符串
  4. 不要用location.hreflocation.search里取到的值直接拼 HTML

还有一个小众但很值得注意的点:DOM 型 XSS 不只在innerHTML里有,document.writeelement.outerHTMLa 标签的 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 += '<p>xx</p>',不仅性能差,还会导致原有节点的状态丢失(比如输入框焦点、已绑定的事件)。用createElementDocumentFragment更合理。

还有几个“老手才知道”的细节。比如element.closest('选择器'),可以从当前元素向上查找最近的匹配祖先元素,写事件委托时堪称神器,比手动parentNode循环可靠得多。再比如element.insertAdjacentHTML('beforeend', html),它和innerHTML类似,但不会动原有兄弟节点,适合在容器末尾追加一段结构,性能也比innerHTML +=好。

我在实际带项目时发现,能把 DOM 操作写得干净的人,学 Vue 和 React 的速度通常也更快,因为他们理解“组件最终还是要渲染成 DOM”这件事。所以说,这一节内容不是过时技术,而是真正的地基工程。

写到这里,我想说句真心话:DOM 这套东西看起来琐碎,但它像乐高底座一样,撑起了整个前端大厦。别再被网上那些“DOM 太老、直接用框架”的观点带偏。先把积木玩明白,再去看框架怎么帮你省事儿,你才能真正看懂框架在做什么,遇到框架解决不了的边缘情况时,你也能凭原生能力全身而退。我到现在写组件封装,还是会偶尔翻一下NodeElement的原生 API 文档,因为最扎实的东西,永远在这些看似基础的细节里。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 23:16:46

Typora免费替代与正版授权指南:Markdown所见即所得编辑器实战

这次我们来看 Typora。熟悉 Markdown 写作的人应该都听过这个名字&#xff1a;本地优先、所见即所得、界面干净&#xff0c;写技术博客的人用它的比例非常高。它的核心体验是“写字的时候直接看到排版效果”&#xff0c;不需要左右分栏预览&#xff0c;光标走到哪&#xff0c;样…

作者头像 李华
网站建设 2026/9/9 23:14:15

macOS上编译lincity-ng:从jam到CFLAGS的完整踩坑指南

前阵子折腾了一晚上&#xff0c;终于在自己的 MacBook 上把 lincity-ng 编译通过、跑了起来。这个老牌开源项目其实挺好玩的&#xff0c;跟 SimCity 一个路子&#xff0c;但完全免费、源码可看&#xff0c;而且对硬件要求极低——我愿称之为公司电脑上的摸鱼神器。但吐槽的地方…

作者头像 李华
网站建设 2026/9/9 23:11:01

从单机到分布式:高可用消息中间件架构演进与落地实践

从单机消息队列到分布式高可用消息中间件体系落地&#xff0c;这个话题我断断续续折腾了快两年。从最开始业务里一个单体应用内部的队列&#xff0c;到后面支撑多条业务线的分布式消息集群&#xff0c;中间踩过的坑、趟过的雷&#xff0c;比想象中多得多。尤其是当你真正面对线…

作者头像 李华
网站建设 2026/9/9 23:10:19

开源自托管看板工具Kanass:从Docker部署到团队任务管理实战

前段时间我把团队的日常任务管理从微信群和 Excel 里彻底搬了出来&#xff0c;换成一个叫 Kanass 的开源看板工具。Kanass 这个名字你可能不熟&#xff0c;简单说&#xff0c;它是一个长得像 Trello 的自托管看板任务管理软件&#xff0c;可以部署在自己的服务器上&#xff0c;…

作者头像 李华
网站建设 2026/9/9 23:08:16

5分钟部署免费开源ERP:ERPNext上手指南

5分钟部署免费开源ERP&#xff1a;ERPNext上手指南 【免费下载链接】erpnext Free and Open Source Enterprise Resource Planning (ERP) 项目地址: https://gitcode.com/GitHub_Trending/er/erpnext ERPNext是一款免费开源的ERP系统&#xff0c;总账、采购、销售、库存…

作者头像 李华
网站建设 2026/9/9 23:08:11

yuzu模拟器:免费把Switch游戏搬上电脑,5分钟装完

yuzu模拟器&#xff1a;免费把Switch游戏搬上电脑&#xff0c;5分钟装完 【免费下载链接】yuzu 任天堂 Switch 模拟器 项目地址: https://gitcode.com/GitHub_Trending/yu/yuzu yuzu 是一款免费开源的任天堂 Switch 模拟器&#xff0c;Windows、Linux、Android 三个平台…

作者头像 李华