刚接触前端开发时,经常会碰到一句话:“获取一下DOM”“操作DOM”“DOM加载完成以后再执行”。代码看多了,大概知道它和网页元素有关,但真要解释DOM是什么,反而容易说不清楚。
DOM的完整名称是Document Object Model,中文通常翻译为“文档对象模型”。这个名字听起来有些抽象,不过放到网页开发里理解并不复杂:浏览器拿到HTML以后,会把页面内容整理成一套可以被程序访问和操作的对象结构,这套结构就是我们平时所说的DOM。
也正因为有了DOM,JavaScript才能知道页面里有哪些标题、按钮、图片和输入框,并进一步修改它们。

从一段HTML开始理解DOM
先看一个非常普通的HTML页面:
<body> <h1>我的网站</h1> <p>欢迎访问本站</p> <button>点击查看</button> </body>
我们看到的是一段HTML文本,但浏览器并不是把这些字符原封不动地摆到屏幕上。页面加载过程中,浏览器会解析这段HTML,并在内部形成一套有层级关系的节点结构。
大致可以理解成:
Document └── html └── body ├── h1 │ └── "我的网站" ├── p │ └── "欢迎访问本站" └── button └── "点击查看"
这就是常说的DOM树。
html、body、h1、p、button在这里不再只是HTML标签,而成为浏览器中的节点对象。JavaScript可以找到这些对象,读取它们的信息,也可以修改、删除或者新增节点。
DOM并不等于HTML源码
这是理解DOM时很关键的一点。
HTML更接近网页最初提供给浏览器的文档内容,而DOM是浏览器解析文档以后形成的对象模型。两者关系很近,但不能完全画等号。
例如HTML中原本有这样一段内容:
<p id="message">今天星期一</p>
页面运行以后,JavaScript把文字修改掉:
document.querySelector('#message').textContent = '今天星期二';此时用户看到的页面已经变成“今天星期二”,浏览器开发者工具中的DOM结构也会随之变化,但服务器最初返回的HTML文件并不会因为这次操作自动被改写。
所以平时在Chrome开发者工具的Elements面板里看到的内容,更接近当前页面的DOM状态,而不一定等同于网站最初返回的HTML源码。
这也是为什么“查看网页源代码”和“检查元素”有时候会看到不同内容。页面运行过程中,脚本可能已经增加、删除或者修改了不少节点。
JavaScript为什么需要DOM
JavaScript本身是一门编程语言,但它并不会凭空知道页面里有一个按钮或者某段文字。浏览器通过DOM提供了一组接口,让脚本能够找到并操作当前文档。
开发中很常见的一段代码:
const button = document.querySelector('.submit-button');这里的document代表当前网页文档,而querySelector()负责根据CSS选择器寻找匹配的元素。找到以后,返回的并不是一段HTML字符串,而是一个可以继续操作的元素对象。
接下来就可以修改它:
button.textContent = '提交成功';
也可以增加样式类:
button.classList.add('success');或者监听用户点击:
button.addEventListener('click', function () {
console.log('用户点击了按钮');
});平时所说的“JavaScript操作网页”,很大一部分其实就是在通过DOM操作页面里的节点对象。
DOM里不只有HTML标签
学习DOM时很容易把“节点”和“元素”当成一回事,其实节点的范围更大。
HTML元素当然是节点,例如div、p和img都属于元素节点;但标签里面的文字同样可以表现为文本节点,HTML中的注释也可以作为注释节点存在。
例如:
<p>你好</p>
从DOM角度看,并不只是一个p。可以把它理解为一个段落元素节点,里面还有一个内容为“你好”的文本节点。
日常业务开发不一定天天需要区分每一种节点类型,不过在处理childNodes、文本内容或者遍历DOM时,这个区别就会显现出来。
父节点和子节点从哪里来
所谓DOM树,并不是为了画图好看,它确实保留了HTML中的层级关系。
比如:
<div class="article"> <h2>文章标题</h2> <p>文章内容</p> </div>
这里div可以看作h2和p的父元素,而标题和段落又属于它的子元素。开发时经常会沿着这种关系寻找内容,例如查找父元素、获取子元素或者寻找相邻元素。
理解这种层级以后,很多DOM API的命名也会变得顺眼,例如parentElement、children、firstElementChild这些属性,本质上都是围绕节点之间的关系展开。
DOM让网页真正“动”起来
如果一个HTML页面完全没有脚本,它当然也能正常显示,只不过内容通常比较固定。真正进入网站后台、购物网站、在线表单或者Web应用以后,会发现大量页面内容都在运行过程中发生变化。
点击“加入购物车”以后右上角数量增加,提交评论以后列表里马上出现新内容,切换选项卡以后下面的区域发生变化,这些效果并不一定需要重新加载整个网页。
JavaScript可以直接修改当前DOM,于是浏览器马上根据新的页面结构更新显示结果。
例如创建一个新列表项:
const item = document.createElement('li');
item.textContent = '新的文章';
document.querySelector('ul').appendChild(item);原始HTML里可能根本没有这个li,但脚本运行以后,它已经成为当前DOM的一部分,用户也会在页面里看到它。
理解到这里,很多“动态网页”的实现方式就容易明白了:并不是每次都重新生成一个完整页面,而是脚本根据用户操作和数据变化不断调整DOM。
点击事件也和DOM关系很大
网页交互除了修改内容,另一个经常遇到的东西就是事件。
按钮被点击、输入框发生变化、表单提交、键盘按下某个键,这些行为都可以产生事件,开发者再根据事件执行对应逻辑。
例如:
const menu = document.querySelector('.menu');
menu.addEventListener('click', function () {
menu.classList.toggle('open');
});这里先从DOM中找到菜单元素,再给这个元素注册点击事件。用户点击之后,脚本改变元素的class,CSS再根据新的class控制菜单是否显示。
实际开发中HTML、CSS、JavaScript和DOM经常就是这样串起来工作的:HTML提供初始内容,浏览器建立DOM,JavaScript操作DOM,CSS负责最终呈现。
DOM操作并不是越多越好
既然DOM这么方便,为什么前端性能优化里又经常提到“减少DOM操作”?原因在于页面结构发生变化之后,浏览器可能还需要重新计算样式、布局和绘制结果。
偶尔改一个标题或者增加一个按钮通常没有什么问题,但如果代码短时间内大量增加、删除和调整页面节点,浏览器需要处理的工作也会增加。
例如一次插入几千条列表数据,如果每增加一条就单独改一次页面,通常就不是很理想的处理方式。开发时往往会先整理好数据和节点,再尽量集中更新。
所以DOM不是一个“性能差的东西”,真正需要注意的是操作方式。页面规模越大、更新越频繁,DOM处理方式对体验的影响就越明显。
DOM和虚拟DOM不是一回事
接触React、Vue等前端框架以后,还会频繁看到“虚拟DOM”这个词。它和浏览器里的DOM有关,但并不是同一个概念。
真实DOM是浏览器维护的文档对象结构,而前端框架所说的虚拟DOM,通常是框架在JavaScript层面维护的一种页面结构表示。框架可以先比较前后状态发生了哪些变化,再决定如何更新真实页面。
因此,“虚拟DOM”并不是用来取代浏览器DOM。无论框架内部采用什么更新机制,最后用户真正看到的网页仍然需要落实到浏览器实际呈现的页面结构上。
刚学习前端时没必要一开始就钻进虚拟DOM的实现细节,先把真实DOM和JavaScript操作页面的关系弄明白,后面看React、Vue中的组件更新会顺畅很多。
开发者工具里的DOM最直观
想真正理解DOM,不妨直接打开浏览器开发者工具看看。
在Chrome或者Edge里打开任意网页,按F12进入开发者工具,再切换到Elements面板,可以看到页面当前的元素结构。选中某个元素以后,可以临时修改文字、class或者HTML结构,页面上的显示结果通常会马上跟着变化。
这种操作其实就是最直观的DOM实验。
甚至可以在Console里运行:
document.querySelector('h1')看看浏览器返回了什么,再试着执行:
document.querySelector('h1').textContent = '我修改了标题'页面标题马上变化以后,DOM这个概念基本就不再抽象了。你操作的不是服务器上的HTML文件,而是浏览器当前正在维护的页面文档对象。
理解DOM后很多代码就通了
DOM这个名字听上去像一套很复杂的理论,实际开发里却非常具体。查找按钮是DOM操作,修改文字是DOM操作,生成列表是DOM操作,监听点击事件同样离不开DOM。
如果一定要用一句比较容易记的话来理解,可以把HTML看作网页交给浏览器的文档内容,而DOM则是浏览器把这份文档解析以后形成的、可以被程序操作的对象结构。
JavaScript正是借助这层结构,才有机会真正参与页面运行。
后面再看到document、querySelector()、getElementById()、addEventListener()这些代码时,就不用把它们当成互不相关的API。它们都围绕同一件事情展开:找到当前文档中的对象,并根据业务需要读取、修改或者监听它们。
poxiaoxi博客
精彩评论