2022年7月13日

浏览器事件简介

事件 是某事发生的信号。所有的 DOM 节点都生成这样的信号(但事件不仅限于 DOM)。

这是最有用的 DOM 事件的列表,你可以浏览一下:

鼠标事件:

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

键盘事件

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

表单(form)元素事件

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

Document 事件

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

CSS 事件

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

还有很多其他事件。我们将在下一章中详细介绍具体事件。

事件处理程序

为了对事件作出响应,我们可以分配一个 处理程序(handler)—— 一个在事件发生时运行的函数。

处理程序是在发生用户行为(action)时运行 JavaScript 代码的一种方式。

有几种分配处理程序的方法。让我们来看看,从最简单的开始。

HTML 特性

处理程序可以设置在 HTML 中名为 on<event> 的特性(attribute)中。

例如,要为一个 input 分配一个 click 处理程序,我们可以使用 onclick,像这样;

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

在鼠标点击时,onclick 中的代码就会运行。

请注意,在 onclick 中,我们使用单引号,因为特性本身使用的是双引号。如果我们忘记了代码是在特性中的,而使用了双引号,像这样:onclick="alert("Click!")",那么它就无法正确运行。

HTML 特性不是编写大量代码的好位置,因此我们最好创建一个 JavaScript 函数,然后在 HTML 特性中调用这个函数。

在这里点击会运行 countRabbits()

<script>
  function countRabbits() {
    for(let i=1; i<=3; i++) {
      alert("Rabbit number " + i);
    }
  }
</script>

<input type="button" onclick="countRabbits()" value="Count rabbits!">

我们知道,HTML 特性名是大小写不敏感的,所以 ONCLICKonClick 以及 onCLICK 都一样可以运行。但是特性通常是小写的:onclick

DOM 属性

我们可以使用 DOM 属性(property)on<event> 来分配处理程序。

例如 elem.onclick

<input id="elem" type="button" value="Click me">
<script>
  elem.onclick = function() {
    alert('Thank you');
  };
</script>

如果一个处理程序是通过 HTML 特性(attribute)分配的,那么随后浏览器读取它,并从特性的内容创建一个新函数,并将这个函数写入 DOM 属性(property)。

因此,这种方法实际上与前一种方法相同。

这两段代码工作相同:

  1. 只有 HTML:

    <input type="button" onclick="alert('Click!')" value="Button">
  2. HTML + JS:

    <input type="button" id="button" value="Button">
    <script>
      button.onclick = function() {
        alert('Click!');
      };
    </script>

在第一个例子中,button.onclick 是通过 HTML 特性(attribute)初始化的,而在第二个例子中是通过脚本初始化的。这是它们唯一的不同之处。

因为这里只有一个 onclick 属性,所以我们无法分配更多事件处理程序。

在下面这个示例中,我们使用 JavaScript 添加了一个处理程序,覆盖了现有的处理程序:

<input type="button" id="elem" onclick="alert('Before')" value="Click me">
<script>
  elem.onclick = function() { // 覆盖了现有的处理程序
    alert('After'); // 只会显示此内容
  };
</script>

要移除一个处理程序 —— 赋值 elem.onclick = null

访问元素:this

处理程序中的 this 的值是对应的元素。就是处理程序所在的那个元素。

下面这行代码中的 button 使用 this.innerHTML 来显示它的内容:

<button onclick="alert(this.innerHTML)">Click me</button>

可能出现的错误

如果你刚开始写事件 —— 请注意一些细微之处。

我们可以将一个现存的函数用作处理程序:

function sayThanks() {
  alert('Thanks!');
}

elem.onclick = sayThanks;

但要注意:函数应该是以 sayThanks 的形式进行赋值,而不是 sayThanks()

// 正确
button.onclick = sayThanks;

// 错误
button.onclick = sayThanks();

如果我们添加了括号,那么 sayThanks() 就变成了一个函数调用。所以,最后一行代码实际上获得的是函数执行的 结果,即 undefined(因为这个函数没有返回值)。此代码不会工作。

……但在标记(markup)中,我们确实需要括号:

<input type="button" id="button" onclick="sayThanks()">

这个区别很容易解释。当浏览器读取 HTML 特性(attribute)时,浏览器将会使用 特性中的内容 创建一个处理程序。

所以,标记(markup)会生成下面这个属性:

button.onclick = function() {
  sayThanks(); // <-- 特性(attribute)中的内容变到了这里
};

不要对处理程序使用 setAttribute

这样的调用会失效:

// 点击 <body> 将产生 error,
// 因为特性总是字符串的,函数变成了一个字符串
document.body.setAttribute('onclick', function() { alert(1) });

DOM 属性是大小写敏感的。

将处理程序分配给 elem.onclick,而不是 elem.ONCLICK,因为 DOM 属性是大小写敏感的。

addEventListener

上述分配处理程序的方式的根本问题是 —— 我们不能为一个事件分配多个处理程序。

假设,在我们点击了一个按钮时,我们代码中的一部分想要高亮显示这个按钮,另一部分则想要显示一条消息。

我们想为此事件分配两个处理程序。但是,新的 DOM 属性将覆盖现有的 DOM 属性:

input.onclick = function() { alert(1); }
// ...
input.onclick = function() { alert(2); } // 替换了前一个处理程序

Web 标准的开发者很早就了解到了这一点,并提出了一种使用特殊方法 addEventListenerremoveEventListener 来管理处理程序的替代方法。它们没有这样的问题。

添加处理程序的语法:

element.addEventListener(event, handler[, options]);
event
事件名,例如:"click"
handler
处理程序。
options
具有以下属性的附加可选对象:
  • once:如果为 true,那么会在被触发后自动删除监听器。
  • capture:事件处理的阶段,我们稍后将在 冒泡和捕获 一章中介绍。由于历史原因,options 也可以是 false/true,它与 {capture: false/true} 相同。
  • passive:如果为 true,那么处理程序将不会调用 preventDefault(),我们稍后将在 浏览器默认行为 一章中介绍。

要移除处理程序,可以使用 removeEventListener

element.removeEventListener(event, handler[, options]);
移除需要相同的函数

要移除处理程序,我们需要传入与分配的函数完全相同的函数。

这不起作用:

elem.addEventListener( "click" , () => alert('Thanks!'));
// ....
elem.removeEventListener( "click", () => alert('Thanks!'));

处理程序不会被移除,因为 removeEventListener 获取了另一个函数 —— 使用相同的代码,但这并不起作用,因为它是一个不同的函数对象。

下面是正确方法:

function handler() {
  alert( 'Thanks!' );
}

input.addEventListener("click", handler);
// ....
input.removeEventListener("click", handler);

请注意 —— 如果我们不将函数存储在一个变量中,那么我们就无法移除它。由 addEventListener 分配的处理程序将无法被“读回”。

多次调用 addEventListener 允许添加多个处理程序,如下所示:

<input id="elem" type="button" value="Click me"/>

<script>
  function handler1() {
    alert('Thanks!');
  };

  function handler2() {
    alert('Thanks again!');
  }

  elem.onclick = () => alert("Hello");
  elem.addEventListener("click", handler1); // Thanks!
  elem.addEventListener("click", handler2); // Thanks again!
</script>

正如我们在上面这个例子中所看到的,我们可以 同时 使用 DOM 属性和 addEventListener 来设置处理程序。但通常我们只使用其中一种方式。

对于某些事件,只能通过 addEventListener 设置处理程序

有些事件无法通过 DOM 属性进行分配。只能使用 addEventListener

例如,DOMContentLoaded 事件,该事件在文档加载完成并且 DOM 构建完成时触发。

// 永远不会运行
document.onDOMContentLoaded = function() {
  alert("DOM built");
};
// 这种方式可以运行
document.addEventListener("DOMContentLoaded", function() {
  alert("DOM built");
});

所以 addEventListener 更通用。虽然这样的事件是特例而不是规则。

事件对象

为了正确处理事件,我们需要更深入地了解发生了什么。不仅仅是 “click” 或 “keydown”,还包括鼠标指针的坐标是什么?按下了哪个键?等等。

当事件发生时,浏览器会创建一个 event 对象,将详细信息放入其中,并将其作为参数传递给处理程序。

下面是一个从 event 对象获取鼠标指针的坐标的示例:

<input type="button" value="Click me" id="elem">

<script>
  elem.onclick = function(event) {
    // 显示事件类型、元素和点击的坐标
    alert(event.type + " at " + event.currentTarget);
    alert("Coordinates: " + event.clientX + ":" + event.clientY);
  };
</script>

event 对象的一些属性:

event.type
事件类型,这里是 "click"
event.currentTarget
处理事件的元素。这与 this 相同,除非处理程序是一个箭头函数,或者它的 this 被绑定到了其他东西上,之后我们就可以从 event.currentTarget 获取元素了。
event.clientX / event.clientY
指针事件(pointer event)的指针的窗口相对坐标。

还有很多属性。其中很多都取决于事件类型:键盘事件具有一组属性,指针事件具有另一组属性,稍后我们将详细讨论不同事件,那时我们再对其进行详细研究。

event 对象在 HTML 处理程序中也可用

如果我们在 HTML 中分配了一个处理程序,那么我们也可以使用 event 对象,像这样:

<input type="button" onclick="alert(event.type)" value="Event type">

这是可能的,因为当浏览器读取特性(attribute)时,它会创建像这样的处理程序:function(event) { alert(event.type) }。也就是说:它的第一个参数是 "event",而主体取自于该特性(attribute)。

对象处理程序:handleEvent

我们不仅可以分配函数,还可以使用 addEventListener 将一个对象分配为事件处理程序。当事件发生时,就会调用该对象的 handleEvent 方法。

例如:

<button id="elem">Click me</button>

<script>
  let obj = {
    handleEvent(event) {
      alert(event.type + " at " + event.currentTarget);
    }
  };

  elem.addEventListener('click', obj);
</script>

正如我们所看到的,当 addEventListener 接收一个对象作为处理程序时,在事件发生时,它就会调用 obj.handleEvent(event) 来处理事件。

我们也可以对此使用一个类:

<button id="elem">Click me</button>

<script>
  class Menu {
    handleEvent(event) {
      switch(event.type) {
        case 'mousedown':
          elem.innerHTML = "Mouse button pressed";
          break;
        case 'mouseup':
          elem.innerHTML += "...and released.";
          break;
      }
    }
  }

  let menu = new Menu();
  elem.addEventListener('mousedown', menu);
  elem.addEventListener('mouseup', menu);
</script>

这里,同一个对象处理两个事件。请注意,我们需要使用 addEventListener 来显式设置事件,以指明要监听的事件。这里的 menu 对象只监听 mousedownmouseup,而没有任何其他类型的事件。

handleEvent 方法不必通过自身完成所有的工作。它可以调用其他特定于事件的方法,例如:

<button id="elem">Click me</button>

<script>
  class Menu {
    handleEvent(event) {
      // mousedown -> onMousedown
      let method = 'on' + event.type[0].toUpperCase() + event.type.slice(1);
      this[method](event);
    }

    onMousedown() {
      elem.innerHTML = "Mouse button pressed";
    }

    onMouseup() {
      elem.innerHTML += "...and released.";
    }
  }

  let menu = new Menu();
  elem.addEventListener('mousedown', menu);
  elem.addEventListener('mouseup', menu);
</script>

现在事件处理程序已经明确地分离了出来,这样更容易进行代码编写和后续维护。

总结

这里有 3 种分配事件处理程序的方式:

  1. HTML 特性(attribute):onclick="..."
  2. DOM 属性(property):elem.onclick = function
  3. 方法(method):elem.addEventListener(event, handler[, phase]) 用于添加,removeEventListener 用于移除。

HTML 特性很少使用,因为 HTML 标签中的 JavaScript 看起来有些奇怪且陌生。而且也不能在里面写太多代码。

DOM 属性用起来还可以,但我们无法为特定事件分配多个处理程序。在许多场景中,这种限制并不严重。

最后一种方式是最灵活的,但也是写起来最长的。有少数事件只能使用这种方式。例如 transtionendDOMContentLoaded(上文中讲到了)。addEventListener 也支持对象作为事件处理程序。在这种情况下,如果发生事件,则会调用 handleEvent 方法。

无论你如何分类处理程序 —— 它都会将获得一个事件对象作为第一个参数。该对象包含有关所发生事件的详细信息。

在下一章中,我们将学习更多关于一般事件和不同类型事件的内容。

任务

重要程度: 5

button 添加 JavaScript 代码,使得 <div id="text"> 在我们点击该按钮时消失。

示例:

打开一个任务沙箱。

重要程度: 5

创建一个按钮,在被点击时,隐藏自己。

就像这样:

可以在处理程序中使用 this 来引用“元素自身”:

<input type="button" onclick="this.hidden=true" value="Click to hide">
重要程度: 5

在变量中有一个按钮。它上面没有处理程序。

执行以下代码之后,哪些处理程序会在按钮被点击时运行?会显示哪些 alert?

button.addEventListener("click", () => alert("1"));

button.removeEventListener("click", () => alert("1"));

button.onclick = () => alert(2);

答案:12

第一个处理程序会触发,因为它没有被 removeEventListener 移除。要移除处理程序,我们需要传递正确的所分配的函数。在代码中,传递了一个新的函数,该函数看起来相同,但仍然是另一个函数。

要移除一个函数对象,我们需要存储对它的引用,像这样:

function handler() {
  alert(1);
}

button.addEventListener("click", handler);
button.removeEventListener("click", handler);

无论 addEventListener 怎样,button.onclick 处理程序都会触发。

重要程度: 5

点击球场中任意一点,让球在球场中移动。就像这样:

要求:

  • 球的中心应该恰好在点击时鼠标指针位置的下方(如果在球不越过球场边缘的情况下,能实现的话)。
  • 最好添加一些 CSS 动画。
  • 球不能越过场地边界。
  • 页面滚动时,布局不能被破坏。

注意:

  • 代码还应该适用于不同大小的球和球场,而不应该绑定到任何固定值。
  • 使用 event.clientX/event.clientY 属性来获取点击坐标。

打开一个任务沙箱。

首先,我们需要选择一种定位球的方法。

我们不能使用 position:fixed,因为滚动页面会造成球被移出球场。

所以我们应该使用 position:absolute,并且要使定位真正可靠,应该使 field 自身具有 position:absolute

然后,球将相对于球场定位:

#field {
  width: 200px;
  height: 150px;
  position: relative;
}

#ball {
  position: absolute;
  left: 0; /* 相对于最接近的祖先(field) */
  top: 0;
  transition: 1s all; /* left/top 的 CSS 动画,使球飞起来 */
}

接下来我们需要指定正确的 ball.style.left/top。它们现在包含相对于球场的坐标。

这是示意图:

我们有 event.clientX/clientY —— 点击位置的窗口相对坐标。

要获取点击位置的球场相对坐标 left,我们可以减去球场左边缘和边框的宽度:

let left = event.clientX - fieldCoords.left - field.clientLeft;

通常情况下,ball.style.left 表示“元素的左边缘”(球)。因此,如果我们将其指定为 left,那么球的边缘而非球的中心将位于鼠标光标下方。

我们需要将球向左移动球宽度的一半,向上移动球高度的一半,以使其居中。

所以,最后的 left 将是:

let left = event.clientX - fieldCoords.left - field.clientLeft - ball.offsetWidth/2;

使用相同的逻辑来计算垂直坐标。

请注意,球的宽度/高度必须在我们访问 ball.offsetWidth 时就已知。应该在 HTML 或 CSS 中指定。

使用沙箱打开解决方案。

重要程度: 5

创建一个在点击时打开/折叠的菜单:

P.S. 源文档的 HTML/CSS 将被修改。

打开一个任务沙箱。

HTML/CSS

首先,让我们创建 HTML/CSS。

菜单是页面上的一个独立图形组件,所以最好把它放入一个单独的 DOM 元素中。

菜单项的列表可以被作为列表 ul/li 列出。

下面是示例结构:

<div class="menu">
  <span class="title">Sweeties (click me)!</span>
  <ul>
    <li>Cake</li>
    <li>Donut</li>
    <li>Honey</li>
  </ul>
</div>

我们对标题使用 <span>,因为 <div> 有一个隐式的 display:block,它会占据 100% 的水平宽度。

就像这样:

<div style="border: solid red 1px" onclick="alert(1)">Sweeties (click me)!</div>

因此,如果我们在它上面设置 onclick,那么它也会捕获文本右侧的点击。

……由于 <span> 有一个隐式的 display: inline,它恰好占据了足以容纳所有文本的位置:

<span style="border: solid red 1px" onclick="alert(1)">Sweeties (click me)!</span>

切换菜单

切换菜单应更改箭头并显示/隐藏菜单列表。

所有这些更改都可以通过 CSS 完美处理。在 JavaScript 中,我们应该通过添加/移除 .open 类来标记菜单的当前状态。

没有它,菜单就会被关闭:

.menu ul {
  margin: 0;
  list-style: none;
  padding-left: 20px;
  display: none;
}

.menu .title::before {
  content: '▶ ';
  font-size: 80%;
  color: green;
}

……有 .open 后,箭头会改变,列表会出现:

.menu.open .title::before {
  content: '▼ ';
}

.menu.open ul {
  display: block;
}

使用沙箱打开解决方案。

重要程度: 5

有一个消息列表。

使用 JavaScript 在每条消息的右上角添加一个关闭按钮。

结果应该如下所示:

打开一个任务沙箱。

我们可以使用 position:absolute(并使窗格 position:relative)或者 float:right 来添加按钮。float:right 的好处是按钮永远都不会与文本重叠,但是 position:absolute 则提供了更大的自由度。选择权在你自己手上。

然后,对于每个窗格(pane),代码可以像这样:

pane.insertAdjacentHTML("afterbegin", '<button class="remove-button">[x]</button>');

然后 <button> 变成了 pane.firstChild,因此我们可以像这样为它添加处理程序:

pane.firstChild.onclick = () => pane.remove();

使用沙箱打开解决方案。

重要程度: 4

创建一个“轮播图(carousel)” —— 一条可以通过点击箭头来滚动图像的图像带。

之后,我们可以为其添加更多功能:无限滚动,动态加载等。

P.S. 对于这个任务,HTML/CSS 结构实际上占解决方案的 90%。

打开一个任务沙箱。

图像带可以表示为图像 <img>ul/li 列表。

通常,这样的图像带是很宽的,但我们在其周围放置了一个固定大小的 <div> 来“剪切”它,因此,只有图像带的一部分是可见的:

为了使列表水平显示,我们需要为 <li> 应用正确的 CSS 属性,例如 display: inline-block

对于 <img> 来说,我们应该调整 display,因为默认情况下它是 inline。在 inline 元素下方为 “letter tails” 保留了额外的空间,因此,我们可以使用 display:block 来将其删除。

我们可以移动 <ul> 来进行滚动。有很多方法可以实现这一点,例如,通过修改 margin-left 或者使用 transform: translateX()(性能更好):

外部的 <div> 具有固定的宽度,因此,会裁剪掉“多余”的图像。

整个轮播图是页面上的一个独立的“图形组件”,因此我们最好将其包装到一个单独的 <div class="carousel"> 中,并在其中对其进行样式设置。

使用沙箱打开解决方案。

教程路线图

评论

在评论之前先阅读本内容…
  • 如果你发现教程有错误,或者有其他需要修改和提升的地方 — 请 提交一个 GitHub issue 或 pull request,而不是在这评论。
  • 如果你对教程的内容有不理解的地方 — 请详细说明。
  • 使用 <code> 标签插入只有几个词的代码,插入多行代码可以使用 <pre> 标签,对于超过 10 行的代码,建议你使用沙箱(plnkrJSBincodepen…)