Skip to content
This page has been auto-translated and may contain errors.View in English

DOM

docs.scrimba.com

你的 HTML 文件加载一次,然后页面就坐在那里。但是一个随着你输入而建议结果的搜索框、一个点击时打开的菜单、一个滴答计数的计数器,这些都不在 HTML 文件中。加载后有东西改变了页面上的内容,那个东西就是 JavaScript 通过 DOM 到达页面。这章讲的就是这种到达:JavaScript 如何找到页面的各个部分并改变它们,使页面在阅读者面前更新。

什么是 DOM

当浏览器加载你的页面时,它读取你的 HTML 并从中构建一个**对象树**:一个用于页面的对象,一个用于 <body> 的对象,一个用于里面的每个标题、段落和按钮。这个树就是 DOM,是 Document Object Model 的缩写。你的 JavaScript 可以读取这个树并改变它,当它这样做时,你看到的页面会立即更新。

值得记住的部分是:DOM 不是你的 HTML 文件。它是页面的活动版本,位于浏览器的内存中。你的 .html 文件是起始配方;DOM 是浏览器正在提供的完成的菜肴,JavaScript 可以在每个人还在吃的时候重新整理盘子。

DOM(Document Object Model)是浏览器从你的 HTML 构建的**内存中的树**。每个元素都成为一个对象,按照你的标签嵌套的方式嵌套:<body> 包含一个 <main>,它包含一个 <section>,它包含一个段落。JavaScript 通过全局 document 获得这个树的把手,从那里它可以走过树,读取任何元素,并改变它。

稍后节省你困惑的区别是 DOM 与你的源 HTML 分开。HTML 被解析一次来构建树;之后,树就是活动的。改变 DOM 页面更新;你原始 HTML 文件在磁盘上从不改变。这就是为什么一旦 JavaScript 运行,"查看源代码"看起来可能与屏幕上看到的完全不同。

DOM 是浏览器的文档活动对象模型:浏览器通过解析你的 HTML 构建的**节点**树(元素、文本、注释和文档本身),以及它从中渲染的东西。这是一个语言中立的 API,通过 document 和挂在它上面的对象公开给 JavaScript,但不是 JavaScript 语言的一部分;它是运行时提供的浏览器特性。

实际上重要的框架是:HTML 源是一个输入,被解析一次到树中,DOM 从那时起就是真实的来源。在你的脚本运行后读取 document.body.innerHTML,你得到的是当前序列化的树,而不是你发送的字节。这也是为什么 DOM 直接来自 HTML:你写的标签定义初始树,JavaScript 做的一切都是之后编辑那个树。操纵 DOM 有源没有的成本,因为改变可以触发浏览器重新计算布局和重绘,这是你在下面高级部分中跟随的线索。

DOM 中的每个元素都是一个对象,与你在 objects 中遇到的类型相同,拥有你可以读取和改变的属性。这就是全部技巧:因为页面是对象,而你已经知道如何处理对象,改变页面就是改变对象属性。

Juno什么是 DOM 浏览器把你的 HTML 变成一个称为 DOM 的对象树,这个树是活动页面。你的 HTML 文件是配方;DOM 是正在提供的饭菜,JavaScript 可以在它在桌子上时改变它。每个元素都是一个对象,所以改变页面意味着改变对象属性。
Juno什么是 DOM DOM 是浏览器从你的 HTML 构建的内存中的树,通过 document 到达。它与你的源文件分开:解析一次,然后树就是活动的,这就是为什么"查看源代码"可能不同于你看到的。改变树和页面更新。
Juno什么是 DOM DOM 是浏览器从 HTML 解析并渲染的节点树;它是一个浏览器 API,不是语言的一部分。源是一个解析一次的输入,树是之后的真实,所以 innerHTML 反映当前树,而不是你发送的字节。编辑可以造成布局和重绘成本,这就是为什么写模式稍后重要。

选择元素

在你能改变一个元素之前,你必须在树中找到它。你用选择器——与 CSS 相同的方式——指向它,浏览器给你返回匹配的对象。

主力是 document.querySelector。你给它一个**CSS 选择器**,你在 CSS 中使用的相同类型的模式,它返回第一个匹配的元素:

js
const card = document.querySelector(".card"); // class="card" 的第一个元素
const total = document.querySelector("#total"); // id="total" 的元素

.card 按类匹配,#total 按 id 匹配,像 "button" 这样的普通词按标签名匹配。querySelector 给你第一个匹配并在那里停止。当你想要每一个匹配时,改用 querySelectorAll,它返回一个你可以循环的列表:

js
const cards = document.querySelectorAll(".card"); // 所有 class="card" 的元素
console.log(cards.length); // 它找到了多少个

如果没有匹配,querySelector 返回 null,这是 JavaScript 说"这里没有任何东西"的方式。

document.querySelector 接受任何 CSS 选择器并返回第一个匹配的元素,如果没有匹配则返回 nulldocument.querySelectorAll 返回所有匹配作为一个列表:

js
const toggle = document.querySelector(".menu-toggle"); // 第一个匹配,或 null
const items = document.querySelectorAll(".card"); // 每个匹配,作为一个列表

选择器是完整的 CSS,不仅仅是类和 id:querySelector("nav a") 找到导航内的第一个链接,querySelector("input[type='email']") 按属性匹配。无论什么在样式表中选择它,这里也选择它。

null 返回是要计划的。因为一个缺失是 null,像 document.querySelector(".missing").textContent 这样的代码会抛出,因为你不能读取 null 的属性。当一个元素可能不存在时,先检查:if (toggle) { ... }

document.querySelector(selector) 返回按文档顺序匹配 CSS 选择器的第一个元素,或 nulldocument.querySelectorAll(selector) 返回所有匹配的 NodeList。两者都接受完整的 CSS 选择器语法,所以组合符、属性选择器和伪类都工作:

js
const firstError = document.querySelector(".form .field.is-invalid input");
const externalLinks = document.querySelectorAll("a[href^='http']");

结果的两个属性值得了解。首先,来自 querySelectorAllNodeList静态的:它是调用时的快照,所以之后添加到页面的元素不会出现在其中。这与较旧的 API(getElementsByClassNamegetElementsByTagName)不同,它们返回当 DOM 更改时更新自身的活动 HTMLCollection;更多关于那个差异的信息在最后一部分。其次,作用域对性能和正确性很重要:在一个元素而不是 document 上调用 querySelector 只搜索该元素的子树(card.querySelector(".title")),这既在大页面上更快,又不受匹配其他地方的影响。只要你已经拥有容器,就选择作用域形式。

Juno选择元素 要找到一个元素,给 document.querySelector 一个 CSS 选择器如 .card#total,它给你第一个匹配。当你想要所有匹配时使用 querySelectorAll 作为一个列表。如果没有匹配你得到 null,这意味着"这里没有任何东西"。
Juno选择元素querySelector 返回第一个匹配,querySelectorAll 返回每个匹配作为一个列表,两者都接受你在样式表中会使用的任何 CSS 选择器。一个缺失返回 null,所以读取它的属性会抛出;当元素可能不在那里时用 if (element) 保护。
Juno选择元素 两个方法都接受完整 CSS 选择器;querySelectorAll 交回一个静态 NodeList,一个将不会看到后来的添加的快照。在一个元素上而不是 document 上调用 querySelector 来只搜索它的子树,这既更快又避免匹配整个页面的东西。并记住缺失是 null

读取和改变元素

一旦你持有一个元素,你通过它的属性读取和改变它,与你读取和改变任何对象的方式相同。这是页面开始真正移动的地方。

你会使用最多的属性是 textContent,元素内的文本。你可以读它或设置它:

js
const total = document.querySelector("#total");
total.textContent = "42 items"; // 页面现在显示"42 items"

要改变某些东西的外观,你通常添加或移除一个类并让 CSS 做样式。classList 有三个方法:

js
const card = document.querySelector(".card");
card.classList.add("selected"); // 添加一个类
card.classList.remove("selected"); // 移除它
card.classList.toggle("selected"); // 如果缺失则添加,如果存在则移除

你也可以通过 .style 直接设置单个样式,并用 setAttribute 设置像 srchref 这样的属性:

js
card.style.color = "crimson"; // 一个内联样式
const avatar = document.querySelector(".avatar");
avatar.setAttribute("src", "beijing.jpg"); // 设置图像源

首先选择 classList 和仅用 .style 做一次性调整,因为保持你的样式在 CSS 中保持它在一个地方。

textContent 读写元素内的文本。classList 切换类以便样式保留在你的 CSS 中。.style 设置单个内联样式,setAttribute 设置任何属性:

js
const badge = document.querySelector(".badge");
badge.textContent = `${count} new`; // 设置文本
badge.classList.toggle("is-hidden", count === 0); // 用一个条件切换
badge.setAttribute("aria-label", `${count} new messages`); // 设置一个属性

classList.toggle 接受一个可选的第二个参数:一个布尔值,在 true 时强制类打开,在 false 时关闭,这比 addremove 周围的 if/else 更干净。

早期值得声明的警告:还有 innerHTML,它将一个元素的内容设置为原始 HTML、标签和全部。这很诱人因为它看起来灵活,但是对于来自用户的任何文本使用 textContent,而不是 innerHTML从用户输入设置 innerHTML 让那个输入注入真实的 HTML 到你的页面中,这是一个安全漏洞。当你设置纯文本时,textContent 无论如何都是正确的工具。

读写表面很宽,但四个属性承载大部分工作:textContent 用于文本,classList 用于状态类,.style 用于计算一次性样式,setAttribute 用于属性。倾向于 classList 而不是 .style 使呈现在 CSS 中生活,JavaScript 仅翻转**状态类**(像 is-openis-loading 这样的类,你的样式表然后样式化):

js
const panel = document.querySelector(".panel");
panel.classList.toggle("is-open", shouldOpen);
panel.setAttribute("aria-expanded", String(shouldOpen)); // 属性是字符串

尖端是 innerHTML。设置它将字符串解析为 HTML 并从中构建真实节点,所以那个字符串中的任何标记变成活动的:一个用户提供的文本中的 <script>onerror 处理器在你的页面中运行。那是跨网站脚本(XSS):通过你作为文本对待的数据注入可执行内容。textContent 永远不解析 HTML;它设置文本且仅文本,所以它是安全的默认值且更快的,因为浏览器跳过 HTML 解析。将 innerHTML 限制为你完全控制的标记,永远不要通过连接用户输入来构建它。当你需要将用户数据插入结构化标记时,用安全方法设置结构并将不受信任的值通过 textContent 放入其中,这是下一部分构建的模式。

属性上的另一个注意:setAttribute 写入 HTML 属性(一个字符串),而许多元素也公开一个可以不同的活动属性input.value 反映用户现在输入的内容;input.getAttribute("value") 返回标记中的原始默认值。对于表单字段,读属性,而不是属性。

Juno读取和改变元素textContent 设置元素内的文本,并通过用 classList.addremovetoggle 添加或移除类来改变它的外观。用 .style 做一次性调整,用 setAttribute 做像图像源这样的事。依靠类使你的样式保留在你的 CSS 中。
Juno读取和改变元素textContent 用于文本,classList 用于状态类,.style 用于一次性,setAttribute 用于属性;classList.toggle 甚至接受一个布尔值来强制它打开或关闭。避开 innerHTML 用于任何用户输入的东西,因为它注入真实 HTML 到你的页面中。对于纯文本,textContent 无论如何都是正确的工具。
Juno读取和改变元素classList 翻转状态类并保持样式在 CSS 中;用 setAttribute 用于属性,记住它们是字符串。innerHTML 将其字符串解析为活动 HTML,所以其中的用户数据是一个 XSS 漏洞;textContent 永不解析且既安全又更快。对于表单字段读活动 value 属性,而不是属性。

创建和移除元素

改变已经存在的元素会走得很远,但有时那里还没有任何东西:你添加的一个待办事项,一个到达的搜索结果。为此你构建新元素并把它们放入树中,并取出旧的。

你用 document.createElement 制作一个新元素,给它一些内容,然后用 append 把它加到页面:

js
const list = document.querySelector(".todo-list");
const item = document.createElement("li"); // 一个新的 <li>,还没有在页面上
item.textContent = "Buy oat milk"; // 给它一些文本
list.append(item); // 现在它显示,在列表的末尾

来自 createElement 的新元素仅在内存中存在,直到你在某处 append 它;那是把它放在页面上的步骤。要从页面移除一个元素,在它上面调用 remove

js
item.remove(); // 从页面消失

document.createElement(tag) 构建一个元素,append 把它作为父的最后一个子添加,remove 从树中取出一个元素:

js
const list = document.querySelector(".todo-list");
const item = document.createElement("li");
item.textContent = "Buy oat milk"; // 安全:文本,不解析为 HTML
item.classList.add("todo-item");
list.append(item); // 将它附加到页面

textContent 在附加之前设置文本,而不是通过构建 HTML 字符串,所以用户输入的文本永远不能注入标记。append 也接受纯字符串和几个节点一次(list.append(item, "or", another)),这对于混合内容是方便的。

一个抓住人的细节:创建一个元素本身什么都看不到。它在内存中坐着,直到你把它附加到已经在树中的东西。在那之前它是真实但离屏的,这正是高级部分用来有效构建的属性。

createElement 构建一个分离的节点,append 作为父的最后一个子插入节点(或字符串),remove 从树中分离一个节点。效率问题是有趣的,因为在活动树中插入可以造成成本。

每次你改变 DOM 的方式影响几何,浏览器可能需要重新计算位置和大小(reflow,也称为布局)然后重绘。在一个循环内做那个,每个迭代附加一个节点到活动页面,你可以触发那个工作重复。修复是离 DOM 构建和一次插入。一个 DocumentFragment 是一个轻量级的、离屏的容器,正好用于这个:你附加节点到片段(它不在活动树中,所以没有 reflow),然后一次附加片段,它作为单一操作移动它的子:

js
const list = document.querySelector(".results");
const fragment = document.createDocumentFragment(); // 离屏容器

for (const name of names) {
  const li = document.createElement("li");
  li.textContent = name; // 用户数据的安全插入
  fragment.append(li); // 没有 reflow:片段是离 DOM 的
}

list.append(fragment); // 一个插入到活动树中

附加片段将它倒入父,所以片段本身不出现在 DOM 中;仅它的子出现。同样的原则驱动更广的习惯:批处理 DOM 写。读取一个布局属性(像 offsetHeight)强制浏览器冲洗待处理布局,所以在一个循环中交错读和写导致布局抖动,重复强制 reflows。分组你的读,然后分组你的写。对于一把握节点这都不重要;对于数百个它是光滑和卡顿的区别。

Juno创建和移除元素document.createElement 构建一个新元素,用 textContent 给它文本,用 append 把它放在页面上。一个新元素仅在内存中生活,直到你附加它,所以那个步骤是使它显示的。要从页面移除一个,在它上面调用 remove
Juno创建和移除元素createElement 构建它,append 将它作为最后一个子附加,remove 把它取出。在附加之前用 textContent 设置文本,而不是构建 HTML 字符串,所以用户输入的任何东西都不能注入标记。记住一个创建的元素保持看不见,直到你把它附加到已经在页面上的东西。
Juno创建和移除元素 在一个循环中附加到活动树可以每次触发 reflow;离 DOM 在一个 DocumentFragment 中构建并一次附加它,所以子一起移入。在一个循环中间读取一个布局属性强制布局并导致抖动,所以批处理你的读然后你的写。对于几个节点它是没问题的;对于数百个它是光滑对比卡顿。

DOM 从这里走向哪里

选择、读取、改变、创建和移除覆盖了大部分 JavaScript 对一个页面做的事,所有它在下面是相同的移动:在树中找一个对象并改变它的属性。因为 DOM 是对象,你在 objects 中学到的一切直接应用于这里,这章中的小工具集(querySelectortextContentclassListcreateElementappend)是大部分工具箱。

到目前为止缺少的是时序。这里的每个例子都运行一次,从上到下,但一个真实的页面改变以响应阅读者做的:一个点击、一个按键、一个提交的表单。把你的 DOM 改变接线到那些时刻是 events,下一章,其中 addEventListener 连接"用户做了这个"到"所以页面做了那个"。一旦选择和改变坐在响应事件旁边,你可以构建本手册其余部分针对的交互页面。