如何優雅的封裝一個DOM事件庫

餘大彬發表於2018-08-11

1、DOM0級事件和DOM2級事件

DOM 0級事件是元素內的一個私有屬性:div.onclick = function () {},對一個私有屬性賦值(在該事件上繫結一個方法)。由此可知DOM 0級事件只能給元素的某一個行為繫結一次方法,第二次繫結會把前面的覆蓋掉。

DOM 2級事件是讓DOM元素通過原型鏈一直找到EventTarget這個內建類原型上的addEventListener方法來實現的。

DOM 2可以給某一個元素的同一個行為繫結多個不同的方法

//例項 1
obj.addEventListener(事件型別 , 處理函式 , false)
//IE9以下不相容,可以為一個事件繫結多個處理程式,並且按照繫結時的順序去執行
//例項2
div.addEventListener('click' , function f() {} , false); //1
div.addEventListener('click' , function f() {} , false); //2
//事件1和事件2雖然執行的函式一樣,但是函式f()的地址不一樣,所以是2個處理函式,執行2次
//例項3
function f() {};
div.addEventListener('click' , f , false); //1
div.addEventListener('click' , f , false); //2
//事件1和事件2執行的函式都是f(),但因為地址一樣,所以只執行一次。即某一個元素的同一個行為只能繫結一次相同的方法

1.1、DOMContentLoaded和loaded

DOM 2還提供了DOM 0中沒有的行為型別 -> DOMContentLoaded:當頁面中的DOM結構(HTML結構)載入完成觸發的行為。而onload事件則是當頁面中的所有資源全部載入完成(圖片、html結構、音視訊...)才會被執行。

jQuery中的$(document).ready(function () {}),等價於$(function () {}),事件原理就是DOM2中新增的DOMContentLoaded事件。

1.2、事件的移除

DOM 0級事件的移除:div.onclick = null;

DOM 2級事件的移除:

function fn() {};
div.addEventListener('click',fn,false);
div.removeEventListener('click',fn,false);

1.3、DOM2事件機制

  • 只能給某個元素的同一個行為繫結多個"不同"的方法
  • 當行為觸發,會按照繫結的先後順序把繫結的方法執行
  • 執行的方法中的this是當前繫結事件的元素本身

1.4、IE6-8下的事件機制

在IE6-8瀏覽器中,不支援addEventListener/removeEventLiatener,如果想實現DOM 2事件繫結,只能用attachEvent(),移除用detachEvent()。

 obj.attachEvent('on'+type , func);只能在冒泡階段發生,一個事件同樣可以繫結多個處理函式。與obj.addEventListener('type' , func , false)不一樣的是,即使函式的地址是一樣的,繫結多少次就執行多少次。即同一個函式可以繫結多次

與標準瀏覽器的事件池機制對比:

  • this問題:IE6-8中當方法執行的時候,方法中的this不是當前元素,而指的是window
  • 重複問題:可以給同一個元素的同一個行為繫結多個相同的方法
  • 順序問題:執行的時候順序是混亂的,標準瀏覽器是按照繫結順序依次執行

2、處理this問題

/*
 *    bind: 處理DOM2級事件繫結的相容性問題
 *    @parameter:
 *    curEle: 要繫結事件的元素
 *    eventType: 要繫結的事件型別('click','mouseover'...)
 *    eventFn: 要繫結的方法
 */
 var tempFn = {};
 function bind(curEle,eventType,eventFn){
    if ("addEventListener" in document) {//標準瀏覽器
        curEle.addEventListener(eventType,eventFn,false);
        return ;
    }
    var tempFn[eventFn] = function () {
        eventFn.call(curEle);
    };
    curEle.attachEvent("on" + eventType,tempFn);
 }
 
 function unbind(curEle,eventType,eventFn) {
    if ("removeEventListener" in document) {
        curEle.removeEventListener(eventType,eventFn,false);
        return ;
    }
    curEle.detachEvent("on" + eventType,tempFn[eventFn]);
 }

//分析
 
// 1、知若想改變IE下事件執行函式的this的指向,可以在函式執行的時候改變this,即用函式eventFn.call('curEle'),這樣雖然解決了this指向的問題,
   但又丟擲了一個新的問題:即不知道該如何移除該事件函式,因為繫結的是一個匿名函式,而匿名函式的地址我們是無法知道的。
  所以要先把匿名函式定義時的地址賦值給一個變數temp var tempFn = function () { eventFn.call('curEle'); }; //2、為什麼要把tempFn設定成一個全域性變數 若tempFn不是一個全域性變數,而是寫在函式內部的私有變數,而私有變數只能在函式內部進行訪問,
所以我們在bind()函式裡的tempFn在unbind()函式裡是不能訪問的,因此也就不能移除該事件函式。所以若想移除該事件函式,tempFn就必須是全域性變數

擴充:我們知道:寫在函式內部的變數是私有變數,一個函式的私有變數只能在函式的內部進行訪問。

  • 若在一個函式裡要用到另一個函式裡的變數,可以把該變數設定成全域性的變數,這樣兩個函式都可以訪問到。(這可能會造成全域性汙染)
  • 若幾個函式的作用是為同一個/同一類元素提供方法去使用,且不同方法中要用到其它方法裡的變數等,那麼可以用該元素的自定義變數來儲存這些變數,就可以實現在不同方法中的訪問。(不會造成全域性汙染)

上面的程式碼除了全域性變數可能造成汙染外。還有一種不得不考慮的情況就是:當為不同的事件繫結方法時,不同的事件可能執行相同的方法(如mouseover 和 click 都執行fn1方法時),如果仍然將這些方法儲存在一起,那麼移除某一類事件的方法時就可能出錯,因此我們需要為不同的事件建立不同的陣列來儲存繫結在其上的方法。

所以我們需要對程式碼進行進一步的優化。

function bind(curEle,eventType,eventFn){
        ...
        var tempFn = function () {
            eventFn.call('curEle');
        };
        tempFn.photo = eventFn;//給傳入的每一個函式做一個唯一標識
        //首先判斷該自定義屬性之前是否存在,不存在的話建立一個,由於要儲存多個方法,所以我們讓其值是一個陣列
        //為什麼要對不同的事件型別建立不同的陣列呢,因為不同的事件可能執行相同的方法。如mouseover 和 click 都執行fn1方法時,移除的時候就可能出錯
        if (!curEle['bindFn' + eventType]) {
            curEle['bindFn' + eventType] = [];
        }
        curEle['bindFn' + eventType].push(tempFn);
        curEle.attachEvent("on" + eventType,tempFn);
    }
 
    function unbind(curEle,eventType,eventFn) {
        ...
        var arr = curEle['bindFn' + eventType];
        for (var i = 0; i < arr.length; i ++) {
            if (arr[i].photo === eventFn) {
                arr.splice(i,1);//找到後,把自己儲存容器中對應的移除掉,與事件池中保持一致
                curEle.detachEvent("on" + eventType,arr[i]);//把事件池中對應的方法移除掉
                break;
            }
        }
    }

3、處理重複問題

function bind(curEle,eventType,eventFn){
        if ("addEventListener" in document) {
            //省略程式碼
        }
        //省略程式碼
        //處理重複問題:如果每一次往自定義屬性新增方法前,看一下是否已經有了,有的話就不用重複新增,同理,也就不用往事件池裡儲存了
        var arr = curEle['bindFn' + eventType];
        for (var i = 0; i < arr.length ;i ++) {
            if (arr[i].photo === eventFn) {
                return ;
            }
        }
        arr.push(tempFn);
        curEle.attachEvent("on" + eventType,tempFn);
    }

4、處理順序問題

我們知道在IE6-8下,事件的執行順序是無序的,這是由瀏覽器的事件池機制所決定的。所以要改善這個問題,我們模仿標準瀏覽器的事件執行順序,可以自己寫一個事件池來使方法的執行順序有序執行。聽起來有點繞,我們來看一下具體的實現就清楚了。

  //建立自己的事件池,並把需要給當前元素繫結的方法依次增加到事件池中
    function on(curEle,eventType,eventFn) {
        if (!curEle['myEvent' + eventType]) {
            curEle['myEvent' + eventType] = [];
        }
        var arr = curEle['myEvent' + eventType];
        for (var i = 0; i < arr.length; i ++) {
            if (arr[i] === eventFn) return ;
        }
        arr.push(eventFn)
        bind(curEle,eventType,run);//把run方法繫結到自定義的bind()函式中,這個bind函式解決了this指向和重複問題。因此繫結後run方法的this指向當前點選元素
    }
 
   //在自己的事件池中把某一個方法移除
    function off(curEle,eventType,eventFn) {
        var arr = curEle['myEvent' + eventType];
        for (var i = 0; i < arr.length; i ++) {
            if (arr[i] === eventFn) {
                arr.splice(i,1);
            }
        }
    }
 
    //由於IE6-8瀏覽器DOM2級事件執行多個繫結方法時會出現順序混亂,我們就只給它繫結一個run方法,然後在run方法裡執行事件池on裡繫結的方法。
    function run(event) {
        event = event || window.event;
        var flag = event.target ? true :false ;//IE6-8下不相容event.target
        if (!flag) {//做非相容處理
            event.target = window.srcElement;
            event.pageX = event.clientX + document.documentElement.scrollLeft;
            event.pageY = event.clentY +document.documentElement.scrollTop;
            event.preventDefault = function () {
                event.returnValue = false ;
            }
            event.stopPropagation = function () {
                event.cancleBubble = true ;
            }
        }
        //獲取事件池中繫結的方法,並且讓這些方法依次執行
        var arr = event.target['myEvent' + event.type];
        for (var i = 0; i < arr.length; i ++) {
            arr[i].call(event.target,event);//把事件物件傳遞給當前執行的函式
        }    
    }

5、一個完整的DOM庫

以上就是對封裝整個DOM庫的思考,可見分析的整個過程是多麼的煎熬。然而整個的DOM庫封裝後,程式碼卻少的可憐。我們一起來看一下。

//繫結事件
function
on(ele,type,fn) { if(ele.addEventListener) { ele.addEventListener(type,fn,false); } else{ if (!ele['myEvent' + type]) { ele['myEvent' + type] = []; ele.attachEvent('on' + type,function(){//在這裡繫結run方法 run.call(ele); }) } let arr = ele['myEvent' + type]; for(let i = 0; i < arr.length; i++) { if (arr[i] == fn) { return; } } arr.push(fn); } }
//解決IE下事件執行順序的run方法
function run() { let e = window.event;//在IE6-8下,事件物件是儲存在全域性的event屬性上的 e.target = e.srcElement; e.preventDefault = function () { e.returnValue = false ; } e.stopPropagation = function () { e.cancleBubble = true ; } let arr = this['myEvent' + event.type]; for(let i = 0; i < arr.length; i++) { if(arr[i] == null) {//在這裡刪除被解綁的方法 arr.splice(i,1); i--; } arr[i].call(this,event); } }
//解除事件
function off(ele,type,fn) { if (ele.removeEventListener) { ele.removeEventListener(type,fn,false); } else { let arr = ele["myEvent" + type]; for(let i = 0; i < arr.length; i++) { if (arr[i] == fn) { arr[i] = null; //arr.splice(i,1);這裡為什麼不能直接刪除掉,而是要用null來佔位。答案是:為了不改變arr的長度。使run能正確執行。 return; } } } }

相關文章