事件

103 阅读8分钟

前言

JavaScript通过事件和HTML进行交互,事件代表浏览器窗口中某个有意义的时刻。在事件发生时会执行监听器(事件处理程序)订阅事件。这种模型被称为“观察者模式”,能够做到页面行为(JavaScript)与展示(HTML,CSS)的分离。如果你学过flutter,就很容易理解了,flutter的页面展示和行为是耦合在一起的。

定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新

事件流

事件流描述了页面接收事件的顺序,IE和Netscape分别提出了相反的事件流方案

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Document</title>
</head>
<body>
  <div></div>
</body>
</html>

根据上面的文档结构来解释一下两种事件方案

  1. 事件冒泡(IE事件流) 事件从目标元素沿着DOM树一直向上触发,直到到达document对象。也就是<div> -> <body> -> <html> -> document

  2. 事件捕获(Netscape事件流) 事件从document对象沿着DOM树向下触发,最终到达事件目标对象。也就是 document -> <html> -> <body> -> <div>

DOM2Events规范 该规范规定事件流分为3个阶段:事件捕获 -> 到达目标 -> 事件冒泡,依然使用上面的HTML来对流程进行说明

事件捕获阶段 document -> <html> -> <body>

到达目标 <body> -> <div>

事件冒泡阶段 <div> -> <body> -> <html> -> document

虽然DOM2Events规定捕获阶段不命中事件目标,但是现代浏览器也会在捕获阶段在事件目标对象上触发事件。最终结果是在事件目标对象上有两个机会来处理事件

只有事件目标依然存在与文档中时,事件才会冒泡

事件处理程序

为了响应事件而调用的处理程序被称为事件处理程序(事件监听器),比如用户触发的click或者加载完成的load

可以通过HTML属性添加事件处理程序,也可以通过DOM0或者DOM2规范的方式添加事件处理程序,IE还有自己独特的事件处理程序添加方式,但是由于IE已经不是主流浏览器了,这里就不做过多介绍了

通过HTML属性添加事件处理程序

  1. 通过事件属性值指定的字符串就是要执行的JavaScript代码,属性值不能使用HTML语法字符,如&,",<,>,可以使用单引号代替双引号包含属性值,如果要使用双引号,可以使用实体对字符进行转义
<!DOCTYPE html>
<html lang="en">
<head>
  <title>Document</title>
</head>
<style>
  #app,#app1 {
    width: 100px;
    height: 100px;
    background-color: aqua;
    margin-bottom: 10px;
  }
</style>
<body>
  <div id="app" onclick='console.log("</div>")'>app</div>
  <div id="app1" onclick="console.log(&quot;&lt;/div&gt;&quot;)">app1</div>
</body>
</html>
  1. 事件处理程序的作用域 浏览器会通过一个函数来封装属性的值(指定事件属性值的JavaScript代码,如onclick的值),函数中会有个一个特殊的局部变量event,也就是事件对象,函数中的this值相当于事件的目标元素,所以我们可以直接在属性值中访问event和this
<div id="app" onclick="console.log(event,this)">app</div>

这里需要注意的是,如果我们通过一下方式指定的事件处理程序,那么它内部的this是window对象,如果需要访问事件对象和event还是必须要在绑定事件处理程序传入这两个值。而且,如果在script标签加载完之前点击了,就会报错,可以使用try-catch将属性值值进行包裹

<div id="app" onclick="try{handleClick()}catch(e){}">app</div>
<script>
    function handleClick() {
      console.log(this)
    }
</script>

这个包装函数还会通过with扩展事件处理程序的作用域链,包括document对象,this,如果元素是一个表单form元素,则还会包括this.form,但是不同的浏览器对于作用域链的扩展可能不同,这样在读取属性时可能报错

  <form>
        <input type="text" name="username" value="">
        <input type="button" value="Echo Username" onclick="console.log(username.value)">
  </form>
function() {
    with(document) {
        with(this.form) {
            with(this) {
                // 指定的事件处理程序,也就是属性值
                console.log(username.value)
            }
        }
    }
}

DOM0事件处理程序

在JavaScript中把函数赋值给DOM元素的事件处理程序属性,函数的第一个参数就是事件对象,函数的this就是事件目标对象,通过这种方式指定的事件处理程序的执行是在事件流的冒泡阶段。如果需要注销事件处理程序,则将该属性值设置为null即可,同理通过HTML属性绑定的事件处理程序也可以通过这种方式注销,也就是说这两种方式指定事件处理函数的本质都是一样的,而且后面的会覆盖前面的,通过HTML属性指定的方式只是将属性值通过函数包装后赋值给事件处理程序属性

const btn = document.getElementById('btn')
btn.onclick = function (event) {
  console.log(event, this)
}

DOM2事件处理程序

通过addEventListener()和removeEventListener()来添加和注销事件处理程序,addEventListener()接收3个参数,分别是事件名,事件处理函数和是否在捕获阶段调用事件处理函数,如果为true则事件会在捕获阶段执行,如果为false则会在冒泡阶段调用事件处理函数,下面的代码会先输出通过addEventListener()函数绑定的事件处理函数,然后输出HTML字符串,因为通过HTML绑定的事件处理函数也是在冒泡阶段执行的,如果把最后一个参数改为false则输出顺序就会调换,会执行先绑定的输出HTMl的代码

<button id="btn" onclick="console.log('HTML')">BTN</button>
const btn = document.getElementById('btn')

btn.addEventListener(
  'click',
  function (event) {
    console.log(event, this)
  },
  true
)

可以通过addEventListener()来添加多个事件处理程序

通过removeEventListener()移除事件处理程序,但是传入的函数要和绑定时的函数为同一个函数

事件对象

事件对象包含了与特定事件的相关属性和方法,如果可以在HTML属性中直接通过event访问,如果是DOM0和DOM2则可以会被传递到事件处理程序的第一个参数

事件处理程序中的target和currentTarget,如果事件处理程序都绑定给了目标元素,那么这两个值都是目标元素,如果绑定给了父级元素,那么target值为事件的目标元素,currentTarget的值为父级元素

stopPropagation() 会取消事件后续的捕获或者冒泡阶段,如果在捕获阶段调用,绑定在冒泡阶段的事件处理函数也会被取消

preventDefault() 取消事件的默认行为,如链接元素点击时的跳转

事件内存与性能

事件委托

事件处理程序过多会导致性能问题,一是事件处理函数占用过多内存,二是事件处理函数绑定是DOM操作的耗时导致页面交互的延迟。为了解决这个问题,可以利用事件冒泡,将事件统一指定给共同祖先节点的事件处理程序即可。

删除事件处理程序

页面中的元素被删除时,被删除的元素如果还有绑定事件处理程序,那么就不会被垃圾收集程序回收内存,所以需要在删除元素之前解绑其相关的事件处理程序

如果页面卸载后事件处理程序没有被清理,那么它们依然会残留在内存中,之后浏览器每次加载和卸载页面(前进,后退,刷新),内存中残留对象的数量都会增加,可以在页面的unload事件删除所有事件处理程序

模拟事件

通常事件都是由浏览器或者用户触发的,但是我们可以通过JavaScript去主动触发事件,支持事件冒泡,取消事件默认行为等

模拟事件的基本过程是先对应事件类型的构造函数创建事件对象,然后使用dispatchEvent()触发事件,这个方法存在于所有支持事件的DOM节点上,document和window也支持,可以通过addEventListener()给目标对象添加事件处理程序,或者通过HTML属性和DOM0的方式添加,不过这两种方式不支持自定义的DOM事件

<button id="btn" onclick="console.log('HTML')">BTN</button>
const btn = document.getElementById('btn')
let e = new MouseEvent('click', {})
btn.dispatchEvent(e)

上面的代码模拟了鼠标点击事件,控制台中会输出HTML

JavaScript除了能模拟鼠标,键盘这些通用事件,还能模拟自定义的DOM事件,下面我们模拟了一个事件名为ce,并且冒泡的自定义DOM事件,根据事件触发后的输出可以看出事件一直冒泡到了window

<button id="btn" onclick="console.log('HTML')">BTN</button>
let ce = new CustomEvent('ce', {bubbles: true})
btn.addEventListener('ce', () => {
  console.log('ce on btn')
})
document.body.addEventListener('ce', () => {
  console.log('ce on body')
}, false)
document.addEventListener('ce', ()=> {
  console.log('ce on document')
}, false)
window.addEventListener('ce', () => {
  console.log('ce on window')
}, false)
btn.dispatchEvent(ce)
// ce on btn
// ce on body
// ce on document
// ce on window

总结

本文对事件流,事件绑定,事件对象,事件性能优化做了比较系统的总结,如果希望更加详细深入的了解,推荐阅读文末相关的参考文献

参考文献

  1. JavaScript高级程序设计
  2. www.runoob.com/design-patt…
  3. developer.mozilla.org/zh-CN/docs/…
  4. developer.mozilla.org/zh-CN/docs/…
  5. developer.mozilla.org/zh-CN/docs/…