JS事件那些事儿 一次整明白

8,492 阅读17分钟

DOM 事件流

事件流包括三个阶段。简而言之:事件一开始从文档的根节点流向目标对象(捕获阶段),然后在目标对象上被触发(目标阶段),之后再回溯到文档的根节点(冒泡阶段)。

DOM 事件流

事件捕获阶段(Capture Phase)

事件的第一个阶段是捕获阶段。事件从文档的根节点出发,随着 DOM 树的结构向事件的目标节点流去。途中经过各个层次的 DOM 节点,并在各节点上触发捕获事件,直到到达事件的目标节点。捕获阶段的主要任务是建立传播路径,在冒泡阶段,事件会通过这个路径回溯到文档跟节点。

目标阶段(Target Phase)

当事件到达目标节点的,事件就进入了目标阶段。事件在目标节点上被触发,然后会逆向回流,直到传播至最外层的文档节点。

冒泡阶段(Bubble Phase)

事件在目标元素上触发后,并不在这个元素上终止。它会随着 DOM 树一层层向上冒泡,直到到达最外层的根节点。也就是说,同一个事件会依次在目标节点的父节点,父节点的父节点...直到最外层的节点上被触发。

冒泡过程非常有用。它将我们从对特定元素的事件监听中释放出来,相反,我们可以监听 DOM 树上更上层的元素,等待事件冒泡的到达。如果没有事件冒泡,在某些情况下,我们需要监听很多不同的元素来确保捕获到想要的事件。

所有的事件都要经过捕捉阶段和目标阶段,但是有些事件会跳过冒泡阶段。例如,让元素获得输入焦点的 focus 事件以及失去输入焦点的 blur 事件就都不会冒泡。

事件处理程序

HTML 事件处理程序

<!-- 输出 click -->
<input type="button" value="Click Me" onclick="console.log(event.type)">

<!-- 输出 Click Me this 值等于事件的目标元素 -->
<input type="button" value="Click Me" onclick="console.log(this.value)">

当然在 HTML 中定义的事件处理程序也可以调用其它地方定义的脚本:

<!-- Chrome 输出 click -->
<script>
    function showMessage(event) {
        console.log(event.type);
    }
</script>
<input type="button" value="Click Me" onclick="showMessage(event)">

通过 HTML 指定的事件处理程序都需要HTML的参与,即结构和行为相耦合,不易维护。

DOM0 级事件处理程序

<input type="button" value="Click Me" id="btn">
<script>
    var btn=document.getElementById("btn");
    btn.onclick=function(){
        console.log(this.id); // 输出 btn
    }
</script>

这里是将一个函数赋值给一个事件处理程序的属性,以这种方式添加的事件处理程序会在事件流的冒泡阶段被处理。要删除事件将 btn.onclick 设置为 null 即可。

DOM2 级事件处理程序

DOM2 级事件定义了addEventListener()removeEventListener()两个方法,用于处理和删除事件处理程序的操作。

所有 DOM 节点都包含这两个方法,它们接受3个参数:要处理的事件名作为事件处理程序的函数一个布尔值。最后的布尔值参数是 true 表示在捕获阶段调用事件处理程序,如果是 false(默认) 表示在冒泡阶段调用事件处理程序。

<input type="button" value="Click Me" id="btn">
<script>
    var btn=document.getElementById("btn");
    btn.addEventListener("click",function(){
        console.log(this.id);
    },false);
    btn.addEventListener("click",function(){
        console.log('Hello word!');
    },false);
</script>

上面代码两个事件处理程序会按照它们的添加顺序触发,先输出 btn 再输出 Hello word!

通过 addEventListener()添加的事件处理程序只能使用 removeEventListener()来移除,移除时传入的参数与添加时使用的参数相同,即匿名函数无法被移除。

<input type="button" value="Click Me" id="btn">
<script>
    var btn=document.getElementById("btn");
    var handler = function(){
        console.log(this.id);
    }
    btn.addEventListener("click", handler, false);
    btn.removeEventListener("click",handler, false);
</script>

IE 事件处理程序

IE通常都是特立独行的,它添加和删除事件处理程序的方法分别是:attachEvent()detachEvent()

同样接受事件处理程序名称与事件处理程序函数两个参数,但跟addEventListener()的区别是:

  • 事件名称需要加“on”,比如“onclick”;
  • 没了第三个布尔值,IE8及更早版本只支持事件冒泡;
  • 仍可添加多个处理程序,但触发顺序是反着来的。

还有一点需要注意,DOM0 和 DOM2 级的方法,其作用域都是在其所依附的元素当中,attachEvent()则是全局,即如果像之前一样使用this.id,访问到的就不是 button 元素,而是 window,就得不到正确的结果。

跨浏览器事件处理程序

直接上码:

var EventUtil={
    addHandler:function(element,type,handler){
        if(element.addEventListener){
            element.addEventListener(type,handler,false);
        } else if(element.attachEvent){
            element.attachEvent(“on”+ type,handler);
        } else {
            element[“on” + type]=handler;
        }
    },
    removeHandler:function(element,type,handler){
        if(element.removeEventListener){
            element.removeEventListener(type,handler,false);
        } else if(element.detachEvent){
            element.detachEvent(“on”+ type,handler);
        } else {
            element[“on” + type]=null;
        }
    }
}

事件对象

在触发 DOM 上的某个事件时,会产生一个事件对象 event,这个对象中包含着所有与事件有关的信息。所有的浏览器都支持 event 对象,但支持方式不同。

DOM 中的事件对象

兼容 DOM 的浏览器会将一个 event 对象传入到事件处理程序中。event 对象包含与创建它的特定事件有关的属性和方法。触发的事件类型不一样,可用的属性和方法也不一样。

不过,所有事件都会有下面列出的成员。

  • bubbles (boolean) — 表明事件是否冒泡

  • cancelable (boolean) — 这个变量指明这个事件的默认行为是否可以通过调用 event.preventDefault 来阻止。也就是说,只有 cancelable 为 true 的时候,调用 event.preventDefault 才能生效。

  • currentTarget(element) — 当事件遍历DOM时,标识事件的当前目标。它总是引用事件处理程序附加到的元素,而不是event.target,event.target标识事件发生的元素。

  • defaultPrevented (boolean) — 这个状态变量表明当前事件对象的 preventDefault 方法是否被调用过

  • eventPhase (number) — 这个数字变量表示当前这个事件所处的阶段(phase):none(0),capture(1),target(2),bubbling(3)。

  • preventDefault(function) — 这个方法将阻止浏览器中用户代理对当前事件的相关默认行为被触发。比如阻止<a>元素的 click 事件加载一个新的页面

  • stopImmediatePropagation (function) — 这个方法将阻止当前事件链上所有的回调函数被触发,也包括当前节点上针对此事件已绑定的其他回调函数。

  • stopPropagation (function) — 阻止捕获和冒泡阶段中当前事件的进一步传播。

  • target (element) — 事件起源的 DOM 节点(获取标签名:ev.target.nodeName)

  • type (String) — 事件的名称

  • isTrusted (boolean) — 如果一个事件是由设备本身(如浏览器)触发的,而不是通过 JavaScript 模拟合成的,那个这个事件被称为可信任的(trusted)

  • timestamp (number) — 事件发生的时间

在事件处理程序内部,对象 this 始终等于 currentTarget 的值,而 target 则只包含事件的实际目标。如果直接将事件处理程序指定给了目标元素, 则 this、currentTarget、target 包含相同的值。

<input type="button" value="Click Me" id="btn">
<script>
    var btn=document.getElementById("btn");
    btn.onclick = function (event) {
        console.log(event.currentTarget === this); // true
        console.log(event.target === this); // true
    }
</script>

如果事件处理程序存在于按钮的父节点,那么这些值是不同的。

<input type="button" value="Click Me" id="btn">
<script>
    var btn=document.getElementById("btn");
    document.body.onclick = function (event) {
        console.log(event.currentTarget === document.body); // true
        console.log(this === document.body); // true
        console.log(event.target === btn); // true
    }
</script>

在需要通过一个函数处理多个事件时,可以使用 type 属性。

<input type="button" value="Click Me" id="btn">
<script>
    var btn=document.getElementById("btn");
    var handler = function(event) {
        switch (event.type) {
            case "click":
                console.log("clicked");
                break;
            case "mouseover":
                event.target.style.backgroundColor = "red";
                break;
            case "mouseout":
                event.target.style.backgroundColor = "";
                break;
        }
    }
    btn.onclick = handler;
    btn.onmouseover = handler;
    btn.onmouseout = handler;
</script>

跨浏览器的事件对象

万恶的 IE!直接上码:

EventUtil = {
    addHandler: function(element,type,handler){
        // 省略代码
    },
    removeHandler: function(element,type,handler){
        // 省略代码
    },
    getEvent: function(event){
        return event?event:window.event;
    },
    getTarget: function(event){
        return event.target || event.srcElement;
    },
    preventDefault: function(event){
        if(event.preventDefault){
            event.preventDefault();
        }else{
            event.returnValue = false;
        }
    },
    stopProgagation: function(event){
        if(event.stopProgagation){
            event.stopProgagation();
        }else{
            event.cancelBubble = true;
        }
    }
};

更多详情参见:跨浏览器的事件对象

阻止事件冒泡/停止传播(Stopping Propagation)

可以通过调用事件对象的 stopPropagation 方法,在任何阶段(捕获阶段或者冒泡阶段)中断事件的传播。此后,事件不会在后面传播过程中的经过的节点上调用任何的监听函数。

<input type="button" value="Click Me" id="btn">
<script>
    var btn=document.getElementById("btn");
    btn.onclick = function (event) {
        console.log("Clicked"); // 触发
        event.stopPropagation();
    }
    document.body.onclick = function (event) {
        console.log("Body clicked"); // 传播阻断 不触发
    }
</script>

调用 event.stopPropagation()不会阻止当前节点上此事件其他的监听函数被调用。如果你希望阻止当前节点上的其他回调函数被调用的话,你可以使用更激进的 event.stopImmediatePropagation()方法。

阻止浏览器默认行为

当特定事件发生的时候,浏览器会有一些默认的行为作为反应。最常见的事件不过于 link 被点击。当一个 click 事件在一个<a>元素上被触发时,它会向上冒泡直到 DOM 结构的最外层 document,浏览器会解释 href 属性,并且在窗口中加载新地址的内容。

在 web 应用中,开发人员经常希望能够自行管理导航(navigation)信息,而不是通过刷新页面。为了实现这个目的,我们需要阻止浏览器针对点击事件的默认行为,而使用我们自己的处理方式。这时,我们就需要调用 event.preventDefault().

我们可以阻止浏览器的很多其他默认行为。比如,我们可以在 HTML5 游戏中阻止敲击空格时的页面滚动行为,或者阻止文本选择框的点击行为。

调用 event.stopPropagation()只会阻止传播链中后续的回调函数被触发。它不会阻止浏览器的自身的行为。

事件类型

事件的种类可谓相当繁多,不同的事件类型具有不同的信息,常用的大致可分为如下几类:

  • UI:load、 error(错误触发)、select、resize、scroll等
  • 焦点:blur、focus、change(当用户提交对元素值的更改时触发)等
  • 鼠标:click、dblclick、mousedown/up、mouseenter/leave、mousemove、mouseover等

一些注意点:1、除 mouseenter/leave 所有鼠标事件都会冒泡; 2、只有在同一个元素上相继触发 mousedown/up 事件,才会触发 click 事件,只有触发两次 click 事件才会触发一次 dblclick 事件,4个事件的触发顺序为 mousedown → mouseup → click → mousedown → mouseup → click → dblclick (ie8及之前双击事件会跳过第二个 mousedown 和 click 事件);

  • 键盘:keydown/up、keypress
  • 触摸:touchstart(即使已经有一个手指放在屏幕上也会触发)、touchend、touchmove等

在触摸屏幕上的元素时事件的发生顺序为:touchstart → mouseover → mousemove(一次) → mousedown → mouseup → click → touchend;

  • 手势:gesturestart、gestureend、gesturechange等

1、gesturestart:当一个手指已经按在屏幕上,而另一个手指又触摸在屏幕时触发。 2、gesturechange:当触摸屏幕的任何一个手指的位置发生变化时触发。3、gestureend:当任何一个手指从屏幕上面移开时触发。注意:只有两个手指都触摸到事件的接收容器时才触发这些手势事件。

  • 设备:orientationchange(检测设备屏幕旋转)、deviceorientation(检测设备方向变化)等

上面这些,大都是人为操作,还有些事件是网页状态带来的,比如:网页加载完成、提交表单、网页出错等。

除此之外,还有变动事件,复合事件,HTML5新加入的一些事件,不再一一列出。完整列表可在这里查看 Web Events

内存和性能

事件委托/代理事件监听

事件委托利用了事件冒泡,只指定一个事件处理程序,就可以管理某一类型的所有事件。例如,click 事件会一直冒泡到 document 层次,也就是说,我们可以为整个页面指定一个 onclick 事件处理程序,而不必为每个可点击的元素分别添加事件处理程序。

<ul id="myLinks">
    <li id="goSomewhere">Go somewhere</li>
    <li id="doSomething">Do something</li>
    <li id="sayHi">Say hi</li>
</ul>
<script>
    var list = document.getElementById("myLinks");
    EVentUtil.addHandler (list, "click", function (event) {
        event = EVentUtil.getEvent(event);
        var target = EVentUtil.gitTarget(event);

        switch(target.id) {
            case "doSomething":
                document.title = "I changed the document's title";
                break;
            case "goSomewhere":
                location.href = "https://heycoder.cn/";
                break;
            case "sayHi":
                console.log("hi");
                break;
        }
    })
</script>

移除事件处理程序

大多是 IE 的锅!

内存中留有那些过时不用的“空事件处理程序”,也是造成 web 应用程序内存与性能问题的主要原因。

在两种情况下,可能会造成上述问题:

第一种情况就是从文档中移除带有事件处理程序的元素时。这可能是通过纯粹的DOM操作,例如使用removeChild()replaceChild()方法,但更多地是发生在使用 innerHTML 替换页面中某一部分的时候。如果带有事件处理程序的元素被 innerHTML 删除了,那么原来添加到元素中的事件处理程序极有可能被当作垃圾回收。来看下面的例子:

<div id="myDiv">
    <input type="button" value="ClickMe" id="myBtn">
</div>
<script>
    var btn = document.getElementById("myBtn");
    btn.onclick=function(){
        document.getElementById("myDiv").innerHTML="Processing…";
    }
</script>

这里,有一个按钮被包含在<div>元素中,为避免双击,单击这个按钮时就将按钮移除并替换成一条消息;这是网站设计中非常流行的一种做法。但问题在于,当按钮被从页面中移除时,它还带着一个事件处理程序呢,在<div>元素中设置 innerHTML 可以把按钮移走,但事件处理各种仍然与按钮保持着引用联系。有的浏览器(尤其是IE)在这种情况下不会作出恰当的处理,它们很有可能会将对元素和事件处理程序的引用都保存在内存中。如果你想知道某个元即将被移除,那么最好手工移除事件处理程序。如下面的例子所示:

<div id="myDiv">
    <input type="button" value="ClickMe" id="myBtn">
</div>
<script>
    var btn = document.getElementById("myBtn");
    btn.onclick=function(){
        btn.onclick=null;
        document.getElementById("myDiv").innerHTML="Processing…";
    }
</script>

在此,我们设置<div>的innerHTML属性之前,先移除了按钮的事件处理程序。这样就确保了内存可以被再次利用,而从DOM中移除按钮也做到了干净利索。

注意,在事件处理程序中删除按钮也能阻止事件冒泡。目标元素在文档中是事件冒泡的前提。

导致“空事件处理程序”的另一情况,就是卸载页面中的时候。毫不奇怪,IE在这种情况下依然是问题最多的浏览器,尽管其他浏览器或多或少也有类似的问题。如果在页面被卸载之前没有清理干净事件处理程序。那它们就会滞留在内存中。每次加载完页面再卸载页面时(可能是在两个页面间来加切换,也可以是单击了“刷新”按钮),内存中滞留的对象数目就会增加,因为事件处理程序占用的内存并没有被释放。

一般来说,最好的做法是在页面卸载之前 ,先通过 onunload 事件处理程序移除所有事件处理程序。在此,事件委托技术再次表现出它的优势——需要跟踪的事件程序越少,移除它们就越容易,对这种类似的操作,我们可把它想象成:只要是通过 onload 事件处理程序添加的东西,最后都要通过 onunload 事件处理程序将它们移除。

注:不要忘了,使用 onunload 事件处理程序意味着页面不会被缓存在 bfcachek 中,如果你在意这个问题,那么就只能在 IE 中通过onunload 来移除事件处理程序了。

一些常用的操作

load 事件:

load 事件可以在任何资源(包括被依赖的资源)被加载完成时被触发,这些资源可以是图片,css,脚本,视频,音频等文件,也可以是 document 或者 window。

Image 元素 load:

// window onload
EVentUtil.addHandler (window, "load", function () {
    var image = new Image();
    // 要在指定 src 属性之前先指定事件
    EVentUtil.addHandler (image, "load", function () {
        console.log("Image loaded!");
    });
    image.src = "smile.gif";
})

注意:新图像元素不一定要添加到文档后才开始下载,只要设置了 src 属性就会开始下载。

script 元素 load:

// window onload
EVentUtil.addHandler (window, "load", function () {
    var script = document.createElement("script");
    EVentUtil.addHandler (script, "load", function (event) {
        console.log("loaded!");
    });
    script.src = "example.js";
    document.body.appendChild(script);
})

与图像不同,只有设置了 script 元素的 src 属性并将元素添加到文档后,才会开始下载 js 文件,对于 script 元素而言指定 src 属性和指定事件处理程序的先后顺序就不重要了。

onbeforeunload 事件(HTML5事件):

window.onbeforeunload 让开发人员可以在想用户离开一个页面的时候进行确认。这个在有些应用中非常有用,比如用户不小心关闭浏览器的 tab,我们可以要求用户保存他的修改和数据,否则将会丢失他这次的操作。

EVentUtil.addHandler (window, "onbeforeunload", function (event) {
    if (textarea.value != textarea.defaultValue) {
        return 'Do you want to leave the page and discard changes?';
    }
});

需要注意的是,对页面添加 onbeforeunload 处理会导致浏览器不对页面进行缓存,这样会影响页面的访问响应时间。 同时,onbeforeunload 的处理函数必须是同步的(synchronous)。

resize 事件:

在一些复杂的响应式布局中,对 window 对象监听 resize 事件是非常常用的一个技巧。仅仅通过 css 来达到想要的布局效果比较困难。很多时候,我们需要使用 JavaScript 来计算并设置一个元素的大小。

EVentUtil.addHandler (window, "resize", function (event) {
    // update the layout
});

error 事件:

当我们的应用在加载资源的时候发生了错误,我们很多时候需要去做点什么,尤其当用户处于一个不稳定的网络情况下。Financial Times 中,我们使用 error 事件来监测文章中的某些图片加载失败,从而立刻隐藏它。由于“DOM Leven 3 Event”规定重新定义了 error 事件不再冒泡,我们可以使用如下的两种方式来处理这个事件。

imageNode.addEventListener('error', function(event) {
    image.style.display = 'none';
});

不幸的是,addEventListener 并不能处理所有的情况。而确保图片加载错误回调函数被执行的唯一方式是使用让人诟病内联事件处理函数(inline event handlers)。

<img src="http://example.com/image.jpg" onerror="this.style.display='none';" />

原因是你不能确定绑定 error 事件处理函数的代码会在 error 事件发生之前被执行。而使用内联处理函数意味着在标签被解析并且请求图片的时候,error监听器也将并绑定。

获取鼠标在网页中的坐标:

From:js鼠标事件参数,获取鼠标在网页中的坐标

直接上码:

// 鼠标事件参数 兼容性封装 Test Already.
var EventUtil = {
    getEvent : function(e){
        return e || window.event;
    },

    getTarget : function(e){
        return this.getEvent(e).target || this.getEvent(e).srcElement;
    },

    getClientX : function(e){
        return this.getEvent(e).clientX;
    },

    getClientY : function(e){
        return this.getEvent(e).clientY;
    },

    // 水平滚动条偏移
    getScrollLeft : function(){
        return  document.documentElement.scrollLeft ||    // 火狐 IE9及以下滚动条是HTML的
                window.pageXOffset ||                     // IE10及以上 window.pageXOffset
                document.body.scrollLeft;                 // chrome 滚动条是body的
    },

    // 垂直滚动条偏移
    getScrollTop : function(){
        return  document.documentElement.scrollTop ||    // 火狐 IE9 及以下滚动条是 HTML 的
                window.pageYOffset ||                    // IE10 及以上 window.pageXOffset
                document.body.scrollTop;                 // chrome 滚动条是body的,不过目前新版的滚动条也放到 document上去了
    },

    getPageX : function(e){
        return (this.getEvent(e).pageX)?( this.getEvent(e).pageX ):( this.getClientX(e)+this.getScrollLeft() );
    },

    getPageY : function(e){
        return (this.getEvent(e).pageY)?( this.getEvent(e).pageY ):( this.getClientY(e)+this.getScrollTop() );
    }
};

附加

关于Chrome浏览器 document.body.scrollTop 一直为0的问题可参考这里

写在后面的话:对于 js 事件总会有一种朦胧美的感觉,最近也算没太忙,索性再翻看下 js 高程,本篇文章算是高程事件章节的读书笔记,也会有所扩展。