浏览器事件系统

引言

JavaScriptHTML之间的交互是经过事件实现的。事件,就是文档或浏览器窗口中发生的一些特定的交互瞬间。浏览器的事件系统相对比较复杂。尽管全部的主要浏览器已经实现了“DOM2级事件”,但这个规范自己并无涵盖全部事件类型,随着DOM3级的出现,DOM事件API变得更加丰富。另外浏览器对象模型(BOM)也支持一些事件,但这些事件与文档对象模型(DOM)之间的关系并不清晰,由于BOM事件长期没有规范能够遵循(HTML5后来给出了说明)。本文主要介绍浏览器DOM的事件系统,包括事件流的三个阶段,事件处理程序的三种方式的不一样(DOM0DOM2IE),考虑到IE中的事件处理和事件对象的差别如何作兼容性处理,事件对象中的属性如何运用到实际应用中以及它们之间的差别,以及事件捕获与冒泡的前后顺序问题。javascript

文章开头先简短介绍下本文的几个重要知识点:html

  • DOM事件处理程序有三种方式,DOM0onTypeIE9如下的attachEventdetachEventDOM2addEventListenerremoveEventListener
  • DOM2级的优势是能够经过addEventListener的第三个参数来指定是捕获仍是冒泡,而且能够为同一个DOM元素注册多个同类型的事件处理程序;而DOM0对每一个事件只支持一个事件处理程序
  • DOM0DOM2的事件处理程序都会自动传入event对象;IE中的event对象取决于指定的事件处理程序的方法,因此在IE中会有window.eventevent两种状况;event对象里有一些颇有用 处的属性,好比target、currentTargetpreventDefaultstopPropagationstopImmediatePropagation
  • 对于DOM0ontype,给元素的事件行为绑定方法都是在当前元素事件行为的冒泡阶段(或者目标阶段)执行的。对于DOM2addEventListener,为了最大限度的兼容,大可能是状况下都是将事件处理程序添加到事件冒泡阶段。不是特别须要,不建议在事件捕获阶段注册事件处理程序。
  • 事件处理函数的兼容性处理要考虑到DOM0IE9如下的事件处理方式,事件对象与事件对象属性的兼容性处理要考虑到IE中的不一样
  • event.stopPropagation() 方法阻止事件冒泡到父元素,阻止任何父事件处理程序被执行(通常咱们认为stopPropagation是用来阻止事件冒泡的,其实该函数也能够阻止捕获事件)
  • event.target指向引发触发事件的元素,而event.currentTarget则是事件绑定的元素,只有被点击的那个目标元素的event.target才会等于event.currentTarget

多数支持DOM事件流的浏览器都实现了一种特定的行为;即便“DOM2级事件”规范明确要求捕获阶段不会涉及事件目标,但IE九、Safari、Chrome、Firefox和Opera9.5及更高版本都会在捕获阶段触发事件对象上的事件。结果,就是有两个机会在目标对象上操做事件。前端

事件流

事件流描述的是从页面中接受事件的顺序。但有意思的是,IENetscape开发团队竟然提出了两个截然相反的事件流概念。IE的事件流是事件冒泡流,标准的浏览器事件流是事件捕获流。不过,W3C为了制定标准,采起了折中的方式:先捕获再冒泡(经过addEventListener给出的第三个参数同时支持冒泡与捕获)。具体地,同一个DOM元素能够注册多个同类型的事件,经过addEventListener来注册事件,removeEventListener来解除事件。java

注意要想注册过的事件可以被解除,必须将回调函数保存起来,不然没法解除。node

DOM事件流分为三个阶段:捕获阶段目标阶段冒泡阶段。先调用捕获阶段的处理函数,其次调用目标阶段的处理函数,最后调用冒泡阶段的处理函数。(下面的图中没有标html标签)git

img

(1)捕获阶段:事件从window对象自上而下向目标节点传播的阶段;github

(2)目标阶段:真正的目标节点正在处理事件的阶段;面试

(3)冒泡阶段:事件从目标节点自下而上向window对象传播的阶段。浏览器

捕获是从上到下,事件先从window对象,而后再到document(对象),而后是html标签(经过document.documentElement获取html标签),而后是body标签(经过document.body获取body标签),而后按照普通的html结构一层一层往下传,最后到达目标元素。框架

而事件冒泡的流程恰好是事件捕获的逆过程。 接下来咱们看个事件冒泡的例子:

// 例3
<div id="outer">
    <div id="inner"></div>
</div>
......
window.onclick = function() {
    console.log('window');
};
document.onclick = function() {
    console.log('document');
};
document.documentElement.onclick = function() {
    console.log('html');
};
document.body.onclick = function() {
    console.log('body');
}
outer.onclick = function(ev) {
    console.log('outer');
};
inner.onclick = function(ev) {
    console.log('inner');
};
复制代码

img

正如咱们下面提到的onclick给元素的事件行为绑定方法都是在当前元素事件行为的冒泡阶段(或者目标阶段)执行的。

DOM事件级别

DOM级别一共能够分为四个级别:DOM0级DOM1级DOM2级DOM3级。而DOM事件分为3个级别:DOM 0级事件处理,DOM 2级事件处理和DOM 3级事件处理。因为DOM 1级中没有事件的相关内容,因此没有DOM 1级事件。又由于IE和其余浏览器在DOM2级别上事件处理又不同,所以通常能够将事件处理方式分为三类,即DOM0DOM2IE。下面是从DOM级别上来划分

DOM 0级事件

el.onclick=function(){}

var btn = document.getElementById('btn');
 btn.onclick = function(){
     alert(this.innerHTML);
 }
复制代码

当但愿为同一个元素/标签绑定多个同类型事件的时候(如给上面的这个btn元素绑定3个点击事件),是不被容许的。DOM0事件绑定,给元素的事件行为绑定方法,这些方法都是在当前元素事件行为的冒泡阶段(或者目标阶段)执行的

DOM 2级事件

el.addEventListener(event-name, callback, useCapture)

  • event-name: 事件名称,能够是标准的DOM事件
  • callback: 回调函数,当事件触发时,函数会被注入一个参数为当前的事件对象 event
  • useCapture: 默认是false,表明事件句柄在冒泡阶段执行(或者说注册的是冒泡事件),true表示事件句柄在捕获阶段执行 (或者说注册的是捕获事件)
var btn = document.getElementById('btn');
btn.addEventListener("click", test, false);
function test(e){
	e = e || window.event;
    alert((e.target || e.srcElement).innerHTML);
    btn.removeEventListener("click", test)
}
//IE9-:attachEvent()与detachEvent()。
//IE9+/chrom/FF:addEventListener()和removeEventListener()
复制代码

IE9如下的IE浏览器不支持 addEventListener()和removeEventListener(),使用 attachEvent()与detachEvent() 代替,由于IE9如下是不支持事件捕获的,因此也没有第三个参数,第一个事件名称前要加on。能够对此作个兼容性处理:

DOM 3级事件

在DOM 2级事件的基础上添加了更多的事件类型。

  • UI事件,当用户与页面上的元素交互时触发,如:load、scroll

  • 焦点事件,当元素得到或失去焦点时触发,如:blur、focus

  • 鼠标事件,当用户经过鼠标在页面执行操做时触发如:dblclick、mouseup

  • 滚轮事件,当使用鼠标滚轮或相似设备时触发,如:mousewheel

  • 文本事件,当在文档中输入文本时触发,如:textInput

  • 键盘事件,当用户经过键盘在页面上执行操做时触发,如:keydown、keypress

  • 合成事件,当为IME(输入法编辑器)输入字符时触发,如:compositionstart

  • 变更事件,当底层DOM结构发生变化时触发,如:DOMsubtreeModified

  • 同时DOM3级事件也容许使用者自定义一些事件。

总结:

  • DOM2级的好处是能够添加多个事件处理程序;DOM0对每一个事件只支持一个事件处理程序;

  • 经过DOM2添加的匿名函数没法移除,addEventListenerremoveEventListenerhandler必须同名

  • 做用域:DOM0的handler会在所属元素的做用域内运行,IE的handler会在全局做用域运行,this === window

  • 触发顺序:添加多个事件时,DOM2会按照添加顺序执行,IE会以相反的顺序执行,请谨记

跨浏览器的事件处理程序

兼容ie9如下的浏览器和DOM0

var EventUtil = {
  // element是当前元素,能够经过getElementById(id)获取
  // type 是事件类型,通常是click ,也有多是鼠标、焦点、滚轮事件等等
  // handle 事件处理函数
  addHandler: (element, type, handler) => {
    // 先检测是否存在DOM2级方法,再检测IE的方法,最后是DOM0级方法(通常不会到这)
    if (element.addEventListener) {
      // 第三个参数false表示冒泡阶段
      element.addEventListener(type, handler, false);
    } else if (element.attachEvent) {
      element.attachEvent(`on${type}`, handler)
    } else {
      element[`on${type}`] = handler;
    }
  },

  removeHandler: (element, type, handler) => {
    if (element.removeEventListener) {
      // 第三个参数false表示冒泡阶段
      element.removeEventListener(type, handler, false);
    } else if (element.detachEvent) {
      element.detachEvent(`on${type}`, handler)
    } else {
      element[`on${type}`] = null;
    }
  }
}

// 获取元素
var btn = document.getElementById('btn');
// 定义handler
var handler = function(e) {
  console.log('我被点击了');
}
// 监听事件
EventUtil.addHandler(btn, 'click', handler);
// 移除事件监听
// EventUtil.removeHandler(button1, 'click', clickEvent);
复制代码

事件代理

因为事件会在冒泡阶段向上传播到父节点,所以能够把子节点的监听函数定义在父节点上,由父节点的监听函数统一处理多个子元素的事件。这种方法叫作事件的代理(delegation),也叫事件委托。事件代理有如下两个优势:

  • 减小内存消耗,提升性能

假设有一个列表,列表之中有大量的列表项,咱们须要在点击每一个列表项的时候响应一个事件

// 例4
<ul id="list">
  <li>item 1</li>
  <li>item 2</li>
  <li>item 3</li>
  ......
  <li>item n</li>
</ul>
复制代码

若是给每一个列表项一一都绑定一个函数,那对于内存消耗是很是大的,效率上须要消耗不少性能。借助事件代理,咱们只须要给父容器ul绑定方法便可,这样无论点击的是哪个后代元素,都会根据冒泡传播的传递机制,把容器的click行为触发,而后把对应的方法执行,根据事件源,咱们能够知道点击的是谁,从而完成不一样的事。

  • 动态绑定事件

在不少时候,咱们须要经过用户操做动态的增删列表项元素,若是一开始给每一个子元素绑定事件,那么在列表发生变化时,就须要从新给新增的元素绑定事件,给即将删去的元素解绑事件,若是用事件代理就会省去不少这样麻烦。

接下来咱们来实现上例中父层元素 #list 下的 li 元素的事件委托到它的父层元素上:

// 给父层元素绑定事件
document.getElementById('list').addEventListener('click', function (e) {
  // 兼容性处理
  var event = e || window.event;
  var target = event.target || event.srcElement;
  // 判断是否匹配目标元素
  if (target.nodeName.toLocaleLowerCase === 'li') {
    console.log('the content is: ', target.innerHTML);
  }
});
复制代码

事件对象

DOM0DOM2的事件处理程序都会自动传入event对象,即触发DOM上的某个事件时,会产生一个事件对象,里面包含着全部和事件有关的信息。IE中的event对象取决于指定的事件处理程序的方法

IE的handler会在全局做用域运行,this === window 因此在IE中会有window.eventevent两种状况

另外在IE中,事件对象的属性也不同,对应关系以下:

srcElement => target returnValue => preventDefault() cancelBubble => stopPropagation() IE 不支持事件捕获,于是只能取消事件冒泡,但stopPropagation能够同时取消事件捕获和冒泡

只有在事件处理程序期间,event对象才会存在,一旦事件处理程序执行完成,event对象就会被销毁

1. event. preventDefault()

若是调用这个方法,默认事件行为将再也不触发。什么是默认事件呢?例如表单一点击提交按钮(submit)跳转页面、a标签默认页面跳转或是锚点定位等。

不少时候咱们使用a标签仅仅是想当作一个普通的按钮,点击实现一个功能,不想页面跳转,也不想锚点定位。

//方法一:
<a href="javascript:;">连接</a>
复制代码

也能够经过JS方法来阻止,给其click事件绑定方法,当咱们点击A标签的时候,先触发click事件,其次才会执行本身的默认行为

//方法二:
<a id="test" href="http://www.cnblogs.com">连接</a>
<script>
test.onclick = function(e){
    e = e || window.event;
    return false;
}
</script>

//方法三:
<a id="test" href="http://www.cnblogs.com">连接</a>
<script>
test.onclick = function(e){
    e = e || window.event;
    e.preventDefault();
}
</script>
复制代码

接下来咱们看个例子:输入框最多只能输入六个字符,如何实现?

// 例5
 <input type="text" id='tempInp'>
 <script> tempInp.onkeydown = function(ev) { ev = ev || window.event; let val = this.value.trim() //trim去除字符串首位空格(不兼容) // this.value=this.value.replace(/^ +| +$/g,'') 兼容写法 let len = val.length if (len >= 6) { this.value = val.substr(0, 6); //阻止默认行为去除特殊按键(DELETE\BACK-SPACE\方向键...) let code = ev.which || ev.keyCode; if (!/^(46|8|37|38|39|40)$/.test(code)) { ev.preventDefault() } } } </script>
复制代码

2. event.stopPropagation() & event.stopImmediatePropagation()

event.stopPropagation() 方法阻止事件冒泡到父元素,阻止任何父事件处理程序被执行(通常咱们认为stopPropagation是用来阻止事件冒泡的,其实该函数也能够阻止捕获事件)。上面提到事件冒泡阶段是指事件从目标节点自下而上向window对象传播的阶段。 咱们在上面例子中的inner元素click事件上,添加 event.stopPropagation()这句话后,就阻止了父事件的执行,最后只打印了'inner'

inner.onclick = function(ev) {
    console.log('inner');
    ev.stopPropagation();
};
复制代码

stopImmediatePropagation 既能阻止事件向父元素冒泡,也能阻止元素同事件类型的其它监听器被触发。而 stopPropagation 只能实现前者的效果。咱们来看个例子:

<body>
  <button id="btn">click me to stop propagation</button>
</body>
......
const btn = document.querySelector('#btn');
btn.addEventListener('click', event => {
  console.log('btn click 1');
  event.stopImmediatePropagation();
});
btn.addEventListener('click', event => {
  console.log('btn click 2');
});
document.body.addEventListener('click', () => {
  console.log('body click');
});
// btn click 1
复制代码

如上所示,使用stopImmediatePropagation后,点击按钮时,不只body绑定事件不会触发,与此同时按钮的另外一个点击事件也不触发。

3. event.target & event.currentTarget

老实说这二者的区别,并很差用文字描述,咱们先来看个例子:

<div id="a">
  <div id="b">
    <div id="c">
      <div id="d"></div>
    </div>
  </div>
</div>
<script>
  document.getElementById('a').addEventListener('click', function(e) {
    console.log(
      'target:' + e.target.id + '&currentTarget:' + e.currentTarget.id
    )
  })
  document.getElementById('b').addEventListener('click', function(e) {
    console.log(
      'target:' + e.target.id + '&currentTarget:' + e.currentTarget.id
    )
  })
  document.getElementById('c').addEventListener('click', function(e) {
    console.log(
      'target:' + e.target.id + '&currentTarget:' + e.currentTarget.id
    )
  })
  document.getElementById('d').addEventListener('click', function(e) {
    console.log(
      'target:' + e.target.id + '&currentTarget:' + e.currentTarget.id
    )
  })
</script>
复制代码

img

当咱们点击最里层的元素d的时候,会依次输出:

target:d&currentTarget:d
target:d&currentTarget:c
target:d&currentTarget:b
target:d&currentTarget:a
复制代码

从输出中咱们能够看到,event.target指向引发触发事件的元素,而event.currentTarget则是事件绑定的元素,只有被点击的那个目标元素的event.target才会等于event.currentTarget也就是说,event.currentTarget始终是监听事件者,而event.target是事件的真正发出者

4. 跨浏览器的事件对象

var EventUtil = {
    addHandler: function (el, type, handler) {
        if (el.addEventListener) {
            el.addEventListener(type, handler, false);
        } else if (el.attachEvent) {
            el.attachEvent('on' + type, handler);
        } else {
            el['on' + type] = handler;
        }
    },
    removeHandler: function (el, type, handler) {
        if (el.removeEventListener) {
            el.removeEventListerner(type, handler, false);
        } else if (el.detachEvent) {
            el.detachEvent('on' + type, handler);
        } else {
            el['on' + type] = null;
        }
    },
    getEvent: function (e) {
        return e ? e : window.event;
    },
    getTarget: function (e) {
        return e.target ? e.target : e.srcElement;
    },
    preventDefault: function (e) {
        if (e.preventDefault) {
            e.preventDefault();
        } else {
            e.returnValue = false;
        }
    },
    stopPropagation: function (e) {
        if (e.stopPropagation) {
            e.stopPropagation();
        } else {
            e.cancelBubble = true;
        }
    }
};
复制代码

捕获与冒泡的顺序问题

当有多层交互嵌套时,事件捕获和冒泡的前后顺序看起来是很差肯定的。下将分 5 种状况讨论它们的顺序,以及如何规避意外状况的发生。

1.在外层 div 注册事件,点击内层 div 来触发事件时,捕获事件老是要比冒泡事件先触发(与代码顺序无关)

假设,有这样的 html 结构:

<div id="test" class="test">
   <div id="testInner" class="test-inner"></div>
</div>
复制代码

而后,咱们在外层 div 上注册两个 click 事件,分别是捕获事件和冒泡事件,代码以下:

const btn = document.getElementById("test");
 
//捕获事件
btn.addEventListener("click", function(e){
    alert("capture is ok");
}, true);
 
//冒泡事件
btn.addEventListener("click", function(e){
    alert("bubble is ok");
}, false);
复制代码

点击内层的 div,先弹出 capture is ok,后弹出 bubble is ok。只有当真正触发事件的 DOM 元素是内层的时候,外层 DOM 元素才有机会模拟捕获事件和冒泡事件。

2.当在触发事件的 DOM 元素上注册事件时,哪一个先注册,就先执行哪一个

html 结构同上,js 代码以下:

const btnInner = document.getElementById("testInner");

//冒泡事件
btnInner.addEventListener("click", function(e){
    alert("bubble is ok");
}, false);
 
//捕获事件
btnInner.addEventListener("click", function(e){
    alert("capture is ok");
}, true);
复制代码

本例中,冒泡事件先注册,因此先执行。因此,点击内层 div,先弹出 bubble is ok,再弹出 capture is ok

3.当外层 div 和内层 div 同时注册了捕获事件时,点击内层 div 时,外层 div 的事件必定会先触发

const btn = document.getElementById("test");
const btnInner = document.getElementById("testInner");

btnInner.addEventListener("click", function(e){
    alert("inner capture is ok");
}, true);

btn.addEventListener("click", function(e){
    alert("outer capture is ok");
}, true);
复制代码

虽然外层 div 的事件注册在后面,但会先触发。因此,结果是先弹出 outer capture is ok,再弹出 inner capture is ok

4.同理,当外层 div 和内层 div 都同时注册了冒泡事件,点击内层 div 时,必定是内层 div 事件先触发。

const btn = document.getElementById("test");
const btnInner = document.getElementById("testInner");

btn.addEventListener("click", function(e){
    alert("outer bubble is ok");
}, false);

btnInner.addEventListener("click", function(e){
    alert("inner bubble is ok");
}, false);
复制代码

先弹出 inner bubble is ok,再弹出 outer bubble is ok

5.阻止事件的派发

一般状况下,咱们都但愿点击某个div 时,就只触发本身的事件回调。好比,明明点击的是内层 div,可是外层div的事件也触发了,这是就不是咱们想要的了。这时,就须要阻止事件的派发。

事件触发时,会默认传入一个 event 对象,这个 event 对象上有一个方法:stopPropagation。MDN 上的解释是:阻止 捕获 和 冒泡 阶段中,当前事件的进一步传播。因此,经过此方法,让外层 div 接收不到事件,天然也就不会触发了。

btnInner.addEventListener("click", function(e){
    //阻止冒泡
    e.stopPropagation();
    alert("inner bubble is ok");
}, false);
复制代码

参考文章

  1. JavaScript高级程序设计(第三版)
  2. event.target和event.currentTarget的区别
  3. JS事件:捕获与冒泡、事件处理程序、事件对象、跨浏览器、事件委托
  4. javascript事件流
  5. 「前端面试题系列7」JavaScript 中的事件机制(从原生到框架)
  6. DOM事件机制
相关文章
相关标签/搜索