前言
近幾年來,在移動端上因原生開發成本高和效率低而導致湧現出來的一大批優秀前端框架,以及專門針對移動端裝置的前端開發框架(如 RN/Weex),大前端的概念被不斷地提及。在這樣的背景之下,前端技術也將逐漸成為移動端開發者的必備技能。筆者作為一名移動端開發者,在接觸了前端開發之後,發現了雖然前端相較於移動端有著很大的不同,不過前端有不少值得移動端學習的地方,並且兩者在不少方面也有著相似之處。在大前端的話題圈裡,有不少共同的話題,例如:MVC和MVVM架構、元件化、響應式程式設計、工程化(打包工具、包管理工具)等等。筆者打算從前端和移動端(以iOS平臺為例)的事件機制談起,對比兩端的實現方法有哪些相同和不同之處,同時也算是對前端與移動端的事件機制做一些總結吧。
事件機制
無論是前端還是移動端,使用者在瀏覽網頁或者APP時,通常會在螢幕上產生很多互動操作,例如點選、選擇、滾動螢幕、鍵盤輸入等待,並且網頁或APP也會根據不同的操作進行響應變化。這種基於事件的處理方式,本質上是一種訊息傳遞機制,稱之為事件機制。
在事件機制中,有3樣最重要的東西:
- 事件生產者
- 事件物件
- 事件消費者
事件生產者可以產生一系列的事件物件,然後事件物件攜帶著必要的資訊,傳遞給事件消費者。
前端篇
一、事件流及事件繫結
EMCAScript標準規定事件流包含三個階段,分別為事件捕獲階段,處於目標階段,事件冒泡階段。
<html>
<body>
<div>
<button id="mybtn" onclick="buttonClickHandler(event)">點我試試</button>
</div>
</body>
</html>
<script>
function buttonClickHandler(event) {
console.log('button clicked')
}
</script>
複製程式碼
在上面的程式碼中,如果點選按鈕button,則標準事件觸發分別經歷以下三個階段:
target.addEventListener(type, listener, useCapture);
// 標準註冊事件函式
// target:文件節點、document、window 或 XMLHttpRequest。
// 函式的引數:註冊事件型別type,事件的回撥函式,事件註冊在捕獲期間還是冒泡期間
// 例如:給button註冊onclick事件,要是在捕獲階段註冊,則 button.addEventListener("click",function(){},true);
target.removeEventListener(type, listener, useCapture); //在某一個元素上撤銷已註冊的事件。
複製程式碼
下面看一個Chrome瀏覽器中的例子:
<html>
<head>
<style>
ul{
background : gray;
padding : 20px;
}
ul li{
background : green;
}
</style>
</head>
<body>
<ul>
<li>點我試試</li>
</ul>
<script>
var ul = document.getElementsByTagName('ul')[0];
var li = document.getElementsByTagName('li')[0];
document.addEventListener('click',function(e){console.log('document clicked')},true);//第三個引數為true使用捕獲
ul.addEventListener('click',function(e){console.log('ul clicked')},true);
li.addEventListener('click',function(e){console.log('li clicked')},true);
</script>
</body>
</html>
複製程式碼
以上程式碼中,我們建立了一個列表項,點選“點我試試”,看看會有什麼情況發生:
document clicked
ul clicked
li clicked
複製程式碼
在我們的開發者工具控制檯上,可以看到列印出了這樣三行結果,這是我們預料之中的事情,因為在這裡事件捕獲起了作用,點選事件依次觸發了document、ul節點、li節點。
而在IE中只支援冒泡機制,所以只能在冒泡階段進行事件繫結以及事件撤銷:
target.attachEvent(type, listener); //target: 文件節點、document、window 或 XMLHttpRequest。
//函式引數: type:註冊事件型別;
// listener:事件觸發時的回撥函式。
target.detachEvent(type,listener); //引數與註冊引數相對應。
複製程式碼
下面看一個IE瀏覽器裡的例子:
<html>
<body>
<ul>
<li>點我試試</li>
</ul>
<script>
var ul = document.getElementsByTagName('ul')[0];
var li = document.getElementsByTagName('li')[0];
document.attachEvent('onclick',function(event){console.log('document clicked')})
ul.attachEvent('onclick',function(event){console.log('ul clicked')});
li.attachEvent('onclick',function(event){console.log('li clicked')});
</script>
</body>
</html>
複製程式碼
同樣地,我們點選“點我試試”,開發者工具控制檯裡列印出了下面的結果:
li clicked
ul clicked
document clicked
複製程式碼
然而有時候事件的捕獲機制以及冒泡機制也會帶來副作用,比如冒泡機制會觸發父節點上原本並不希望被觸發的監聽函式,所以有辦法可以使得冒泡提前結束嗎?我們只需要在希望事件停止冒泡的位置,呼叫event物件的stopPropagation函式(IE瀏覽器中為cancelBubble)即可終止事件冒泡了。比如在上面IE瀏覽器中示例程式碼作如下修改:
li.attachEvent('onclick',function(event){
console.log('li clicked');
event.cancelBubble=true;
});
複製程式碼
修改後,再次點選“點我試試”,在控制檯裡只列印出一行結果,ul節點和document不會再接收到冒泡上來的click事件,因而它們註冊的事件處理函式也將不會被觸發了:
li clicked
複製程式碼
二、事件委託
什麼是事件委託呢?
事件委託就是利用事件冒泡機制,指定一個事件處理程式,來管理某一型別的所有事件。這個事件委託的定義不夠簡單明瞭,可能有些人還是無法明白事件委託到底是啥玩意。查了網上很多大牛在講解事件委託的時候都用到了取快遞這個例子來解釋事件委託,不過想想這個例子真的是相當恰當和形象的,所以就直接拿這個例子來解釋一下事件委託到底是什麼意思:
公司的員工們經常會收到快遞。為了方便籤收快遞,有兩種辦法:一種是快遞到了之後收件人各自去拿快遞;另一種是委託前臺MM代為簽收,前臺MM收到快遞後會按照要求進行簽收。很顯然,第二種方案更為方便高效,同時這種方案還有一種優勢,那就是即使有新員工入職,前臺的MM都可以代替新員工簽收快遞。
這個例子之所以非常恰當形象,是因為這個例子包含了委託的兩層意思:
首先,現在公司裡的員工可以委託前臺MM代為簽收快遞,即程式中現有的dom節點是有事件的並可以進行事件委託;其次,新入職的新員工也可以讓前臺MM代為簽收快遞,即程式中新新增的dom節點也是有事件的,並且也能委託處理事件。
為什麼要用事件委託呢?
當dom需要處理事件時,我們可以直接給dom新增事件處理程式,那麼當許多dom都需要處理事件呢?比如一個ul中有100li,每個li都需要處理click事件,那我們可以遍歷所有li,給它們新增事件處理程式,但是這樣做會有什麼影響呢?我們知道新增到頁面上的事件處理程式的數量將直接影響到頁面的整體執行效能,因為這需要不停地與dom節點進行互動,訪問dom的次數越多,引起瀏覽器重繪和重排的次數就越多,自然會延長頁面的互動就緒時間,這也是為什麼可以減少dom操作來優化頁面的執行效能;而如果使用委託,我們可以將事件的操作統一放在js程式碼裡,這樣與dom的操作就可以減少到一次,大大減少與dom節點的互動次數提高效能。同時,將事件的操作進行統一管理也能節約記憶體,因為每個js函式都是一個物件,自然就會佔用記憶體,給dom節點新增的事件處理程式越多,物件越多,佔用的記憶體也就越多;而使用委託,我們就可以只在dom節點的父級新增事件處理程式,那麼自然也就節省了很多記憶體,效能也更好。
事件委託怎麼實現呢?因為冒泡機制,既然點選子元素時,也會觸發父元素的點選事件。那麼我們就可以把點選子元素的事件要做的事情,交給最外層的父元素來做,讓事件冒泡到最外層的dom節點上觸發事件處理程式,這就是事件委託。
在介紹事件委託的方法之前,我們先來看看處理事件的一般方法:
<ul id="list">
<li id="item1" >item1</li>
<li id="item2" >item2</li>
<li id="item3" >item3</li>
</ul>
<script>
var item1 = document.getElementById("item1");
var item2 = document.getElementById("item2");
var item3 = document.getElementById("item3");
item1.onclick = function(event){
alert(event.target.nodeName);
console.log("hello item1");
}
item2.onclick = function(event){
alert(event.target.nodeName);
console.log("hello item2");
}
item3.onclick = function(event){
alert(event.target.nodeName);
console.log("hello item3");
}
</script>
複製程式碼
上面的程式碼意思很簡單,就是給列表中每個li節點繫結點選事件,點選li的時候,需要找一次目標li的位置,執行事件處理函式。
那麼我們用事件委託的方式會怎麼做呢(檢視示例)?
<ul id="list">
<li id="item1" >item1</li>
<li id="item2" >item2</li>
<li id="item3" >item3</li>
</ul>
<script>
var item1 = document.getElementById("item1");
var item2 = document.getElementById("item2");
var item3 = document.getElementById("item3");
var list = document.getElementById("list");
list.addEventListener("click",function(event){
var target = event.target;
if(target == item1){
alert(event.target.nodeName);
console.log("hello item1");
}else if(target == item2){
alert(event.target.nodeName);
console.log("hello item2");
}else if(target == item3){
alert(event.target.nodeName);
console.log("hello item3");
}
});
</script>
複製程式碼
我們為父節點新增一個click事件,當子節點被點選的時候,click事件會從子節點開始向上冒泡。父節點捕獲到事件之後,通過判斷event.target來判斷是否為我們需要處理的節點, 從而可以獲取到相應的資訊,並作處理。很顯然,使用事件委託的方法可以極大地降低程式碼的複雜度,同時減小出錯的可能性。
我們再來看看當我們動態地新增dom時,使用事件委託會帶來哪些優勢?首先我們看看正常寫法:
<ul id="list">
<li id="item1" >item1</li>
<li id="item2" >item2</li>
<li id="item3" >item3</li>
</ul>
<script>
var list = document.getElementById("list");
var item = list.getElementsByTagName("li");
for(var i=0;i<item.length;i++){
(function(i){
item[i].onclick = function(){
alert(item[i].innerHTML);
}
})(i);
}
var node=document.createElement("li");
var textnode=document.createTextNode("item4");
node.appendChild(textnode);
list.appendChild(node);
</script>
複製程式碼
點選item1到item3都有事件響應,但是點選item4時,沒有事件響應。說明傳統的事件繫結無法對動態新增的元素而動態的新增事件。
而如果使用事件委託的方法又會怎樣呢(檢視示例)?
<ul id="list">
<li id="item1" >item1</li>
<li id="item2" >item2</li>
<li id="item3" >item3</li>
</ul>
<script>
var list = document.getElementById("list");
document.addEventListener("click",function(event){
var target = event.target;
if(target.nodeName == "LI"){
alert(target.innerHTML);
}
});
var node=document.createElement("li");
var textnode=document.createTextNode("item4");
node.appendChild(textnode);
list.appendChild(node);
</script>
複製程式碼
當點選item4時,item4有事件響應,這說明事件委託可以為新新增的DOM元素動態地新增事件。我們可以發現,當用事件委託的時候,根本就不需要去遍歷元素的子節點,只需要給父級元素新增事件就好了,其他的都是在js裡面的執行,這樣可以大大地減少dom操作,這就是事件委託的精髓所在。
移動端篇(iOS)
在網頁上當我們講到事件,我們會講到事件的捕獲以及傳遞方式(冒泡),那麼在移動端上,其實也離不開這幾個問題,下面我們將從這幾個方面來介紹iOS的事件機制: 1、 如何找到最合適的控制元件來處理事件?2、找到事件第一個響應者之後,事件是如何響應的?
一、事件的產生和傳遞
iOS中的事件可以分為3大型別:
- 觸控事件
- 加速計事件
- 遠端控制事件
這裡我們只討論iOS中最為常見的觸控事件。
響應者物件
學習觸控事件之前,我們需要了解一個比較重要的概念:響應者(UIResponder)。
在iOS中不是任何物件都能處理事件,只有繼承了UIResponder的物件才能接受並處理事件,我們稱之為“響應者物件”。
之所以繼承自UIResponder的類就能夠接收並處理觸控事件,是因為UIResponder提供了下列屬性和方法來處理觸控事件:
- (nullable UIResponder*)nextResponder;
- (BOOL)canBecomeFirstResponder; // default is NO
- (BOOL)becomeFirstResponder;
- (BOOL)canResignFirstResponder; // default is YES
- (BOOL)resignFirstResponder;
- (BOOL)isFirstResponder;
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
複製程式碼
當觸控事件產生時,系統在會在觸控的不同階段呼叫上面4個方法。
事件的產生
- 發生觸控事件後,系統會將該事件加入到一個由UIApplication管理的事件佇列中。
- UIApplication會從事件佇列中取出最前面的事件,並將事件分發下去,首先傳送事件給應用程式的主視窗(keyWindow)。
- 主視窗會在檢視層次結構中找到一個最合適的檢視來處理觸控事件。
- 找到合適的檢視控制元件後,就會呼叫檢視控制元件的touches方法來作具體的事件處理。
事件的傳遞
我們的app中,所有的檢視都是按照一定的結構組織起來的,即樹狀層次結構,每個view都有自己的superView,包括controller的topmost view(controller的self.view)。當一個view被add到superView上的時候,他的nextResponder屬性就會被指向它的superView,當controller被初始化的時候,self.view(topmost view)的nextResponder會被指向所在的controller,而controller的nextResponder會被指向self.view的superView。
應用如何找到最合適的控制元件來處理事件?
- 首先判斷當前控制元件自己是否能接受觸控事件;
- 判斷觸控點是否在自己身上;
- 在當前控制元件的子控制元件陣列中從後往前遍歷子控制元件,重複前面的兩個步驟(所謂從後往前遍歷子控制元件,就是首先查詢子控制元件陣列中最後一個元素,然後執行1、2步驟);
- 在上述過程中找到了合適的view,比如叫做fitView,那麼會把這個事件交給這個fitView,再遍歷這個fitView的子控制元件,直至沒有更合適的view為止;
- 如果沒有符合條件的子控制元件,那麼就認為自己最合適處理這個事件,也就是自己是最合適的view。
在這個尋找最合適的響應控制元件的過程中,所有參與遍歷的控制元件都會呼叫以下兩個方法來確定控制元件是否是更合適的響應控制元件:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
複製程式碼
具體原理可參考:iOS 事件傳遞 hitTest方法與PointInside方法。
二、事件的響應
響應者鏈
在iOS檢視中所有控制元件都是按一定層級結構進行組織的,也就是說控制元件是有先後擺放順序的,而能夠響應事件的控制元件按照這種先後關係構成一個鏈條就叫“響應者鏈”。也可以說,響應者鏈是由多個響應者物件連線起來的鏈條。前面提到UIResponder是所有響應者物件的基類,在UIResponder類中定義了處理各種事件的介面。而UIApplication、 UIViewController、UIWindow和所有繼承自UIView的UIKit類都直接或間接的繼承自UIResponder,所以它們的例項都是可以構成響應者鏈的響應者物件。 在iOS中響應者鏈的關係可以用下圖表示:
當檢視響應觸控事件時,會自動呼叫自己的touches方法處理事件:
#import "DYView.h"
@implementation DYView
//只要點選控制元件,就會呼叫touchBegin,如果沒有重寫這個方法,就不能響應處理觸控事件
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{
...
// 預設會把事件傳遞給上一個響應者,上一個響應者是父控制元件,交給父控制元件處理
[super touchesBegan:touches withEvent:event];
// 注意不是呼叫父控制元件的touches方法,而是呼叫父類的touches方法,最終會把事件傳遞給nextResponder
}
@end
複製程式碼
無論當前子控制元件能否處理事件,都會把事件上拋給父控制元件(上一個響應者),如果父控制元件實現了touches方法,則會處理觸控事件,也就是說一個觸控事件可以只由一個控制元件進行處理,也可以由多個控制元件進行響應處理。所以, 整個觸控事件的傳遞和響應過程可概括如下:
- 當一個事件發生後,事件會由UIApplication沿著傳遞鏈分發下去,即UIApplication -> UIWindow -> UIView -> initial view,直到尋找最合適的view。
- 最合適的view之後開始響應事件:首先看initial view能否處理這個事件,如果不能則會將事件傳遞給其上級檢視;如果上級檢視仍然無法處理則會繼續往上傳遞;一直傳遞到檢視控制器view controller;如果不能則接著判斷該檢視控制器能否處理此事件,如果還是不能則繼續向上傳 遞;(對於第二個圖檢視控制器本身還在另一個檢視控制器中,則繼續交給父檢視控制器的根檢視,如果根檢視不能處理則交給父檢視控制器處理);一直到 window,如果window還是不能處理此事件則繼續交給application處理,如果最後application還是不能處理此事件則將其丟棄。
- 在事件的響應中,如果某個控制元件實現了touches方法,則這個事件將由該控制元件來接受,如果呼叫了[super touches….];就會將事件順著響應者鏈條往上傳遞,傳遞給上一個響應者;接著就會呼叫上一個響應者的touches方法。
三、事件繫結和事件代理
事件繫結
在iOS應用開發中,經常會用到各種各樣的控制元件,比如按鈕(UIButton)、開關(UISwitch)、滑塊(UISlider)等以及各種自定義控制元件。這些控制元件用來與使用者進行互動,響應使用者的操作。這些控制元件有個共同點,它們都是繼承於UIControl類。UIControl是控制元件類的基類,它是一個抽象基類,我們不能直接使用UIControl類來例項化控制元件,它只是為控制元件子類定義一些通用的介面,並提供一些基礎實現,以在事件發生時,預處理這些訊息並將它們傳送到指定目標物件上。
iOS中的事件繫結是一種Target-Action機制,其操作主要使用以下兩個方法:
// 新增繫結
- (void)addTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents
// 解除繫結
- (void)removeTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents
複製程式碼
當我們需要給一個控制元件(例如按鈕)繫結一個點選事件時,可做如下處理:
[button addTarget:self action:@selector(clickButton:) forControlEvents:UIControlEventTouchUpInside];
複製程式碼
當按鈕的點選事件發生時,訊息會被髮送給target(這裡即為self物件),觸發target物件的clickButton:方法來處理點選點選事件。這個過程可用下圖來描述:
[button addTarget:nil action:@selector(clickButton:) forControlEvents:UIControlEventTouchUpInside];
複製程式碼
上面的程式碼目標物件為nil,那麼它首先會檢查button自身這個類有沒有實現clickButton:這個方法,如果實現了這個方法就會呼叫,否則就會根據響應者鏈找到button.nextResponder,再次檢查是否實現了clickButton:方法,直到UIApplication(其實是AppDelegate),如果還是沒有實現,則什麼也不做。
事件代理
在IOS中委託通過一種@protocol的方式實現,所以又稱為協議.協議是多個類共享的一個方法列表,在協議中所列出的方法沒有相應的具體實現(相當於介面),需要由使用協議的類來實現協議中的方法。
委託是指給一個物件提供機會對另一個物件中的變化做出反應或者影響另一個物件的行為。其基本思想是:兩個物件協同解決問題。一個物件非常普通,並且打算在廣泛的情形中重用。它儲存指向另一個物件(即它的委託)的引用,並在關鍵時刻給委託發訊息。訊息可能只是通知委託發生了某件事情,給委託提供機會執行額外的處理,或者訊息可能要求委託提供一些關鍵的資訊以控制所發生的事情。
下面用用一個例子來說明代理在iOS開發中的具體應用:
還是以取快遞為例,員工可以委託前臺MM代為簽收快遞,所以員工和前臺MM之間有一個協議(protocol):
@protocol signDelegate <NSObject>
- (void)signTheExpress;
@end
複製程式碼
這個協議裡宣告瞭一個簽收快遞(signTheExpress)的方法。
員工可用下面定義的類表示:
##employer.h
@protocol signDelegate <NSObject>
- (void)signTheExpress;
@end
@interface employer : NSObject
/**
* delegate 是employer類的一個屬性
*/
@property (nonatomic, weak) id<signDelegate> delegate;
- (void)theExpressDidArrive;
@end
複製程式碼
employer.m
#import "employer.h"
@implementation employer
- (void)theExpressDidArrive{
if ([self.delegate respondsToSelector:@selector(signTheExpress)]) {
[self.delegate signTheExpress];
}
}
@end
複製程式碼
再來看看前臺MM這個類的實現:
#import "receptionMM.h"
#import "employer.h"
@interface receptionMM ()<signDelegate> //<signDelegate>表示遵守signDelegate協議,並且實現協議裡面的方法
@end
@implementation receptionMM
/**
* 快遞員到了
*/
- (void)theCourierCome{
DaChu *employer1 = [[employer alloc] init];
employer1.delegate = self; //說明前臺MM充當代理的角色。
[employer1 theExpressDidArrive]; //某個員工的快遞到了
}
- (void)signTheExpress{
NSLog(@"快遞簽收了");
}
@end
複製程式碼
在iOS開發中,使用委託的主要目的在於解耦,因為不同的模組有自己的角色,對於事件的處理需要由特定模組完成以保持資料和UI的分離,同時也能降低程式的複雜度。
總結
雖然前端和移動端的開發存在很大的差異,但僅從事件機制來看,兩者也存在很多相似的概念:例如前端的dom數的概念和App頁面中的view樹很類似,前端事件的捕獲和冒泡機制和iOS事件的傳遞鏈和響應鏈機制也有相似之處,以及兩端都有事件繫結和事件代理的概念。但由於移動端頁面元素高度物件化的特徵,對於事件的處理機制相對也更復雜一點,一些設計模式的應用的目的有所差異。比如事件委託在前端開發上主要是降低程式碼的複雜度,而在iOS開發上則主要在於解決模組間的解耦問題。
並且前端和移動端平臺上也都有許多優秀的框架,因而關於前端和移動端的事件機制還有很多內容可以談,比如Vue.js的Event Bus、ReactiveCocoa中統一的訊息處理機制,希望有時間可以再探討一番。
參考文獻
1、iOS事件機制