深入理解DOM事件機制

xyyojl發表於2019-02-24

前言

本文主要介紹:

  1. DOM事件級別
  2. DOM事件流
  3. DOM事件模型
  4. 事件代理
  5. Event物件常見的方法和屬性

一、DOM事件級別

針對不同級別的DOM,我們的DOM事件處理方式也是不一樣的。

DOM級別一共可以分為4個級別:DOM0級「通常把DOM1規範形成之前的叫做DOM0級」,DOM1級,DOM2級和 DOM3級,而DOM事件分為3個級別:DOM0級事件處理,DOM2級事件處理和DOM3級事件處理。如下圖所示:

DOM級別與DOM事件.jpg

1.DOM 0級事件

在瞭解DOM0級事件之前,我們有必要先了解下HTML事件處理程式,也是最早的這一種的事件處理方式,程式碼如下:

<button type="button" onclick="fn" id="btn">點我試試</button>

<script>
    function fn() {
        alert('Hello World');
    }
</script>
複製程式碼

那有一個問題來了,那就是fn要不要加括號呢?

在html的onclick屬性中,使用時要加括號,在js的onclick中,給點選事件賦值,不加括號。為什麼呢?我們通過事實來說話:

// fn不加括號
<button type="button" onclick="fn" id="btn">點我試試</button>

<script>
    function fn() {
        alert('Hello World');
    }
    console.log(document.getElementById('btn').onclick);
    // 列印的結果如下:這個函式裡面包括著fn,點選之後並沒有彈出1
    /*
    ƒ onclick(event) {
    	fn
    }
    */
</script>

// fn 加括號,這裡就不重複寫上面程式碼,只需要修改一下上面即可
<button type="button" onclick="fn()"  id="btn">點我試試</button>
<script>
// 列印的結果如下:點選之後可以彈出1
/*
ƒ onclick(event) {
	fn()
}
*/
</script>
複製程式碼

上面的程式碼我們通過直接在HTML程式碼當中定義了一個onclick的屬性觸發fn方法,這樣的事件處理程式最大的缺點就是HTML與JS強耦合,當我們一旦需要修改函式名就得修改兩個地方。當然其優點就是不需要操作DOM來完成事件的繫結

DOM0事件繫結,給元素的事件行為繫結方法,這些方法都是在當前元素事件行為的冒泡階段(或者目標階段)執行的

那我們如何實現HTML與JS低耦合?這樣就有DOM0級處理事件的出現解決這個問題。DOM0級事件就是將一個函式賦值給一個事件處理屬性,比如:

<button id="btn" type="button"></button>

<script>
    var btn = document.getElementById('btn');
    
    btn.onclick = function() {
        alert('Hello World');
    }
    
    // btn.onclick = null; 解綁事件 
</script>
複製程式碼

上面的程式碼我們給button定義了一個id,然後通過JS獲取到了這個id的按鈕,並將一個函式賦值給了一個事件處理屬性onclick,這樣的方法便是DOM0級處理事件的體現。我們可以通過給事件處理屬性賦值null來解綁事件。DOM 0級的事件處理的步驟:先找到DOM節點,然後把處理函式賦值給該節點物件的事件屬性。

DOM0級事件處理程式的缺點在於一個處理程式「事件」無法同時繫結多個處理函式,比如我還想在按鈕點選事件上加上另外一個函式。

var btn = document.getElementById('btn');
    
btn.onclick = function() {
    alert('Hello World');
}
btn.onclick = function() {
    alert('沒想到吧,我執行了,哈哈哈');
}
複製程式碼

2.DOM2級事件

DOM2級事件在DOM0級事件的基礎上彌補了一個處理程式無法同時繫結多個處理函式的缺點,允許給一個處理程式新增多個處理函式。也就是說,使用DOM2事件可以隨意新增多個處理函式,移除DOM2事件要用removeEventListener。程式碼如下:

<button type="button" id="btn">點我試試</button>

<script>
    var btn = document.getElementById('btn');

    function fn() {
        alert('Hello World');
    }
    btn.addEventListener('click', fn, false);
    // 解綁事件,程式碼如下
    // btn.removeEventListener('click', fn, false);  
</script>
複製程式碼

DOM2級事件定義了addEventListener和removeEventListener兩個方法,分別用來繫結和解綁事件

target.addEventListener(type, listener[, useCapture]);
target.removeEventListener(type, listener[, useCapture]);
/*
	方法中包含3個引數,分別是繫結的事件處理屬性名稱(不包含on)、事件處理函式、是否在捕獲時執行事件處理函式(關於事件冒泡和事件捕獲下面會介紹)
*/
複製程式碼

注:

IE8級以下版本不支援addEventListener和removeEventListener,需要用attachEvent和detachEvent來實現:

// IE8級以下版本只支援冒泡型事件,不支援事件捕獲所以沒有第三個引數
// 方法中包含2個引數,分別是繫結的事件處理屬性名稱(不包含on)、事件處理函式
btn.attachEvent('onclick', fn); // 繫結事件 
btn.detachEvent('onclick', fn); // 解綁事件 
複製程式碼

3.DOM3級事件

DOM3級事件在DOM2級事件的基礎上新增了更多的事件型別,全部型別如下:

  1. UI事件,當使用者與頁面上的元素互動時觸發,如:load、scroll
  2. 焦點事件,當元素獲得或失去焦點時觸發,如:blur、focus
  3. 滑鼠事件,當使用者通過滑鼠在頁面執行操作時觸發如:dbclick、mouseup
  4. 滾輪事件,當使用滑鼠滾輪或類似裝置時觸發,如:mousewheel
  5. 文字事件,當在文件中輸入文字時觸發,如:textInput
  6. 鍵盤事件,當使用者通過鍵盤在頁面上執行操作時觸發,如:keydown、keypress
  7. 合成事件,當為IME(輸入法編輯器)輸入字元時觸發,如:compositionstart
  8. 變動事件,當底層DOM結構發生變化時觸發,如:DOMsubtreeModified

同時DOM3級事件也允許使用者自定義一些事件

DOM事件級別的發展使得事件處理更加完整豐富,而下一個問題就是之前提到的DOM事件模型。「事件冒泡和事件捕獲」

二、DOM事件流

為什麼是有事件流?

假如在一個button上註冊了一個click事件,又在其它父元素div上註冊了一個click事件,那麼當我們點選button,是先觸發父元素上的事件,還是button上的事件呢,這就需要一種約定去規範事件的執行順序,就是事件執行的流程。

瀏覽器在發展的過程中出現了兩種不同的規範

  • IE9以下的IE瀏覽器使用的是事件冒泡,先從具體的接收元素,然後逐步向上傳播到不具體的元素。
  • Netscapte採用的是事件捕獲,先由不具體的元素接收事件,最具體的節點最後才接收到事件。
  • 而W3C制定的Web標準中,是同時採用了兩種方案,事件捕獲和事件冒泡都可以。

三、DOM事件模型

DOM事件模型分為捕獲和冒泡。一個事件發生後,會在子元素和父元素之間傳播(propagation)。這種傳播分成三個階段。

(1)捕獲階段:事件從window物件自上而下向目標節點傳播的階段;

(2)目標階段:真正的目標節點正在處理事件的階段;

(3)冒泡階段:事件從目標節點自下而上向window物件傳播的階段。

上文中講到了addEventListener的第三個引數為指定事件是否在捕獲或冒泡階段執行,設定為true表示事件在捕獲階段執行,而設定為false表示事件在冒泡階段執行。那麼什麼是事件冒泡和事件捕獲呢?可以用下圖來解釋:

深入理解DOM事件機制

1.事件捕獲

捕獲是從上到下,事件先從window物件,然後再到document(物件),然後是html標籤(通過document.documentElement獲取html標籤),然後是body標籤(通過document.body獲取body標籤),然後按照普通的html結構一層一層往下傳,最後到達目標元素。我們只需要將addEventListener的第三個引數改為true就可以實現事件捕獲。程式碼如下:

<!-- CSS 程式碼 -->
<style>
    body{margin: 0;}
    div{border: 1px solid #000;}
    #grandfather1{width: 200px;height: 200px;}
    #parent1{width: 100px;height: 100px;margin: 0 auto;}
    #child1{width: 50px;height: 50px;margin: 0 auto;}
</style>

<!-- HTML 程式碼 -->
<div id="grandfather1">
    爺爺
    <div id="parent1">
        父親
        <div id="child1">兒子</div>
    </div>
</div>

<!-- JS 程式碼 -->
<script>
    var grandfather1 = document.getElementById('grandfather1'),
        parent1 = document.getElementById('parent1'),
        child1 = document.getElementById('child1');
    
    grandfather1.addEventListener('click',function fn1(){
        console.log('爺爺');
    },true)
    parent1.addEventListener('click',function fn1(){
        console.log('爸爸');
    },true)
    child1.addEventListener('click',function fn1(){
        console.log('兒子');
    },true)

    /*
        當我點選兒子的時候,我是否點選了父親和爺爺
        當我點選兒子的時候,三個函式是否呼叫
    */
    // 請問fn1 fn2 fn3 的執行順序?
    // fn1 fn2 fn3 or fn3 fn2 fn1  
</script>
複製程式碼

先來看結果吧:

深入理解DOM事件機制

當我們點選id為child1的div標籤時,列印的結果是爺爺 => 爸爸 => 兒子,結果正好與事件冒泡相反。

2.事件冒泡

所謂事件冒泡就是事件像泡泡一樣從最開始生成的地方一層一層往上冒。我們只需要將addEventListener的第三個引數改為false就可以實現事件冒泡。程式碼如下:

//html、css程式碼同上,js程式碼只是修改一下而已
var grandfather1 = document.getElementById('grandfather1'),
    parent1 = document.getElementById('parent1'),
    child1 = document.getElementById('child1');

grandfather1.addEventListener('click',function fn1(){
    console.log('爺爺');
},false)
parent1.addEventListener('click',function fn1(){
    console.log('爸爸');
},false)
child1.addEventListener('click',function fn1(){
    console.log('兒子');
},false)

/*
   當我點選兒子的時候,我是否點選了父親和爺爺
   當我點選兒子的時候,三個函式是否呼叫
*/
// 請問fn1 fn2 fn3 的執行順序?
// fn1 fn2 fn3 or fn3 fn2 fn1  
複製程式碼

先來看結果吧:

深入理解DOM事件機制

比如上圖中id為child1的div標籤為事件目標,點選之後後同時也會觸發父級上的點選事件,一層一層向上直至最外層的html或document。

注:當第三個引數為false或者為空的時候,代表在冒泡階段繫結。

四、事件代理(事件委託)

1.事件代理含義和為什麼要優化?

由於事件會在冒泡階段向上傳播到父節點,因此可以把子節點的監聽函式定義在父節點上,由父節點的監聽函式統一處理多個子元素的事件。這種方法叫做事件的代理(delegation)。

舉個例子,比如一個宿舍的同學同時快遞到了,一種方法就是他們都傻傻地一個個去領取,還有一種方法就是把這件事情委託給宿舍長,讓一個人出去拿好所有快遞,然後再根據收件人一一分發給每個宿舍同學;

在這裡,取快遞就是一個事件,每個同學指的是需要響應事件的 DOM 元素,而出去統一領取快遞的宿舍長就是代理的元素,所以真正繫結事件的是這個元素,按照收件人分發快遞的過程就是在事件執行中,需要判斷當前響應的事件應該匹配到被代理元素中的哪一個或者哪幾個。

那麼利用事件冒泡或捕獲的機制,我們可以對事件繫結做一些優化。 在JS中,如果我們註冊的事件越來越多,頁面的效能就越來越差,因為:

  • 函式是物件,會佔用記憶體,記憶體中的物件越多,瀏覽器效能越差
  • 註冊的事件一般都會指定DOM元素,事件越多,導致DOM元素訪問次數越多,會延遲頁面互動就緒時間。
  • 刪除子元素的時候不用考慮刪除繫結事件

2.優點

  • 減少記憶體消耗,提高效能

假設有一個列表,列表之中有大量的列表項,我們需要在點選每個列表項的時候響應一個事件

// 例4
<ul id="list">
  <li>item 1</li>
  <li>item 2</li>
  <li>item 3</li>
  ......
  <li>item n</li>
</ul>
複製程式碼

如果給每個列表項一一都繫結一個函式,那對於記憶體消耗是非常大的,效率上需要消耗很多效能。藉助事件代理,我們只需要給父容器ul繫結方法即可,這樣不管點選的是哪一個後代元素,都會根據冒泡傳播的傳遞機制,把容器的click行為觸發,然後把對應的方法執行,根據事件源,我們可以知道點選的是誰,從而完成不同的事。

  • 動態繫結事件

在很多時候,我們需要通過使用者操作動態的增刪列表項元素,如果一開始給每個子元素繫結事件,那麼在列表發生變化時,就需要重新給新增的元素繫結事件,給即將刪去的元素解綁事件,如果用事件代理就會省去很多這樣麻煩。

2.如何實現

接下來我們來實現上例中父層元素 #list 下的 li 元素的事件委託到它的父層元素上:


<ul id="list">
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
</ul>

<script>
// 給父層元素繫結事件
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);
    }
});
</script>
複製程式碼

這是常規的實現事件委託的方法,但是這種方法有BUG,當監聽的元素裡存在子元素時,那麼我們點選這個子元素事件會失效,所以我們可以聯絡文章上一小節說到的冒泡事件傳播機制來解決這個bug。改進的事件委託程式碼:

<ul id="list">
    <li>1 <span>aaaaa</span></li>
    <li>2 <span>aaaaa</span></li>
    <li>3 <span>aaaaa</span></li>
    <li>4</li>
</ul>

<script>


// 給父層元素繫結事件
document.getElementById('list').addEventListener('click', function (e) {
    // 相容性處理
    var event = e || window.event;
    var target = event.target || event.srcElement;
    // 判斷是否匹配目標元素
    /* 從target(點選)元素向上找currentTarget(監聽)元素,
    找到了想委託的元素就觸發事件,沒找到就返回null */
    while(target.tagName !== 'LI'){
    
        if(target.tagName === 'UL'){
            target = null
            break;
        }
        target = target.parentNode
    }
    if (target) {
    console.log('你點選了ul裡的li')
    }
});
複製程式碼

五、Event物件常見的方法和屬性

1.event. preventDefault()

如果呼叫這個方法,預設事件行為將不再觸發。什麼是預設事件呢?例如表單一點選提交按鈕(submit)重新整理頁面、a標籤預設頁面跳轉或是錨點定位等。

使用場景1:使用a標籤僅僅是想當做一個普通的按鈕,點選實現一個功能,不想頁面跳轉,也不想錨點定位。

方法一

<a href="javascript:;">連結</a>
複製程式碼

方法二

使用JS方法來阻止,給其click事件繫結方法,當我們點選A標籤的時候,先觸發click事件,其次才會執行自己的預設行為

<a id="test" href="http://www.google.com">連結</a>
<script>
    test.onclick = function(e){
        e = e || window.event;
        return false;
    }
</script>
複製程式碼

方法三

<a id="test" href="http://www.google.com">連結</a>
<script>
    test.onclick = function(e){
        e = e || window.event;
        e.preventDefault();
    }
</script>
複製程式碼

使用場景2:輸入框最多隻能輸入六個字元,如何實現?

實現程式碼如下:

<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() 方法阻止事件冒泡到父元素,阻止任何父事件處理程式被執行。demo程式碼如下:

// 在事件冒泡demo程式碼的基礎上修改一下
child1.addEventListener('click',function fn1(e){
    console.log('兒子');
    e.stopPropagation()
},false)
複製程式碼

stopImmediatePropagation 既能阻止事件向父元素冒泡,也能阻止元素同事件型別的其它監聽器被觸發。而 stopPropagation 只能實現前者的效果。我們來看個例子:

<button id="btn">點我試試</button>
<script>
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');
});
</script>
複製程式碼

根據列印出來的結果,我們發現使用 stopImmediatePropagation後,點選按鈕時,不僅body繫結事件不會觸發,與此同時按鈕的另一個點選事件也不觸發。

3.event.target & event.currentTarget

深入理解DOM事件機制

從上面這張圖片中我們可以看到,event.target指向引起觸發事件的元素,而event.currentTarget則是事件繫結的元素

總結

因此不必記什麼時候e.currentTargete.target相等,什麼時候不等,理解兩者的究竟指向的是誰即可。

  • e.target 指向觸發事件監聽的物件「事件的真正發出者」。
  • e.currentTarget 指向新增監聽事件的物件「監聽事件者」。

六、參考文章

相關文章