DOM 基础与节点操作
浏览器把 HTML 文档解析成一棵内存中的树,JavaScript 通过操作这棵树来改变页面内容,这棵树就是 DOM(Document Object Model,文档对象模型)。本文从树的模型讲起,覆盖节点查找、遍历、增删改与性能优化。
一、DOM 概念与文档树
DOM 把 HTML 文档表示为节点组成的树,树的根是 document,往下依次是 <html>、<head>、<body> 及各层元素:
<!DOCTYPE html>
<html>
<head>
<title>示例</title>
</head>
<body>
<h1 id="title">你好</h1>
<ul>
<li>苹果</li>
<li>香蕉</li>
</ul>
</body>
</html>对应的树结构:
document
└── html
├── head
│ └── title
└── body
├── h1#title
├── ul
│ ├── li
│ └── li
└── (文本节点、注释等)几个关键认知:
- 树上的每个元素、文本、注释、属性都是一个节点(Node)。
- 元素之间有父子、兄弟关系,正是这些关系支撑了遍历 API。
- 修改 DOM 是"操作树",浏览器会同步反映到页面渲染上(部分场景会触发重排)。
二、节点类型与 nodeType
每个节点都有一个 nodeType 数值属性,标明它是什么类型的节点:
| nodeType | 名称 | 常量 | 代表内容 |
|---|---|---|---|
| 1 | 元素节点 | Node.ELEMENT_NODE | <div>、<p> 等标签 |
| 2 | 属性节点 | Node.ATTRIBUTE_NODE | 元素上的属性(已废弃,少用) |
| 3 | 文本节点 | Node.TEXT_NODE | 元素内的文字 |
| 8 | 注释节点 | Node.COMMENT_NODE | <!-- 注释 --> |
| 9 | 文档节点 | Node.DOCUMENT_NODE | document 本身 |
| 10 | 文档类型节点 | Node.DOCUMENT_TYPE_NODE | <!DOCTYPE html> |
| 11 | 文档片段节点 | Node.DOCUMENT_FRAGMENT_NODE | DocumentFragment |
document.nodeType; // 9
document.documentElement.nodeType; // 1(<html> 是元素节点)
document.body.firstChild; // 可能是文本节点(换行/空格)或元素判断节点类型推荐用 nodeType 或 nodeName/tagName:
const el = document.querySelector("h1");
el.nodeType; // 1
el.nodeName; // "H1",元素节点返回大写标签名
el.tagName; // "H1"
el.firstChild.nodeType; // 3(文本节点)元素之外,文本节点(nodeType 3)尤其容易被忽略——HTML 源码里的换行和空格也会生成文本节点,这正是"看似没有子元素却 firstChild 取到换行符"的原因。
三、选择元素:四类查询 API 对比
3.1 getElementById 与 getElementsByClassName / TagName
document.getElementById("title"); // 按 id 查找,返回单个元素(或 null)
document.getElementsByClassName("item"); // 按 class 查找,返回 HTMLCollection(实时)
document.getElementsByTagName("li"); // 按标签名查找,返回 HTMLCollection(实时)
document.getElementsByName("username"); // 按 name 属性查找,返回 NodeListgetElementsBy* 返回的 HTMLCollection 是实时集合:DOM 变化后集合会自动更新,遍历时注意长度会变。
3.2 querySelector 与 querySelectorAll
document.querySelector("#title"); // 选择器语法,返回第一个匹配
document.querySelectorAll("ul > li"); // 返回 NodeList(静态快照)
document.querySelectorAll("[data-type='a']"); // 属性选择器3.3 两组 API 对比
| 对比项 | getElementById / getElementsBy* | querySelector / querySelectorAll |
|---|---|---|
| 参数 | 指定方法名(id/class/tag/name) | CSS 选择器,灵活强大 |
| 返回值 | 单个元素 / HTMLCollection | 单个元素 / NodeList |
| 集合特性 | HTMLCollection 实时更新 | NodeList 静态快照 |
| 性能 | 稍快(原生索引) | 稍慢(需解析选择器) |
| 典型场景 | 按 id 取元素 | 复杂选择、批量操作 |
// 实时集合 vs 静态快照的差异
const live = document.getElementsByTagName("li");
const staticList = document.querySelectorAll("li");
const li = document.createElement("li");
document.querySelector("ul").appendChild(li);
console.log(live.length); // 变为 3(实时)
console.log(staticList.length); // 仍是 2(快照)四、节点遍历
有了元素,还要能在树中上下左右移动。遍历属性分为"只含元素"与"包含所有节点"两组:
| 属性 | 含义 | 只含元素 |
|---|---|---|
childNodes | 所有子节点(含文本、注释) | children |
parentNode | 父节点 | parentElement |
firstChild / lastChild | 第一个/最后一个子节点 | firstElementChild / lastElementChild |
nextSibling / previousSibling | 相邻兄弟节点 | nextElementSibling / previousElementSibling |
const ul = document.querySelector("ul");
ul.children; // HTMLCollection:li、li
ul.childNodes; // NodeList:文本节点、li、文本节点、li、文本节点
ul.firstElementChild; // 第一个 li
ul.lastElementChild; // 最后一个 li
ul.parentElement; // body(或更高层容器)
ul.nextElementSibling; // ul 的下一个兄弟元素选择器查找与遍历组合,可以写出健壮的定位逻辑:
// 找到列表中的第一个元素项,再向上找所在区块
const firstItem = document.querySelector("ul li");
const section = firstItem.closest(".card"); // closest 沿祖先链向上匹配
firstItem.matches(".active"); // 判断自身是否匹配选择器五、创建节点
创建元素用 document.createElement,创建文本用 document.createTextNode:
const li = document.createElement("li"); // 创建 <li> 元素
const text = document.createTextNode("橘子"); // 创建文本节点
li.appendChild(text); // 组合起来创建后默认是"孤儿节点",不在文档树中,必须插入才能显示。两种插入风格的对比:
| 传统方法 | 现代方法 | 作用 |
|---|---|---|
appendChild(node) | append(...nodes) | 追加到末尾(可一次多个) |
insertBefore(node, ref) | prepend(...nodes) | 插到开头 |
| — | before(...nodes) / after(...nodes) | 插到自身前/后 |
| — | replaceWith(...nodes) | 替换自身 |
const list = document.querySelector("ul");
// 传统写法
const liA = document.createElement("li");
liA.textContent = "苹果";
list.appendChild(liA);
// 现代写法(可传字符串,自动转文本)
list.append("梨", "桃");
list.prepend("首项");
liA.before("前一项"); // 在 liA 之前插入文本注意 append 与 appendChild 的区别:append 接受多个节点或字符串,字符串会被当作文本;appendChild 只接受一个节点,传入字符串会报错。
六、插入与删除节点
删除与替换同样有新旧两套 API:
const list = document.querySelector("ul");
const first = list.firstElementChild;
list.removeChild(first); // 传统:从父节点删除子节点,返回被删节点
first.remove(); // 现代:节点自行删除,无返回值
const newLi = document.createElement("li");
newLi.textContent = "新项";
list.replaceChild(newLi, list.lastElementChild); // 传统:替换
list.lastElementChild.replaceWith(newLi); // 现代:替换| 方法 | 作用 | 返回值 |
|---|---|---|
parent.removeChild(child) | 父节点删除子节点 | 被删除的节点 |
node.remove() | 节点从树中移除 | 无 |
parent.replaceChild(new, old) | 替换子节点 | 被替换的节点 |
old.replaceWith(new) | 替换自身 | 无 |
parent.insertBefore(node, ref) | 插到 ref 之前 | 插入的节点 |
node.before() / node.after() | 插到自身前/后 | 无 |
removeChild 返回被删节点,可以先保存再插回其他位置——这就是"移动节点"的手法:一个节点同一时刻只能出现在树中一个位置,再次插入会自动从原位置移除。
七、innerHTML 与 textContent 的区别
这是新手最容易踩坑的一对:
| 对比项 | innerHTML | textContent |
|---|---|---|
| 读写内容 | 读写 HTML 片段 | 读写纯文本 |
| 解析行为 | 会解析 HTML 标签 | 全部按文本处理 |
| 危险点 | 注入用户输入可能 XSS | 安全,自动转义 |
| 空白处理 | 保留原始 HTML | 忽略多余空白(读取时合并) |
const box = document.querySelector("#box");
box.innerHTML = "<b>加粗</b>"; // 生成真实 <b> 元素
box.textContent = "<b>加粗</b>"; // 显示字面量 <b>加粗</b>
// 读取差异
box.innerHTML; // "<b>加粗</b>"
box.textContent; // "加粗"安全结论:展示用户输入一律用 textContent;确需 innerHTML 时先转义 <、>,或使用 textContent 拼接后再插入。类似地,outerHTML 读写包含元素自身在内的 HTML。
八、DocumentFragment 与批量操作性能
频繁的"创建 → 插入"会迫使浏览器反复重排(reflow)。DocumentFragment(文档片段)是一个不在文档树中的轻量容器,先把节点全部挂进去,再一次插入:
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement("li");
li.textContent = `项目 ${i}`;
fragment.appendChild(li); // 先挂到片段,不触发页面重排
}
document.querySelector("ul").appendChild(fragment); // 一次性插入,只触发一次重排| 对比项 | 逐个 appendChild | DocumentFragment |
|---|---|---|
| 重排次数 | 每插入一个都可能重排 | 只在最终插入时重排一次 |
| 代码组织 | 循环内直接操作真实 DOM | 统一在片段中组装 |
| 适用场景 | 少量节点 | 批量渲染、模板填充 |
其他性能要点:
- 批量改样式:先
display: none,改完再显示;或合并类名减少重排。 - 读写分离:连续读取布局属性(如
offsetWidth)会强制刷新布局,尽量先读后写。 - 现代框架(Vue/React)内部也大量借助"离屏构建 + 一次性挂载"的思路。
九、综合示例:动态渲染列表
把上面的 API 组合成一个小工具函数,支持数据驱动渲染:
<ul id="list"></ul>
<script>
const data = [
{ name: "苹果", price: 5 },
{ name: "香蕉", price: 3 },
{ name: "橘子", price: 4 },
];
function renderList(items) {
const list = document.querySelector("#list");
const fragment = document.createDocumentFragment();
items.forEach((item) => {
const li = document.createElement("li");
// 用 textContent 拼文本,避免注入
li.textContent = `${item.name}:${item.price} 元`;
fragment.appendChild(li);
});
list.replaceChildren(fragment); // 清空并一次性挂载(replaceChildren 现代方法)
}
renderList(data);
</script>十、小技巧与常见误区
document.body在<body>解析完前是null,脚本要放在</body>前或用DOMContentLoaded再操作。document.documentElement是<html>,document.head是<head>。element.contains(other)判断是否包含后代节点;element.isConnected判断节点是否在文档树中。- 用
===比较节点即可判断是否同一个节点,两个querySelector命中的可能是同一对象。
DOM 操作是浏览器开发的基石,掌握"查找 → 遍历 → 创建 → 插入 → 删除"这条主线,再配合 Fragment 与读写分离的性能意识,就能写出稳定高效的前端代码。