01DOM 是什么与节点树
浏览器拿到你写的 HTML,是怎么把它变成一个可以被 JS 操控的模型的? 答案就是 DOM(Document Object Model)——一棵"倒过来的树", 每一个标签、每一段文本、每一条注释都变成了对象,可以读、查、改、增、删。 这一课让你把这棵树看清、把入口握紧、把节点关系一次钉死。
一、DOM 是什么:JS 与 HTML 的桥梁
浏览器读到 <html> 时并不会把标签当作「文字」——它会立刻把整页解析成一棵
对象树,每个标签、每段文本、每条注释都是树上的一个「节点(Node)」,
每个节点都有属性、方法,还能被 JS 取出来随意操作。这棵对象树,就是 DOM。
JS 通过 DOM API(一组浏览器内置的对象与方法)来读写这棵树:
querySelector() 查节点、textContent 改文字、
createElement() 新建节点、addEventListener() 绑定事件。
学完整个 DOM 章节,你会反复用到这几个动作。
| 维度 | HTML(源代码) | DOM(内存对象) | 由谁生成 |
|---|---|---|---|
| 本质 | 写在文件里的字符串文本 | 浏览器内存里的一棵对象树 | 你写 / 浏览器建 |
| 动态性 | 写完就定型 | JS 可以随时增删改 | — |
| 谁能改 | 改文件 → 重新加载 | el.remove()、el.innerHTML = ... | JS 任意改 |
| 常见观察 | F12 → Sources 面板看原文 | F12 → Elements 面板看树 | — |
二、document 是入口
整棵 DOM 树的「树根」,叫 document。它实际上是浏览器全局对象
window 上的一个属性:window.document === document。
在普通脚本里直接写 document 就行,不必带 window.。
document 身上挂着一堆常用属性,可以一键读出整页状态:
| 属性 | 含义 | 典型取值 |
|---|---|---|
document.title | 页签标题(<title> 内容) | "前端自习室" |
document.URL | 当前页完整网址 | "https://..." |
document.body | <body> 元素节点 | <body> |
document.head | <head> 元素节点 | <head> |
document.forms | 页内所有表单集合 | HTMLCollection |
document.images | 页内所有图片集合 | HTMLCollection |
document.documentElement | <html> 元素节点 | <html> |
点运行查看这些属性的实时取值——改完 document.title,浏览器页签的文字也会跟着变。
document.title = "..." 那行改成你想看的标题,回头看浏览器页签document 自身就是一个 节点(Node),
类型是 Node.DOCUMENT_NODE(值为 9)。所以它身上也有
nodeType / nodeName 这些通用属性,下一节看。三、节点树:DOM 是树形结构
把浏览器解析后的 DOM 画出来,就是一棵倒过来的树:
根在顶端 document,向下分叉出 <html>,
再分叉出 <head> / <body>,如此类推。
每一个圈圈都是一个节点(Node)。
每个节点都有三种「身份证」属性,任何时候取出来都一致:
nodeType(类型编号)、nodeName(名字)、nodeValue(值)。
| 常量 | 数值 | 对应节点 | nodeName | nodeValue |
|---|---|---|---|---|
Node.ELEMENT_NODE | 1 | HTML 元素(<div> 等) | 标签名大写 "DIV" | null |
Node.TEXT_NODE | 3 | 文字、空白、换行 | "#text" | 文本内容 |
Node.COMMENT_NODE | 8 | 注释 <!-- --> | "#comment" | 注释内容 |
Node.DOCUMENT_NODE | 9 | document 自身 | "#document" | null |
用 DOMParser 解析一段 HTML 字符串,就能立刻得到一棵完整的节点树——这是后面所有 DOM 操作的「原材料」。
type=3)的 nodeValue 才有意义,
元素节点(type=1)的 nodeValue 永远是 null——
元素里的文字要用 textContent,下一课细讲。四、节点关系:parent / child / sibling
节点之间有三类关系:
父节点(parent)、
子节点(child)、
兄弟节点(sibling)。
DOM 给你 6 个属性把这三种关系打通:parentNode 找爹、
childNodes / firstChild / lastChild 找孩子、
previousSibling / nextSibling 找邻居。
| 属性 | 含义 | 备注 |
|---|---|---|
parentNode | 父节点 | 每个节点只有一个爹 |
childNodes | 所有直接子节点列表 | 含空白文本节点 |
firstChild | 第一个子节点 | 可能是空白文本 |
lastChild | 最后一个子节点 | 可能是空白文本 |
previousSibling | 上一个兄弟 | 可能是空白文本 |
nextSibling | 下一个兄弟 | 可能是空白文本 |
一个最容易被坑的细节:HTML 源码里的换行和缩进,全部都是文本节点。
所以 <div> 里只有一行 <p>,
它的 childNodes.length 也可能是 2(前后各一个空文本节点)。
parentNode 一路向上爬,把每一层的「自己 / 父节点」打印出来node.childNodes.forEach(c => walk(c)),再判断
node.nodeType === 1 才处理元素——可以避免对注释和空白文本做多余工作。五、元素的特殊属性
上一节的 6 个「通用属性」对任何节点都适用。
但日常 80% 的操作都在元素节点(nodeType === 1)上,
DOM 给元素单独开了一套「跳过空白」的属性,找孩子更干净。
| 需求 | Node 通用版(含空白) | Element 专用版(仅元素) |
|---|---|---|
| 当前节点的标签名 | nodeName | tagName(始终大写) |
| 所有直接子 | childNodes | children |
| 子节点数量 | childNodes.length | childElementCount |
| 第一个子 | firstChild | firstElementChild |
| 最后一个子 | lastChild | lastElementChild |
| 上一兄弟 | previousSibling | previousElementSibling |
| 下一兄弟 | nextSibling | nextElementSibling |
关键差异:children 只算元素子节点,自动跳过空白文本——
这是日常开发应该默认用「Element 版」的根本原因,少踩一半坑。
childNodes 把换行 / 缩进也算进去(数量多),children 只算真元素(数量少)el.children / el.firstElementChild 而不是
el.childNodes / el.firstChild,少写一堆空值判断。六、DOM 操作的核心四件套(先讲「读」)
所有 DOM 操作可以归为 4 类: 读(query / get)、 改(textContent / innerHTML)、 增(createElement + append)、 删(remove)。 这一课只讲「读」:把节点查出来,最经典的 4 个 API。
| 动作 | 核心 API | 返回 | 本节是否讲 |
|---|---|---|---|
| 读(query) | querySelector / querySelectorAll | 单个元素 / NodeList | ✓ 本节 |
| 改(update) | textContent / innerHTML / setAttribute | 字符串 | 第 02 课 |
| 增(create) | createElement + append / prepend | 新元素 | 第 03 课 |
| 删(remove) | element.remove() | — | 第 03 课 |
「读」这一步的黄金组合:document.querySelector(css选择器)
返回单个元素,document.querySelectorAll(...) 返回多个元素。
它们通用、灵活、跟 CSS 选择器完全一样,是现代 DOM 首选。
七、DOMContentLoaded vs load
浏览器读 HTML 是一行一行往下走的:读到什么标签,就立刻解析对应的节点。
当HTML 整张解析完、DOM 树构建好那一刻,会触发
DOMContentLoaded 事件;等所有图片 / 样式表 / 脚本都加载完,
才触发 load 事件。
| 事件 | 触发时机 | DOM 可访问? | 资源全好? | 典型场景 |
|---|---|---|---|---|
DOMContentLoaded | HTML 解析完成 | ✓ | ✗(图片等可能还在) | 尽早初始化 JS 逻辑 |
load | 所有资源加载完毕 | ✓ | ✓ | 统计埋点 / 启动特效 |
在 <head> 里写的 JS 会在 HTML 还没解析完时就执行——
此时 下面的元素还不存在,直接
document.querySelector(...) 会拿到 null。
解决办法:要么把 JS 放最末尾(最常用),要么监听
DOMContentLoaded,要么给 <script> 加
defer 属性。
document.querySelector("#box") 提前读会拿到 nulllearn.js 注入到 <script> 里并以 defer
加载,等到 DOM 树构建完才执行,所以 lab 里大多数 querySelector
都能拿到结果。真实项目里,
优先用 <script defer> 或把脚本放 </body> 前,
不要依赖 DOMContentLoaded 兜底——它是个保险,不是首要方案。八、本章小结
- DOM = 浏览器把 HTML 解成的一棵对象树,JS 通过 DOM API 读写它——和 HTML 原文是两个东西。
document是入口(nodeType === 9);document.title/.URL/.body/.head是一组秒读页面的属性。- 每个节点都有
nodeType/nodeName/nodeValue三件套;常见类型:元素1、文本3、注释8、文档9。 - 节点关系 6 属性:
parentNode/childNodes/firstChild/lastChild/previousSibling/nextSibling——含空白文本节点。 - 元素专属 6 属性跳过空白:
tagName/children/childElementCount/firstElementChild/lastElementChild/previousElementSibling/nextElementSibling。 - DOM 四件套:读 / 改 / 增 / 删;本课仅讲「读」
querySelector/querySelectorAll。 DOMContentLoaded= HTML 解析完;load= 全资源完;脚本最好用<script defer>或放底部。
document.title = "改成你的名字" 看浏览器页签;
② 写一段含 <h1>、一段文字、一条注释的 HTML 字符串,用
DOMParser 解析,遍历输出每个节点的 nodeType / nodeName / nodeValue;
③ 把同一段 HTML 拿去比较:childNodes.length 与
children.length 的差,看看哪个算上了空白文本;
④ 在一个嵌套 4 层的元素上连写:
e.parentNode.parentNode.parentNode.parentNode,看一路爬回哪里;
⑤ F12 → Elements 面板随便点一个元素,在控制台输入
$0.nodeType、$0.tagName、$0.children.length,
把上面学到的属性现场验证一次。