页面上的标题怎么换掉?搜索结果怎样逐条插入?用户输入为什么不能随手交给 innerHTML?这些看似不同的问题,其实都在操作同一个对象:浏览器根据 HTML 建立的 DOM。
DOM 把页面表示成一棵可以查询和修改的节点树。JavaScript 找到树上的节点后,就能读取文字、切换样式状态、修改属性,或者创建和删除整段界面。
本章会从一棵很小的树开始,一直做到可运行的任务清单。所有示例都面向浏览器环境;如果直接在没有 DOM 的 Node.js 脚本中运行,document 并不存在。
完成本章后,你应该能够:
Document、元素、文本和注释节点之间的关系;querySelector() 与 querySelectorAll() 在明确范围内查询元素;NodeList、动态 HTMLCollection 和真正的数组;textContent 或 innerHTML;classList、属性和 style 表达页面状态;浏览器读取 HTML 时,会把标签之间的嵌套关系转换成对象之间的父子关系。全局对象 document 代表当前文档,也是进入这棵树的入口。
以下 HTML 不只包含三个标签,它还描述了一棵树:body 是 main 的父元素,h1 和 p 是兄弟元素,两个元素里的文字又各自形成文本节点。
<body>
<main>
<h1>今日计划</h1>
<p>先完成 DOM 练习</p>
</main>
</body>常见节点类型如下:
这些数字适合在调试时识别节点,不必把它们当作日常 API 去死记。实际业务代码通常直接使用 element.children、element.textContent 等更明确的接口。

下面的完整页面刻意保留了列表中的换行与缩进。打开浏览器开发者工具查看输出:
<!doctype html>
<html lang="zh-CN">
<body>
<ul id="drink-list">
<li>茶</li>
<li>水</li>
</ul>
<script>
const list = document.querySelector("#drink-list"
浏览器控制台会得到:
9 #document
1 UL
3 #text 茶
children = 2
childNodes = 5children 只包含两个 li 元素。childNodes 还包含 ul 内的换行和缩进,因此一共有五个节点。只要源码排版改变,空白文本节点的数量就可能改变,所以不要依赖 childNodes[1] 之类的脆弱位置去寻找元素。
当目标是 HTML 元素时,优先使用 children、firstElementChild 和 nextElementSibling。只有确实要处理文字或注释节点时,才使用 childNodes、firstChild 和 nextSibling。
拿到一个节点后,可以沿节点关系在局部结构中移动。最常用的元素遍历接口可以按方向记忆:
假设每条通知都由标题、正文和操作区组成:
<article class="notice">
<h2>系统维护</h2>
<p>今晚 23:00 开始维护。</p>
<div class="actions"><button type="button">知道了</button></div>
</article>从按钮出发,可以先到操作区,再到通知卡片;从标题出发,则可以横向到正文:
const button = document.querySelector(".notice button");
const actions = button.parentElement;
const notice = actions.parentElement;
const title = notice.firstElementChild;
const message = title.nextElementSibling;
console.log(notice.className); // notice
console.log(message.textContent); // 今晚 23:00 开始维护。parentElement 返回元素父节点,而 parentNode 可以返回包括 Document 在内的其他节点类型。同样,nextElementSibling 会跳过空白文本节点,nextSibling 则不会。
下面这种代码把页面结构写死在索引里:
const target = document.body.children[1].children[2].children[0];只要设计人员在中间插入一个元素,路径就会指向错误位置。更稳的策略是先用选择器找到稳定区域,再在很近的父子或兄弟关系中遍历:
const settings = document.querySelector("#account-settings");
const firstField = settings.firstElementChild;
const helpText = firstField.nextElementSibling;如果要向上寻找“最近一个符合条件的祖先”,可使用 closest():
const removeButton = document.querySelector("[data-action='remove']");
const row = removeButton.closest(".task-row");closest() 会先检查元素自身,再逐层向上检查;找不到时返回 null。
遍历要求你先知道附近节点的位置,选择器则可以按标签、ID、类名、属性和它们的组合直接查询。把“找谁”和“在哪里找”写清楚,是 DOM 代码可维护的关键。

getElementById() 根据唯一 ID 查询,querySelector() 接受任意有效的 CSS 选择器。后者只返回第一个匹配元素:
const profile = document.getElementById("profile");
const saveButton = document.querySelector("#profile button[type='submit']");
const missing = document.querySelector(".does-not-exist");
console.log(missing); // null页面中的 id 应保持唯一。重复 ID 会让 HTML 语义和查询结果变得不可靠,不应把“刚好拿到第一个”当成可用规则。
查询结果可能是 null。当元素是可选的,可以先判断;当它按页面约定必须存在,可以尽早抛出明确错误:
const status = document.querySelector("#save-status");
if (status) {
status.textContent = "已保存";
}
const form = document.querySelector("#profile-form");
if (!form) {
throw new Error("缺少 #profile-form 元素");
}querySelectorAll() 返回静态 NodeList。它记录查询发生那一刻的匹配结果;后续 DOM 变化不会自动改写这份列表。
<section id="shop">
<article class="item featured">笔记本</article>
<article class="item">铅笔</article>
</section>
<script>
const shop = document.querySelector("#shop");
const snapshot = shop.querySelectorAll(
这里的区别很重要:
snapshot 是静态 NodeList,长度仍为 2;liveCollection 是动态 HTMLCollection,会反映当前 DOM,所以长度变成 3;querySelectorAll() 会生成一份新的静态结果,长度是 3。NodeList 可以用索引、length 和 forEach(),但它不是真正的数组。需要 map()、filter() 等数组方法时,先转换:
const texts = Array.from(snapshot, (item) => item.textContent);
console.log(texts); // ["笔记本", "铅笔"]查询不必总从 document 开始。元素本身也提供 querySelector() 和 querySelectorAll():
const cart = document.querySelector("#shopping-cart");
const checkedItems = cart.querySelectorAll(".item.is-checked");这段代码不会误选页面其他区域的 .item.is-checked。范围越明确,选择器越容易理解,也越不容易在页面扩展后误伤其他组件。
查询到元素后,最常见的操作是更新内容。此时必须回答一个问题:手中的字符串应该作为普通文字显示,还是作为 HTML 结构解析?
textContent 把内容当作文字const message = document.querySelector("#message");
message.textContent = "<strong>保存成功</strong>";页面会原样显示尖括号和标签名,不会创建 strong 元素。显示用户名、评论、搜索词、接口消息等外部内容时,这是安全的默认选项。
innerHTML 会启动 HTML 解析器const message = document.querySelector("#message");
message.innerHTML = "<strong>保存成功</strong>";这次浏览器会创建一个 strong 子元素。两种写法处理同一字符串时,结果可以用以下页面验证:
<p id="plain"></p>
<p id="rich"></p>
<script>
const value = "<strong>新消息</strong>";
document.querySelector("#plain").textContent = value;
document.querySelector("#rich").innerHTML = value;
console.log(document.
<strong>新消息</strong>
0
新消息
1
不要把用户输入、地址栏参数、评论内容或未经确认的接口字段直接赋给 innerHTML。其中的标签、事件属性或危险链接可能被浏览器解释为可执行页面内容,形成 XSS。若只需要显示文字,使用 textContent;若确实需要富文本,应采用经过审查的清洗方案和明确的允许列表。
当结构由程序确定、文字来自外部时,创建节点能自然分开“结构”和“数据”:
const notice = document.createElement("p");
const label = document.createElement("strong");
label.textContent = "提交者:";
notice.append(label, userName);userName 即使包含类似标签的字符,也只会作为文本节点插入。还要避免频繁使用 element.innerHTML += ...:它会重新解析并替换该元素的后代节点,可能让原节点的状态和监听关系丢失。
innerText 也能读写文字,但它更接近用户在当前布局中“看得见”的文本,会考虑 CSS 显示状态。一般的数据读写优先用 textContent;确实需要渲染后的可见文本时,才考虑 innerText。
DOM 提供多条修改外观的路径。选择哪一条,取决于你要表达的是界面状态、元素语义,还是一个运行时计算出来的数值。
classList把视觉规则放在 CSS,把 JavaScript 的职责限制为切换状态类:
.panel {
opacity: 0.55;
}
.panel.is-ready {
opacity: 1;
}const panel = document.querySelector(".panel");
panel.classList.add("is-ready");
panel.classList.remove("is-loading");
console.log(panel.classList.contains("is-ready")); // true
panel.classList.toggle("is-collapsed");
panel.classList.toggle("has-error", errorCount > 0);toggle(name) 会在有类名时删除、没有时添加。第二个布尔参数可以明确指定最终状态:真值保证添加,假值保证删除。相比直接覆盖 className,classList 不会意外擦掉元素上其他有用的类。
HTML attribute 是标记上的键值信息,getAttribute()、setAttribute()、hasAttribute() 和 removeAttribute() 用于操作它们:
const link = document.querySelector(".help-link");
link.setAttribute("href", "/help/dom");
link.setAttribute("aria-label", "打开 DOM 帮助");
console.log(link.getAttribute("href")); // /help/dom许多 attribute 会映射为 DOM property,例如 input.value、button.disabled、image.alt。property 往往更适合当前运行状态:
<input id="nickname" value="小明">const input = document.querySelector("#nickname");
input.value = "小夏";
console.log(input.value); // 小夏,当前值
console.log(input.getAttribute("value")); // 小明,标记中的初始值布尔 attribute 只看是否存在,不看字符串内容。disabled="false" 仍然表示禁用。要启用按钮,应写 button.disabled = false 或 button.removeAttribute("disabled")。
自定义数据可以放进 data-*,并通过 dataset 访问。连字符名称会转成驼峰形式:
<li data-task-id="T-104" data-priority="high">整理笔记</li>const task = document.querySelector("[data-task-id='T-104']");
console.log(task.dataset.taskId); // T-104
task.dataset.priority = "normal";dataset 中的值都是字符串。无障碍所需的 aria-* 通常使用 getAttribute() 和 setAttribute(),并在视觉状态变化时同步更新。
style 适合少量动态数值元素的 style 对象修改的是行内样式。CSS 带连字符的名称在 JavaScript 中通常改为驼峰形式:
const progress = document.querySelector(".progress-bar");
progress.style.width = "60%";
progress.style.backgroundColor = "seagreen";
progress.style.setProperty("--progress", "60%");进度宽度、拖拽坐标等数据驱动的值适合直接设置。若要切换一整套颜色、间距和阴影,状态类通常更清楚。
读取 element.style.color 只能看到行内样式,不会自动给出样式表最终计算出的颜色。确实需要渲染后的值时,可调用:
const finalStyle = getComputedStyle(progress);
console.log(finalStyle.width);动态界面经常要增加整块结构。一个安全、可读的流程是:先创建元素,再设置文字与属性,最后插入目标容器。
<ol id="tasks"></ol>const tasks = document.querySelector("#tasks");
function createTask(text, priority) {
const item = document.createElement("li");
const label = document.createElement("span");
item.classList.add("task");
item.dataset.priority = priority;
label.textContent =
常用方法可以按相对位置理解:
parent.append(node):放到父元素内部末尾;parent.prepend(node):放到父元素内部开头;element.before(node):放到元素前面,成为它的兄弟;element.after(node):放到元素后面,成为它的兄弟;element.replaceWith(node):用新节点替换当前元素;element.remove():把当前元素从树上移除。append() 可以一次接收多个节点或字符串。较早的 appendChild() 只接收一个节点,并返回被插入的节点。新代码通常可以根据是否需要返回值、是否要插入多个值来选择。
const urgent = createTask("立即备份", "urgent");
tasks.prepend(urgent);
const note = document.createElement("p");
note.textContent = "共有三项任务";
tasks.after(note);
second.remove();执行后,列表中只剩“立即备份”和“整理 DOM 笔记”,说明段落位于列表之后。
一个节点在同一时刻只能位于树中的一个位置。把已有节点再次 append() 到另一个容器,不会生成副本,而是把它从原位置移动过去。
const archive = document.querySelector("#archive");
archive.append(first); // first 从 #tasks 移到 #archive确实需要副本时,可以使用 cloneNode(true) 深复制后代结构:
const copy = first.cloneNode(true);
archive.append(copy);复制出来的是新的节点对象。用 JavaScript 附加的事件监听器不会随 cloneNode() 复制,重复的 id 也需要主动避免。
页面刷新后,纯 DOM 操作产生的节点会消失,因为修改发生在当前页面的内存结构中,并没有自动写回 HTML 文件或持久化到存储。
少量 DOM 修改通常无需担心性能。真正容易拖慢页面的是在大循环中反复查询同一节点、频繁触碰实时 DOM,或者交错读取布局和写入样式。

如果容器在循环期间不会变化,不要每次都重新查询:
// 不必要地重复查询
items.forEach((item) => {
document.querySelector("#result-list").append(createResult(item));
});// 查询一次,明确更新范围
const resultList = document.querySelector("#result-list");
items.forEach((item) => {
resultList.append(createResult(item));
});DocumentFragment 是轻量的临时容器。可以先在其中装配节点,最后一次移入页面:
const resultList = document.querySelector("#result-list");
const fragment = document.createDocumentFragment();
items.forEach((item) => {
const li = document.createElement("li");
li.textContent = item.name;
fragment.append(li);
});
resultList.replaceChildren(fragment);replaceChildren() 会先清空容器,再放入新节点。若要保留已有内容,就改用 resultList.append(fragment)。插入完成后,fragment 本身会变空,因为它的子节点已经移动到页面里。
DocumentFragment 不是“永远只触发一次渲染”的保证,浏览器会自行安排样式计算与绘制;它的实际价值是减少对实时 DOM 的逐项插入,并让批量构建的意图更明确。
某些属性会要求浏览器给出当前布局,例如 offsetWidth、getBoundingClientRect()。如果每写一次样式就马上读取布局,浏览器可能被迫反复同步计算:
// 交错写入和读取,规模大时可能造成布局抖动
cards.forEach((card) => {
card.style.width = "50%";
console.log(card.offsetWidth);
});能分阶段时,先集中读取,再集中写入:
const widths = Array.from(cards, (card) => card.offsetWidth);
cards.forEach((card, index) => {
card.style.setProperty("--old-width", `${widths[index]}px`);
card.classList.add("is-compact");
});优化前先确认页面确实存在性能问题。几十个节点的清晰实现通常比复杂的微优化更重要;当列表达到成千上万项时,还应考虑分页或虚拟列表,而不是一次把所有节点塞进 DOM。
下面把查询、文本写入、属性、节点创建与删除组合成一个小应用。点击事件只负责触发更新,核心仍然是如何安全、准确地维护 DOM。
createElement() 和 textContent 创建,不能拼接用户输入到 HTML 字符串。aria-pressed。DocumentFragment 一次加入页面。<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>安全任务清单</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 42rem; margin: 3
<strong>测试</strong> 时,页面应显示这些字符,而不是生成加粗标签。aria-pressed 和统计数字一起更新。li、span 和两个 button 组成。data-task-id,删除时在控制台记录对应 ID。NodeList 找到已完成项后逐个 remove()。replaceChildren() 一次替换。document 是进入这棵树的入口。children 与带 Element 的遍历属性只处理元素;childNodes 等节点接口还会遇到文字、注释和空白。querySelector() 返回首个匹配元素或 null;querySelectorAll() 返回静态 NodeList。NodeList 不会跟随 DOM 自动变化;动态 HTMLCollection 会变化,但两者都不等于真正数组。textContent;只有在 HTML 内容可信并经过可靠处理时才考虑 innerHTML。classList 表达界面状态,用 property 表达当前控件状态,用 attribute 保存标记语义,用 style 设置少量动态值。createElement() 配合 append()、prepend()、before()、after() 和 remove(),可以安全维护节点的完整生命周期。