首页 / 文章

DOM节点并非您上传的HTML字符串。

DOM 并非你上传的 HTML 字符串。解析树、实时的 NodeList 以及重排现象,解释了为何在 JavaScript 和 React 前端中以字符串思维方式会导致问题。

发布者:REACTAPP.TOP阅读约 5 分钟1180 词

来源: 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,而会展现出它的真实面目——即浏览器对页面的实时呈现。