javascript事件機制底層實現原理

風靈使發表於2018-06-03

前言

又到了扯淡時間了,我最近在思考javascript事件機制底層的實現,但是暫時沒有勇氣去看chrome原始碼,所以今天我來猜測一把

我們今天來猜一猜,探討探討,javascript底層事件機制是如何實現的

部落格裡面關於事件繫結與執行順序一塊理解有誤,請看最新部落格

基礎知識

事件捕獲/冒泡

我們點選一個span,我可能就想點選一個span,事實上他是先點選document,然後點選事件傳遞到span的,而且並不會在span停下,span有子元素就會繼續往下,最後會依次回傳至document,我們這裡偷一張圖:
這裡寫圖片描述
我們這裡偷了一張圖,這張圖很好的說明了事件的傳播方式

事件冒泡即由最具體的元素(文件巢狀最深節點)接收,然後逐步上傳至document

事件捕獲會由最先接收到事件的元素然後傳向最裡邊(我們可以將元素想象成一個盒子裝一個盒子,而不是一個積木堆積)

這裡我們進入dom事件流,這裡我們詳細看看javascript事件的傳遞方式
DOM事件流

DOM2級事件規定事件包括三個階段:

① 事件捕獲階段

② 處於目標階段

③ 事件冒泡階段

事件物件

所謂事件物件,是與特定物件相關,並且包含該事件詳細資訊的物件。

事件物件作為引數傳遞給事件處理程式(IE8之前通過window.event獲得),所有事件物件都有事件型別type與事件目標targetIE8之前的srcElement我們不關注了)

各個事件的事件引數不一樣,比如滑鼠事件就會有相關座標,包含和建立他的特定事件有關的屬性和方法,觸發的事件不一樣,引數也不一樣(比如滑鼠事件就會有座標資訊),我們這裡題幾個較重要的

PS:以下的兄弟全部是隻讀的,所以不要妄想去隨意更改,IE之前的問題我們就不關注了
bubbles

表明事件是否冒泡
cancelable

表明是否可以取消事件的預設行為
currentTarget

某事件處理程式當前正在處理的那個元素
defaultPrevented

為true表明已經呼叫了preventDefault(DOM3新增)
eventPhase

呼叫事件處理程式的階段:1 捕獲;2 處於階段;3 冒泡階段

這個屬性的變化需要在斷點中檢視,不然你看到的總是0
target

事件目標(繫結事件那個dom)
trusted

true表明是系統的,false為開發人員自定義的(DOM3新增)
type

事件型別
view

與事件關聯的抽象檢視,發生事件的window物件
preventDefault

取消事件預設行為,cancelabletrue時可以使用
stopPropagation

取消事件捕獲/冒泡,bubblestrue才能使用
stopImmediatePropagation

取消事件進一步冒泡,並且組織任何事件處理程式被呼叫(DOM3新增)

在我們的事件處理內部,thiscurrentTarget相同

模擬javascript事件機制

在此之前,我們來說幾個基礎知識點

dom唯一標識

在頁面上的dom,每個dom都應該有其唯一標識——_zid(我們這裡統一為_zid)/sourceIndex,但是多數瀏覽器可能認為,這個介面並不需要告訴使用者所以我們都不能獲得

但是IE將這個介面放出來了——sourceIndex

我們這裡以百度首頁為例:

var doms = document.getElementsByTagName('*');
var str = '';
for (var i = 0, len = doms.length; i < len; i++) {
    str += doms[i].tagName + ': ' + doms[i].sourceIndex + '\n';
}

這裡寫圖片描述

這裡寫圖片描述

可以看到,越是上層的_zid越小

其實,dom _zid生成規則應該是以樹的正序而來(好像是吧…..),反正是從上到下,從左到右

有了這個後,我們來看看我們如何獲得一個dom的註冊事件集合

獲取dom註冊事件集合

比如我們為一個dom同時繫結了2個click事件,又給他繫結一個keydown事件,那麼對於這個dom來說他就具有3個事件了

我們有什麼辦法可以獲得一個dom註冊的事件呢???

答案很遺憾,瀏覽器都沒有放出api,所以我們暫時不能知道一個dom到底被註冊了多少事件……

PS:如果您知道這個問題的答案,請留言

有了以上兩個知識點,我們就可以開始今天的扯淡了

注意:下文進入猜想時間
補充點

這裡通過園友 JexCheng 的提示,其實一些瀏覽器是提供了獲取dom事件節點的方法的

DOM API是沒有。不過瀏覽器提供了一個除錯用的介面。
Chrome在console下可以執行下面這個方法:
getEventListeners(node),
獲得物件上繫結的所有事件監聽函式。

注意,是在console裡面執行getEventListeners方法
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
  <title></title>
</head>
<body>
<div id="d">ddssdsd</div>
  <script type="text/javascript">
    var node = document.getElementsByTagName('*');
    var d = document.getElementById('d');
    d.addEventListener('click', function () {
      alert();
    }, false);
    d.addEventListener('click', function () {
      alert('我是第二次');
    }, false);
    d.onclick = function () {
      alert('不規範的繫結');
    }
    d.addEventListener('click', function () {
      alert();
    }, true);

    d.addEventListener('mousedown', function () {
      console.log('mousedown');
    }, true);
    var evets = typeof getEventListeners == 'function' && getEventListeners(d)
  </script>
</body>
</html>

以上程式碼在chrome中的console結果為:
這裡寫圖片描述
可以看到,無論何種繫結,這裡都是可以獲取的,而且獲取的物件與我們模擬的物件比較接近

事件註冊發生的事

首先,我們為dom註冊事件的語法是:

dom.addEventListener('click', function () {
    alert('ddd');
})

以上述程式碼來說,我作為瀏覽器,以這個程式碼來說,在註冊階段我便可以儲存以下資訊:

<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title></title>
    <style type="text/css">
         #p { width: 300px; height: 300px; padding: 10px;  border: 1px solid black; }
         #c { width: 100px; height: 100px; border: 1px solid red; }
    </style>
</head>
<body>
    <div id="p">
        parent
        <div id="c">
            child
        </div>
    </div>
    <script type="text/javascript">
        var p = document.getElementById('p'),
        c = document.getElementById('c');
        c.addEventListener('click', function () {
            alert('子節點捕獲')
        }, true);

        c.addEventListener('click', function () {
            alert('子節點冒泡')
        }, false);

        p.addEventListener('click', function () {
            alert('父節點捕獲')
        }, true);

        p.addEventListener('click', function () {
            alert('父節點冒泡')
        }, false);
    </script>
</body>
</html>

這裡寫圖片描述

這裡,我們為parentchild繫結了click事件,所以瀏覽器可以獲得如下佇列結構:

/****** 第一步-註冊事件 ******/
//頁面事件儲存在一個佇列裡
//以_zid排序
var eventQueue = [
  {
    _zid: 'parent',
    handlers: {
      click: {
        captrue: [fn, fn],
        bubble: [fn, fn]
      }
    }
  },
  {
    _zid:'child',
    handlers:{
      click: {
        captrue: [],
        bubble: []
      }
    }
  },
  {
    _zid: '_zid',
    handlers: {
    //……
    }
  }
];

就那parent這個div來說,我們為他繫結了兩個click事件(我們其實可以繫結3個4個或者更多,所以事件集合是一個陣列,執行具有先後順序)

其中註冊事件時候,又會分冒泡和捕獲,而且這裡以_zid排序(比如:document->body->div#p->div#c

然後第一個階段就結束了

PS:我想底層c++語言一定有類似的這個佇列,而且可以釋放介面,讓我們獲取一個dom所註冊的所有事件

注意,此處佇列是這樣,但是我們真正點選一個元素,可能就只抽取其中一部分關聯的物件組成一個新的佇列,供下面使用

初始化事件引數

第二步就是初始化事件引數,我們可以通過addEventListener,建立事件引數,但是我們這裡簡單模擬即可:

注意,為了方便理解,我們這裡暫不考慮mousedown

/****** 第二步-初始化事件引數 ******/
var Event = {};
Event.type = 'click';
Event.target = el;//當前手指點選最深dom元素
//初始化資訊
//......
//滑鼠位置資訊等

在這裡比較關鍵的就是我們一定要好好定義我們的target!!!

於是可以進入我們的關鍵步驟了,觸發事件

觸發事件

事件觸發分三步走,首先是捕獲然後是處於階段最後是冒泡階段:

/****** 第三步-觸發事件 ******/
var isTarget = false;
Event.eventPhase = 1;
//首先是捕獲階段,事件執行至event.target為止,我們這裡只關注click
for (var index = 0, length = eventQueue.lenth; index < length; index++) {
  //獲取捕獲時期該元素的click事件集合
  var clickHandlers = eventQueue[index].handlers.click.captrue;
  for (var i = 0, len = clickHandlers.length; i < len; i++) {
    Event.currentTarget = clickHandlers[i]; //事件處理程式當前正在處理的那個元素
    //執行至target便跳出迴圈,不再執行下面的操作
    if (Event.target._zid == eventQueue[index]._zid) {
      Event.eventPhase = 2;//當前階段
      isTarget = true;
    }
    //執行繫結事件
    clickHandlers[i](Event);
    //如果阻止冒泡,跳出所有迴圈,不執行後面的事件
    if (Event.bubbles) {
      return;
    }
  }
  //若是當前已經是target便不再向下捕獲
  if(isTarget) break;
}
Event.eventPhase = 3;
//冒泡階段
for(var index = eventQueue.lenth; index !=0; index--) {
  //如果zid小於等於當前元素,說明不需要處理
  if(eventQueue[index]._zid <= Event.target._zid) continue;
  //需要處理的部分了
  var clickHandlers = eventQueue[index].handlers.click.bubble;

  //此段程式碼可以重構,暫時不管
  for (var i = 0, len = clickHandlers.length; i < len; i++) {
    Event.currentTarget = clickHandlers[i]; //事件處理程式當前正在處理的那個元素
    //執行繫結事件
    clickHandlers[i](Event);
    //如果阻止冒泡,跳出所有迴圈,不執行後面的事件
    if (Event.bubbles) {
      return;
    }
  }
}

這個註釋寫的很清楚了應該能表達清楚我的意思,於是我們這裡就簡單的模擬了事件機制的底層原理了:)

PS:如果您覺得不對,請留言

驗證猜想

現在,基礎理論提出來了,我們需要驗證下這個想法是否站得住腳,所以這裡提了幾個例子,首先我們回到上面的問題吧
驗證一:點選問題

http://sandbox.runjs.cn/show/pesvelp1

首先我們來看這個問題,我們分別為parentchild註冊了兩個click事件,一次冒泡一次捕獲

當我們點選父元素時,我們按照理論的執行邏輯如下:

開始遍歷事件佇列(由document開始)

當遍歷物件如果註冊了click事件就會觸發,如果阻止了冒泡,執行後便跳出迴圈不再執行

因為之前並沒有註冊事件,所以直接到了parent,這裡發現parent_zidtarget_zid相等

於是便將狀態置為處於目標階段,並打上標記跳出捕獲迴圈,不再執行後面的事件控制程式碼

Event.eventPhase = 2;//當前階段
isTarget = true;

捕獲結束後,開始執行冒泡的事件,迴圈由後向前,開始是childclick事件,但是此時child_zid大於target_zid所以繼續迴圈

最後會執行parent以上的dom註冊的click事件,沒有就算了

至於點選child的邏輯我們這裡就不分析了

驗證二:突然移除dom

我們這裡對上題做一個變形,我們在parent點選時候(捕獲階段)將child div給刪除,看看有什麼情況

http://sandbox.runjs.cn/show/f1ke5vp8

<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title></title>
    <style type="text/css">
         #p { width: 300px; height: 300px; padding: 10px;  border: 1px solid black; }
         #c { width: 100px; height: 100px; border: 1px solid red; }
    </style>
</head>
<body>
    <div id="p">
        parent
        <div id="c">
            child
        </div>
    </div>
    <script type="text/javascript">
        var p = document.getElementById('p'),
        c = document.getElementById('c');
        c.addEventListener('click', function () {
            alert('子節點捕獲')
        }, true);

        c.addEventListener('click', function () {
            alert('子節點冒泡')
        }, false);

        p.addEventListener('click', function () {
            alert('父節點捕獲')
            p.removeChild(c);
        }, true);

        p.addEventListener('click', function () {
            alert('父節點冒泡')
        }, false);
    </script>
</body>
</html>

其實這裡還有一個優化點,相信大家都知道:

移除dom並不會移除事件控制程式碼,這個必須手動釋放

就是因為這個原因,我們的整個邏輯仍然會執行,各位自己可以試試

驗證三:child阻止冒泡

我們這裡再將上題稍加變形,在child 冒泡階段組織冒泡,其實這個不用說,parentclick不會執行

<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title></title>
    <style type="text/css">
         #p { width: 300px; height: 300px; padding: 10px;  border: 1px solid black; }
         #c { width: 100px; height: 100px; border: 1px solid red; }
    </style>
</head>
<body>
    <div id="p">
        parent
        <div id="c">
            child
        </div>
    </div>
    <script type="text/javascript">
        var p = document.getElementById('p'),
        c = document.getElementById('c');
        c.addEventListener('click', function () {
            alert('子節點捕獲')
        }, true);

        c.addEventListener('click', function (e) {
            alert('子節點冒泡')
            e.stopPropagation();
        }, false);

        p.addEventListener('click', function () {
            alert('父節點捕獲')
        }, true);

        p.addEventListener('click', function () {
            alert('父節點冒泡')
        }, false);
    </script>
</body>
</html>

驗證四:模擬click事件

<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title></title>
    <style type="text/css">
         #p { width: 300px; height: 300px; padding: 10px;  border: 1px solid black; }
         #c { width: 100px; height: 100px; border: 1px solid red; }
    </style>
</head>
<body>
    <div id="p">
        parent
        <div id="c">
            child
        </div>
    </div>
    <script type="text/javascript">
        alert = function (msg) {
            console.log(msg);
        }

        var p = document.getElementById('p'),
        c = document.getElementById('c');
        c.addEventListener('click', function (e) {
            console.log(e);
            alert('子節點捕獲')
        }, true);
        c.addEventListener('click', function (e) {
            console.log(e);
            alert('子節點冒泡')
        }, false);

        p.addEventListener('click', function (e) {
            console.log(e);
            alert('父節點捕獲')
        }, true);

        p.addEventListener('click', function (e) {
            console.log(e);
            alert('父節點冒泡')
        }, false);

        document.addEventListener('keydown', function (e) {
            if (e.keyCode == '32') {
                var type = 'click'; //要觸發的事件型別
                var bubbles = true; //事件是否可以冒泡
                var cancelable = true; //事件是否可以阻止瀏覽器預設事件
                var view = document.defaultView; //與事件關聯的檢視,該屬性預設即可,不管
                var detail = 0;
                var screenX = 0;
                var screenY = 0;
                var clientX = 0;
                var clientY = 0;
                var ctrlKey = false; //是否按下ctrl
                var altKey = false; //是否按下alt
                var shiftKey = false;
                var metaKey = false;
                var button = 0; //表示按下哪一個滑鼠鍵
                var relatedTarget = 0; //模擬mousemove或者out時候用到,與事件相關的物件
                var event = document.createEvent('Events');
                event.myFlag = '葉小釵';
                event.initEvent(type, bubbles, cancelable, view, detail, screenX, screenY, clientX, clientY,
ctrlKey, altKey, shiftKey, metaKey, button, relatedTarget);

                console.log(event);
                c.dispatchEvent(event);
            }
        }, false);
    </script>
</body>
</html>

http://sandbox.runjs.cn/show/pesvelp1

我們最後模擬一下click事件,這裡按空格便會觸發childclick事件,這裡依然走我們上述邏輯

所以,我們今天到此為止

結語

今天,我們一起模擬猜測了javascript事件機制的底層實現,這裡只做了最簡單最單純的模擬

比如兩個平級dom(div)點選時候這裡的演算法就有一點問題,但是無傷大雅,探討嘛,至於事情的真相如何,這裡就只能拋磚引玉了。

相關文章