JavaScript:浏览器事件

简介

之前所了解的操作 DOM 的方法都是即时生效的,而使用「事件」可以根据用户的操作运行操作 DOM 的代码。

  • 事件 - 什么是事件
  • 回应事件 - 如何监听事件并在事件发生时作出回应
  • 事件数据 - 掌控事件中所包含的数据
  • 停止事件 - 防止事件触发多重反应
  • 事件生命周期 - 事件的生命周期阶段
  • DOM 就绪状态 - 事件可以知道 DOM 何时准备就绪,可以与之进行交互

事件

什么是事件?有哪些事件?比如点击购买按钮而弹出了购买提示框,这里因为用户的点击而运行了操作 DOM 的代码弹出提示框的行为就是「事件」,而其中点击的行为就是事件中的「点击事件」。

另外 Google Chrome 有个挺有意思的 monitorEvents() 函数可以显示当前页面发生了什么事件,打开一个网站比如 DuckDuckGo 并打开控制台输入运行

monitorEvents(document); // 监控 document 对象下的事件

如果想了解更多有关 monitorEvents() 的信息,请查看文档

回应事件

事件目标

元素接口是节点接口的子代,节点接口继承自 EventTarget 接口。所有节点和元素均继承自 EventTarget 接口。

根据 EventTarget 页面 的解释,EventTarget:

是一个由可以接收事件的对象实现的接口,并且可以为它们创建侦听器。

而且

元素、文档和窗口是最常见的事件目标,但是其他对象也可以是事件目标...

EventTarget 位于整个链条顶端。也就是说,它不会从任何其他接口继承任何属性或方法。相反,所有其他接口都继承自它,因此包含它的属性和方法。这意味着,以下每一项都是「事件目标」:

  • document 对象
  • 段落元素
  • 视频元素
  • 等等

document 对象和任何 DOM 元素都可以成为事件目标。因为元素接口和文档接口都继承自 EventTarget 接口。因此,任何个别元素都继承自元素接口,也就相应地继承自 EventTarget 接口。同样,文档对象来自文档接口,因此也相应地继承自 EventTarget 接口。

EventTarget 接口没有任何 属性,而只有三个方法!这些方法是:

  • .addEventListener()
  • .removeEventListener()
  • .dispatchEvent()

添加事件监听器

使用 .addEventListener() 方法可以帮助监听 事件并对其作出回应!除了「监听事件」,此外还有几种不同的「说法」,以下是几个例子:

  • 监听事件
  • 侦听事件
  • 挂接事件
  • 回应事件

...所有这些说法都具有相同的含义,并可以互换使用。

让我们使用一段伪代码来解释如何设置事件监听器:

<event-target>.addEventListener(<event-to-listen-for>, <function-to-run-when-an-event-happens>);

可见,事件监听器需要三个要素:

  • 事件目标 - 称为目标
  • 要监听的事件类型 - 称为类型
  • 事件发生时运行的函数 - 称为监听器

<event-target>(即目标)正如我们刚才所讲的:网站上的所有东西都是一个事件目标(例如 document 对象、<p> 元素等)。

<event-to-listen-for>(即类型)是我们想要回应的事件。这可能是点击、双击、按下键盘上的按键、滚动鼠标滚轮、提交表单等等,诸如此类,不胜枚举!

<function-to-run-when-an-event-happens>(即监听器)是事件实际发生时运行的函数。

let duckSearchInput = document.querySelector('#search_form_input_homepage');
// 对于 duckSearchInput 元素设置(第一个参数)监听「点击事件」和(第二个参数)监听器函数
duckSearchInput.addEventListener('click', function () {
  // 收到监听事件后要做的功能代码写在这
  alert('欢迎使用搜索功能');
});

DuckDuckGo 并打开控制台输入运行上述代码,然后点击搜索框看看,此时会弹出提示框,内容为「欢迎使用搜索功能」。

小结

  • 查看 EventTarget 接口信息的 MDN 文档
  • 查看 addEventListener() 信息的 MDN 文档
  • 查看所有可以监听的事件的完整列表的 MDN 文档

移除事件监听器

如前所述,我们可以使用事件目标的 .addEventListener() 方法来开始监听特定的事件并对其作出响应。假设你只想监听第一个点击事件,并对其进行响应,而忽略所有其他点击事件。但.addEventListener() 事件会监听并响应所有点击事件。

.addEventListener() 规范的最新版本允许将一个对象作为第三个参数进行传递,这个对象可以用来配置 .addEventListener() 方法的行为方式。特别地,你可以选择只监听一个事件,但这个配置对象尚未得到广泛支持)。

要移除事件监听器,我们需要使用 .removeEventListener() 方法。这听起来很简单,对吧?但是,在查看 .removeEventListener() 之前,我们需要简单回顾一下对象相等性。这个跳转似乎有点奇怪,但你很快就会明白其中的用意。

JavaScript 中的对象相等性

相等性是大多数编程语言中的一个常见任务,但在 JavaScript 中,这可能有点棘手,因为 JavaScript 会进行所谓的「强制类型转换」,即尝试将所比较的项目转换为相同的类型(例如字符串、数字)。JavaScript 既有允许进行强制类型转换 的双等号 (==) 运算符,也有防止在比较时进行强制类型转换的三等号 (===) 符号。

{ name: 'Richard' } === { name: 'Richard' }; // false

虽然两个对象看起来完全一样但是也不表示它们完全相同,相同的信息并不表示就完全相同,在使用 JavaScript 对象和处理对等性时需要思考,它们是两个不同的对象吗?或者是引用同一对象的两个不同名称吗?

var a = {
    myFunction: function quiz() { console.log('hi'); }
};
var b = {
    myFunction: function quiz() { console.log('hi'); }
};

a.myFunction === b.myFunction; // false
function quiz() { ... }

var a = {
    myFunction: quiz
};
var b = {
    myFunction: quiz
}
a.myFunction === b.myFunction; // true

为什么要关心对象/函数的平等性呢?原因在于,.removeEventListener() 方法要求我们向其传递与传递给 .addEventListener() 的函数完全相同的监听器函数 。

<event-target>.removeEventListener(<event-to-listen-for>, <function-to-remove>);

可见,事件监听器需要三个要素:

  1. 事件目标 - 称为目标
  2. 要监听的事件类型 - 称为类型
  3. 要移除的函数 - 称为监听器

请记住,监听器 函数必须是与 .addEventListener() 调用中使用的函数完全 相同的函数...而不仅仅是一个看起来相同的函数。

先看一个例子

function myEventListeningFunction() {
    console.log('howdy');
}

// 为 点击 事件添加一个监听器,来运行 `myEventListeningFunction` 函数
document.addEventListener('click', myEventListeningFunction);

// 立即移除 应该运行`myEventListeningFunction`函数的 点击 事件监听器
document.removeEventListener('click', myEventListeningFunction);

它可以运作,是因为 .addEventListener().removeEventListener

  • 具有相同的目标
  • 具有相同的类型
  • 并传递完全相同的监听器

再看一个

// 为 点击 事件添加一个监听器,来运行 `myEventListeningFunction` 函数
document.addEventListener('click', function myEventListeningFunction() {
    console.log('howdy');
});

// 立即移除 应该运行`myEventListeningFunction`函数的 点击 事件监听器
document.removeEventListener('click', function myEventListeningFunction() {
    console.log('howdy');
});

这段代码无法成功移除事件监听器。那么,为什么这段代码无法运作呢?

  • .addEventListener().removeEventListener 具有相同的目标
  • .addEventListener().removeEventListener 具有相同的类型
  • .addEventListener().removeEventListener 具有各自不同的监听器函数...它们并未指向完全相同的函数(这正是事件监听器移除失败的原因!)

小结

  • 查看 removeEventListener() 信息的 MDN 文档
  • 查看相等性比较和相同性信息的 MDN 文档

事件的阶段

事件阶段

事件的生命周期包括三个不同的阶段,分别是:

  • 捕获阶段
  • 目标阶段
  • 冒泡阶段

而且,它们按照以上顺序发生;首先是 捕获 ,其次是 目标 ,再次是 冒泡 阶段。

大多数事件处理器都在目标阶段运行,例如当你将点击事件处理器附加到按钮时。事件到达按钮(即其目标),而在那里只有一个处理器,因此事件处理器得以运行。

但是,有时你会有一个项目集合——例如列表,并希望有一个处理器能够覆盖每个项目(同时仍可使用个别处理器来处理某些项目)。默认情况下,如果你点击一个子项目,而且处理器并未拦截点击,则事件会向上“冒泡”至父项目,并继续冒泡,直至得到处理或其抵达文档(document)。

另一方面,捕获让父项目可以在事件到达子项目之前将其拦截下来。

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Document</title>
</head>
<body>
    <p>
        <button>点击</button>
    </p>
</body>
</html>

假设标签 button 有个点击事件,那么事件发生阶段是这样的:

  1. button 元素被点击
  2. 事件经历捕获阶段(从外往里 html > body > 'p' > 'button')
  3. 事件达到目标(button 元素)
  4. 事件切换到冒泡阶段,并开始向上爬升 DOM 树(从里往外 'button' > 'p' > 'body' > 'html')

嗯,感觉挺有道理的,但为什么要知道这些?

document.addEventListener('click', function () {
   console.log('document 被点击了');
});

document.body.addEventListener('click', function () {
    console.log('body 被点击了');
});

打开任意网页在开发者工具里的控制台输入并运行上述代码,按直觉来说 bodydocument 的里面,那么点击时应该先发生「document 被点击了」接着才是「body 被点击了」对吧?其实是相反的。所以到底发生了什么?大致如下

  1. body 被点击
  2. 开始「捕获阶段」从外往里寻找
  3. 找到目标并进入「目标阶段」,碰到 body时运行监听器函数
  4. 切换到「冒泡阶段」从里往外向上爬升 DOM 树

到目前为止只使用两个参数来调用 .addEventListener() 方法:

  • 事件类型
  • 监听器函数

实际上,.addEventListener() 方法还有第三个参数,即 useCapture 参数。默认情况下,当仅使用两个参数来调用 .addEventListener() 时,该方法会默认使用冒泡阶段。

document.addEventListener('click', function () {
   console.log('document 被点击了');
});

但如果加入了第三个参数(为 true)时,则表示应该在捕获阶段提早激活监听器。

document.addEventListener('click', function () {
   console.log('document 被点击了');
},true);

document.body.addEventListener('click', function () {
    console.log('body 被点击了');
});

请记住,.addEventListener() 方法的第三个参数是捕获阶段的布尔值。当为 true 时意味着监听器在捕获阶段运行,当为 false 时在冒泡阶段运行。

事件对象

现在已经知道事件监听器以特定的顺序触发,以及如何解释和控制这个顺序,是时候把焦点转移到事件本身的细节上了。

当事件发生时,浏览器包含一个事件对象。这只是一个常规的 JavaScript 对象,包含大量有关事件本身的信息。根据 MDN .addEventListener() 的监听器函数:

在发生指定类型的事件时,会收到一个通知(一个实现事件接口的对象)

document.addEventListener('click', function (event) {  // ← 全新的 `event` 参数!
   console.log(event); // 得到事件数据对象
});

请注意添加到监听器函数的新的 event 参数。现在,当监听器函数被调用时,它就可以存储传递给它的事件数据了!而这个数据可以加以利用非常强大, 比如点击时鼠标位于页面中的坐标位置等等

默认操作

如前所述,事件对象存储了大量信息,我们可以使用这些数据来做各种事情。不过,专业人员经常使用事件对象来做的一件事,就是阻止默认操作的发生。这听起来好像有点奇怪,让我们一起来探讨一下。

想一想,比如在移动网页上要做一个向左滑动可以显示已读标记的功能,就像查阅邮件一样,可 touchmove 的默认事件会拖动网页,这就是你不想让它发生的。

如果没有事件对象只能任由默认操作发生。不过,事件对象上有一个 .preventDefault() 方法,处理器可以调用该方法来阻止默认操作发生!

const duckAdLink = document.querySelector('#search_button_homepage');

duckAdLink.addEventListener('click', function (event) {
    event.preventDefault();
    console.log("搜索按钮功能失效了")
});

DuckDuckGo 打开开发者工具的控制台运行上述代码,接着输入内容并点击搜索按钮会发现页面没有跳转到搜索结果。

小结

  • 事件的阶段:
    • 捕获阶段
    • 目标阶段
    • 冒泡阶段
  • 事件对象
  • 使用 .preventDefault() 阻止默认操作

避免过多事件

var myCustomDiv = document.createElement('div');

for (let i = 1; i <= 200; i++) {
    let newElement = document.createElement('p');
    newElement.textContent = 'This is paragraph number ' + i;

    newElement.addEventListener('click', function respondToTheClick() {
        console.log('A paragraph was clicked.');
    });

    myCustomDiv.appendChild(newElement);
}

document.body.appendChild(myCustomDiv);

要创建一个 <div> 元素,附加两百个段落元素,并在创建过程中向每个段落附上一个带有 respondToTheClick 函数的事件监听器。但是上述代码有点问题,创建了两百个功能相同的 respondToTheClick 函数,所以进一步优化而不是重复创建。

let myCustomDiv = document.createElement('div');

function respondToTheClick() {
    console.log('A paragraph was clicked.');
}

for (let i = 1; i <= 200; i++) {
    let newElement = document.createElement('p');
    newElement.textContent = 'This is paragraph number ' + i;

    newElement.addEventListener('click', respondToTheClick);

    myCustomDiv.appendChild(newElement);
}

document.body.appendChild(myCustomDiv);

现在生成的所有 <p> 元素都有同一个监听器函数了但还是有两百个事件监听器。如果把所有监听器转移到 <div> 上呢?

let myCustomDiv = document.createElement('div');

function respondToTheClick() {
    console.log('A paragraph was clicked.');
}

for (let i = 1; i <= 200; i++) {
    let newElement = document.createElement('p');
    newElement.textContent = 'This is paragraph number ' + i;

    myCustomDiv.appendChild(newElement);
}

myCustomDiv.addEventListener('click', respondToTheClick);

document.body.appendChild(myCustomDiv);

现在只有:

  • 一个事件监听器
  • 一个监听器函数

浏览器现在无需在内存中存储两百个不同的事件监听器和两百个不同的监听器函数。这大大提高了性能!

但注意,我们失去了对单个段落的访问权限,就无法将特定的段落元素作为目标。

为了将这个高效的代码与先前访问单个段落项目的能力结合起来可以使用名为 event delegation 的进程。

事件代理

先前所说的事件对象就是找回原有功能的诀窍!

事件对象有一个 .target 属性。该属性引用了事件的目标。还记得捕获、目标和冒泡阶段吗?...它们现在也会派上用场!

假设你点击了一个段落元素。整个过程大致如下:

  • 段落元素被点击
  • 事件经历捕获阶段
  • 事件达到目标
  • 事件切换到冒泡阶段,并开始向上爬升 DOM 树
  • 当它碰到 <div> 元素时,就会运行监听器函数
  • 在监听器函数中,event.target 是被点击的元素

因此,event.target 让我们可以直接访问被点击的段落元素。由于我们可以直接访问该元素,因此我们可以访问它的 .textContent、修改它的样式、更新它所拥有的类——我们可以对它进行任何操作!

// 创建一个 DIV 用于放入 P
let myCustomDiv = document.createElement('div');
// 抽离函数
function respondToTheClick(evt) {
    console.log('A paragraph was clicked: ' + evt.target.textContent);
}
// 生成 200 个 P
for (let i = 1; i <= 200; i++) {
    let newElement = document.createElement('p');
    newElement.textContent = 'This is paragraph number ' + i;
    // 重复操作将 P 放入 DIV 中
    myCustomDiv.appendChild(newElement);
}
// 将 DIV 放入 body
document.body.appendChild(myCustomDiv);
// 监听 DIV 而不是每个 P
myCustomDiv.addEventListener('click', respondToTheClick);

检查事件代理中的节点类型

在以上所使用的代码段中,我们将事件监听器直接添加到了 <div>元素中。监听器函数会记录一条消息,注明一个段落元素被点击(然后是目标元素的文本)。这可以完美地运作!

但是,在运行该消息之前,我们无法确保被点击的确实是一个 <p> 标签。在这个代码段中,<p>标签是 <div> 元素的直接子元素,但如果我们具有以下 HTML,会发生什么情况:

<article id="content">
  <p>Brownie lollipop <span>carrot cake</span> gummies lemon drops sweet roll dessert tiramisu. Pudding muffin <span>cotton candy</span> croissant fruitcake tootsie roll. Jelly jujubes brownie. Marshmallow jujubes topping sugar plum jelly jujubes chocolate.</p>

  <p>Tart bonbon soufflé gummi bears. Donut marshmallow <span>gingerbread cupcake</span> macaroon jujubes muffin. Soufflé candy caramels tootsie roll powder sweet roll brownie <span>apple pie</span> gummies. Fruitcake danish chocolate tootsie roll macaroon.</p>
</article>

在这个填充文本中注意到有一些 <span> 标签,如果想从 <article> 监听对 <span> 的点击,你也许会想使用以下代码:

document.querySelector('#content').addEventListener('click', function (evt) {
    console.log('A span was clicked with text ' + evt.target.textContent);
});

这样做是可以的,但有一个重要缺陷。当任何一个段落元素被点击时,监听器函数仍会触发!换句话说,这个监听器函数并没有验证事件目标是否确实是一个 <span> 元素。让我们将这个检查添加上去:

document.querySelector('#content').addEventListener('click', function (evt) {
    if (evt.nodeName === 'SPAN') {  // ← 验证目标是我们需要的元素
        console.log('A span was clicked with text ' + evt.target.textContent);
    }
});

请记住,每个元素都从节点接口继承属性。从节点接口继承的属性之一就是 .nodeName。我们可以使用这个属性来验证目标元素确实是我们正在查找的元素。当一个 <span> 元素被点击时,它将有一个 .nodeName 属性为「SPAN」,因此检查将通过,并且该消息将会被记录。但是,如果一个 <p> 元素被点击,它将有一个 .nodeName 属性为「P」,因此检查将失败,并且该消息将不会被记录。

nodeName 的大写问题

.nodeName 属性将返回一个大写字符串,而不是一个小写字符串。因此当执行检查时,请确保:

  • 检查大写字母,或者
  • .nodeName 转换为小写
// 用大写字母检查
if (evt.nodeName === 'SPAN') {
    console.log('A span was clicked with text ' + evt.target.textContent);
}
 // 将 nodeName 转换为小写
if (evt.nodeName.toLowerCase() === 'span') {
    console.log('A span was clicked with text ' + evt.target.textContent);
}

小结

事件代理是将管理子元素事件的能力委托给父元素的过程。通过使用以下技能做到了这一点:

  • 事件对象及其 .target 属性
  • 事件的不同阶段

知道 DOM 何时准备就绪

当 HTML 被接收、转换为令牌并构建文档对象模型时是一个连续的过程。当解析器到达一个 <script> 标签时,它必须等待下载脚本文件并执行该 JavaScript 代码。这是一个要点,也是 JavaScript 文件位置之所以十分重要的关键!

<!DOCTYPE html>
<html lang="en">
<head>
  <link rel="stylesheet" href="/css/styles.css" />
  <script>
    document.querySelector('footer').style.backgroundColor = 'purple';
  </script>
...

这并不是完整的 HTML 文件...这是目前已经解析的所有部分。请注意,这是使用内联 JavaScript,而非指向外部文件的。内联文件的执行速度会更快,因为浏览器不必再发出网络请求来获取 JavaScript 文件。但这个内联版本和 HTML 链接到外部 JavaScript 文件的结果将完全相同。

说回代码,这段代码本身是没有错误的,但是运行时仍会报错,原因就在于前文所述的「当解析器到达一个 <script> 标签时,它必须等待下载脚本文件并执行该 JavaScript 代码。」。当 .querySelector() 方法运行时...所构建的文档对象模型中尚没有可供选择的 <footer> 元素!因此,它不会返回 DOM 元素,而是会返回 null。这将导致一个错误,因为它相当于运行以下代码:

null.style.backgroundColor = 'purple';

所以一般而言将 JavaScript 文件移到了页面底部。如果 DOM 是连续构建的,那么将 JavaScript 代码移到页面的最底部,则当 JavaScript 代码运行的时候,所有 DOM 元素都已经存在了!

除了将 JavaScript 代码移到页面的最底部外还可以使用使用浏览器事件进行解决。

内容已加载事件

当文档对象模型被完全加载时,浏览器将触发一个事件。这个事件被称为 DOMContentLoaded 事件,我们可以使用监听任何其他事件的方式来监听这个事件:

document.addEventListener('DOMContentLoaded', function () {
    console.log('the DOM is ready to be interacted with!');
});

说回之前的 HTML 代码

<!DOCTYPE html>
<html lang="en">
<head>
    <link rel="stylesheet" href="/css/styles.css" />
    <script>
      document.addEventListener('DOMContentLoaded', function () {
          document.querySelector('footer').style.backgroundColor = 'purple';
      });
    </script>
...

仍旧将 JavaScript 代码放在 <head> 元素中,但使用 DOMContentLoaded 事件并将代码放入其中,当浏览器读取到这行代码时会阻止 DOM-styling 所以代码不会运行,且走 DOM 构建完成后运行代码。

除了使用 DOMContentLoaded 事件也有人使用 load 事件,即

document.onload(...)

load 会比 DOMContentLoaded 更晚触发——load 会等到所有图像、样式表等加载完毕(HTML 引用的所有东西)。很多年长的开发者会使用 load 来代替 DOMContentLoaded,因为后者不被最早的浏览器支持。但是如果需要检测代码的运行时间,通常 DOMContentLoaded 是更好的选择。

但这是不建议的做法,尽管 <head> 中的 JavaScript 代码会先在 <body> 中的 JavaScript 代码之前运行,所以除了确实某段 JavaScript 代码需要尽快运行,否则仍建议将 JavaScript 代码放置在页面底部 </body> 结束标签之前。

小结

查看 DOMContentLoaded 信息的 MDN 文档

Conners Hua

这个家伙很懒,什么都没有留下。

您可能还喜欢...

发表评论

电子邮件地址不会被公开。 必填项已用*标注