DOM

1. 定义

文档对象模型,这里用到了面向对象的一些概念。简单地,我们可以把html中的每一个结点(标签)都可以看作一个dom对象,而每一个dom对象的展示形式、属性、方法可能都会不一样,但它们同属一类。

文档对象模型(Document Object Model,简称DOM),是W3C组织推荐的处理可扩展标志语言的标准编程接口。在网页上,组织页面(或文档)的对象被组织在一个树形结构中,用来表示文档中对象的标准模型就称为DOM。Document Object Model的历史可以追溯至1990年代后期微软与Netscape的“浏览器大战”,双方为了在JavaScript与JScript一决生死,于是大规模的赋予浏览器强大的功能。微软在网页技术上加入了不少专属事物,既有VBScript、ActiveX、以及微软自家的DHTML格式等,使不少网页使用非微软平台及浏览器无法正常显示。DOM即是当时蕴酿出来的杰作。

2. DOM树

在html文档中,dom对象是按树形结构组织的。根结点是一个叫作document的全局对象。在它下面有headbody等结点。

概念:

以下为例:

<html>
    <head>
        <title></title>
    </head>
    <body>
    </body>
</html>

每一个html页面都是一棵Dom树,它的根节点html,里面通常至少有两个孩子节点(通常我们也称之为子节点):headbodyhtmlheadbody父亲节点headbody互为兄弟节点。而headbody又会是其它节点的父亲节点,如headtitle节点,而html则为title祖先节点

3. 属性

Attribute和Property的区别

特别需要提醒注意的是:html中某个节点的属性(attribute)和对应dom对象的属性(property)是不一样的,尽管有时候它们很像(像input的value和id属性),为了说明这一点,我们看下面这个简单的例子:

<script>
    function test() {
        const input = document.getElementById('t');
        alert(input.value);
        input.value = 'lalala';
        alert(input.value);
        alert(input.getAttribute('value'));
    }
</script>
<input id="t" type="text" value="abc" />
<input type="button" value="test" onclick="test()">

上例中,id为t的一个文本框中初始显示值为它的value属性(attribute),我们在一个用户事件中,通过JavaScript脚本修改它的value属性(property)值为lalala,这时候我们在页面中看到该文本框中显示的值也已变成为了lalala,并且如果我们再获取它的value属性(property)的时候,它的值也确实改变成为了lalala。

但是,如果我们在浏览器的开发者工具中查看这个节点,我们会看到,它的value属性(attribute)依然是abc不变,通过该dom对象的getAttribute方法获取它的value属性(attribute)值也是abc。

3.1. 属性(Attribute)

前一章节,我们已经介绍过Dom节点的属性,通常我们在html页面中在一个节点的开始标签的标签名称后面添加属性,不同属性之间使用空格隔开。

<tagname attr1="value1" attr2=value2 attr3='value3' attr4>
</tagname>

3.1.1. 属性值为一个字符串

上例中,attr1attr3的属性值都是一个字符串,但有时候我们使用双引号,在另外一些时候我们使用单引号。

多数时候,我们在书写html的时候,都应该使用双引号,这是一个习惯。但有的时候,我们也会使用单引号,典型的情况是:我们在初始化的时候将一整行数据绑定到列表的一个属性上,因为属性值必须为一个字符串,所以我们需要将数据转换成字符串,而当我们将其转换为字符串之后,通常这个字符串中便会有双引号,这时候如果属性值外层使用双引号的话,就会出错,所以这时候我们会使用单引号避免这问题。

const tpl = `<div data-value='{{=it}}'></div>`;
const data = JSON.stringify({a: 'test'});
// render(data, tpl);

事实上,attr2的属性值也是一个字符串,所以我们定下来这样的规范,即属性值必须加引号(双引号或单引号)。这样就不会有人会误以为一些属性值为数字或布尔值。

attr4,有些属性甚至没有值,只是定义这个属性即可,典型的有类似disable,readonly,controls这样的属性。一般情况下,这一类属性用来决定两种状态。

3.1.2. 获取/设置属性值

我们使用节点元素的getAttribute来获取某一个属性值,使用setAttribute设置属性值。

// 属性值为字符串
dom.getAttribute('attr-name');
// 属性值为数字,需要转换
parseInt(dom.getAttribute('attr-name'), 10);
// 属性值为boolean,需要使用字符串来判定
dom.getAttribute('attr-name') === 'true';

3.2. 属性(Property)

一部分DOM节点元素也提供了一些属性方便我们使用, 如idstyle等。我们可以直接使用这些属性进行一些操作。像前面介绍过的,此属性(Property)不同于彼属性(Attribute)。

3.3. 样式

样式style属性估计是我们使用最多的属性了,通过它,我们可以使用JavaScript脚本操作页面上的元素,使它改变颜色,形状等。如我们将某个节点元素的背景色修改为红色

node.style.backgroundColor = 'red';

4. Dom 节点操作

4.1. 查找

如果要对页面上的一个元素进行操作,首先必须查找到该元素才可以。所以,我们首先需要掌握查找dom元素的方法。

4.1.1. 在整个页面中查找dom元素

这种方法我们在项目中几乎不会使用,这里提一下,大家做了解即可,在项目开发过程中禁止这种用法。

4.1.2. 通过id查找

document.getElementById('id');
document.getElementsByClassName('classNames');
document.getElementsByName('name');
document.getElementsByTagName('tagname');
document.querySelector('tagname');
document.querySelector<HTMLInputElement>('#id');
document.querySelector<HTMLSpanElement>('.class');
document.querySelector<HTMLTextAreaElement>('[attribute-name]');
document.querySelector<HTMLAnchorElement>('[attribute-name=attribute-value]');

4.1.3. 查找子节点

  1. 单个节点查找

    fd.data.node.querySelector('tagname');
    fd.data.node.querySelector<HTMLInputElement>('#id');
    fd.data.node.querySelector<HTMLSpanElement>('.class');
    fd.data.node.querySelector<HTMLTextAreaElement>('[attribute-name]');
    fd.data.node.querySelector<HTMLAnchorElement>('[attribute-name=attribute-value]');
    
  2. 多个节点查找

    Array.from(fd.data.node.querySelectorAll('tagname'));
    Array.from(fd.data.node.querySelectorAll<HTMLInputElement>('.class'));
    Array.from(fd.data.node.querySelectorAll<HTMLSpanElement>('[attribute-name]'));
    Array.from(fd.data.node.querySelectorAll<HTMLAnchorElement>('[attribute-name=attribute-value]'));
    

4.1.4. 查找父(祖先)节点

node.parentElement as HTMLDivElement;
node.closest('tagname');
node.closest('.class') as HTMLInputElement;
node.closest('[attribute-name]') as HTMLSpanElement;
node.closest('[attribute-name=attribute-value]') as HTMLAnchorElement;

4.2. 判定节点位置关系

node1.contains(node2);  // true or false

4.3. 新增

4.3.1. 追加子节点

const child = document.createElement('tagname');
node.appendChild(child);

const child = document.createElement('tagname');
node.insertAdjacentElement('beforeend', child);

node.insertAdjacentText('beforeend', 'text');

node.insertAdjacentHTML('beforeend', '<tagname></tagname>');

4.3.2. 在第一个子节点前插入

const child = document.createElement('tagname');
node.insertAdjacentElement('afterbegin', child);

node.insertAdjacentText('afterbegin', 'text');

node.insertAdjacentHTML('afterbegin', '<tagname></tagname>');

4.3.3. 在某个节点前插入

const new_node = document.createElement('tagname');
node.parentElement.insertBefore(new_node, node);

const new_node = document.createElement('tagname');
node.insertAdjacentElement('beforebegin', new_node);

node.insertAdjacentText('beforebegin', 'text');

node.insertAdjacentHTML('beforebegin', '<tagname></tagname>');

4.3.4. 在某个节点后插入

const child = document.createElement('tagname');
node.insertAdjacentElement('afterend', new_node);

node.insertAdjacentText('afterend', 'text');

node.insertAdjacentHTML('afterend', '<tagname></tagname>');

4.4. 修改

node.parentElement.replaceChild(new_node, node);

4.5. 删除

node.remove();

node.parentElement.removeChild(node);

4.6. 复制节点

const deep = true;
node.cloneNode(deep);

5. 事件

5.1. 捕获/冒泡

这是一个事件触发时序的问题,多数情况下这两种区别我们并不需要关心,但如果遇到特殊情况了,那就得能搞清楚原理了。

5.1.1. 捕获

有以下两个节点

<div>
    <input type="button" value="test" />
</div>

如果在按钮上点击,div就会先捕获到事件,而input会后于div捕获到该事件,以下代码会先输出div,后输出input

const div = document.querySelector('div');
const input = document.querySelector('input');
div.addEventListener('click', (e) => {
    alert('capture-div');
}, true);
input.addEventListener('click', (e) => {
    alert('capture-input');
}, true);

5.1.2. 冒泡

有以下两个节点

<div>
    <input type="button" value="test" />
</div>

如果在按钮上点击,事件先在input上触发响应,然后才会冒泡到div上,以下代码会先输出input,后输出div,所捕获不同的是,addEventListener的第三个参数为false

const div = document.querySelector('div');
const input = document.querySelector('input');
div.addEventListener('click', (e) => {
    alert('pop-div');
}, false);
input.addEventListener('click', (e) => {
    alert('pop-input');
}, false);

5.1.3. 事件流

有聪明的朋友马上就会问:“如果我同时绑定了捕获事件和冒泡事件呢”?

<div>
    <input type="button" value="test" />
</div>

如果在按钮上点击,事件先在input上触发响应,然后才会冒泡到div上,以下代码会先输出input,后输出div,所捕获不同的是,addEventListener的第三个参数为false

const div = document.querySelector('div');
const input = document.querySelector('input');
div.addEventListener('click', (e) => {
    alert('capture-div');
}, true);
input.addEventListener('click', (e) => {
    alert('capture-input');
}, true);
div.addEventListener('click', (e) => {
    alert('pop-div');
}, false);
input.addEventListener('click', (e) => {
    alert('pop-input');
}, false);

它们的响应顺序为

  1. capture-div
  2. capture-input
  3. pop-input
  4. pop-div

5.1.4. 阻止捕获/冒泡

有的时候,我们希望点击事件再传播,就需要将其阻止,如下例,我们希望事件不再向上冒泡,使用e.stopPropagation();之后,果然div上面的响应不再被触发。

<div>
    <input type="button" value="test" />
</div>
const div = document.querySelector('div');
const input = document.querySelector('input');
div.addEventListener('click', (e) => {
    alert('pop-div');
}, false);
input.addEventListener('click', (e) => {
    alert('pop-input');
    e.stopPropagation();
}, false);

如果是捕获事件呢?在前文中我没有说阻止事件冒泡,而是说阻止事件传播,是因为方法stopPropagation不仅仅是用来阻止冒泡而已,看下例:

<div>
    <input type="button" value="test" />
</div>
const div = document.querySelector('div');
const input = document.querySelector('input');
div.addEventListener('click', (e) => {
    alert('pop-div');
    e.stopPropagation();
}, true);
input.addEventListener('click', (e) => {
    alert('pop-input');
}, true);

同样的,事件只在div节点上被触发了一次。

那么,是不是stopPropagation能够解决我们所有的问题了呢?答案是否定的,看下例

<div>
    <input type="button" value="test" />
</div>
const div = document.querySelector('div');
const input = document.querySelector('input');
div.addEventListener('click', (e) => {
    alert('capture-div');
}, true);
input.addEventListener('click', (e) => {
    alert('capture-input');
    e.stopPropagation();
}, true);
input.addEventListener('click', (e) => {
    alert('pop-input');
}, false);
input.addEventListener('click', (e) => {
    alert('lalala');
}, false);
div.addEventListener('click', (e) => {
    alert('pop-div');
}, false);

糟糕,在capture-input之后,pop-input,甚至lalala都出来了,这也许不是我们想要的结果,怎么做呢,答案是将stopPropagation换成stopImmediatePropagation就可以了。那么方法stopPropagationstopImmediatePropagation的差别是什么呢?从名字上看,就差了一个Immediate,英国普通话讲,这个意思是立即,马上的意思,加上Immediate就马上停止了。实际上,stopImmediatePropagation确实是让事件马上停止了传播,而stopPropagation则是到当前响应事件的dom节点为止,这其中的差异,不可不察。

5.2. 委托

什么又是事件委托呢,事件委托是指利用事件模型,在外层的某个节点上绑定事件,而它内部的其它节点则委托这个节点进行事件的响应处理。

例如

<ul>
    <li data-value="1">test</li>
    <li data-value="2">test</li>
    <li data-value="3">test</li>
    <li data-value="4">test</li>
    <li data-value="5">test</li>
    <li data-value="6">test</li>
    <li data-value="7">test</li>
    <li data-value="8">test</li>
    <li data-value="9">test</li>
    <li data-value="10">test</li>
</ul>

利用事件模型,我们只需要给外层的ul节点绑定一次事件即可,无须给每个li绑定事件

const ul = document.querySelector('ul');
ul.addEventListener('click', (e) => {
    alert(e.target.dataset.value);
});

它的最佳使用场景是什么呢?就是现今移动端的列表页面。因为列表中的某一行的事件,如果交由每一行去做,监听的事件太多了,会拖慢整个页面的响应速度,某些低端的android机性能可能会是很差的。但这还不是最要命的,要命的是,列表里的内容是不固定的,通常我们是会在列表快要到底部时再加载更多内容,甚至在某些对性能要求非常严格的场景下,我们需要移除最上面已经加载过了一些内容,防止一直加载更多导致列表过大性能下降。这个时候如果不使用委托,可以想像整个页面的性能和开发的复杂程度。使用委托,只需要给列表外层的某个节点绑定一次事件就搞定了,无论是从性能还是从开发的复杂程度上讲,都是最优的。

6. 方法

前面我们已经提到过了一些dom节点元素的方法,以及设置和获取属性(Attribute)的方法。除了那些之外,一些特定的节点还有一些独特的方法,比如Formsubmit方法,比如Videoplay,stop方法等。通过这些方法,才将一个静态的页面变成为了可以接受用户事件的页面。