写 JavaScript 就别想绕开 DOM。你随便打开一个网页,按 F12 在控制台敲一行document.querySelector('video'),再补一句v.style.rotate = '-90deg',整个视频立刻横过来——这种“指哪打哪”的爽感,就是 DOM 操作最直观的样子。作为前端开发或者说所有跟浏览器打交道的人,DOM 就是你操作页面的遥控器。这套东西不难,但细节非常多,很多新手写业务代码能跑,一换场景就懵,根本原因就是光背了 API,没搞懂 DOM 到底是怎么组织的。
这篇内容适合所有正在学 JavaScript 的人,不管你刚看完基础语法,还是已经在用框架写项目,都值得回头把 DOM 这层补扎实。我会从 DOM 的底层结构讲起,逐步拆到查询、修改、事件、监听、报错排查,最后用一个控制台旋转视频的小例子把整个知识点串起来。里面所有代码我都自己跑过,坑也替你踩好了,你照着敲就行。
1. 先把 DOM 这层窗户纸捅破
1.1 DOM 不是 JavaScript 的专利,而是浏览器的“内存地图”
很多人刚开始学的时候会有个错觉:DOM 是 JavaScript 的一部分。这个印象不怪你,因为几乎所有教材里 DOM 都是跟着 JavaScript 一起教的,网上搜“JavaScript 基础”也一定会带上 DOM。但严格来说,DOM 的全称是 Document Object Model,文档对象模型,它是由浏览器提供的一套标准接口,跟你用的浏览器、脚本语言都没绑定关系。JavaScript 只是操作 DOM 最常用的一门语言,理论上任何能调用浏览器接口的脚本都能操作它。
你可以把 DOM 理解成浏览器在内存里画的一张“地图”。浏览器拿到 HTML 源代码之后,并不是直接往屏幕上一丢,而是先把那串纯文本解析成一棵结构化的树,每个标签、每段文字、每个属性都对应树上的一个节点。JavaScript 要改页面上的东西,第一步就是顺着这棵树找到目标节点,然后改它的属性、内容或者样式。这个过程不需要重新请求服务器,页面就实时变了。
理解了这一点,你就知道“DOM 操作慢”这句话是怎么回事了。操作对象不是浏览器屏幕,而是一棵可能包含几万个节点的树。你每改一次节点,浏览器都可能触发 reflow(重新计算布局)或者 repaint(重新绘制),改得越频繁,性能越受影响。框架所谓的“性能优化”,本质上也绕不开减少对 DOM 的直接操作次数。
1.2 从 HTML 到 DOM 树:一次解析就完成的转换
给你一个最简单的页面结构,我们看看浏览器是怎么拆的:
<!DOCTYPE html> <html> <head> <title>示例页面</title> </head> <body> <div id="app"> <p class="content">你好,DOM</p> </div> </body> </html>这段代码在浏览器里不是按字符串顺序平铺处理的,解析器会从根节点document开始,一层一层往下读,遇到新标签就生成一个元素节点,遇到文字就生成一个文本节点,遇到属性就挂在对应元素节点上。最终形成的树大致是这样:
document(根节点)html(元素节点)head(元素节点)title(元素节点)- “示例页面”(文本节点)
body(元素节点)div#app(元素节点)p.content(元素节点)- “你好,DOM”(文本节点)
注意几个关键点:第一,这棵树的最顶层是document,不是html。你可以用document.documentElement去拿html节点,但document本身是整棵树的入口。第二,文本节点也是节点,哪怕是换行符和空格,在 DOM 树里都可能生成文本节点。第三,注释节点也存在,不过平时操作得少。
很多新手在写“通过父元素找子元素”的循环时,会遇到childNodes和children的结果对不上,原因就在这:childNodes返回所有子节点,包括文本节点和注释节点;children只返回元素节点。你要是想遍历“标签”而不是“文字”,老老实实用children,否则会踩到一堆看不见的空文本节点。
1.3 三类节点:元素、文本与属性的区分逻辑
DOM 规范里节点类型有很多种,但我们日常打交道的主要是三类:元素节点(对应 HTML 标签)、文本节点(对应文字内容)、属性节点(对应标签上的id、class、src这些)。
为什么要把它们区分开?因为操作方式完全不一样。元素节点可以用getElementById、querySelector这些方法直接找,文本节点一般得通过元素节点的textContent或innerText去读写,属性节点则走setAttribute、getAttribute或直接点语法el.id = 'xxx'。
还有一个容易混淆的点:getAttribute('value')和el.value在表单元素里经常会读出不一样的值。前者读的是 HTML 里写死的那个属性值,后者读的是元素当前的实际值。你在输入框里打了字之后,再用getAttribute('value')回去读,拿到的还是初始值,只有el.value才是最新的。这个区别在写表单校验时就特别重要,后面我会专门展开。
2. 最常用的 DOM 操作:查找元素、修改样式、增删节点
2.1 查找元素:querySelector 为什么是首选
操作 DOM 的第一步永远是“先找到那个元素”。老一点的 API 有getElementById、getElementsByClassName、getElementsByTagName,后来 DOM 规范加了querySelector和querySelectorAll,这两个是选择器风格的方法,参数就是你写 CSS 时用的那种选择器语法。
我的建议是:新代码统一用querySelector/querySelectorAll,别混着用。原因有三个:
- 一个方法覆盖所有查找场景。不管你是按 id、class、属性还是层级关系找,一个选择器就能写完,不用记三套不同命名风格的方法。
- 和 CSS 语法统一,可读性好。团队里谁看代码都明白
.list li.active是在找什么,换成getElementsByClassName一长串反而费劲。 querySelector返回的是查找结果里的第一个元素,符合“精确查找用 CSS 选择器”的直觉。
不过要注意,querySelectorAll返回的是NodeList,它是个类数组,但不是一个真正的数组。你直接对它调用map、filter会报错,得先转一下:Array.from(document.querySelectorAll('.item'))或者用展开运算符[...document.querySelectorAll('.item')]。
另外提一个被问烂了的问题:querySelector和getElementById到底谁快?实测下来,在很大很复杂的文档里getElementById确实有优势,因为它是按 id 索引直接命中。但在绝大多数业务页面里,这个性能差异可以忽略不计。为了几微秒的差距牺牲代码可读性,不值得。真要优化,重点应该放在减少查询次数上——把反复查同一个元素的结果存到变量里,而不是每次用都重新查一遍。
2.2 修改内容与样式:innerHTML、textContent 和 style 操作
找到元素之后的“改”,大概分成三类:改内容、改属性、改样式。
改内容有两条路,innerHTML和textContent。它们最大的区别是:innerHTML会把字符串当作 HTML 解析,插入<b>标签真的会加粗;textContent则是把字符串当成纯文本,就算里面写着<b>也只会给你显示原文符号。很多人图省事喜欢用innerHTML,我劝你改掉这个习惯。只要你插入的内容里混进了一丁点用户输入,就很容易成为 XSS 注入的入口。这个问题我在安全部分会详细说,这里先记住:默认用textContent,除非你明确需要插入带格式的 HTML。
改属性可以分成两类情况。一类是标准属性,像id、href、src、value,直接通过点语法修改就行:img.src = 'new.jpg'。另一类是自定义属性,或者叫非标准属性,比如>// 等价于 el.dataset.id = '123' // 等价于 el.getAttribute('data-id', '123')
改动自定义属性之后,用getAttribute也能读到,但dataset的好处是属性名会自动把>const v = document.querySelector('video'); v.style.rotate = '-90deg';
只要页面上有一个<video>,这段代码就能让视频逆时针旋转 90 度。注意不是transform: rotate(-90deg),而是新的独立属性rotate。它和transform: rotate()的区别在于,CSStransform里面如果还有其他变换函数,写在一起容易互相覆盖,而rotate是独立属性,写起来更省心,浏览器兼容性也足够新了。以后在控制台鼓捣页面玩,这一招非常实用。
2.3 创建节点:document.createElement 的正确用法
有时候你需要在页面上动态新增元素,而不是修改已有的。这时候就用document.createElement创建元素节点,然后通过各种插入方法把它挂到 DOM 树里。老牌的三个方法是appendChild、insertBefore和replaceChild,后来新增了append、prepend、before、after等更直观的方法。
还是要提醒一次childNodes和children的坑,appendChild接受任意节点,包括文本节点和注释节点,而业务里通常只想挂元素,所以只要你插入的是document.createElement创建的元素,就没问题。
一个高频场景是“循环创建列表”。很多人第一反应是拼接字符串然后innerHTML一次性塞进去,这个写法简单,但有两个问题:一是性能,每次改innerHTML都会让浏览器重新解析那一整块内容,循环里反复操作会非常卡;二是安全,字符串拼接容易带上未经转义的内容,为 XSS 留了后门。正确做法是循环里createElement,然后appendChild。有些人担心创建几百个节点会不会慢,其实在循环里反复 append 确实会影响性能,所以更好的做法是用DocumentFragment先把所有节点攒起来,最后一次挂到页面:
const list = document.getElementById('list'); const fragment = document.createDocumentFragment(); for (let i = 0; i < 100; i++) { const li = document.createElement('li'); li.textContent = '第 ' + i + ' 项'; fragment.appendChild(li); } list.appendChild(fragment);DocumentFragment是一个“隐形容器”,它不属于主 DOM 树,所有对它的操作都不会引发页面重绘。等全部节点都攒好了,一次性把整个片段挂上去,浏览器只处理一次渲染,性能会好很多。这个技巧在渲染大量列表项时非常有用。
2.4 删除节点:removeChild 与 remove 的区别
删除节点听起来最简单,但里面也有坑。老写法是先找到父元素,再通过父元素把子节点删掉:
const parent = document.getElementById('list'); const child = document.getElementById('item-1'); parent.removeChild(child);新一点的写法直接用child.remove()就行,不需要再向上找父节点。这个方法兼容性已经很好,可以放心用。
还有个新手特别容易犯的错误:写了element.remove()之后还想继续用这个元素干点别的,结果发现它已经不在 DOM 树里。这只是你把它从可见 DOM 树里摘下来了,并不是销毁了,变量引用还活着,元素本身还在内存里,后面想还能重新挂回去。如果你确实想“删除”一个元素并释放内存,除了remove()之外,还要把原变量置为null,并且确保没有其他变量引用它。
另外,删除和隐藏是完全两回事。隐藏用display: none或者visibility: hidden,元素还在 DOM 树上,还能被querySelector找到,只是用户看不见。删除则意味着从结构上移除。这个区别在做单页应用时特别重要,很多人把“用户点删除”做成了display: none,结果页面上数据越来越多,最后带来一堆隐藏节点的包袱。
3. 事件监听与动态交互,让页面真正“活”起来
3.1 addEventListener 和 onclick 的区别,以及一个常被忽略的细节
给元素绑事件,有两条路线。老式写法是onclick直接赋值:
button.onclick = function() { console.log('clicked'); };新写法是用addEventListener:
button.addEventListener('click', function() { console.log('clicked'); });两者最大的区别是,onclick同一时间只能绑一个处理函数,再绑一个会把前面的覆盖掉;addEventListener可以给同一个事件绑多个处理函数,按绑定顺序依次执行。还有,addEventListener支持第三个参数,可以指定事件在捕获阶段还是冒泡阶段处理,onclick只能在冒泡阶段处理。所以从灵活性来说,addEventListener是绝对的主流。
但大家经常忽略一个细节:addEventListener的第三个参数如果不传,默认是false,也就是冒泡阶段触发。你如果绑了一个点击事件,又想阻止事件一路冒泡上去,需要在回调里调event.stopPropagation()。但如果你用了一些组件库,某些组件内部可能会在捕获阶段就把事件拦截了,你的监听根本不会触发。遇到“明明绑了事件就是没反应”的情况,先别怀疑选择器,可以试试把第三个参数改成true在捕获阶段监听。
还有一点容易被忽略:匿名函数一旦绑上去,就没法移除了。如果你想用removeEventListener解绑,那回调函数必须是一个具名函数,得先在外部定义好再传进去。
3.2 事件冒泡与事件委托:动态列表监听的推荐方案
浏览器在事件处理上有两个阶段:捕获阶段从根节点一路向内走到目标元素,冒泡阶段从目标元素一路向外走回根节点。绝大多数业务代码处理的是冒泡阶段。这不是理论空谈,冒泡是“事件委托”的基础。
事件委托的核心思路是:与其给子元素一个个绑事件,不如把事件绑在它们的共同父元素上,利用事件冒泡,在父元素里分清到底是哪个子元素触发的。这个方案在两种场景下特别香:
第一种是列表项特别多,一个个绑不仅代码啰嗦,还消耗内存。第二种是列表项是动态生成的,比如点击“加载更多”后新增了 10 个卡片,如果一开始就给每个卡片绑了事件,新卡片就没人管了。但事件委托绑在父元素上,不管子元素后来新增多少,事件都能通过冒泡到达父元素,天然就支持动态内容。
具体写法是这样的:
document.getElementById('list').addEventListener('click', function(event) { const target = event.target.closest('.item'); if (!target) return; console.log('点击了', target.dataset.id); });这里用closest从目标元素向上找到最近的匹配元素,好处是即使你点的是列表项里的一个小图标,也能正确判断出是哪一个列表项。这段代码不仅性能更好,还彻底避免了“动态生成的元素没绑定事件”这个经典需求。
3.3 MutationObserver:监听 DOM 元素自身变化的高级 API
搜索热词里单独把“DOM 监听 API”列出来了,说明很多人不知道 DOM 本身也是可以被监听的。除了用户操作触发的事件,有时候你想知道某个 DOM 节点是否被脚本改动了,这种情况下可以用MutationObserver。
比如你要监听某个区域的子元素是否新增或删除:
const target = document.getElementById('content'); const observer = new MutationObserver(function(mutationsList) { for (const mutation of mutationsList) { if (mutation.type === 'childList') { console.log('节点变化了,变化类型:', mutation.type); } } }); observer.observe(target, { childList: true, subtree: true });MutationObserver的配置选项有三个常用的:childList(监听子节点增删)、attributes(监听属性变化)、characterData(监听文本内容变化)。如果你想监听元素上某个具体属性,可以在observe的配置里写attributeFilter: ['class', 'style'],只关注这几个属性的变化。
为什么不用老旧的MutationEvent?因为MutationEvent是在 DOM 变化时同步触发的,一次大量修改会触发大量事件,直接把页面卡死。MutationObserver是异步的,浏览器会把多次变化合并成一批回调给你处理,性能完全不在一个量级。
实际使用中,一个很有价值的场景是监控第三方脚本有没有偷偷改你的页面。比如投放系统的广告脚本加载后可能会改掉你某个视频容器的尺寸,你可以用MutationObserver监听这个容器的style变化,发现异常就记录下来,或者直接把错误报告给后端。
不过要提醒一句,MutationObserver存在循环触发的风险。回调里如果又改了同一个节点的属性,它又会触发监听,如果不加防抖或条件判断,很容易造成死循环。我自己的习惯是在回调里用标志位控制,或者直接取消观察再修改:
observer.disconnect(); target.setAttribute('data-fixed', 'true'); observer.observe(target, { attributes: true });3.4 DOM 操作与表单:HTML5 表单验证和 JS 提交的真实差异
热搜里有个词条问“JavaScript 中表单提交和 H5 的区别”,这也是一个非常现实的 DOM 问题。HTML5 基本每个浏览器都内置了一套表单验证能力,你给<input>加上required、type="email"、minlength这些属性,浏览器会在提交时自动校验。这套机制的好处是:不用 JavaScript 也能实现基础校验。
但很多人不知道,如果表单里的提交按钮是用 JavaScript 触发的form.submit(),浏览器的内置验证是不会自动执行的。你写了required,但用户绕过提交按钮,用 JS 去提交,照样能通过。真正要触发 HTML5 验证,得先调用form.checkValidity()或者form.reportValidity(),前者只返回校验结果,后者会把错误提示气泡显示出来。
JavaScript 提交的方式有几种:form.submit()会直接提交并刷新页面;fetch或XMLHttpRequest提交则是在不刷新页面的前提下把数据发给后端,这就是单页应用里最常见的 AJAX 提交方式。你用 AJAX 提交时,HTML5 的校验完全不会自动触发,必须自己手动处理:
const form = document.getElementById('loginForm'); form.addEventListener('submit', function(event) { event.preventDefault(); if (!form.checkValidity()) { form.reportValidity(); return; } // 到这里才能安全地发起 fetch 请求 });这里有个很容易踩的坑:在表单提交事件里,忘掉event.preventDefault()会导致页面刷新,然后前端代码里的数据都被清掉了。这个“页面一闪”的现象特别经典,几乎每个写前端的人都经历过。一定要记得在submit回调的第一行就把默认行为停掉。
4. DOM 运行时报错排查与安全边界,别在生产环境踩坑
4.1 null 读取属性和 undefined is not a function
前端运行时报错里,最经典的一句就是Cannot read properties of null (reading 'xxx')。翻译成人话就是:你拿着一个null当节点用了,而null上没有你要的那个属性或方法。为什么会拿到null?十个里面有九个是document.querySelector没找到元素。
排查这个问题的顺序,我一般固化成一个套路:
- 先确认选择器有没有写错。
#app少了个#,或者把class和id搞混了,都会查不到。 - 确认脚本执行时机。如果脚本写在
<head>里,而你要找的元素在<body>后面,脚本执行的时候 DOM 还没构建完,就找不到。这时候要么把<script>放到页面底部,要么在DOMContentLoaded事件回调里再执行。 - 检查目标元素是不是在 iframe 里。如果是,要用 iframe 的
contentDocument而不是外层页面去查找。 - 最后再怀疑是不是元素被动态渲染了。比如使用框架时,某个列表数据还没回来,DOM 压根不存在。
我还见过一种隐蔽情况:两段代码都用了document.querySelector('.item'),前面一段代码有 JS 报错中断了,导致后面代码没执行。这时候没有 DOM 相关的报错,反而只有一个TypeError报错,很容易让人误判。所以排查任何页面上“看不见的问题”,都先完整看一遍控制台的错误列表,不要只盯某一条。
4.2 用字符串调用函数与避免 eval
搜索引擎里有人搜“JavaScript 通过字符串调用函数”,这个需求常见于后端配置下发给前端,告诉前端“点击这个按钮时执行openModal函数”。实现方式有两种,但只有一种是安全的。
第一种是在全局作用域里查找:把函数挂到window上,然后通过window['openModal']()调用。这个方案符合 JS 的对象模型,闭包也不受影响,前提是函数确实定义在全局作用域下,或者被显式挂载到了window:
window.openModal = function() { console.log('open'); }; const funcName = 'openModal'; if (typeof window[funcName] === 'function') { window[funcName](); }第二种是eval,但强烈不建议。eval会把字符串当成代码执行,等于把解释权完整交给了调用者,一旦字符串内容可控,就相当于给攻击者留了一扇门。如果你只是想把一段 JSON 字符串转成对象,直接用JSON.parse,不要用eval。
“字符串调用函数”这件事,经常和另一个问题连在一起:为什么有些函数没有定义在window上?因为用let、const声明在模块作用域里的变量不会挂到全局对象上。所以你在模块文件里声明的函数,用window['funcName']()是找不到的。解决思路是显式注册到全局:window.handleAction = handleAction,或者用 Map 维护一个函数注册表,从 Map 里按名字取。这样既不污染太多全局变量,又安全可控。
4.3 DOM 型 XSS 与输入校验的安全底线
DOM 型 XSS 是前端安全里的常见问题,原理说起来很简单:JavaScript 拿到了用户输入(或从 URL、存储、网络接口取到的外部数据),然后把它当成 HTML 插进了页面。攻击者提供的字符串里藏了一段<script>或者一个带事件属性的标签,页面上就多出了不该存在的代码,能够窃取你的数据。
最常见的危险操作就是innerHTML。比如一个评论系统,直接把用户输入的评论内容塞进innerHTML,攻击者发一条评论,内容里写着<img src=x onerror="偷数据代码">,页面一渲染就会执行。防的办法有两个层面。
第一层是输出转义。如果你想保留用户输入的纯文本,就用textContent而不是innerHTML。惰性观点是“我封装一个转义函数”,但最干净的方案就是不要让用户的输入走 HTML 解析器,直接textContent根本不会触发转义问题。
第二层是输入过滤。如果某些场景确实需要用户输入富文本,那就不能只靠前端过滤了,必须用成熟的富文本安全方案,在后端也做一层校验。前端过滤只负责防手滑,防不了恶意用户直接改请求。顺便说一句,任何把用户输入拼进innerHTML、outerHTML、document.write或者eval里的代码,都值得在代码审查时被打回去重写。处理用户字符串时,其实还有一个容易忽略的点:很多“javascript: 和 h5 表单提交区别”类的问题,本质都跟“输入没有做统一处理”有关,表单校验只是其中一环,最终防线永远是“输出时不信任任何前端传过来的东西”。
4.4 一个容易懵的问题:未 new 完的对象为什么能使用 prototype
这个话题看着和 DOM 关系不大,但它其实是理解浏览器对象模型的一把钥匙。很多人写代码时看到Element.prototype、HTMLElement.prototype上的方法产生疑惑:这个对象还没有真正构造完,为什么 prototype 上的方法就能用?
答案在于 JavaScript 原型链的机制。构造函数执行的过程中,this的原型在函数体执行之前就已经被设置好了。你用new调用构造函数时,引擎会先创建一个新对象,把这个对象的原型指向构造函数的prototype属性,然后才执行函数体。所以在函数体内部,即使你还没有执行到return,对象本身的继承关系已经建立,this通过原型链能找到挂在 prototype 上的方法。这就是“新对象还没构造完,prototype 上的方法已经可用了”的原因。
这个原理对 DOM 操作的启示是:DOM 元素对象也遵循同样的原型链,从元素节点逐层连接到HTMLElement.prototype、Element.prototype、Node.prototype,再连接到最上层的EventTarget.prototype。你才能在任何一个元素上调用addEventListener,因为这个方法定义在EventTarget.prototype上,所有元素都继承自它。理解了原型链,你也就知道为什么我们可以给Element.prototype扩展新方法。
5. 把知识串起来:一个网页视频旋转功能的小实战
5.1 需求拆解与代码实现
热搜里那条javascript:v = document.queryselector('video');v.style.rotate = '-90deg'其实可以扩展成一个小实战。假设你有一个在线视频教学网站,视频默认横屏,但你想在控制台或者浏览器书签里写一小段脚本,把任意网页的视频旋转 90 度,方便在竖屏显示器上观看。
第一步,找到页面上所有视频,而不是只找第一个。因为有些页面有多个视频,或者有预览图但不是真正的<video>:
const videos = document.querySelectorAll('video'); videos.forEach((v) => { v.style.rotate = '-90deg'; });第二步,加上一些保护逻辑:如果页面上一个视频都没有,提示一下,不要让代码白跑一趟:
if (videos.length === 0) { console.log('当前页面没有找到视频标签'); }第三步,把这串代码封装成一个可复用的函数,下次在控制台直接调用:
function rotateAllVideos(deg = -90) { const videos = document.querySelectorAll('video'); if (videos.length === 0) { console.warn('页面上没有 video 元素'); return; } videos.forEach((v) => { v.style.rotate = deg + 'deg'; }); return videos.length; } rotateAllVideos(-90);最后一步,提供一个恢复原状的函数。这个细节很多人会漏掉:改了样式之后不知道怎么还原。由于style.rotate是行内样式,直接赋空字符串就会让浏览器回到样式表默认状态:
function resetAllVideos() { document.querySelectorAll('video').forEach((v) => { v.style.rotate = ''; }); }这段小工具代码,把所有前文提到的知识点串起来了:查找元素、遍历节点、修改样式、封装成函数。你把它保存成书签,在任意网页点击书签,就能自动执行。
5.2 给多个视频同时操作与异常处理
在真实页面上跑这段代码时,会遇到很多“干净 demo”里遇不到的情况。
第一个坑是<video>外面套了一层自定义播放器容器,视频旋转了,但容器没旋转,导致视频旋转以后一半露在外面。解决办法是把旋转样式作用于外层容器,而不是视频本身:
const video = document.querySelector('video'); const wrapper = video.closest('div'); // 找到最近的父容器 if (wrapper) { wrapper.style.rotate = '-90deg'; }第二个坑是某些播放器禁止了右键,但控制台操作不受影响。如果你要在控制台执行,得注意页面是否对console做了篡改。有些防御手段会在页面加载时把console.log重写成空函数,你调试时看不到输出。这时候直接用alert或者把调试信息展示在页面上。
第三个坑是 CSP(内容安全策略)限制。有些站点通过 HTTP 头禁止了内联脚本运行,你在地址栏输入javascript:代码可能会被浏览器拦截,但是 F12 控制台里执行通常不受影响。遇到没反应的情况,检查控制台是不是有 CSP 报错。
第四个坑是视频本身有transform: rotate(...)的样式。style.rotate和 CSS 里的transform是两个独立属性,如果你旋转之后视频没有按预期变化,可能就是页面原本已经写了 transform 相关样式。可以利用开发者工具的“Styles”面板看看当前生效样式,再做相应的合并处理。
5.3 一个合并对象的常用写法
写这种多功能小工具时,经常要处理配置项,就会遇到“合并两个对象”的需求。比如刚才的视频旋转工具,你想支持用户传入自定义配置,预设的配置和用户配置要合并。两种写法都行:
第一种是Object.assign:
const defaultConfig = { deg: -90, target: 'video' }; const userConfig = { target: '.player' }; const finalConfig = Object.assign({}, defaultConfig, userConfig); // finalConfig = { deg: -90, target: '.player' }第二种是展开运算符:
const finalConfig = { ...defaultConfig, ...userConfig };这两种写法都能实现“后面的覆盖前面的”效果。它们在业务代码里极其常见,尤其是在封装通用函数、处理配置项时。不过要注意它们都是浅拷贝,如果配置项里有嵌套对象,需要更深层的合并时要自己实现或引入工具函数。比如配置里有{ controls: { volume: 0.5 } },浅拷贝之后,后传入的配置可能把整个controls覆盖掉,而不是只覆盖某个字段。这个细节在写复杂配置时非常容易埋雷。
5.4 如何用事件委托监听动态列表
再给这个实战补一个真实需求:视频网站一般会有“推荐列表”,用户点“加载更多”之后,新的视频卡片加进来了。如果按照老思路给每个卡片绑点击事件,那新卡片不会自动有事件。这是我最常被问到的问题之一。
用事件委托来解决就非常简单。在列表容器上绑一个点击事件,通过closest判断用户是否点到了卡片元素:
const list = document.getElementById('recommendList'); list.addEventListener('click', function(event) { const card = event.target.closest('.video-card'); if (!card) return; const videoId = card.dataset.id; playVideoById(videoId); });这段代码有几个好处:不管列表里的卡片是初始存在的还是后来加载的,只要它们都在#recommendList下面,点击就能被捕获;事件只绑定了一个,不会因为列表数据上千导致内存储存几百个监听函数;代码的意图也很清楚,一看就知道这个列表的点击由谁处理。
如果把动态加载也加上,整个流程就完整了。真实项目里,写完推荐列表的渲染函数之后,事件委托代码完全不用动。这种“一次绑定,永久生效”的特性,是事件委托最大的价值。
6. 写在最后:DOM 学习的一个核心建议
如果你刚把上面这些内容跟着敲了一遍,你会发现 DOM 操作本质上并不复杂,难的是在真实环境里遇到各种意料之外的情况。我个人的体会是:不要急于背 API,多打开控制台去折腾,把页面上的元素当成实验对象,今天旋转变换一下视频、明天给列表批量加样式,后天用MutationObserver盯着某个元素看它啥时候被改。踩几次坑,比看十篇教程都管用。
最后再分享一个小技巧:写 DOM 操作代码时,时刻问自己两句话。第一句,这个节点现在真的存在吗?第二句,这段代码执行时,用户输入的内容有没有可能进入 HTML 解析流程?想清楚这两个问题,能避开前端开发里绝大部分常见的坑。剩下那些看不懂的报错,就交给控制台和断点调试,一行一行看数据到底在哪个环节变了,答案总会浮出来。