DOM实战基础

DOM的概念

  • Document object model

  • Tree of nodes/elements created by the browser

  • JavaScript can be used to read/write/manipulate to the DOM

  • Object Oriented Representation

  • 文档对象模型

  • 由浏览器创建的节点或者元素的一颗树结构

  • JavaScript可以对DOM进行读写操作

  • 基于对象的表示

DOM常用的API和属性

DOM的基础属性

  • document.title: 标题

  • document.head: head节点

  • document.body: body节点

  • document.all: 所有节点的集合

  • document.forms: 所有表单节点集合

  • document.links: 所有链接节点集合

  • document.images: 所有图片节点集合

  • document.getElementById(): 通过id获取元素

  • document.getElementsByClassName(): 通过类名获取元素集合

  • document.getElementsByTagName(): 通过标签名获取元素集合

  • document.querySelector(): 类似于jQuery的$选择器函数,可以支持id、类名、标签名等,仅支持选择第一个匹配的元素

  • document.querySelectorAll(): 支持选择一个集合

DOM的遍历

  • Element.parentNode: 父节点
  • Element.parentElement: 父元素,这个属性与parentNode大多数情况是相同的,除了父元素为Document或者DocumentFragment的情况
  • Element.childNodes: 子节点集合
  • Element.firstChild: 第一个子节点
  • Element.firstElementChild: 第一个元素子节点,这个与firstChild节点的区别在于会过滤空格、换行符等造成的节点
  • Element.lastChild: 最后一个子节点
  • Element.lastElementChild: 最后一个元素子节点,同样是过滤空格、换行符等造成的节点
  • Element.nextSibling: 下一个兄弟节点
  • Element.nextElementSibling: 下一个元素兄弟节点,同样是过滤空格、换行符等造成的节点
  • Element.previousSibling: 上一个兄弟节点
  • Element.previousElementSibling: 上一个元素兄弟节点,同样是过滤空格、换行符等造成的节点

DOM元素的创建

  • document.createElement(): 创建元素
  • Element.className: 设置类属性
  • Element.id: 设置id属性
  • Element.setAttribute(): 设置属性
  • document.createTextNode(): 创建文本节点
  • Element.appendChild(): 追加子节点
  • Element.insertBefore(newNode, referenceNode): 在引用节点前添加新的节点

DOM事件

  • Element.addEventListener(): 添加事件添加
  • Event.target: 事件目标
  • Event.target.id, Event.target.className, Event.target.classList: 事件目标的id, class, class列表
  • Event.type: 事件类型
  • Event.clientX: 点击位置距离视口的横坐标
  • Event.clientY: 点击位置距离视口的纵坐标
  • Event.offsetX: 点击位置距离元素的横坐标
  • Event.offsetY: 点击位置距离元素的纵坐标
  • Event.altKey: 触发事件时是否按下Alt键
  • Event.ctrlKey: 触发事件时是否按下Ctrl键
  • Event.shiftKey: 触发事件时是否按下Shift键

DOM事件类型

  • click: 点击事件
  • dbclick: 双击事件
  • mousedown: 鼠标按下
  • mouseup: 鼠标松开
  • mouseenter: 鼠标进入目标区域,不会冒泡并且,在后代元素上移动到当前元素不会触发(MDN参考:Similar to mouseover, it differs in that it doesn’t bubble and that it isn’t sent when the pointer is moved from one of its descendants’ physical space to its own physical space.)
  • mouseleave: 鼠标离开目标区域并包括所有子元素,不会冒泡(MDN参考: Similar to mouseout, it differs in that it doesn’t bubble and that it isn’t sent until the pointer has moved from its physical space and the one of all its descendants.)
  • mouseover: 鼠标经过目标区域,会冒泡
  • mouseout: 鼠标离开目标区域或其中的子元素
  • mousemove: 鼠标移动
  • keydown: 键盘按键按下
  • keyup: 键盘按键抬起
  • keypress: 键盘按键按下后触发
  • focus: 获得焦点触发
  • blur: 丢失焦点触发
  • cut: 剪切时触发
  • paste: 粘贴时触发
  • input: 输入时触发
  • change: 内容改变时触发

理解虚拟DOM

正文

我最近写了关于DOMshadow DOM具体是什么,以及他们的差异。回顾一下,文档对象模型是一个HTML文档基于对象的表示,和操作对象的一个接口。影子DOM(shadow DOM)可以想做是”精简版”的DOM。它也是HTML元素基于对象的表示,但不是完整独立的文档。相反的,影子DOM允许我们分离我们的DOM成为更小的、具备封装性的单元并在HTML文档中使用。

另一个相似的术语你可能遇见过的是“虚拟DOM”(virtual DOM)。尽管这个概念已经出现了许多年,让它变得流行起来的还是在React框架中的使用。在这篇文章中,我会涉及虚拟DOM具体是什么,和原始的DOM有什么差别,以及如何使用。

为什么我们需要虚拟DOM?

为了理解为什么会出现虚拟DOM,先回顾一下原始DOM。就如前面提到的,DOM中有两部分内容,基于对象的HTML文档标识和操作对象的API。

例如,让我们看一个无序列表和列表项的简单HTML文档

1
2
3
4
5
6
7
8
9
<!doctype html>
<html lang="en">
<head></head>
<body>
<ul class="list">
<li class="list__item">List item</li>
</ul>
</body>
</html>

文档会像下面的DOM树进行表示

1
2
3
4
5
6
7
html
|
|-- head lang="en"
|-- body
|-- ul class="list"
|--li class="list__item"
|--"List item"

设想一下我们想要修改第一个列表项的内容为“List item one”,并且添加第二个列表项。为了做到这一点,我们需要使用DOM API查找到想要更新的元素,创建新的元素,添加属性和内容,最终更新DOM元素。

1
2
3
4
5
6
7
8
const listItemOne = document.getElementsByClassName("list__item")[0];
listItemOne.textContent = "List item one";

const list = document.getElementsByClassName("list")[0];
const listItemTwo = document.createElement("li");
listItemTwo.classList.add("list__item");
listItemTwo.textContent = "List item two";
list.appendChild(listItemTwo);

DOM无法解决的问题

当1998年DOM的第一版规范发布时,我们构建和管理网页与现在有很大的不同。那时并没有像现在这样依赖于DOM API来创建和更新页面内容。

document.getElementsByClassName()这样的简单方法小范围使用是好的,但是如果我们每几秒去更新页面中的多个元素时,这会让查询和更新DOM变得耗性能。

更进一步,由于API的组织方式,通常执行一些更耗性能的操作,例如更新文档的一大块内容比查找然后更新具体的元素要来得简单。回到我们的例子,某种程度上替换整个无序列表成新的比修改具体的元素要容易。

1
2
3
4
5
const list = document.getElementsByClassName("list")[0];
list.innerHTML = `
<li class="list__item">List item one</li>
<li class="list__item">List item two</li>
`;

在这个例子中,两个方法之间的性能差异可能并不明显。但是,随着页面大小的增加,仅选择和修改必须的内容变得尤其重要。

虚拟DOM的解决方式

虚拟DOM的提出就是为了高效解决这些频繁更新DOM的难题。不像DOM或者影子DOM,虚拟DOM并不是官方的规范,而是和DOM交互的新方法。

一个虚拟DOM可以设想为原始DOM的一份拷贝。这份拷贝可以不使用DOM的API而进行频繁的操作和更新。一旦所有的更新都被应用到虚拟DOM后,我们可以找到要应用到原始DOM上的具体变化差异并通过高效和有针对性的进行更新。

虚拟DOM看起来像什么?

虚拟DOM的名字让这个概念给人有点神秘的感觉。实际上,虚拟DOM就是一个常规的JavaScript对象。

让我们回顾一下之前创建的DOM树:

1
2
3
4
5
6
7
html
|
|-- head lang="en"
|-- body
|-- ul class="list"
|--li class="list__item"
|--"List item"

这颗树可以通过JavaScript对象来表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const vdom = {
tagName: "html",
children: [
{ tagName: "head" },
{
tagName: "body",
children: [
{
tagName: "ul",
attributes: { "class": "list" },
children: [
{
tagName: "li",
attributes: { "class": "list__item" },
textContent: "List item"
}
]
}
]
}
]
}

我们可以设想这个对象作为我们的虚拟DOM。就像原始DOM,这也是基于对象的HTML文档表示。但是因为这是一个纯粹的JavaScript对象,我们可以自由和频繁的操作而不用接触实际的DOM,直到需要的时候。

通常使用多个小块的虚拟DOM,而不是使用一个虚拟DOM来表示整个对象。例如,我们在使用一个list组件,对应我们的无序列表元素。

1
2
3
4
5
6
7
8
9
10
11
const list = {
tagName: "ul",
attributes: { "class": "list" },
children: [
{
tagName: "li",
attributes: { "class": "list__item" },
textContent: "List item"
}
]
}

虚拟DOM的工作原理

现在我们看到了虚拟DOM的结构,接下来看一下它是如何在DOM中解决性能和使用问题的。

就如我提到的,我们可以使用虚拟DOM找到需要应用到DOM中的具体变更内容,然后单独更新这些变更。让我们回到无序列表的例子并做一些之前使用DOM API操作的变更。

首先我们要创建一份虚拟DOM的拷贝,包含我们想要的修改。因为我们不需要使用DOM API,我们可以只创建一个新的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const copy = {
tagName: "ul",
attributes: { "class": "list" },
children: [
{
tagName: "li",
attributes: { "class": "list__item" },
textContent: "List item one"
},
{
tagName: "li",
attributes: { "class": "list__item" },
textContent: "List item two"
}
]
};

这份拷贝是用于创建一个和原始虚拟DOM的变更内容(“diff”),在这里是列表和更新的列表。变更内容如下:

1
2
3
4
5
6
7
8
9
10
11
const diffs = [
{
newNode: { /* 新版的列表1 */ },
oldNode: { /* 原始版本的列表1 */ },
index: /* 在父元素的子节点列表中元素索引 */
},
{
newNode: { /* 列表2 */ },
index: { /* */ }
}
]

变更内容提供了如何更新实际DOM的指南。一旦所有的变更内容都收集到了,我们就可以批量更新DOM,而且是仅更新必需的内容。

例如,我们可以遍历每一个的变更内容,然后添加一个新元素或者基于变更内容更新旧的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const domElement = document.getElementsByClassName("list")[0];

diffs.forEach((diff) => {
const newElement = document.createElement(diff.newNode.tagName);
/* 添加属性 */

if (diff.oldNode) {
// 如果有旧的版本,用新的版本替换
domElement.replaceChild(newElement, diff.index);
} else {
// 如果没有旧的版本,创建一个新的节点
domElement.appendChild(newElement);
}
})

注意这里只是简单讲述虚拟DOM是如何工作的,有很多案例并没有涉及到。

虚拟DOM和框架

通常虚拟DOM会和框架一同使用,而不是像上面这样直接操作。

像React和Vue这样的框架使用虚拟DOM来执行高效的DOM更新。例如,我们的list组件通过React可以这样写:

1
2
3
4
5
6
import React from 'react';
import ReactDOM from 'react-dom';

const list = React.createElement("ul", { className: "list" },
React.createElement("li", { className: "list__item" }, "List item")
);

如果我们更新我们的列表,我们可以只是重写整个列表模板,然后再次调用ReactDOM.render()方法,传递一个新的列表。

1
2
3
4
5
6
const newList = React.createElement("ul", { className: "list" },
React.createElement("li", { className: "list__item" }, "List item one"),
React.createElement("li", { className: "list__item" }, "List item two")
);

setTimeout(() => ReactDOM.render(newList, document.body), 5000));

因为React使用了虚拟DOM,即使我们重新渲染了整个模板,只有实际的变化会被更新。如果通过开发者工具进行查看,可以查看具体的元素和具体元素的部分有变化。

DOM和虚拟DOM

总结一下,虚拟DOM是一个可以让我们和DOM元素进行简单高效的交互工具。它是DOM的JavaScript对象表示,我们可以根据我们的需要频繁的修改。对于这个对象的修改会被整理后,有针对性的对实际DOM进行更新。

FE-DOM-Priciple.md

列举DOM编程的原则:

渐进增强(Progressive enhancement)

应该根据内容使用标记良好的结构,然后再逐步加强这些内容。

平稳退化(Graceful degradation)

缺乏必要的CSS和DOM支持的访问者仍然可以支持访问到你的核心内容。