DOM节点并非您上传的HTML字符串。
DOM 并非你上传的 HTML 字符串。解析树、实时的 NodeList 以及重排现象,解释了为何在 JavaScript 和 React 前端中以字符串思维方式会导致问题。
来源: The DOM Is Not Your HTML: The Difference Every Frontend Developer Should Understand
你编写的是 HTML,但 JavaScript 并不会修改磁盘上的那个文件。它与浏览器中的实时文档对象模型进行交互。
当标记内容被发送到浏览器后,就会形成初始的页面结构。一个文档可能以一个简单的标题开始:
<h1>Hello</h1>
加载完成后,JavaScript 可以找到该标题并替换其文本内容:
document.querySelector("h1").textContent = "Hello World";
此时屏幕上显示的内容为:
<h1>Hello World</h1>
这看起来好像 HTML 文件本身被重写了,但实际上并非如此。服务器上的文件仍然保留着原来的标题。JavaScript 只是改变了浏览器中页面的实时模型——即 DOM。将源标记与这个实时模型分开,有助于理解许多 JavaScript 行为。
HTML 是文档,而 DOM 是其实时表示形式。
HTML 就是你编写的标记语言,例如:
<body>
<h1>Hello</h1>
<button>Click me</button>
</body>
浏览器会将该文档解析为内存中的结构,这种结构就是文档对象模型。其工作流程的大致步骤如下:
HTML
│
│ browser parses
▼
DOM
│
├── body
│ ├── h1
│ └── button
DOM并非只是HTML的字符串副本,而是一种结构化且可编程的文档,JavaScript正是对它进行操作。
那么JavaScript究竟改变了什么?
从以下步骤开始:
<h1 id="title">Hello</h1>
然后执行:
const title = document.querySelector("#title");
title.textContent = "Welcome";
脚本会找到DOM节点并更新其文本内容,此时的实时结构表现为:
<h1 id="title">Welcome</h1>
原始的HTML源代码并未被重新编写。如果这些标记来自服务器或静态文件,那么该源代码中仍然包含:
<h1 id="title">Hello</h1>
两者的区别虽小却至关重要:修改DOM与编辑HTML源代码并非同一回事。
这里有一种简单的方法可以看清两者的差异
打开一个页面,然后通过控制台进行修改:
document.querySelector("h1").textContent = "Changed!";
可见页面会立即更新。对比实时 DOM:
<h1>Changed!</h1>
与原始 HTML 源代码:
<h1>Hello</h1>
由于浏览器是基于其实时 DOM 运行的,两者可能会出现差异。
DOM 不只是你最初编写的代码
假设标记内容初始为:
<ul id="users">
<li>John</li>
</ul>
JavaScript 可以添加另一个元素:
const list = document.querySelector("#users");
const item = document.createElement("li");
item.textContent = "Sarah";
list.appendChild(item);
现在的实时树结构包含:
ul
├── li "John"
└── li "Sarah"
“Sarah”从未出现在原始文件中。浏览器无需重新编写源文档,脚本直接修改了实时 DOM。这一功能是交互式网站的基础。
DOM 是一棵树
“对象模型”听起来比实际概念更复杂。最简单来说,DOM 就是由节点构成的树结构。给定:
<main>
<h1>Hello</h1>
<p>Welcome!</p>
</main>
浏览器会构建出大致如下的结构:
Document
│
└── main
├── h1
│ └── "Hello"
│
└── p
└── "Welcome!"
正是由于这种树形结构,脚本才能遍历诸如以下这样的关联关系:
element.parentElement
element.children
element.nextElementSibling
你并不是在扫描普通文本文件,而是在导航一个结构化的树形结构。
而且 DOM 是可以变化的
源 HTML 通常是初始文档,而内存中的树形结构在运行时始终处于可修改状态。脚本可以分配节点:
const button = document.createElement("button");
设置属性:
button.setAttribute("disabled", "");
更改内容:
button.textContent = "Loading...";
并插入节点:
document.body.append(button);
在应用程序运行过程中,这棵树会不断演变,这也是为什么交互式界面无需为每次细微改动都下载完整的 HTML 文件。
但还有一件事常常让开发者感到困惑
检查页面时可能会发现一些原本并不存在于原始源代码中的 HTML 内容。脚本可能会创建这些内容:
<div class="notification">
Saved successfully
</div>
你并未创建该节点,但开发者工具仍能在“元素”面板中显示它,因为该面板反映的是DOM结构。而“查看源代码”功能则可能完全不显示该节点。
因此,“查看源代码”与“检查元素”的结果显示并不一定一致。一个常见原因是JavaScript在初始HTML加载后会修改DOM结构。
这对前端开发者为何重要?
一旦思维模式从“JavaScript修改HTML”转变为“JavaScript修改DOM”,许多前端行为的原理就会更加清晰。这种视角有助于理解诸如以下的API:
element.classList.add("active");
element.remove();
element.textContent = "Loading...";
element.append(newElement);
这些调用作用于实时的文档结构,浏览器在渲染屏幕时就会使用这个已更新的树形结构。
React呢?
在学习React时,同样的区分也很有帮助。你可能会写出这样的代码:
return <h1>Hello</h1>;
JSX 并非 DOM 本身,而是一种用于描述用户界面的语法。React 负责决定如何将这种描述映射到真实的 DOM 中。在深入研究框架的抽象概念之前先了解 DOM,就能让渲染、状态同步、引用以及 DOM 更新这些概念变得不那么晦涩。
你无需掌握所有内部细节,只需记住以下流程:
HTML
↓
Browser parses it
↓
DOM
↓
JavaScript can modify it
↓
Browser updates the rendered page
框架会在其之上添加更多层次,但浏览器的 DOM 依然是基础。
一个常见错误
开发者有时会将 DOM 的变更视为对原始 HTML 的永久性修改,但实际上并非如此。例如:
document.body.innerHTML = "<h1>New page</h1>";
该分配内容可以替代当前的实时结构,但不会重写服务器上的HTML文件。刷新操作会从源文件重新加载文档。除非更改已被保存并生成了新的HTML,否则原始文档仍会显示。正是这种差异导致了运行时状态与源内容之间的区别。
需要牢记的心智模型
避免混淆这些概念:
HTML = DOM
应采用更接近以下的思维方式:
HTML
│
│ parsed by browser
▼
DOM
│
│ modified by JavaScript
▼
Updated DOM
│
│ used during rendering
▼
What the user sees
将HTML视为原始的源文档,而将DOM视为基于该源文档在浏览器中构建的可修改树结构。脚本用于修改这棵树,而渲染则根据树当前所包含的内容进行。
最终要点
不要将 DOM 与 HTML 文件混为一谈。标记语是您编写的代码;而对象模型则是浏览器在标签页打开时维持的结构。有了这种区分,一些现象就变得显而易见了:为什么脚本能够添加和删除节点,为什么 querySelector() 能够正常工作,为什么 textContent 能更新显示内容,为什么开发者工具能够展示那些并非由您编写的节点,为什么实时树结构会与源代码有所不同,以及为什么 React 最终仍需操作 DOM。
关键的思路其实很简单:您编写 HTML,浏览器构建 DOM,JavaScript 则修改 DOM。一旦理解了这一点,DOM 就不再显得像是一个晦涩难懂的 API,而会展现出它的真实面目——即浏览器对页面的实时呈现。