深入理解事件機制的實現
一、一個例項
假設你在家客廳裡玩遊戲,口渴了,需要到廚房開一壺水,等水開了的時候,為了防止水熬幹,你需要及時把火爐關掉。為了及時瞭解到水是否燒開,你有三種策略可以選擇:
1. 守在廚房內,等水燒開
這種策略顯然是很愚蠢的,採取這種策略,在燒水的過程中你將不能做任何事情,效率極低。
2. 呆在客廳玩遊戲,每隔一兩分鐘跑到廚房看一次
這種策略,在電腦科學中稱為輪詢,即每隔一定的時間,監測一次。在這裡,也是很不明智的,在玩遊戲時需要不斷的分心。
3. 在水壺上安裝一個報警器,當水開了的時候,發出警報
這種策略是最好的,既不耽誤自己玩遊戲,又能在水開了的時候使自己及時獲得通知。這種策略在計算機中通過事件機制來實現。
二、事件機制的組成
通過上面的例項,我們可以抽象出一個事件機制有三個組成部分:
1.事件源:即事件的傳送者,在上例中為水壺;
2.事件:事件源發出的一種資訊或狀態,比如上例的警報聲,它代表著水開了;
3.事件偵聽者:對事件作出反應的物件,比如上例中的你。在設計事件機制時一般把偵聽者設計為一個函式,當事件傳送時,呼叫此函式。比如上例中可以把倒水設計為偵聽者。
三、初步實現
可以使用面向物件設計中的組合模式,把事件偵聽者當做事件源內部的一個物件,當事件發生時,呼叫偵聽者即可:
1 事件源:水壺{ 2 事件偵聽者:你關火;//事件源持有事件偵聽者 3 4 傳送(事件:“水開了”){ 5 你關火(); 6 } 7 }
四、出現多個事件偵聽者的情況
如果你和你女朋友都在客廳玩遊戲,水開的時候應該誰去關火呢?假設精明(懶惰)的你,聽到水開的警報聲,馬上假裝上廁所,你女朋友只能無奈地去關火。這種情況下,水壺發出的警報聲導致了兩個反應:1.你上廁所,2.你女朋友去關火。此時我們要如何實現呢?我們依然可以採用上面的實現方案,再在事件源中新增一個事件偵聽者:
1 事件源:水壺{ 2 事件偵聽者:你上廁所; 3 事件偵聽者:你女朋友關火; 4 5 傳送(事件:“水開了”){ 6 你上廁所(); 7 你女朋友關火(); 8 } 9 10 }
但這種設計有一個重大缺陷:事件源和事件偵聽者過度耦合。所有偵聽者都是硬編碼入事件源中,在程式執行過程中無法更改,靈活性極差。比如,有一天你女朋友外出了,只能你去關火,那麼上面的事件源就需要重新修改。我們可以採用下面的方法使事件源和事件偵聽者解耦:
1.事件源中定義一個列表,比如陣列,用來儲存所有偵聽者;
2.為列表留一個增刪資料的介面,用來隨時新增和刪除偵聽者;
3.當傳送事件時,遍歷並執行列表中的偵聽者
實現如下:
1 事件源:水壺{ 2 3 事件偵聽者:偵聽者列表[]; 4 5 新增事件偵聽者(偵聽者){ 6 偵聽者列表加入偵聽者 7 } 8 刪除事件偵聽者(偵聽者){ 9 偵聽者列表移除偵聽者 10 } 11 12 傳送(事件){ 13 //遍歷並執行列表中的偵聽者 14 for(偵聽者 in 偵聽者列表){ 15 執行偵聽者 16 } 17 } 18 19 }
這種實現方案即為觀察者設計模式,可以讓偵聽者預訂事件。
五、事件源可傳送多種事件的情況
假設你家的水壺有點智慧,當水溫達到90度的時候,會發出一個“水快開了”的警報,為你提前逃到廁所偷懶留出了充足的時間,這種情況下的事件和偵聽者的對應關係如下:
我們可以在新增和刪除偵聽者的時候,把事件型別和偵聽者繫結成一個數組(或物件),再加入偵聽者列表。在傳送事件時,在列表中查詢和當前事件繫結的偵聽器執行:
事件源:水壺{ 事件偵聽者:偵聽者列表[]; 新增事件偵聽者(事件型別,偵聽者){ 帶型別偵聽者=[事件型別,偵聽者];//通過陣列把事件型別和偵聽者繫結 偵聽者列表加入帶型別偵聽者; } 刪除事件偵聽者(事件型別,偵聽者){ 通過事件型別和偵聽者查詢列表中對應的偵聽器刪除; } 傳送(事件型別){ //遍歷並執行列表中的偵聽者 for(帶型別偵聽者 in 偵聽者列表){ if(帶型別偵聽者[0]==事件型別){ 帶型別偵聽者[1]()//執行對應的偵聽器 } } } }
把上面的文字描述翻譯成偽碼如下:
1 //水壺類 2 Kettle{ 3 4 array:Listeners[]; 5 6 addEventListener(eventType,listener){ 7 typeListener=[eventType,listener];//通過陣列把事件型別和偵聽者繫結 8 Listeners.push(typeListener); 9 } 10 11 removeEventListener(eventType,listener){ 12 Listeners.delete([eventType,listener]); 13 } 14 15 dispatch(eventType){ 16 //遍歷並執行列表中的偵聽者 17 for(typeListener in Listeners){ 18 if(typeListener[0]==eventType){ 19 typeListener[1]()//執行對應的偵聽器 20 } 21 } 22 } 23 24 } 25 26 goWc(){ 27 //你上廁所 28 } 29 30 turnOffFire(){ 31 //女朋友關火 32 } 33 34 kettle=new Kettle(); 35 //水壺註冊水快開了事件 36 kettle.addEventListener("水快開了",goWC); 37 kettle.addEventListener("水開了",turnOffFire); 38 kettle.dispatch("水快開了");
優化:遵循"針對介面程式設計"的設計原則,應該為水壺、事件、偵聽器設計一個基類,其他具體的類繼承這些基類;
六、顯示物件上的事件:理解事件流
當事件發生在顯示物件上(比如瀏覽器)的時候,會遇到一個很有趣的問題:頁面的那一部分會擁有某個特定的事件?比如當你點選頁面上的一棟小房子的時候,根據視角的遠近,你點選的物件會發生變化。從最遠處來看你點選的是頁面,鏡頭拉近你點選的是小房子,再拉近你點選的是房子上的一面牆,再拉近你點選的是牆上的一塊磚。也就是說,你點選一次頁面也許會有很多顯示物件發生了點選事件,如果你在每一個顯示物件上都綁定了點選處理程式,那麼這些程式都會執行。這裡會遇到一個問題:這些程式按什麼順序執行。這取決於顯示物件接受到點選事件的順序,一般有兩種模式:事件冒泡和事件捕獲。這種事件在顯示物件上按順序發生的過程稱為事件流。
1. 事件冒泡
事件冒泡,即事件開始時由最具體的元素(比如上例的磚塊)接受,然後逐級向上傳播到較為不具體的節點(文件);
2. 事件捕獲
事件捕獲的思想是不太具體的元素(文件)更早的接受事件,而最具體的元素最後接受到事件(磚塊)。事件捕獲的用意在於事件到達預訂目標之間捕獲它。
在JavaScript中為DOM中的元素新增事件處理程式時,有三個引數,其中第三個引數是一個布林值,當為true時,表示在捕獲階段呼叫事件處理程式,為false時,表示在冒泡階段呼叫事件處理程式,舉例如下:
1 <body> 2 <div id="outer"> 3 <div id="inner" > 4 </div> 5 </div> 6 </body> 7 8 //例一 9 var btn1=document.getElementById("outer"); 10 btn1.addEventListener("click",function(){ 11 alert('outer') 12 },false); 13 14 var btn2=document.getElementById("inner"); 15 btn2.addEventListener("click",function(){ 16 alert('inner') 17 },false); 18 19 //例二 20 var btn1=document.getElementById("outer"); 21 btn1.addEventListener("click",function(){ 22 alert('outer') 23 },false); 24 25 var btn2=document.getElementById("inner"); 26 btn2.addEventListener("click",function(){ 27 alert('inner') 28 },false);
上面例一的事件處理程式都發生在冒泡階段,所以會先輸出inner,再輸出outer。例二中id為outer元素上的事件處理程式發生在捕獲階段,所以會先輸出outer,再輸出inner。
注意:事件流發生在父元素和子元素之間,而不是兩個同級的元