瀏覽器事件系統

引言

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事件機制
相關文章
相關標籤/搜索