阅读书架可以包含标题、按钮及简短的状态消息。读者将项目置顶时,三件事必须一致:程序中的布尔状态、卡片的外观,以及按钮的无障碍状态。只改其中一项,页面便会向不同的观察者表达不同结果。
DOM 提供代表当前文档的对象。事件让程序知道某个动作已发生。普通 JavaScript 对象则保存状态,供下一次文档更新使用。下面会以一个只在浏览器内运行的小示例,把三者连接起来。
DOM 是由对象构成的树
定义
文档对象模型
**文档对象模型(Document Object Model,DOM)**以节点树表示文档。元素节点代表标题、按钮等元素,文本节点保存文本。节点可以有子节点,而其位置决定了父节点与兄弟节点。
浏览器解析 HTML 时会建立 DOM。JavaScript 随后可以改动同一棵树:新增节点、修改文本,或移动现有元素,都会改变当前文档。因此,原始代码文本与当前 DOM 是两种不同的表示。
在浏览器中,window 代表浏览窗口的全局环境,document 则提供其文档的访问接口。DOM 是供 JavaScript 使用的 API,而不是 JavaScript 语言本身。在浏览器之外运行的 JavaScript 程序,不会仅凭使用相同语法就获得某个网页的 DOM。
<section id="shelf">
<h2>Reading shelf</h2>
<p>One item is available.</p>
</section>
此 section 包含一个 h2 元素与一个 p 元素。换行及缩进也可能形成空白文本节点。这解释了 children 与 childNodes 的区别:前者只包含子元素,后者也包含文本等其他类型的节点。
const shelf = document.querySelector("#shelf");
const heading = shelf.firstElementChild;
const paragraph = heading.nextElementSibling;
console.log(heading.tagName); // H2
console.log(paragraph.parentNode === shelf); // true
console.log(shelf.children.length); // 2
以上运算假设前面的文档已完成解析,且仍保留所示结构。firstElementChild、lastElementChild、nextElementSibling 与 previousElementSibling 在元素之间导航。到达边界时就没有下一个元素,例如最后一个元素的 nextElementSibling 是 null。
选择器识别节点;集合记录匹配结果
document.getElementById("shelf") 按 ID 搜索;document.querySelector("#shelf") 使用 CSS 选择器语法,返回第一个匹配元素。查询没有匹配元素时会返回 null。同一份文档中的 ID 应保持唯一;class 则可以描述多个元素。
querySelectorAll 返回静态 NodeList。该次查询决定了集合的成员,但其中的节点仍是可以改动的普通 DOM 节点。相对地,getElementsByTagName 与 getElementsByClassName 返回动态 HTMLCollection,其成员会随文档改动而更新。
例题
预测静态与动态集合
假设 #reading-list 起初包含两个 li 元素。
const list = document.querySelector("#reading-list");
const snapshot = list.querySelectorAll("li");
const liveItems = list.getElementsByTagName("li");
const item = document.createElement("li");
item.textContent = "Browser rendering";
list.appendChild(item);
console.log(snapshot.length); // 2
console.log(liveItems.length); // 3
snapshot[0].textContent = "Revised first title";
由于没有重新查询,新元素不会加入 snapshot;liveItems 会追踪树的变化,因此包含新元素。最后一行仍会修改文档内的第一个元素:静态集合固定的是成员,而不是每个成员对象的状态。
常见错误
删除元素会在迭代过程中改变动态集合
如果向前循环删除了 liveItems[0],原本的第二项就成为新的第一项。此时增加索引,可能跳过该项。要删除动态集合的所有项目,一个直接方法是在长度仍为正数时持续删除第一个元素;另一个方法是先取得静态 querySelectorAll 结果,再逐一处理所记录的节点。
思考检查
静态 NodeList 固定的是什么?
在示例之后,程序修改第一项的文本,并加入第四项。原来的 snapshot 会显示旧文本吗?它的长度会变成四吗?
解答 · 集合成员与节点状态是两回事
第一个项目仍引用同一个第一节点,所以读取其 textContent 会取得修改后的文本。snapshot 的长度仍是二;它没有取得任何后来加入的节点。动态集合则已包含四个项目。
选择符合改动目的的属性
| 操作 | 适合的接口 | 含义 |
|---|---|---|
| 读取或替换普通节点文本 | textContent | 把提供的字符串当作文本。 |
| 读取文本输入框当前的值 | value | 取得控件的当前值。 |
| 新增或删除一个 CSS class | classList.add、remove、toggle | 保留其他 class,改动指定的类标记。 |
| 设置一项内联样式 | style.backgroundColor | 修改该项内联 CSS 属性。 |
| 读取或设置 HTML 属性 | getAttribute、setAttribute | 处理属性的字符串值。 |
innerHTML 会把所赋的字符串解析成标记;textContent 则插入文本。例如,将 textContent 设为 "<strong>Selected</strong>",会显示尖括号与标签名称。当内容是标题或消息,而不是新的 HTML 结构时,这正是所需的行为。
innerText 与实际呈现的文本有关;样式隐藏部分内容时,其结果可能与 textContent 不同。直接更新文本时,textContent 能清楚表达操作目的。
元素的 style 属性表示内联声明,并不列出样式表中所有可能影响该元素的规则。许多交互状态可以通过切换 class,再由 CSS 定义外观,使维护更清晰。两种方式可以共存,但同一个可见状态应有清楚的管理来源。
明确建立及编辑节点
新创建的元素起初未连接到文档。当某个父节点接收它时,它才成为显示文档的一部分。
const list = document.querySelector("#reading-list");
const item = document.createElement("li");
const label = document.createTextNode("Understanding events");
item.appendChild(label);
list.appendChild(item);
appendChild 把节点放到父节点的子节点末尾。加入现有节点会移动该节点,而不会创建副本。insertBefore(newNode, existingChild) 将节点放在指定子节点之前;replaceChild(newNode, oldChild) 替换一个子节点;removeChild(child) 删除该父节点的一个子节点。元素也可以对自己调用 remove()。
父子关系在这里十分重要。传给 replaceChild 的 oldChild,或传给 removeChild 的 child,必须确实属于该父节点。DOM 操作描述的是结构改动,所以执行前应先识别所需的父节点。
思考检查
加入节点会复制它吗?
页面上有两个列表。程序保存第一个列表中某项目的引用,然后调用 secondList.appendChild(item)。之后共有多少个副本?哪个列表拥有该项目?
解答 · 同一节点在树中只有一个位置
仍然只有一个项目节点。它从第一个列表移到第二个列表的末尾,变量仍引用同一节点。要创建第二个独立元素,必须另外构造或明确执行复制操作。
事件把动作连接到处理函数
事件监听器是在事件目标上,为某种事件类型注册的函数。当事件系统向该目标派发适当的 click 事件时,click 监听器便会执行。注册函数并不会立即执行其主体。
const button = document.querySelector("#pin");
button.addEventListener("click", handlePin);
function handlePin(event) {
console.log(event.currentTarget.id);
}
应传入函数 handlePin,而不是调用 handlePin() 后得到的结果。在注册时调用函数,会在错误时间执行它,并以其返回值取代原本想注册的监听器。
事件对象描述已派发的事件。event.currentTarget 是当前运行中的监听器所注册的目标;event.target 则识别事件起源。如果按钮内含一个 span,而读者点击该 span,target 可以是 span,但按钮监听器的 current target 仍是按钮。
对普通 click 等会冒泡的事件,事件沿树传播时,祖先上的监听器也可能执行。因此,书架上的监听器可以观察起源于书架内某个按钮的点击。这不表示每种事件都会冒泡;应按具体事件的行为作判断。
其他事件描述不同的动作。input 跟随输入控件的编辑;change 表示已确认的变更。键盘处理函数会收到 event.key 等数据。按住按键时,keydown 处理函数可以重复执行,所以一次按键与一次鼠标点击未必产生相同的事件序列。
一个完整的置顶交互
例题
追踪卡片两次切换置顶的过程
以下独立文档在内存中保存一个布尔状态,并由它推导可见呈现及无障碍状态。
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reading shelf</title>
<style>
body { max-width: 36rem; margin: 2rem auto; padding: 0 1rem; }
.pinned { border-inline-start: 0.35rem solid currentColor; padding-inline-start: 1rem; }
button { font: inherit; padding: 0.5rem; }
</style>
</head>
<body>
<main>
<h1>Reading shelf</h1>
<article id="book">
<h2>Understanding browser events</h2>
<button id="pin" type="button" aria-pressed="false">
<span aria-hidden="true">★</span> Toggle pin
</button>
<p id="status" aria-live="polite">Not pinned</p>
</article>
</main>
<script>
const book = { title: "Understanding browser events", pinned: false };
const card = document.querySelector("#book");
const button = document.querySelector("#pin");
const status = document.querySelector("#status");
function render() {
card.classList.toggle("pinned", book.pinned);
button.setAttribute("aria-pressed", String(book.pinned));
status.textContent = book.pinned ? "Pinned" : "Not pinned";
}
button.addEventListener("click", () => {
book.pinned = !book.pinned;
render();
});
render();
</script>
</body>
</html>
元素位于 script 之前,因此三次查询都能找到所需节点。book.pinned 是模型状态。render 把该状态应用到 class、aria-pressed 属性及状态文本。把布尔值作为 classList.toggle 的第二个参数,就能让 class 与该值一致:true 加入 class,false 删除 class。
| 阶段 | book.pinned | 卡片含有 pinned | aria-pressed | 状态文本 |
|---|---|---|---|---|
| 初次呈现 | false | 否 | "false" | Not pinned |
| 第一次点击 | true | 是 | "true" | Pinned |
| 第二次点击 | false | 否 | "false" | Not pinned |
处理函数改动一次状态,再通过同一个函数更新所有表示。如果只更新边框,状态文本与按钮状态就会不一致。重新加载文档时,会创建另一个 pinned: false 的对象;此例不会在文档加载之间保存状态。
对象除了属性,还有身份
对象是一组属性的集合。属性值也可以是对象、数组、函数或基本值。点记法使用程序中直接写出的属性名称,方括号记法则可以使用计算得到的键。
例题
两个名称引用同一个可变对象
先创建一个 pinned 属性为 false 的书籍对象,再依次执行以下语句,区分每一步是在构造新对象,还是在赋值另一个引用。
const book = { title: "DOM trees", pinned: false };
const key = "title";
console.log(book.title); // DOM trees
console.log(book[key]); // DOM trees
const alias = book;
alias.pinned = true;
console.log(book.pinned); // true
两次标题读取都输出 DOM trees:key 保存的名称,与点记法选择的是同一个属性。把 book 赋给 alias,会得到同一对象的另一个引用,而不是把属性复制到新的独立对象。因此,设置 alias.pinned 会改变随后 book.pinned 读取的属性,最后输出 true。const 绑定阻止重新赋值该绑定,却不会冻结对象的属性。
例题
建立独立的简单记录
const original = { title: "DOM trees", pinned: true };
const copy = { title: original.title, pinned: original.pinned };
copy.pinned = false;
console.log(original.pinned); // true
console.log(copy.pinned); // false
console.log(original === copy); // false
对象字面量建立了一条新记录。在此例中,两个属性都是基本值,因此两条记录可以独立改动。如果属性保存另一个对象,单纯赋值该属性值仍会共享嵌套对象。
对象也可以通过以 new 调用的函数来构造:
function ReadingItem(title) {
this.title = title;
this.pinned = false;
}
const first = new ReadingItem("DOM trees");
const second = new ReadingItem("Browser events");
console.log(first === second); // false
这里每次 new 调用都会创建独立对象,并以该对象作为 this 执行函数,形成可重复使用的初始化方式。字面量与构造函数是不同的构造技术;两者都会产生可读取及更新属性的对象。
方法调用决定其接收者
普通函数的 this 值取决于调用方式。像 shelf.getCount() 这样的方法调用,其接收者是 shelf。把函数复制到变量,不会永久保留该接收者。
"use strict";
const shelf = {
count: 2,
getCount() {
return this.count;
}
};
console.log(shelf.getCount()); // 2
const detached = shelf.getCount;
// detached() has this === undefined in this strict-mode call.
// Reading this.count there would throw a TypeError.
箭头函数不会建立自己的 this,而是保留外围环境的值。因此,监听器需要取得其注册元素时,使用 event.currentTarget 比假设所有函数形式都提供相同 this 行为更清楚。置顶示例不需要接收者或 this:其处理函数直接使用外围的状态与元素引用。
JSON 是数据的文本表示
JSON 可以表示对象、数组、字符串、数值、布尔值及 null。对象键与字符串值使用双引号;注释及尾随逗号不属于其语法。JSON 文本不包含可执行的方法或 DOM 节点。
例题
解码记录、改动记录,再编码新状态
先有一段 JSON 字符串,描述一本已置顶的书及两个标签。下面逐步从文本创建记录,再把改动后的记录转回文本。
const text = '{"title":"DOM trees","pinned":true,"tags":["web","reading"]}';
const record = JSON.parse(text);
console.log(record.tags[0]); // web
record.pinned = false;
const encoded = JSON.stringify(record);
console.log(typeof encoded); // string
解析会创建 record,其中 pinned 起初为 true,tags 是有两个项目的数组。索引零选出 web,形成第一个输出。下一次赋值把记录的标志改为 false;将该记录编码,会产生含有 "pinned":false 的新 JSON 字符串,所以第二个输出是 string。原来的变量 text 仍保存原本的字符串;改动解析后的对象并没有重写它。
JSON.parse 读取文本并创建值;JSON.stringify 则由支持的值产生文本。两者都不会自动发送或保存数据。之后的单元会在通信中使用这些表示,但数据表示与传输是不同操作。
将同一段 JSON 文本解析两次,会创建两条独立记录。JSON 也不保留共享对象引用的身份:它描述数据值,而不是由引用及方法组成的图。因此,JSON 往返转换并非复制任意 JavaScript 对象的通用方法。
DOM 与库接口是不同层次
jQuery 等库提供自身的元素选择、事件注册及内容更新接口,但这些操作仍作用于浏览器的 DOM。应区分三个层次:JavaScript 是语言,DOM 是浏览器 API,库则在部分操作之上提供另一层接口。不论程序采用哪种接口,理解底层的节点及状态改动仍然有用。
练习
思考检查
练习 1:追踪状态更新
在置顶文档中,处理函数仍改变 book.pinned,但不再调用 render()。一次点击后,布尔值是什么?卡片、状态文本与按钮属性分别显示什么?解释程序状态与文档状态为何不同。
解答 · 练习 1 解答
布尔值变成 true。初次 render 已删除 class、显示“Not pinned”,并把 aria-pressed 设为 "false"。没有新的 render,这些文档值就保持不变。更新普通对象不会自动更新 DOM;程序必须把新值应用到各种呈现。
思考检查
练习 2:识别事件涉及的元素
ID 为 pin 的 button 包含一个星形 span。按钮与其外围 article 各注册了一个 click 监听器。读者点击星形时,在普通冒泡且没有改变传播的前提下,事件的 target 与各监听器的 current target 是什么?
解答 · 练习 2 解答
起源 target 是星形 span。按钮监听器执行时,currentTarget 是按钮;article 监听器执行时,currentTarget 是 article。事件起源保持相同,但当前执行的监听器有不同的注册目标。
思考检查
练习 3:区分引用与 JSON 文本
设 a = { pinned: false }、b = a,以及 c = JSON.parse(JSON.stringify(a))。之后程序设置 b.pinned = true,a.pinned 与 c.pinned 分别是什么?创建 c 会把任何内容写入磁盘吗?
解答 · 练习 3 解答
a.pinned 变成 true,因为 a 与 b 引用同一对象。c.pinned 仍是 false,因为解析先前 JSON 文本时,创建了保存当时值的另一条记录。转换只是在内存中创建字符串,再创建对象,没有执行磁盘写入。
思考检查
练习 4:选择更新操作
标题要显示字面文本 <em>DOM</em>。textContent 与 innerHTML 哪个属性能得到此结果?如果卡片已有 book compact 两个 class,如何在保留它们的同时加入 pinned?
解答 · 练习 4 解答
把标题的 textContent 设为该字符串;它会把尖括号与标签名称视作文本。innerHTML 则会解析出一个强调元素。使用 card.classList.add("pinned") 或 card.classList.toggle("pinned", true),便可保留 book 与 compact,同时加入指定 class。
这里使用的语言工具,会在 JavaScript、函数与集合中进一步说明。