UI 事件

一、鼠标事件

鼠标事件不单单来自于鼠标,还可能来自手机或平板,它们对此类操作进行了模拟以实现兼容。

1.1 鼠标事件类型

mousedown/mouseup

在元素上点击/释放鼠标按钮。

mouseover/mouseout

鼠标指针从一个元素上移入/移出。

mousemove

鼠标在元素上的每个移动都会触发此事件。

click

如果使用的是鼠标左键,则在同一个元素上的 mousedown 及 mouseup 相继触发后,触发该事件。

dblclick

在短时间内双击同一元素后触发。如今已经很少使用了。

contextmenu

在鼠标右键被按下时触发。还有其他打开上下文菜单的方式,例如使用特殊的键盘按键,在这种情况下它也会被触发,因此它并不完全是鼠标事件。

1.2 事件顺序

一个操作可能会触发多个事件。

在单动作触发多个事件时,事件是固定的,例如鼠标被按下时,会触发mousedown—> mouseup —>click的顺序。

1.3 鼠标按钮属性

与点击相关的事件中有一个event.button属性,这个属性可以知道用户具体点击了哪个鼠标按钮。

一般来说,我们不会在click和contextmenu事件中使用,因为我们已经知道了,前者用的是左键,后者用的是右键。

但是mousedown和mouseup事件中则可能需要用到这个属性,因为不管用户点到哪个键,都会触发这两个事件,所以我们需要知道用户到底按的是鼠标的哪个按钮。

event.button 的所有可能值如下:

鼠标按键状态event.button
左键 (主要按键)0
中键 (辅助按键)1
右键 (次要按键)2
X1 键 (后退按键)3
X2 键 (前进按键)4

还有一个 event.buttons 属性,其中以整数的形式存储着当前所有按下的鼠标按键。

1.4 组合键

鼠标是可以跟键盘按键组合的,鼠标事件中会包含组合键的信息

  • shiftKey:Shift
  • altKey:Alt(或对于 Mac 是 Opt)
  • ctrlKey:Ctrl
  • metaKey:对于 Mac 是 Cmd,在 Mac 上我们通常使用 Cmd 代替 Ctrl

如果在事件期间按下了相应的键,则它们为 true。

比如,下面这个按钮仅在 Alt+Shift+click 时才有效:

html
1<button id="button">Alt+Shift+Click on me!</button>
2
3<script>
4  button.onclick = function (event) {
5    if (event.altKey && event.shiftKey) {
6      alert('Hooray!')
7    }
8  }
9</script>

1.5 鼠标坐标:clientX/Y,pageX/Y

  1. 相对于窗口的坐标:clientX 和 clientY。
  2. 相对于文档的坐标:pageX 和 pageY。

1.6 防止在鼠标按下时的选择

双击鼠标后它会选择文本。

如果按下鼠标左键,不松开的情况下移动鼠标,也会选择文本。

如果不想要这种默认行为,最合理的方式是防止浏览器对mousedown进行操作的默认行为。

javascript
1elem.addEventListener('mousedown', function (event) {
2  //这样就不会对文本选择
3  event.preventDefault()
4})

1.7 防止复制

如果我们允许用户选择文本,但是不允许用户复制内容,我们可以用另外一个事件:copy

在copy事件上禁止默认行为就可以保护页面内容不被复制。

javascript
1elem.addEventListener('copy', function (event) {
2  event.preventDefault()
3})

当然,用户如果打开开发者工具还是可以复制的...

1.8 小结

鼠标有以下属性:

  • button —用户点的是鼠标的哪个键

  • 组合键,如果按下则为true:altKey,ctrlKey,shiftKey 和 metaKey(Mac)。

    • mac下的metaKey等于window下的ctrlKey
  • 文档坐标pageX/Y

  • 窗口坐标clientX/Y

mousedown 的默认浏览器操作是文本选择,如果不想要,则可以取消这个默认行为。

二、移动鼠标

2.1 事件 mouseover/mouseout,relatedTarget

  1. mouseover :鼠标指针移动上去触发
  2. mouseout:鼠标指针移动出去触发

image-20220113182656466

这些事件很特别,因为它们具有relatedTarget属性。这个属性是对target的补充。

当鼠标从一个元素离开去另一个元素时,其中一个元素就变成了target,另一个就变成了relatedTarget。

对于mouseover:

  • event.target —— 是鼠标移过的那个元素。
  • event.relatedTarget —— 是鼠标来自的那个元素(relatedTarget → target)。

mouseout则刚好相反:

  • event.target —— 是鼠标移出的那个元素。
  • event.relatedTarget —— 是鼠标当前移动到那个元素(target → relatedTarget)。

这很合理,当鼠标移入a元素时,此时触发mouseover事件,target自然是当前a元素。

当鼠标从a元素转为移入b元素时,对a来说,触发了mouseout事件,对b来说,触发了mouseover事件,所以mouseout下的target就是a,mouseover(此时为b元素触发的事件)中的relatedTarget则是a。

relatedTarget可以是null,比如鼠标来自窗口之外,或者离开了窗口。

2.2 跳过元素

当鼠标移动时,会触发mousemove事件。但不意味着每个元素都会导致一个事件。

浏览器会一直检查鼠标的位置,如果发生了变化,就会触发事件。

如果访问者非常快速地移动鼠标,那么某些 DOM 元素就可能被跳过。

image-20220113210839143

#如果鼠标从上图所示的 #FROM 快速移动到 #TO 元素,则中间的 <div>(或其中的一些)元素可能会#被跳过。mouseout 事件可能会在 #FROM 上被触发,然后立即在 #TO 上触发 mouseover。

这对性能有好处,因为可能有很多中间元素,我们并不想真的要处理每一个移入和离开的过程。

鼠标指针并不会访问所有元素,它可以跳过一些元素。

因此,当鼠标从窗口外调到页面中间,这种情况下,relatedTarget就可能是null。

image-20220113211051142

在鼠标快速移动下,中间元素可能会被忽略。但如果mouseover被触发了,当鼠标离开元素时,则一定会有mouseout事件

2.3 当移动到一个子元素时 mouseout

mouseout有一个重要的知识点——当鼠标指针从元素移动到其后代的时候会触发,例如下面这个结构,从##parent到#child:

html
1<div id="parent">
2  <div id="child">...</div>
3</div>

#如果我们在#parent上,将鼠标指针移到#child上,#parent会得到一个mouseout事件。

image-20220113211850836

根据浏览器的逻辑,鼠标指针可能随时位于单个元素上 —— 嵌套最多的那个元素(或 z-index 最大的那个)。

因此,如果他转到另一个元素(可能是后代),那么它将离开前一个元素。

这里的事件处理有一个重要的细节:

#后代的mouseover事件会冒泡。因此如果#parent具有mouseover处理程序,它也将被触发。

image-20220113212116987

#所以当鼠标移动到#child时,会触发#parent的moustout事件和mouseover事件

我们并不希望发生这种情况,我们可以通过检查relatedTarget来避免这种事的发生。

但还有一种更好的方式:mouseenter和mouseleave事件,它们没有这样的问题。

2.4 mouseenter 和 mouseleave

当鼠标指针进入一个元素时 —— 会触发 mouseenter。而鼠标指针在元素或其后代中的确切位置无关紧要。

当鼠标指针离开该元素时,事件 mouseleave 才会触发。

这两个事件跟mouseover和mouseleave差不多,但它们有以下区别:

  • 在元素内部与后代之间的转换不会有影响
  • 不会冒泡

当鼠标移入子元素时,就算此时触发了子元素的mouseenter,但是并不会冒泡给父元素。

2.5 事件委托

由于mouseenter和mouseleave并不会冒泡,所以我们不能用它们来进行事件委托。

但如果我们一定要用到事件委托,就只能够用mouseover和mouseout来做额外的处理。

这里有个例子

2.6 小结

  1. 快速地鼠标移动可能会忽略掉某些元素
  2. mouseover/out和mouseenter/leave事件还有一个附加属性:relatedTarget。
  3. 如果我们从父元素转到子元素,也会触发mouseover/out事件。浏览器假定鼠标一次只会在最深的那个元素。
  4. mouseenter/leave跟over/out不同,它们仅在鼠标进入和离开时才会触发。它们并不会冒泡。

三、鼠标拖放事件

3.1 鼠标事件实现拖放

本质上来说,一个鼠标的位置变动就是让我们设置元素的left和top。

基本的拖放算法是这样的:

  1. 鼠标点击后,获取元素的坐标:元素当前相对于窗口的坐标、鼠标点击的坐标、鼠标到元素边缘的距离
  2. 鼠标移动,设置元素的属性为position:absolute,并用left和top来移动它的位置
  3. 鼠标抬起后取消移动事件

预览:

ball

直接贴代码:

html
1<style>
2  ##ball {
3    position: absolute;
4  }
5</style>
6<body>
7  <img id="ball" src="https://js.cx/clipart/ball.svg" />
8  <script>
9    let mouseX, mouseY, elemX, elemY, shiftX, shiftY
10    // 1 取消浏览器对图片和一些其他元素的拖放处理的默认行为
11    ball.ondragstart = function () {
12      return false
13    }
14    function onMove(event) {
15      // 4
16      ball.style.left = event.pageX - shiftX + 'px'
17      ball.style.top = event.pageY - shiftY + 'px'
18    }
19    // 2
20    ball.addEventListener('mousedown', function (event) {
21      //取坐标
22      mouseX = event.clientX //鼠标位置x
23      mouseY = event.clientY //鼠标位置y
24      elemX = this.getBoundingClientRect().left //元素的窗口位置x
25      elemY = this.getBoundingClientRect().top //元素的窗口位置Y
26      // 3
27      shiftX = mouseX - elemX //鼠标位置距离元素左边的坐标
28      shiftY = mouseY - elemY //鼠标位置距离元素右边的坐标
29      //2 向document添加移动事件监听
30      document.addEventListener('mousemove', onMove)
31    })
32
33    ball.addEventListener('mouseup', function (event) {
34      //取消移动事件 5
35      document.removeEventListener('mousemove', onMove)
36    })
37  </script>
38</body>

这里一共需要三个 API,分别为鼠标按下去mousedown、鼠标移动mousemove、鼠标抬起mouseup。

  1. 由于拖动的元素是图片,浏览器有自己的对图片和一些其他元素的拖放处理,所以要禁止默认行为ondragstart。

  2. mousemove 会经常被触发,但不会针对每个像素都如此。因此,在快速移动鼠标后,鼠标指针可能会从球上跳转至文档中间的某个位置(甚至跳转至窗口外)。总而言之,快速的鼠标移动让浏览器跳过了ball上的鼠标移动事件,所以这里需要在document上跟踪mousemove,而不是ball上。

  3. shiftX跟shiftY代表的是鼠标的位置距离元素左侧/上侧边缘坐标的距离。

    image-20220114230041227

    本质上来说,一个鼠标的位置变动就是让我们设置元素的left和top。

    我们需要计算鼠标在窗口的位置 - 元素在窗口的位置,就能够得到鼠标距离元素边缘的shiftX和shiftY

    • shiftX=鼠标的pageX - 元素的窗口坐标getBoundingClientRect().left

    • shiftY=鼠标的pageY - 元素的窗口坐标getBoundingClientRect().top

    有个太先进而导致浏览器还未全部兼容的 API,offsetX 跟 offsetY,可以直接得到 shiftX 和 shiftY 的值。

  4. 移动后的鼠标相对于文档(不是窗口)的位置减去shiftX/Y就可以得到left和top。

  5. 当鼠标抬起代表移动要结束,直接删除移动事件就可以了。

3.2 放置目标

球可以放置在任何地方,下面完成一个当球放置在目标元素时,目标元素高亮的功能。

我们需要知道目标元素是什么。当我们拖动时,我们可以利用document.elementFromPoint(clientX,clientY)通过坐标的位置来知道当前被移动到的是哪个元素。

问题是我们的鼠标是一直在球这个元素上的,所以我们需要另外处理一下,否则document.elementFromPoint(clientX,clientY)将会一直返回球(因为它在最上面)。

处理方式如下:

javascript
1ball.hidden = true // 先隐藏球
2currentDrop = document.elementFromPoint(event.clientX, event.clientY) //获取球下面的元素
3ball.hidden = false

当返回的元素是目标元素或者是它的子元素时,我们就可以处理让目标元素高亮的元素了。

javascript
1        #if (currentDrop.closest("#div")) {//匹配球下面的元素的祖先(或自己)的id是否为目标元素的。
2          enterDroppable(div);
3        } else {
4          leaveDroppable(div);
5        }

实现效果:

ballInto

完整代码:

html
1<style>
2  body {
3    height: 2000px;
4    width: 100%;
5  }
6  ##ball {
7    position: absolute;
8  }
9  ##div {
10    width: 200px;
11    height: 100px;
12    border: 1px solid red;
13  }
14</style>
15<body>
16  <div id="div"></div>
17  <img id="ball" src="https://js.cx/clipart/ball.svg" />
18  <script>
19    let mouseX, mouseY, elemX, elemY, shiftX, shiftY, currentDrop;
20    // 1 取消浏览器对图片和一些其他元素的拖放处理的默认行为
21    ball.ondragstart = function () {
22      return false;
23    };
24    function enterDroppable(elem) {
25      elem.style.background = "red";
26    }
27    function leaveDroppable(elem) {
28      elem.style.background = "";
29    }
30    function onMove(event) {
31      // 4
32      ball.style.left = event.pageX - shiftX + "px";
33      ball.style.top = event.pageY - shiftY + "px";
34
35      ball.hidden = true;
36      currentDrop = document.elementFromPoint(event.clientX, event.clientY);
37      ball.hidden = false;
38
39      #if (currentDrop.closest("#div")) {
40        enterDroppable(div);
41      } else {
42        leaveDroppable(div);
43      }
44    }
45    // 2
46    ball.addEventListener("mousedown", function (event) {
47      //取坐标
48      mouseX = event.clientX; //鼠标位置x
49      mouseY = event.clientY; //鼠标位置y
50      elemX = this.getBoundingClientRect().left; //元素的窗口位置x
51      elemY = this.getBoundingClientRect().top; //元素的窗口位置Y
52      // 3
53      shiftX = mouseX - elemX; //鼠标位置距离元素左边的坐标
54      shiftY = mouseY - elemY; //鼠标位置距离元素右边的坐标
55      //2 向document添加移动事件监听
56      document.addEventListener("mousemove", onMove);
57    });
58
59    ball.addEventListener("mouseup", function (event) {
60      //取消移动事件 5
61      document.removeEventListener("mousemove", onMove);
62    });
63  </script>
64</body>

3.3 小结

基础的使用鼠标的拖放方法是这样的:

  1. 事件构成:mousedown —> mousemove —> mouseup (由于浏览器对图片等有默认拖拽行为,所以这里取消dragstart的默认行为)
  2. 拖放本质上就是给被拖放的元素设置left和top,这里需要获取到鼠标指针对于元素的边缘的坐标距离shiftX/shiftY,也可以直接用offsetX/Y但兼容性不好
  3. 使用 document.elementFromPoint 检测鼠标指针下的 “droppable” 的元素。
  4. 由于mousemove事件触发频繁,有可能会忽略元素,所以需要设置在顶层document上。

四、键盘事件

4.1 keydown 和 keyup

  • keydown 键盘按下
  • keyup 键盘抬起

事件对象的key属性允许获得字符,而code属性则允许获取物理按键代码。

以下以按键 z/Z 为例

Keyevent.keyevent.code
Zz(小写)KeyZ
Shift+ZZ(大写)KeyZ
F1F1F1
BackspaceBackspaceBackspace
ShiftShiftShiftRight 或 ShiftLeft

同一个按键 Z,可以与或不与 Shift 一起按下。我们会得到两个不同的字符:小写的 z 和大写的 Z。

event.key 正是这个字符,并且它将是不同的。但是,event.code 是相同的。

event.code可以标明是左边的还是右边的 shift 被按下了。

我们要处理一个热键:Ctrl+Z(或 Mac 上的 Cmd+Z),则可以用以下代码:

javascript
1document.addEventListener('keydown', function (event) {
2  if (event.code == 'KeyZ' && (event.ctrlKey || event.metaKey)) {
3    alert('Undo!')
4  }
5})

event.code 有一个问题。对于不同的键盘布局,相同的按键可能会具有不同的字符。

下面是美式布局(“QWERTY”)和德式布局(“QWERTZ”)

image-20220115221720693

对于同一个按键,美式布局为 “Z”,而德式布局为 “Y”(字母被替换了)。

从字面上看,对于使用德式布局键盘的人来说,但他们按下 Y 时,event.code 将等于 KeyZ。

因此,event.code 可能由于意外的键盘布局而与错误的字符进行了匹配。不同键盘布局中的相同字母可能会映射到不同的物理键,从而导致了它们有不同的代码。

为了可靠地跟踪与受键盘布局影响的字符,使用 event.key 可能是一个更好的方式。

另一方面,event.code 的好处是,即使访问者更改了语言,绑定到物理键位置的 event.code 会始终保持不变。因此,即使在切换了语言的情况下,依赖于它的热键也能正常工作。

我们想要处理与布局有关的按键?那么 event.key 是我们必选的方式。

或者我们希望一个热键即使在切换了语言后,仍能正常使用?那么 event.code 可能会更好。

4.2 自动重复

如果按下一个键足够长的时间,它就会开始“自动重复”:keydown 会被一次又一次地触发,然后当按键被释放时,我们最终会得到 keyup。因此,有很多 keydown 却只有一个 keyup 是很正常的。

对于由自动重复触发的事件,event 对象的 event.repeat 属性被设置为 true。

4.3 默认行为

默认行为各不相同,因为键盘可能会触发很多可能的东西。

例如:

  • 出现在屏幕上的一个字符(最明显的结果)。
  • 一个字符被删除(Delete 键)。
  • 滚动页面(PageDown 键)。
  • 浏览器打开“保存页面”对话框(Ctrl+S)
  • ……等。

阻止对 keydown 的默认行为可以取消大多数的行为,但基于 OS 的特殊按键除外。例如,在 Windows 中,Alt+F4 会关闭当前浏览器窗口。并且无法通过在 JavaScript 中阻止默认行为来阻止它。

4.4 keypress 事件

过去曾经有一个 keypress 事件,还有事件对象的 keyCode、charCode 和 which 属性。

大多数浏览器对它们都存在兼容性问题,虽然浏览器还在支持它们,但现在完全没必要再使用它们。

4.5 小结

按一个按键总是会产生一个键盘事件,无论是符号键,还是例如 Shift 或 Ctrl 等特殊按键。唯一的例外是有时会出现在笔记本电脑的键盘上的 Fn 键。它没有键盘事件,因为它通常是被在比 OS 更低的级别上实现的。

键盘事件:

  • keydown —— 在按下键时(如果长按按键,则将自动重复),
  • keyup —— 释放按键时。

键盘事件的主要属性:

  • code —— “按键代码”("KeyA","ArrowLeft" 等),特定于键盘上按键的物理位置。
  • key —— 字符("A","a" 等),对于非字符(non-character)的按键,通常具有与 code 相同的值。

过去,键盘事件有时会被用于跟踪表单字段中的用户输入。这并不可靠,因为输入可能来自各种来源。

五、滚动事件

scroll事件在window和可滚动元素上都可以运行。

下面是一个滚动事件的示例:

javascript
1window.addEventListener('scroll', function (event) {
2  console.log(this.pageXOffset)
3  console.log(this.pageYOffset)
4})

5.1 防止滚动

我们不能通过在 onscroll 监听器中使用 event.preventDefault() 来阻止滚动,因为默认事件是在滚动发生 之后 才触发的。

我们可以设置overflow: hidden;来让防止滚动。