原文链接:https://javascript.info/mousemove-mouseover-mouseout-mouseenter-mouseleave,translate with ❤️ by zhangbao.

让我们深入了解鼠标在元素之间移动时,所发生事件的更多细节。

mouseover/mouseout,relatedTarget

当鼠标移入一个元素时,会触发 mouseover 事件;对应地,当鼠标离开元素时,会触发 mouseout 事件。

移动:mouseover/out,mouseenter/leave - 图1

这些事件有些特别,包含一个 relatedTarget 属性:

对于 mouseover

  • event.target:鼠标移入的元素。

  • event.relatedTarget:鼠标是从哪个元素移入的。

对于 mouseout,则相反:

  • event.target:鼠标离开的元素

  • event.relatedTarget:鼠标进入的新元素。

下面有个 demo 页面(点击查看):每个笑脸都是一个单独的元素,当鼠标在容器(红色边框包围的区域)和笑脸之间移动的时候,下面的文本框中输出事件触发的情况——targetrelatedTarget

移动:mouseover/out,mouseenter/leave - 图2

上面截图中,鼠标光标最终落在黄色笑脸的位置——先进入容器,再进入黄色笑脸,文本框中展示了这一过程中触发的事件轨迹。

移动:mouseover/out,mouseenter/leave - 图3relatedTarget的值可能为 null

这是正常的,只是意味着鼠标不是来自另一个元素,而是来自窗口外,或是离开了窗口。

我们应该记住,在使用 event.relatedTarget的时候,要考虑到 event.relatedTarget.tagName 为 null`` 的情况,否则代码可能出错。

事件频度

mousemove 事件是鼠标在元素上移动时触发的,但并不是说,每一像素的位置移动都会导致这个事件的触发。

浏览器会时刻检查鼠标位置,当检查发现鼠标位置改变时,就触发事件。

也就是说,如果访问者以非常的速度移动鼠标的话,因为事件并不会因此频频触发,所以中间经过的许多元素可能被忽略:

移动:mouseover/out,mouseenter/leave - 图4

如上图所示,当我们的鼠标非常快速地从 #FROM 移动到 #TO,中间经过的 <div> 可能被忽略。结果是,#FROM 上触发 mouseout 事件,#TO 上触发 mouseover 事件。

在实践中,这是有帮助的。因为我们并不想处理,鼠标很快经过的那些中间元素。

另一方面我们需要知道的是,当鼠标很缓慢地从 #FROM 移动到 #TO 时,我们就要考虑中间经过的那些 <div> 了。

特别地,当鼠标从窗口外快速移入到页面中间时,此时的 relatedTarget = null,因为来自于“无处”。

移动:mouseover/out,mouseenter/leave - 图5

进入子元素时的“额外”mouseout 事件

当鼠标移入一个元素时会触发 mouseover 事件。当鼠标继续进入一个子元素时,有趣的事情发生了,触发了 mouseout 事件,虽然鼠标光标还在元素上,但还是发生了 mouseout 事件!

移动:mouseover/out,mouseenter/leave - 图6

这看起来很奇怪,但可以很容易地解释。

根据浏览器的逻辑,鼠标同一时间只能在一个元素上——最内层嵌套元素(或是层级最高的那个元素)。

鼠标移入了一个元素(即使是后代元素),也表示它离开了之前的元素,简单吧。

下面例子里,div 中嵌套了一个 div,我们为外层 div 绑定了 mouseover/out 事件处理器。

  1. <div class="blue" onmouseover="mouselog(event)" onmouseout="mouselog(event)">
  2. <div class="red"></div>
  3. </div>
  4. <script>
  5. function mouselog(event) {
  6. console.log(event.type + '[target:' + event.target.className + ' ]')
  7. }
  8. </script>

效果如图:

移动:mouseover/out,mouseenter/leave - 图7

  1. 当鼠标进入蓝色 div 时,得到 mouseover[target: blue]

  2. (离开蓝色)进入红色 div 时,先得到 mouseout[target: blue](离开父元素)。

  3. (进入子元素)然后接着得到 mouseover[target: red]

如果处理器逻辑中不区分 target,看起来就像是在第 2. 步中离开(mouseout)父元素,之后再第 3. 步返回到父元素中。

如果我们在进入/离开元素时执行某些操作,那么我们将获得许多额外的“错误”运行。对于简单的东西可能不明显,但对复杂事物来讲,可能就会带来不良副作用。

我们可以通过使用 mouseenter/mouseleave 事件来修复这个问题。

mouseenter 和 mouseleave 事件

mouseenter/leave 事件与 mouseover/mouseout 事件类似,都是在鼠标移入/离开元素时触发的事件。

但有两点不同:

  1. 元素内的转换不计算在内。

  2. mouseenter/leave 不会冒泡。

这比较符合直觉。

当鼠标移入一个元素时,触发 mouseenter 事件,之后不管是否移入子元素,都不会再触发这个事件了。对应地,在鼠标离开这个元素时,也仅会触发一次 mouseleave 事件。

还是上面举的例子,不过外部蓝色 <div> 绑定的事件变为 mouseenter/leave,我们再次执行上述同样的操作——可以看到的是,事件仅在移入/离开蓝色 <div> 时触发,而移入/离开红色

没有触发额外事件,后代元素被忽略了。

  1. <div class="blue" onmouseenter="mouselog(event)" onmouseleave="mouselog(event)">
  2. <div class="red"></div>
  3. </div>
  4. <script>
  5. function mouselog(event) {
  6. console.log(event.type + '[target:' + event.target.className + ' ]')
  7. }
  8. </script>

移动:mouseover/out,mouseenter/leave - 图8

事件委托

mouseenter/mouseleave 事件使用起来非常简易。但由于事件本身不冒泡,所以不能使用事件委托。

想象一下,我们想要处理表格单元格的鼠标移入/离开事件,而且有数百个单元格。

首先想到的解决方案是在 <table> 上绑定事件处理程序,但 mouseentermouseleave 事件是不冒泡的。因此,如果想要这个事件发生在 <td> 上,那么只有 <td> 上的处理程序才能捕获它。

<table> 上的 mouseenter/leave 事件处理器只会在移入/离开表格时调用,无法捕获其内部任何过渡信息。

如果用 mouseover/mouseout 的话就没有这个问题了。

一个简单的处理程序可能如下所示:

  1. // 在鼠标移入时,高亮单元格
  2. table.onmouseover = function (event) {
  3. let target = event.target;
  4. target.style.background = 'pink';
  5. };
  6. table.onmouseout = function (event) {
  7. let target = event.target;
  8. target.style.background = '';
  9. };

从任何元素到表格内的任何元素时,这些处理程序都会调用。

但上面的例子是有问题的,就是 <td> 里面的 <strong> 元素也会被捕捉,这不是我们想要的效果。我们想要处理的是 <td>,不包括它的子元素。

解决方案之一是:

  • 记住当前高亮的 <td> 元素在变量中。

  • mouseover 时,如果仍在当前 <td> 中,就忽略。

  • mouseout 时,如果还未离开当前 <td>,就忽略。

这里我们想要过滤的“额外”情况,是鼠标在 <td> 元素的后代元素中间来回移动的情况。

下面是代码实现:

  1. let currentElem = null;
  2. table.onmousemove = function (event) {
  3. if (currentElem) {
  4. // 在进入新元素前,总要保证鼠标已经离开之前的高亮元素
  5. // 如果还没离开当前的 <td> 元素,就表示我们还在它里面,所以不做任何事
  6. return ;
  7. }
  8. let target = event.target.closest('td');
  9. if (!target || !table.contains(target)) {
  10. // !target 的情况。例如,从 外界 -> table/th
  11. // !target.contains(target) 的情况。例如,在 <td> 里面的 <td>
  12. return ;
  13. }
  14. // OK 现在在新的 <td> 元素上了
  15. currentElem = target;
  16. target.style.background = 'pink';
  17. };
  18. table.onmouseout = function (event) {
  19. if (!currentElem) {
  20. // 当前没有高亮的 <td> 元素,那么不做任何事。
  21. return ;
  22. }
  23. // 我们正在离开元素,去哪里的呢?可能是一个子元素?
  24. let relatedTarget = event.relatedTarget;
  25. if (relatedTarget) { // relatedTarget 可能等于 null。例如离开 table
  26. while (relatedTarget) {
  27. // 去父级链检查,如果仍然是在当前的 currentElem 中移动的
  28. // 表示是中间的过渡状态,忽略(不做去除高亮的处理)
  29. if (relatedTarget === currentElem) {
  30. return ;
  31. }
  32. relatedTarget = relatedTarget.parentNode;
  33. }
  34. }
  35. // 现在,是真的离开元素,去除高亮
  36. currentElem.style.background = '';
  37. currentElem = null;
  38. };

现在将鼠标在表格单元中随意移动,就能看到我们最终想要的效果了。

总结

本章我们介绍了 mouseovermouseoutmousemovemouseentermouseleave 事件。

有些需要我们注意的地方:

  • 快速移动鼠标会触发 mouseovermosemovemouseout 事件,而且会忽略中间元素。

  • mouseover/outmouseenter/leave 事件有一个额外的属性:relatedTarget,指代来源元素/去往元素,与 target 属性相对。

  • mouseover/out 事件冒泡,当我们只给父级绑定它的时候,在子元素中间切换也会导致 mouseoutmouseover 事件的连续触发。因为鼠标同一时间只能在一个元素上(最内层的或层级最高的元素)。

  • mouseover/out 事件不冒泡,它们只会在绑定事件的元素上触发。

练习题

问题

一、改进版提示框

实现一个 JavaScript 版本的提示框,使用元素的 data-tooltip 特性作为提示框内容显示。

有点类似之前的实现的“提示框”任务,但是这里实现的注释元素时可以嵌套的,可以显示最里层的嵌套提示框。

例如,如下的结构:

  1. <div data-tooltip="Here – is the house interior" id="house">
  2. <div data-tooltip="Here – is the roof" id="roof"></div>
  3. ...
  4. <a href="https://en.wikipedia.org/wiki/The_Three_Little_Pigs" data-tooltip="Read on…">Hover over me</a>
  5. </div>

最终的实现效果如这里展示的:demo

PS. 注意,一次只能显示一个提示框。

二、“智能”提示框

编写一个函数,仅当访问者将鼠标移到元素上而不是通过它时才显示元素上的提示框。

也就是说,如果访问者将鼠标移动到元素上并且停下时——才展示提示框。如果是移动鼠标很快的话,就没必要显示了,谁像看到闪动效果呢?

从技术上讲,我们可以测量鼠标经过元素上速度。如果是缓慢地,那么我们认为它“在元素上”时就显示提示框;如果很快,就忽略提示框的显示。

创建一个通用对象 new HoverIntent(options)options 包含下列选项:

  • elem:被跟踪元素。

  • over:在鼠标缓慢经过元素时调用的函数。

  • out:在鼠标离开元素时调用的函数(如果调用了 over)。

使用这个对象创建提示框的例子如下:

  1. // 简单的提示框
  2. let tooltip = document.createElement('div');
  3. tooltip.className = 'tooltip';
  4. tooltip.innerHTML = 'Tooltip';
  5. // 对象会跟踪鼠标运动,调用 over/out
  6. new HoverIntent({
  7. elem,
  8. over() {
  9. tooltip.style.left = elem.getBoundingClientRect().left + 'px';
  10. tooltip.style.top = elem.getBoundingClientRect().bottom + 5 + 'px';
  11. document.body.append(tooltip);
  12. },
  13. out() {
  14. tooltip.remove();
  15. }
  16. });

答案

一、改进版提示框

  1. let tooltip;
  2. document.onmouseover = function(event) {
  3. // important: a fast-moving mouse may "jump" right to a child on an annotated node, skipping the parent
  4. // so mouseover may happen on a child.
  5. let anchorElem = event.target.closest('[data-tooltip]');
  6. if (!anchorElem) return;
  7. // show tooltip and remember it
  8. tooltip = showTooltip(anchorElem, anchorElem.dataset.tooltip);
  9. }
  10. document.onmouseout = function() {
  11. // it is possible that mouseout triggered, but we're still inside the element (cause of bubbling)
  12. // but in this case we'll have an immediate mouseover,
  13. // so the tooltip will be destroyed and shown again
  14. //
  15. // that's an overhead, but here it's not visible
  16. // can be fixed with additional checks
  17. if (tooltip) {
  18. tooltip.remove();
  19. tooltip = null;
  20. }
  21. }
  22. function showTooltip(anchorElem, html) {
  23. let tooltipElem = document.createElement('div');
  24. tooltipElem.className = 'tooltip';
  25. tooltipElem.innerHTML = html;
  26. document.body.append(tooltipElem);
  27. let coords = anchorElem.getBoundingClientRect();
  28. // position the tooltip over the center of the element
  29. let left = coords.left + (anchorElem.offsetWidth - tooltipElem.offsetWidth) / 2;
  30. if (left < 0) left = 0;
  31. let top = coords.top - tooltipElem.offsetHeight - 5;
  32. if (top < 0) {
  33. top = coords.top + anchorElem.offsetHeight + 5;
  34. }
  35. tooltipElem.style.left = left + 'px';
  36. tooltipElem.style.top = top + 'px';
  37. return tooltipElem;
  38. }

在线例子

二、“智能”提示框

  1. 'use strict';
  2. class HoverIntent {
  3. constructor({
  4. sensitivity = 0.1, // speed less than 0.1px/ms means "hovering over an element"
  5. interval = 100, // measure mouse speed once per 100ms
  6. elem,
  7. over,
  8. out
  9. }) {
  10. this.sensitivity = sensitivity;
  11. this.interval = interval;
  12. this.elem = elem;
  13. this.over = over;
  14. this.out = out;
  15. // make sure "this" is the object in event handlers.
  16. this.onMouseMove = this.onMouseMove.bind(this);
  17. this.onMouseOver = this.onMouseOver.bind(this);
  18. this.onMouseOut = this.onMouseOut.bind(this);
  19. // and in time-measuring function (called from setInterval)
  20. this.trackSpeed = this.trackSpeed.bind(this);
  21. elem.addEventListener("mouseover", this.onMouseOver);
  22. elem.addEventListener("mouseout", this.onMouseOut);
  23. }
  24. onMouseOver(event) {
  25. if (this.isOverElement) {
  26. // if we're over the element, then ignore the event
  27. // we are already measuring the speed
  28. return;
  29. }
  30. this.isOverElement = true;
  31. // after every mousemove we'll be check the distance
  32. // between the previous and the current mouse coordinates
  33. // if it's less than sensivity, then the speed is slow
  34. this.prevX = event.pageX;
  35. this.prevY = event.pageY;
  36. this.prevTime = Date.now();
  37. elem.addEventListener('mousemove', this.onMouseMove);
  38. this.checkSpeedInterval = setInterval(this.trackSpeed, this.interval);
  39. }
  40. onMouseOut(event) {
  41. // if left the element
  42. if (!event.relatedTarget || !elem.contains(event.relatedTarget)) {
  43. this.isOverElement = false;
  44. this.elem.removeEventListener('mousemove', this.onMouseMove);
  45. clearInterval(this.checkSpeedInterval);
  46. if (this.isHover) {
  47. // if there was a stop over the element
  48. this.out.call(this.elem, event);
  49. this.isHover = false;
  50. }
  51. }
  52. }
  53. onMouseMove(event) {
  54. this.lastX = event.pageX;
  55. this.lastY = event.pageY;
  56. this.lastTime = Date.now();
  57. }
  58. trackSpeed() {
  59. let speed;
  60. if (!this.lastTime || this.lastTime == this.prevTime) {
  61. // cursor didn't move
  62. speed = 0;
  63. } else {
  64. speed = Math.sqrt(
  65. Math.pow(this.prevX - this.lastX, 2) +
  66. Math.pow(this.prevY - this.lastY, 2)
  67. ) / (this.lastTime - this.prevTime);
  68. }
  69. if (speed < this.sensitivity) {
  70. clearInterval(this.checkSpeedInterval);
  71. this.isHover = true;
  72. this.over.call(this.elem, event);
  73. } else {
  74. // speed fast, remember new coordinates as the previous ones
  75. this.prevX = this.lastX;
  76. this.prevY = this.lastY;
  77. this.prevTime = this.lastTime;
  78. }
  79. }
  80. destroy() {
  81. elem.removeEventListener('mousemove', this.onMouseMove);
  82. elem.removeEventListener('mouseover', this.onMouseOver);
  83. elem.removeEventListener('mouseout', this.onMouseOut);
  84. };
  85. }

在线例子

(完)