引言
JavaScript
與HTML
之間的互動是通過事件實現的。事件,就是文件或瀏覽器視窗中發生的一些特定的互動瞬間。瀏覽器的事件系統相對比較複雜。儘管所有的主要瀏覽器已經實現了“DOM2
級事件”,但這個規範本身並沒有涵蓋所有事件型別,隨著DOM3
級的出現,DOM
事件API
變得更加豐富。另外瀏覽器物件模型(BOM
)也支援一些事件,但這些事件與文件物件模型(DOM
)之間的關係並不清晰,因為BOM
事件長期沒有規範可以遵循(HTML5
後來給出了說明)。本文主要介紹瀏覽器DOM
的事件系統,包括事件流的三個階段,事件處理程式的三種方式的不同(DOM0
,DOM2
,IE
),考慮到IE
中的事件處理和事件物件的差異如何做相容性處理,事件物件中的屬性如何運用到實際應用中以及它們之間的差異,以及事件捕獲與冒泡的先後順序問題。
文章開頭先簡短介紹下本文的幾個重要知識點:
DOM
事件處理程式有三種方式,DOM0
的onType
,IE9
以下的attachEvent
與detachEvent
,DOM2
的addEventListener
與removeEventListener
。DOM2
級的優點是可以通過addEventListener
的第三個引數來指定是捕獲還是冒泡,並且可以為同一個DOM
元素註冊多個同型別的事件處理程式;而DOM0
對每個事件只支援一個事件處理程式DOM0
和DOM2
的事件處理程式都會自動傳入event
物件;IE中的event
物件取決於指定的事件處理程式的方法,所以在IE中會有window.event
、event
兩種情況;event
物件裡有一些很有用 處的屬性,比如target、currentTarget
,preventDefault
,stopPropagation
,stopImmediatePropagation
等- 對於
DOM0
的ontype
,給元素的事件行為繫結方法都是在當前元素事件行為的冒泡階段(或者目標階段)執行的。對於DOM2
的addEventListener
,為了最大限度的相容,大多是情況下都是將事件處理程式新增到事件冒泡階段。不是特別需要,不建議在事件捕獲階段註冊事件處理程式。 - 事件處理函式的相容性處理要考慮到
DOM0
和IE9
以下的事件處理方式,事件物件與事件物件屬性的相容性處理要考慮到IE
中的不同 event.stopPropagation()
方法阻止事件冒泡到父元素,阻止任何父事件處理程式被執行(一般我們認為stopPropagation
是用來阻止事件冒泡的,其實該函式也可以阻止捕獲事件)event.target
指向引起觸發事件的元素,而event.currentTarget
則是事件繫結的元素,只有被點選的那個目標元素的event.target
才會等於event.currentTarget
多數支援DOM事件流的瀏覽器都實現了一種特定的行為;即使“DOM2級事件”規範明確要求捕獲階段不會涉及事件目標,但IE9、Safari、Chrome、Firefox和Opera9.5及更高版本都會在捕獲階段觸發事件物件上的事件。結果,就是有兩個機會在目標物件上操作事件。
事件流
事件流描述的是從頁面中接受事件的順序。但有意思的是,IE
和Netscape
開發團隊居然提出了兩個截然相反的事件流概念。IE的事件流是事件冒泡流,標準的瀏覽器事件流是事件捕獲流。不過,W3C
為了制定標準,採取了折中的方式:先捕獲再冒泡(通過addEventListene
r給出的第三個引數同時支援冒泡與捕獲)。具體地,同一個DOM
元素可以註冊多個同型別的事件,通過addEventListener
來註冊事件,removeEventListener
來解除事件。
注意要想註冊過的事件能夠被解除,必須將回撥函式儲存起來,否則無法解除。
DOM
事件流分為三個階段:捕獲階段
、目標階段
、冒泡階段
。先呼叫捕獲階段的處理函式,其次呼叫目標階段的處理函式,最後呼叫冒泡階段的處理函式。(下面的圖中沒有標html標籤)
(1)捕獲階段:事件從window
物件自上而下向目標節點傳播的階段;
(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');
};
複製程式碼
正如我們下面提到的onclick
給元素的事件行為繫結方法都是在當前元素事件行為的冒泡階段(或者目標階段)執行的。
DOM事件級別
DOM
級別一共可以分為四個級別:DOM0級
、DOM1級
、DOM2級
和DOM3級
。而DOM
事件分為3個級別:DOM 0
級事件處理,DOM 2
級事件處理和DOM 3
級事件處理。由於DOM 1
級中沒有事件的相關內容,所以沒有DOM 1
級事件。又因為IE
和其他瀏覽器在DOM2
級別上事件處理又不一樣,因此一般可以將事件處理方式分為三類,即DOM0
,DOM2
,IE
。下面是從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新增的匿名函式無法移除,
addEventListener
和removeEventListener
的handler
必須同名 -
作用域: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);
}
});
複製程式碼
事件物件
DOM0
和DOM2
的事件處理程式都會自動傳入event
物件,即觸發DOM
上的某個事件時,會產生一個事件物件,裡面包含著所有和事件有關的資訊。IE
中的event
物件取決於指定的事件處理程式的方法
IE的
handler
會在全域性作用域執行,this === window
所以在IE中會有window.event
、event
兩種情況
另外在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 + '¤tTarget:' + e.currentTarget.id
)
})
document.getElementById('b').addEventListener('click', function(e) {
console.log(
'target:' + e.target.id + '¤tTarget:' + e.currentTarget.id
)
})
document.getElementById('c').addEventListener('click', function(e) {
console.log(
'target:' + e.target.id + '¤tTarget:' + e.currentTarget.id
)
})
document.getElementById('d').addEventListener('click', function(e) {
console.log(
'target:' + e.target.id + '¤tTarget:' + e.currentTarget.id
)
})
</script>
複製程式碼
當我們點選最裡層的元素d的時候,會依次輸出:
target:d¤tTarget:d
target:d¤tTarget:c
target:d¤tTarget:b
target:d¤tTarget: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);
複製程式碼
參考文章