Web:DOM基础 & 浏览器事件

DOM基础和浏览器事件Event(鼠标点击事件、键盘事件)

Posted by BlackDn on August 29, 2022

“挂着一串铃铛,攥着一包香囊,在江湖上走走停停。”

Web:DOM 基础 & 浏览器事件

前言

对于 JS 来说,我们仅仅知道其语法还有点不够。我们要知道 JS 是如何获取页面元素,从而增加元素、删除元素、修改元素的;以及 JS 是如何对点击事件、键盘事件进行响应处理的
而这就离不开 DOM 的支持了。
虽然 DOM 也支持其他语言,不过说到前端那还得是 JS。本文代码部分也用的是 JS,所以 Tag 里也加个 JS 吧
似乎有点长,慢慢看吧。

DOM 基础

其实之前在写关于 XSS 的文章的时候有个 基于 DOM 的 XSS(Dom-based XSS)里简单提到了 DOM,这里就再说一下。
DOM(Document Object Model),文档对象模型,HTMLXML文档的编程接口,提供对文档的结构化描述。
简单来说,DOM 将文档解析成节点和对象,这些对象又有很多属性和方法,这就允许我们用代码(脚本语言或程序)对页面(HTMLXML)进行操作。
不然如果我们想动态改变页面,只能傻乎乎地在 HTML 页面以字符串的形式加入代码,那岂不是显得很呆。
比如我们可以通过 DOM 返回所有<p>元素列表:

paragraphs = document.getElementsByTagName("p");
alert(paragraphs[0].nodeName);

DOM 对象

DOM 主要有以下几个对象,用来表示 HTML 页面中的不同内容:

对象 描述
document 代表整个页面,详见MDN:Document
element 代表一个节点(元素)
nodeList 代表一个元素的数组,可以用list.item(1)list[1]访问其条目
attribute 代表一个属性
namedNodeMap 代表通过nameindex访问的键值对元素(Map的数据类型)

用 DOM 和 JavaScript 修改页面

之前提到了,DOM 作为一个接口,常和一些脚本语言结合使用,而最常用的自然是JavaScript了(当然python等其他语言也可以)。
HTML 文档中的每一个元素,包括整个文档、文档头部(header)、表格等都属于 DOM 的一部分,因此 JavaScript 都可以对其进行访问和操作。

给页面添加元素

假设有一个空网页,我们可以用以下代码创建一个<div>元素,并让其具有文本"hello world"

const body = document.body; //获取body
const div = document.createElement("div"); //创建div
div.innerText = "hello world"; //为div设置文本
body.append(div); //将div放入body中

不过除了div.innerTextdiv.textContent同样可以修改其文本。两者区别在于,innerText获取的是这个div内全部的文本,而textContent获取的则是可见的文本
举个例子:

<div>
  <span>hello</span>
  <span style="display: none;">world</span>
</div>

我们得到的innerText内容为"hello""world",因为这两个文本都在div中;而textContent的内仅为"hello"。因为"world"displaynone,所以在页面中不显示。

获取元素

上面这个例子是给页面添加元素的,我们用createElement()创建了新的元素。但是如果元素是页面已有的,我们要怎么获取呢?

querySelector()

第一个方法是用querySelector()进行获取。比如获取上面例子中的<div>

const div = document.querySelector("div");

querySelector()还可以返回classid
假设我们的<div>里的两个<span>分别有idclass

<div>
  <span id="span-id">Span With Id</span>
  <span class="span-class">Span With Class</span>
</div>

querySelector()获取id的时候前面要加个#,而获取class的时候就加个.

const spanWithId = document.querySelector("#span-id");
const spanWithClass = document.querySelector(".span-class");
querySelectorAll()

querySelector()的一个弊端在于其只会返回第一个检索到的元素,如果有多个元素,则后面的会被忽略。
为了解决这种情况,我们可以用querySelectorAll(),他可以匹配所有元素并返回一个列表
比如有一个列表:

<ul>
  <li>The</li>
  <li>test</li>
</ul>
<ul>
  <li>has</li>
  <li>passed</li>
</ul>

通过获取<li>元素,我们可以得到所有的内容:

let elements = document.querySelectorAll("ul > li");
for (let elem of elements) {
  alert(elem.innerHTML); //输出: "The", "test", "has", "passed"
}

但是由于要查找所有元素,其运行速度会略慢于querySelector()

getElementById()

如果我们已经明确元素具有id属性,我们可以使用document.getElementById(id)获取元素。
甚至可以直接用 id 作为对象名进行引用,比如对于id"elem"的元素:

<div id="elem">
  <div id="elem-content">Element</div>
</div>

下面这两种用法的效果是一样的:

let elem = document.getElementById("elem");
elem.style.background = "red";

//等同于:
elem.style.background = "red"; // elem 是对带有 id="elem" 的 DOM 元素的引用
// id="elem-content" 内有连字符,所以它不能成为一个变量
// 但是我们可以通过使用方括号 window['elem-content'] 来访问它

当然,下面那种直接引用的方法是不推荐的,当有其他变量名冲突之后就会被覆盖从而引起难以察觉的错误。

getElementByXXX()

主要有以下几种:

  • getElementsByTagName(tag):查找具有给定标签的元素,当传入*时表示匹配所有元素。
  • getElementsByClassName(className):返回具有给定 CSS 类的元素。
  • getElementsByName(name):返回 name 属性等于传入参数的元素。

上述方法均返回一个数组,但是一定程度上都被弃用了,你可能也发现了这些方法可以被querySelector()代替,能用一个方法解决的事情我干嘛要记这么多捏。
对于上面提到的getElementById()来说,它用到的反而还比较多,可能是因为只返回一个元素而非一个集合而更受欢迎。

matches()

和前几个方法不同,matches()并非查找获取,而是检查是否有匹配的元素,并返回truefalse。因此,很多时候被用来配合Regex来过滤元素
比如我们像过滤链接中的压缩包:

<a href="http://example.com/file.zip">...</a>
<a href="http://www.baidu.com">...</a>
<a href="https://blackdn.github.io"></a>

可以先获取页面全部的内容,再进行筛选:

for (let elem of document.body.children) {
  if (elem.matches('a[href$="zip"]')) {
    alert("The archive reference: " + elem.href);
  }
}

从页面删除元素

拿到了元素后,我们可以将其删除。 一种方法是用元素本身的remove()方法删除

const spanWithId = document.querySelector("#span-id");
spanWithId.remove();
//题外话:当然还可以用append()加回来
div.append(spanWithId);

另一种方法是用父元素的removeChild()删除元素

const div = document.querySelector("div");
const spanWithId = document.querySelector("#span-id");
div.removeChild(spanWithId);

修改元素属性

假设我们的元素又多了一些属性,比如 title 啥的

<div>
  <span title="span-title" id="span-id">Span With Id</span>
  <span class="span-class">Span With Class</span>
</div>

我们可以通过getAttribute()方法获取对应属性的内容,同时可以用 setAttribute 来修改内容:

spanWithId.getAttribute("title"); //内容为:"span-title"
spanWithId.getAttribute("id"); //内容为:"span-id"
spanWithId.setAttribute("title", "new-title"); //title的内容变为:"new-title"

当然,还可以直接把整个属性删掉:

spanWithId.removeAttribute("title");

此外,还可以用点结构来获取/修改属性:

console.log(spanWithId.title); //输出:"span-title"
console.log(spanWithId.id); //输出:"span-id"
spanWithId.title = "new-title"; //title的内容变为:"new-title"

对于那些已经明确的属性,两种方法都可以用,但是对于那些还不确定的、放在变量里的属性,那么就只能用参数的方式进行获取或修改了。

在实际运用中,我们会给元素设置一个data域,而元素的dataset属性则会以键值对的形式保存 data 域里的内容。
比如我们给其中一个<span>一个data-test属性:

<span class="span-class" data-test="I am data test">Span With Class</span>

以下代码会在控制台输出一个DOMStringMap

const div = document.querySelector("div");
const spanWithClass = document.querySelector(".span-class");
console.log(spanWithClass.dataset);
//输出:
//DOMStringMap {
//  test: "i am data test"
//}

同样,我们可以直接从spanWithClass.dataset.test获取"i am data test",还可以用点结构来为其设置新的属性

console.log(spanWithClass.dataset.test);
spanWithClass.dataset.newAttr = "I am new attr";

不仅如此,更多时候我们会想要修改元素的样式,而 DOM 便为此提供了 style 属性
因此我们能够很轻易地修改元素的字体、颜色、背景等样式

spanWithClass.style.backgroundColor = "red";
spanWithClass.style.color = "white";

这里就简单介绍一下,更多内容还是要去看文档嗷,看看有哪些属性哪些方法。

修改 ClassList

当元素有一个或多个class的时候,classList属性能够获取其所有class,方便我们添加、修改、删除class

const spanWithClass = document.querySelector(".span-class");
spanWithClass.classList.add("new-class");
spanWithClass.classList.remove("span-class");

classList还有一个toogle()方法,传入一个class名作为参数。如果classList中存在该class,则会将其删除,并返回false;如果不存在,则会将其加入,并返回true
toogle()的第二个参数表示是否强制执行,为false表示一定返回false,即无论class是否存在都删除(不存在就不操作);反之为true则一定返回true,表示无论 class 是否存在都加入(存在就不进行操作)

spanWithClass.classList.toggle("new-class", true); //强制给spanWithClass添加"new-class"

DOM 常用方法

这里列一下常见的方法,如有需要可以进一步去搜索嗷~

方法 作用
document.getElementById(id) 根据id获取节点
document.getElementsByTagName(name) 根据元素标签获取节点
parentNode.appendChild(node) 为元素添加新的节点
element.innerHTML 获取/设置 HTML 语法表示的元素的后代
element.style.xxx 设置元素的style内容
element.getAttribute() 根据属性名返回元素的属性值。若不存在则返回 null""
element.setAttribute() 根据属性名设置元素的属性值。若不存在,则添加新的属性
element.addEventListener() 为元素设定监听器
window.content 返回主内容窗口的Window 对象.
window.onload 表示网页加载后立刻执行的操作
window.dump() 将信息打印到控制台 console(非标准函数,尽量减少使用)
window.scrollTo() 滚动到文档中的某个坐标。

浏览器事件

在浏览器中,页面/服务端应当对用户的一些行为产生响应或反馈,而这些行为就称之为事件(event)
比如鼠标点击、键盘响应、提交表单等都属于事件
DOM 的所有节点都可以产生事件,而 JS 则就可以探测到这些事件,进行进一步操作

常见事件

这里列出一些常见事件,当然不是全部

鼠标事件:

  • click —— 当鼠标点击一个元素时(触摸屏设备会在点击时生成)。
  • contextmenu —— 当鼠标右键点击一个元素时。
  • mouseover / mouseout —— 当鼠标指针移入/离开一个元素时。
  • mousedown / mouseup —— 当在元素上按下/释放鼠标按钮时。
  • mousemove —— 当鼠标移动时。

键盘事件

  • keydownkeyup —— 当按下和松开一个按键时。

表单(form)元素事件

  • submit —— 当访问者提交了一个 <form> 时。
  • focus —— 当访问者聚焦于一个元素时,例如聚焦于一个 <input>

Document 事件

  • DOMContentLoaded —— 当 HTML 的加载和处理均完成,DOM 被完全构建完成时。

CSS 事件

  • transitionend —— 当一个 CSS 动画完成时。

事件的处理和绑定

对于页面产生的这些事件,我们通常需要进行处理,以便相应这些事件。比如点了按钮后总要发生点什么。
通常我们编写事件处理程序(handler)来对事件进行处理。
说白了就是要写一个函数,来和某个元素(节点)的某个事件进行绑定,从而实现该元素执行该事件时,运行这个函数。
最简单的绑定程序就是在 HTML 中进行绑定。比如<input><button>可以用onclick属性进行绑定:

<input value="Click me" onclick="alert('Click!')" type="button" />

onclick属性表示click事件的处理程序,这里表示点击后执行alert弹窗。
更多时候事件的处理程序是放在JavaScript代码中的。因为三剑客(HTML,CSS,JS)分别表示框架,样式,逻辑,处理程序自然属于一种业务逻辑。

<script>
  function countRabbits() {
    console.log("3");
  }
</script>
<input type="button" onclick="countRabbits()" value="Count rabbits!" />

对于DOM来说,其规定了某个事件的处理程序可以表示为on<event>,这样就不需要在HTML中规定onclick等属性,避免了我们在JS 代码HTML 文件中来回切换。
比如下面这样,实现的效果和上面的countRabbits()一毛一样

<input type="button" id="button" value="Count rabbits!" />
<script>
  const btn = document.querySelector("#button");
  btn.onclick = function () {
    console.log("3");
  };
  //在已经定义countRabbits()的情况下也可以:
  //btn.onclick = countRabbits;
</script>

在尝试上面两种写法的时候,有几点需要注意:

  • HTML 的属性名是大小写不敏感的,因此<input>onclickONCLICKonClick的效果都是一样的;而 DOM 是大小写敏感的,只能用onclick的形式(全部小写)
  • 在 HTML 用属性名进行绑定的时候,需要加括号:onclick="countRabbits()",HTML 中有括号就表示这是个函数;而在 JS 中用 DOM 绑定的时候则不能有括号:btn.onclick = countRabbits;,在 JS 中,有括号的函数表示执行该函数。
  • 虽然 onclick 是一个属性,但是不能通过setAttribute()进行绑定,比如:btn.setAttribute('onclick', function() { console.log("3") });是无效的。

事件监听器 eventListener

虽然上面的方法很简单,但是也有很致命的缺点,就是对于一个元素的一个事件,只能绑定一个处理函数。后绑定的会覆盖之前绑定的。
为了解决这个问题,便有了事件监听器 eventListener。它允许我们使用 addEventListenerremoveEventListener 来为一个元素的某个事件绑定/删除其处理程序。当然,这种情况下允许我们多次调用addEventListener 从而为一个事件绑定多个处理程序。

element.addEventListener(event, handler[, options]);
  • event:事件名,如"click"
  • handler:处理程序
  • options:可选参数。
    {once: true/false}表示该监听器是否只执行一次,如果为 true,那么会在触发后自动删除
    {capture: true/false}表示处理程序合适执行,如果为 true,则在捕获阶段执行;false(默认)则在冒泡阶段执行。捕获冒泡是事件传递的机制,后面会讲到的(应该吧=。=)。 {passive: true/false},如果为 false,那么处理程序将会调用 preventDefault(),拒绝执行浏览器的默认操作(点击链接跳转、按下并拖动鼠标选中文本等)

而对于移除事件监听器removeEventListener来说,只有传入的处理程序(函数)和添加的时候一样才会成功删除。鉴于函数是一种引用对象,保存地址,因此匿名函数是无法被删除的,毕竟匿名函数没办法再次拿到其地址。

//这样并不能成功移除
btn.addEventListener("click", () => console.log("3 Rabbits!"));
btn.removeEventListener("click", () => console.log("stop counting!"));
//这样才可以成功移除
btn.addEventListener("click", countRabbits);
btn.removeEventListener("click", countRabbits);

对于某些事件来说,他们无法通过 DOM 属性进行处理程序的绑定,只能通过addEventListener来绑定,这也导致其更加通用。
比如DOMContentLoaded 事件,其在文档加载完成并且 DOM 构建完成时触发,因此无法通过 DOM 本身的方法绑定。(毕竟人家还没构建完呢)

// 绑定失败
document.onDOMContentLoaded = countRabbits;
// 绑定成功
document.addEventListener("DOMContentLoaded", countRabbits);

还可以是对象而非函数

我们可以在对象中编写处理程序,只要命名一个handleEvent的函数,就能被addEventListener所识别并绑定

let obj = {
  handleEvent(event) {
    console.log("3 rabbits!");
  },
};
btn.addEventListener("click", obj);

这么做的好处之一就是能够让多个元素接受一个对象作为事件处理程序(当然接受已有的函数也有这个好处)
另外一个好处就是能利用类和对象的特性,我们可以将处理程序编写成一个类,在需要的时候实例化出这个对象再传入。这样能进一步进行封装,落实面向对象编程(OOP)

此外,我们可以用点小聪明,来实现一个对象处理不同的事件:

class MousePressEventHandler {
  handleEvent(event) {
    let method = "on" + event.type[0].toUpperCase() + event.type.slice(1);
    this[method](event);
  }
  onMousedown() {
    console.log("mouse pressed");
  }
  onMouseup() {
    console.log("...and released");
  }
}
let handler = new MousePressEventHandler();
btn.addEventListener("mousedown", handler);
btn.addEventListener("mouseup", handler);

可以看到,我们为鼠标的按下和抬起分别写了两种不同的处理程序,并利用event.typemousedownmouseup两个事件类型的字符串构建出其方法名(onMousedownonMouseup),从而选择要执行哪个方法。

事件对象 event

之前的栗子中,我们的事件处理程序仅仅是输出一些东西,并没有进一步操作。但是如果我想获取到我们事件的一些信息呢,比如获取元素对象从而改变其状态、获取鼠标位置信息、获取 Checkbox 是否选中等状态之类的。
这些东西实际上都存储在时间对象event中。当一个事件发生的时候,浏览器会创建一个event对象,这个对象包含很多属性,比如event.type表示事件类型(上面的栗子就是click),event.clientX / event.clientY表示鼠标指针相对窗口的坐标。
事实上不同的事件类型又着自己不同的属性,比如键盘事件鼠标点击事件的属性就有所不同。

这个世界上存在很多的事件种类,除了鼠标事件、键盘事件外,还包括更改元素的change事件,输入内容更改的input事件,剪切事件cut、复制事件copy、粘贴事件paste
不过我就调鼠标和键盘两个相对简单常见的介绍一下好了

鼠标事件

具体可见鼠标事件,这里列出一些常见的属性:

事件属性 意义
event.type 点击类型
event.button 规定了点击的鼠标按键
event.clientX / clientY 鼠标相对窗口的坐标(可理解为相对坐标,左上角为(0, 0)
event.pageX / pageY 鼠标相对文档的坐标(可理解为绝对坐标,左上角为(0, 0)
event.oncopy false时表示文本不允许复制(当然可以通过查看页面源码或开发者工具来复制)

而对于点击事件的类型来说,又分为很多种(不会有人觉得只有 click 吧,不会吧不会吧)

事件类型(event.type) 意义
mousedown/mouseup 点击/释放鼠标
mouseover/mouseout 从一个元素上移入/移出
mousemove 在元素上移动鼠标
click 左键点击鼠标(仍会触发mousedown/mouseup
dblclick 双击某元素(一定程度上弃用了)
contextmenu 打开菜单的事件,由鼠标右键或其他键盘按键触发

由于鼠标存在左键、右键、中键(滚轮),有的甚至还有侧边的前进和后退键(通常为电竞鼠标,可以自定义按键宏的那种,我的雷蛇就有),这些都会触发mousedown/mouseup事件,因此我们可以用event.button来进行区分(以前是用event.which,但是现在弃用了)。

鼠标按键 event.button 值
左键 0
中键 1
右键 2
X1 键(后退键) 3
X2 键(前进键) 4

如果鼠标和某些功能性按键一起按下的话,event还有额外的属性表示。这些属性的值为truefalse,表示鼠标点击的时候这些键是否按下。

事件属性 意义
shiftKey shift是否按下
altKey alt是否按下(Mac 中是opt
ctrlKey ctrl是否按下
metaKey Mac 的command是否按下

键盘事件

为了偷懒按照惯例,这里还是给出一些常见的事件属性,详情可见:键盘:keydown 和 keyup

事件属性 意义
event.type = keydown / keyup 按键被按下 / 弹起
event.key 按键按下后产生的字符
event.code 按下的按键的统一代码
event.repeat 该事件是否自动重复触发

所谓自动重复,指的是当我们按下一个键不放,会产生持续的输入。如果一个键盘发生了自动重复,那么该事件的event.repeat=true

再来解释一下event.keyevent.code
event.key是输入键盘后获取的字符,在不同情况下可能会有所不同,比如大小写的时候获取的字符就不一样。如果输入法的语言不同,获取到的字符也是不一样的。
event.code则是每个键盘特有的唯一编码,不管输入法的语言,不管大小写,只要你按了键盘上这个位置的键,那么其event.code就是相同的。

键盘按键 event.key event.code
Z z(小写) KeyZ
Shift + Z Z(大写) KeyZ
0 0 Digit0
1 1 Digit1
= = Equal
Shitf + = (输出+ + Equal
- - Minus
F1 F1 F1
Backspace Backspace Backspace
Shift Shift ShiftRight/ShiftLeft

所以在设计系统的时候,如果我们希望一个按键即使在切换了语言后,仍能正常使用,那么就应该监听 event.code 的值。
不过,有些时候我们不得不使用event.key。比如美式键盘中,我们的Z在左下角,而在德式键盘中,左下角的按键是Y。但是由于event.code的值是由键盘的物理位置所决定的,因此当德式键盘按下左下角的按键后,event.key='y',但是event.code='KeyZ'。因此,在未知键盘是美式还是德式的情况下,我们想知道被按下的键是 Y 还是 Z,就只能使用event.key了。

事件传递机制:冒泡和捕获

冒泡 Bubbling

为了偷懒减少篇幅且方便大家复制自己玩,这里纯纯用 HTML 写个栗子:

<form style="border: 1px black solid" onclick="console.log('form')">
  FORM
  <div style="border: 1px black solid" onclick="console.log('div')">
    DIV
    <p style="border: 1px black solid" onclick="console.log('p')">P</p>
  </div>
</form>

这里我们给三个嵌套的元素都添加了点击事件的处理方法(在控制台进行输出)。
如果我们点击最外面的<form>,那么就会输出"form",这没啥问题。
但是如果我们点击<div>,那么就会先输出"div",再输出"form"。同理,点击<p>后会依次输出"p""div""form"

这就是冒泡 Bubbling的事件传递机制:事件会从发生的元素开始,依次向上传递给父元素。
因为事件从子元素传递到父元素,这个自下而上的过程就像泡泡从水底冒上来,所以叫冒泡(事件就是泡泡)

比较高级的一点是,父元素可以通过event.target来获取事件发生的元素。我们简单修改一下上面的栗子 🌰:

    <form><div><p>...</p></div></form>		//简单省略一下。。记得删掉onclick
    <script>
      document.querySelector("form").onclick = function(event) {
        console.log('this is handler form: ' + this.tagName + ', you clicked: ' + event.target.tagName);
      };
    </script>

<form>绑定了上面这个处理程序后,当我们点击最里面的<p>,就会输出:this is handler form: FORM, you clicked: P;点击<form>,就会输出:this is handler form: FORM, you clicked: FORM

停止冒泡

冒泡事件从目标元素开始向上冒泡,一直上升到 <html>,到 document 对象。冒泡很好用但是某些情况下会让事件的处理变得很麻烦,因此我们可以在任何一个元素的处理程序中调用event.stopPropagation()来停止冒泡,如同把泡泡戳破一样,事件将不再传递。
比如在上面的<script>中,我们为<p>添加一个新的处理函数,并令其停止冒泡。

document.querySelector("form").onclick = function (event) {
  console.log("this is handler form: " + this.tagName + ", you clicked: " + event.target.tagName);
};
document.querySelector("p").onclick = function (event) {
  console.log("you clicked <p>");
  event.stopPropagation();
};

这样以来,我们点击<p>的时候,仅会显示"you clicked <p>"<form>onclick方法将不再被调用,因此事件并没有传递到它这来。不过点击<div>还是能正常显示"this is handler form: FORM, you clicked: DIV"

要注意的是,在实践中,尽量少用event.stopPropagation(),而是用自定义事件或其他代替手段,因为这样不利于代码的维护和功能的扩展。比如一旦后续决定要在父元素中获取冒泡,需要去找到每一个停止冒泡的子元素并修改代码,贼拉麻烦。

捕获 Capturing

虽然我们先介绍的冒泡,但是根据DOM 标准,最先发生的实际上是捕获

  1. 捕获阶段(Capturing phase)—— 事件(从 Window)向下走近元素。
  2. 目标阶段(Target phase)—— 事件到达目标元素。
  3. 冒泡阶段(Bubbling phase)—— 事件从元素上开始冒泡。

也就是说,当我们点击一个元素的时候,点击事件先从 Window 窗口(document 对象)开始,向下传递(捕获阶段),然后到达目标元素(目标阶段),最后上升回到 Window 窗口(冒泡阶段),并在途中调用处理程序。
之所以不先提及捕获,是因为它不常用,而它之所以不常用,原因也很简单——默认情况下它对处理程序不可见

还记得我们刚提到addEventListener()的时候,它的可选参数中有个{capture: true/false}。当其为true的时候就表示该函数(处理程序)在捕获阶段被调用。

我们简单写一个脚本来为栗子中的每个元素添加处理程序:

    <script>
      for(let element of document.querySelectorAll('*')) {
        element.addEventListener("click", e => console.log(`Capturing: ${elem.tagName}`), true);
        element.addEventListener("click", e => console.log(`Bubbling: ${elem.tagName}`)); //默认为false
      }
    </script>

如此一来,我们点击<form><p>,就会有以下输出:

//点击<p>:
Capturing: FORM
Bubbling: FORM
//点击<p>:
Capturing: FORM
Capturing: DIV
Capturing: P
Bubbling: P
Bubbling: DIV
Bubbling: FORM

最后提一点,如果我们添加了一个捕获阶段的处理程序: addEventListener(..., true),那么在删除它的时候应该也要加上true参数: removeEventListener(..., true)

事件委托 Event Delegation

事件委托说是一种模式(pattern),也可以说是一种思想。说白了就是当我们想要为很多个元素设置处理程序的时候,不是为单个元素设置自己的处理程序,而是利用冒泡/捕获,在他们的父元素中设置。

假设我们有一个菜单,里面有三个按钮。当我们想为按钮设置处理程序的时候,不是给写三个函数分别给三个<button>,而是给他们的父元素<div>写一个函数。

<div id="menu">
  <button data-action="save">Save</button>
  <button data-action="load">Load</button>
  <button data-action="search">Search</button>
</div>

但是很多时候,这些平级的元素并不能相互区分,为此我们可以用<data-action>属性给他们打上标记,上面就将三个按钮标记为保存(save)、加载(load)、查询(search)

class MenuClickHandler {
  constructor(element) {
    this.elem = element;
    elem.onclick = this.onClick.bind(this);
  }

  save() {
    alert("saving");
  }
  load() {
    alert("loading");
  }
  search() {
    alert("searching");
  }
  onClick(event) {
    let action = event.target.dataset.action;
    if (action) {
      this[action]();
    }
  }
}

let menu = document.querySelector("#menu");
new MenuClickHandler(menu);

我们在类中设置了三个处理程序,并根据点击元素的data-action内容(event.target.dataset.action)选择合适的处理程序。
这里要注意,为了让this[action]()正确得到MenuClickHandler中的内容,需要声明elem.onclick = this.onClick.bind(this)。这一行让 this 表示MenuClickHandler对象,否则会表示 DOM 元素(传入的 elem 参数,这里是menu)。
还要注意前面的onclick是小写,后面的onClick是大写嗷。

当然我们可以通过设置类名或 id 来实现这种模式,不过还是更推荐使用 data-action 等属性来进行区分。

“行为”模式 Behavior Pattern

所谓行为模式,指的是我们对一些事件规定自定义的属性值(比如上面的data-action),并用文档范围级别的处理程序追踪并处理事件
比如我们想给一个元素添加一个计数器的行为,那么就给按钮添加data-counter

Counter: <input type="button" value="1" data-counter />

<script>
  document.addEventListener("click", function (event) {
    if (event.target.dataset.counter != undefined) {
      event.target.value++;
    }
  });
</script>

注意这个处理程序是加给文档(document)的,而event.target.dataset.counter != undefined用于判断该元素是否存在data-counter属性。

又比如,我们想通过点击按钮来修改另一个元素的某个属性:
通过点击<button>修改<form>是否可见:

<button data-toggle-id="subscribe-mail">Visibility of form</button>
<form id="subscribe-mail" hidden>Your mail: <input type="email" /></form>
<script>
  document.addEventListener("click", function (event) {
    let id = event.target.dataset.toggleId;
    if (!id) return; //点击的元素不存在data-toggle-id属性
    let elem = document.getElementById(id);
    elem.hidden = !elem.hidden;
  });
</script>

这里我们用data-toggle-id属性来保存按钮点击后想要修改的元素 id(例子中是 form)的 id,如此一来,当点击这个按钮后,我们能很轻易地得到目标元素的 id,从而对其的属性进行修改。

小结

说白了,事件委托的核心思想就是为元素设定一些标记属性,然后在其父元素甚至文档上放置处理程序。通过冒泡机制,在父元素上判断事件发生的元素,并决定是否执行、执行哪一个处理程序。

其好处在于我们无需为每个子元素添加处理程序,这让添加/删除元素变得尤为方便。而且更少的代码意味着更小的 JS 文件,也意味着更少的内存和更快的初始化

不过这又有些局限性,比如它只能适用于存在冒泡的事件,而有部分事件不存在冒泡。其次,事件委托还会加重 CPU 负担,毕竟父元素/文档要对内部所有的事件进行监听——不过对于现在的硬件条件,这点负担可以忽略不计就是了。

后话

本来这两部分是分开的两篇文章,但是我嫌他们太单薄了所以合在了一起
就是没想到一合起来东西还挺多
但是换个角度一想,事件的获取和处理离不开 DOM 的支持
所以放一起还是有点道理的=。=

参考

  1. MDN:文档对象模型 (DOM)

  2. MDN:DOM 概述

  3. Learn DOM Manipulation In 18 Minutes

  4. HTML DOM Document 对象

  5. 搜索:getElement,querySelector

  6. 浏览器事件简介

  7. 鼠标事件键盘:keydown 和 keyup

  8. 冒泡和捕获

  9. UI Events:W3C Working Draft, 04 August 2016

  10. 事件委托