浅谈前端和移动端的事件机制

9,883 阅读22分钟

前言

近几年来,在移动端上因原生开发成本高和效率低而导致涌现出来的一大批优秀前端框架,以及专门针对移动端设备的前端开发框架(如 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,则标准事件触发分别经历以下三个阶段:

我们知道一个HTML文件其实就是一棵DOM树,DOM节点之间是父子层级关系(这与iOS中的view树很类似,后面会说到)。在W3C模型中,任何事件发生时,先从顶层开始进行事件捕获,直到事件触发到达了事件源元素,这个过程叫做事件捕获(这其实也是事件的传递过程);然后,该事件会随着DOM树的层级路径,由子节点向父节点进行层层传递,直至到达document,这个过程叫做事件冒泡(也可以说这是事件的响应过程)。虽然大部分的浏览器都遵循着标准,但是在IE浏览器中,事件流却是非标准的。而IE中事件流只有两个阶段:处于目标阶段,冒泡阶段。 对于标准事件,事件触发一次经历三个阶段,所以我们在一个元素上注册事件也就可以在对应阶段绑定事件,移除事件也同样。
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中响应者链的关系可以用下图表示:

下面我们根据响应者链关系图解释事件传递过程: - 如果当前view是控制器的view,那么控制器(viewController)就是上一个响应者,事件就传递给控制器;如果当前view不是控制器的view,那么父视图就是当前view的上一个响应者,事件就传递给它的父视图; - 如果视图层次结构的最顶级视图也不能处理收到的事件,则其将事件传递给window对象进行处理; - 如果window对象不能处理该事件,则其将事件传递给UIApplication对象; - 如果UIApplication也不能处理该事件,则将其丢弃。

当视图响应触摸事件时,会自动调用自己的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:方法来处理点击点击事件。这个过程可用下图来描述:

因此,Target-Action机制由两部分组成:即目标对象和行为Selector。目标对象指定最终处理事件的对象,而行为Selector则是处理事件的方法。 如果目标对象target为空会怎样呢?如果我们没有指定target,则会将事件分发到响应链上第一个想处理消息的对象上,也就是根据响应者链往上找,若找到,则调用,否则什么也不做。例如下面的代码:
[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事件机制