Angular之作用域与事件(转)
学习Angular,首先要理解其作用域机制。
Angular应用是分层的,主要有三个层面:视图,模型,视图模型。其中,视图很好理解,就是直接可见的界面,模型就是数据,那么视图模型是什么呢?是一种把数据包装给视图调用的东西。
所谓作用域,也就是视图模型中的一个概念。
根作用域
在第一章中,有这么一个很简单的数据绑定例子:
<input ng-model="rootA"/> <div>{{rootA}}</div>
当时我们解释过,这个例子能够运行的的原因是,它的rootA变量被创建在根作用域上。每个Angular应用默认有一个根作用域,也就是说,如果用户未指定自己的控制器,变量就是直接挂在这个层级上的。
作用域在一个Angular应用中是以树的形状体现的,根作用域位于最顶层,从它往下挂着各级作用域。每一级作用域上面挂着变量和方法,供所属的视图调用。
如果想要在代码中显式使用根作用域,可以注入$rootScope。
怎么证实刚才的例子中,$rootScope确实存在,而且变量真的在它上面呢?我们来写个代码:
function RootService($rootScope) { $rootScope.$watch("rootA", function(newVal) { alert(newVal); }); }
这时候我们可以看到,这段代码并未跟界面产生任何关系,但里面的监控表达式确实生效了,也就是说,观测到了根作用域上rootA的变更,说明有人给它赋值了。
作用域的继承关系
在开发过程中,我们可能会出现控制器的嵌套,看下面这段代码:
<div ng-controller="OuterCtrl"> <span>{{a}}</span> <div ng-controller="InnerCtrl"> <span>{{a}}</span> </div> </div> function OuterCtrl($scope) { $scope.a = 1; } function InnerCtrl($scope) { }
注意结果,我们可以看到界面显示了两个1,而我们只在OuterCtrl的作用域里定义了a变量,但界面给我们的结果是,两个a都有值。这里内层的a值显然来自外层,因为当我们对界面作出这样的调整之后,就只有一个了:
<div ng-controller="OuterCtrl"> <span>{{a}}</span> </div> <div ng-controller="InnerCtrl"> <span>{{a}}</span> </div>
这是为什么呢?在Angular中,如果两个控制器所对应的视图存在上下级关系,它们的作用域就自动产生继承关系。什么意思呢?
先考虑在纯JavaScript代码中,两个构造函数各自有一个实例:
function Outer() { this.a = 1; } function Inner() { } var outer = new Outer(); var inner = new Inner();
在这里面添加什么代码,能够让inner.a == 1呢?
熟悉JavaScript原型的我们,当然毫不犹豫就加了一句:Inner.prototype = outer;
function Outer() { this.a = 1; } function Inner() { } var outer = new Outer(); Inner.prototype = outer; var inner = new Inner();
于是就得到想要的结果了。
再回到我们的例子里,Angular的实现机制其实也就是把这两个控制器中的$scope作了关联,外层的作用域实例成为了内层作用域的原型。
以此类推,整个Angular应用的作用域,都存在自顶向下的继承关系,最顶层的是$rootScope,然后一级一级,沿着不同的控制器往下,形成了一棵作用域的树,这也就像封建社会:天子高高在上,分茅裂土,公侯伯子男,一级一级往下,层层从属。
简单变量的取值与赋值
既然作用域是通过原型来继承的,自然也就可以推论出一些特征来。比如说这段代码,点击按钮的结果是什么?
<div ng-controller="OuterCtrl"> <span>{{a}}</span> <div ng-controller="InnerCtrl"> <span>{{a}}</span> <button ng-click="a=a+1">a++</button> </div> </div>
function OuterCtrl($scope) { $scope.a = 1; } function InnerCtrl($scope) { }
点了按钮之后,两个a不一致了,里面的变了,外面的没变,这是为什么?原先两层不是共用一个a吗,怎么会出现两个不同的值?看这句就能明白了,相当于我们之前那个例子里,这样赋值了:
function Outer() { this.a = 1; } function Inner() { } var outer = new Outer(); Inner.prototype = outer; var inner = new Inner(); inner.a = inner.a + 1;
最后这句,很有意思,它有两个过程,取值的时候,因为inner自身上面没有,所以沿着原型往上取到了1,然后自增了之后,赋值给自己,这个赋值的时候就不同了,敬爱的林副主席教导我们:有a就赋值,没有a,创造一个a也要赋值。
所以这么一来,inner上面就被赋值了一个新的a,outer里面的仍然保持原样,这也就导致了刚才看到的结果。
初学者在这个问题上很容易犯错,如果不能随时很明确地认识到这些变量的差异,很容易写出有问题的程序。既然这样,我们可以用一些别的方式来减少变量的歧义。
对象在上下级作用域之间的共享
比如说,我们就是想上下级共享变量,不创建新的,该怎么办呢?
考虑下面这个例子:
function Outer() { this.data = { a: 1 }; } function Inner() { } var outer = new Outer(); Inner.prototype = outer; var inner = new Inner(); console.log(outer.data.a); console.log(inner.data.a); // 注意,这个时候会怎样? inner.data.a += 1; console.log(outer.data.a); console.log(inner.data.a);
这次的结果就跟上次不同了,原因是什么呢?因为两者的data是同一个引用,对这个对象上面的属性修改,是可以反映到两级对象上的。我们通过引入一个data对象的方式,继续使用了原先的变量。把这个代码移植到AngularJS里,就变成了下面这样:
<div ng-controller="OuterCtrl"> <span>{{data.a}}</span> <div ng-controller="InnerCtrl"> <span>{{data.a}}</span> <button ng-click="data.a=data.a+1">increase a</button> </div> </div>
function OuterCtrl($scope) { $scope.data = { a: 1 }; } function InnerCtrl($scope) { }
从这个例子我们就发现了,如果想要避免变量歧义,显式指定所要使用的变量会是比较好的方式,那么如果我们确实就是要在上下级分别存在相同的变量该怎么办呢,比如说下级的点击,想要给上级的a增加1,我们可以使用$parent来指定上级作用域。
<div ng-controller="OuterCtrl"> <span>{{a}}</span> <div ng-controller="InnerCtrl"> <span>{{a}}</span> <button ng-click="$parent.a=a+1">increase a</button> </div> </div>
function OuterCtrl($scope) { $scope.a = 1; } function InnerCtrl($scope) { }
控制器实例别名
从Angular 1.2开始,引入了控制器实例的别名机制。在之前,可能都需要向控制器注入$scope,然后,控制器里面定义可绑定属性和方法都是这样:
function CtrlA($scope) { $scope.a = 1; $scope.foo = function() { }; }
<div ng-controller="CtrlA"> <div>{{a}}</div> <button ng-click="foo()">click me</button> </div>
其实$scope的注入是一个比较冗余的概念,没有必要把这种概念过分暴露给用户。在应用中出现的作用域,有的是充当视图模型,而有些则是处于隔离数据的需要,前者如ng-controller,后者如ng-repeat。在最近版本的AngularJS中,已经可以不显式注入$scope了,语法是这样:
function CtrlB() { this.a = 1; this.foo = function() { }; }
这里面,就完全没有$scope的身影了,那这个控制器怎么使用呢?
<div ng-controller="CtrlB as instanceB"> <div>{{instanceB.a}}</div> <button ng-click="instanceB.foo()">click me</button> </div>
注意我们在引入控制器的时候,加了一个as语法,给CtrlB的实例取了一个别名叫做instanceB,这样,它下属的各级视图都可以显式使用这个名称来调用其属性和方法,不易引起歧义。
在开发过程中,为了避免模板中的变量歧义,应当尽可能使用命名限定,比如a.b,出现歧义的可能性就比单独的b要少得多。
不请自来的新作用域
在一个应用中,最常见的会创建作用域的指令是ng-controller,这个很好理解,因为它会实例化一个新的控制器,往里面注入一个$scope,也就是一个新的作用域,所以一般人都会很自然地理解这里面的作用域隔离关系。但是对于另外一些情况,就有些困惑了,比如说,ng-repeat,怎么理解这个东西也会创建新作用域呢?
还是看之前的例子:
$scope.arr = [1, 2, 3];
<ul> <li ng-repeat="item in arr track by $index">{{item}}</li> </ul>
在ng-repeat的表达式里,有一个item,我们来思考一下,这个item是个什么情况。在这里,数组中有三个元素,在循环的时候,这三个元素都叫做item,这时候就有个问题,如何区分每个不同的item,可能我们这个例子还不够直接,那改一下:
<div>outer: {{sum1}}</div> <ul> <li ng-repeat="item in arr track by $index"> {{item}} <button ng-click="sum1=sum1+item">increase</button> <div>inner: {{sum1}}</div> </li> </ul>
这个例子运行一下,我们会发现每个item都会独立改变,说明它们确实是区分开了的。事实上,Angular在这里为ng-repeat的每个子项都创建了单独的作用域,所以,每个item都存在于自己的作用域里,互不影响。有时候,我们是需要在循环内部访问外层变量的,回忆一下,在本章的前面部分中,我们举例说,如果两个控制器,它们的视图有包含关系,内层控制器的作用域可以通过$parent来访问外层控制器作用域上的变量,那么,在这种循环里,是不是也可以如此呢?
看这个例子:
<div>outer: {{sum2}}</div> <ul> <li ng-repeat="item in arr track by $index"> {{item}} <button ng-click="$parent.sum2=sum2+item">increase</button> <div>inner: {{sum2}}</div> </li> </ul>
果然是可以的。很多时候,人们会把$parent误认为是上下两级控制器之间的访问通道,但从这个例子我们可以看到,并非如此,只是两级作用域而已,作用域跟控制器还是不同的,刚才的循环可以说是有两级作用域,但都处于同一个控制器之中。
刚才我们已经提到了ng-controller和ng-repeat这两个常用的内置指令,两者都会创建新的作用域,除此之外,还有一些其他指令也会创建新的作用域,很多初学者在使用过程中很容易产生困扰。
第一章我们提到用ng-show和ng-hide来控制某个界面块的整体展示和隐藏,但同样的功能其实也可以用ng-if来实现。那么这两者的差异是什么呢,所谓show和hide,大家很好理解,就是某个东西原先有,只是控制是否显式,而if的含义是,如果满足条件,就创建这块DOM,否则不创建。所以,ng-if所控制的界面块,只有条件为真的时候才会存在于DOM树中。
除此之外,两者还有个差异,ng-show和ng-hide是不自带作用域的,而ng-if则自己创建了一级作用域。在用的时候,两者就是有差别的,比如说内部元素访问外层定义的变量,就需要使用类似ng-repeat那样的$parent语法了。
相似的类型还有ng-switch,ng-include等等,规律可以总结,也就是那些会动态创建一块界面的东西,都是自带一级作用域。
“悬空”的作用域
一般而言,在Angular工程中,基本是不需要手动创建作用域的,但真想创建的话,也是可以做到的。在任意一个已有的作用域上调用$new(),就能创建一个新的作用域:
var newScope = scope.$new();
刚创建出来的作用域是一个“悬空”的作用域,也就是说,它跟任何界面模板都不存在绑定关系,创建它的作用域会成为它的$parent。这种作用域可以经过$compile阶段,与某视图模板进行融合。
为了帮助理解,我们可以用DocumentFragment作类比,当作用域被创建的时候,就好比是创建了一个DocumentFragment,它是不在DOM树上的,只有当它被append到DOM树上,才能够被当做普通的DOM来使用。
那么,悬空的作用域是不是什么用处都没有呢?也不是,尽管它未与视图关联,但是它的一些方法仍然可以用。
我们在第一章里提到了$watch,这就是定义在作用域原型上的。如果我们想要监控一个数据的变化,但这个数据并非绑定到界面上的,比如下面这样,怎么办?
function IsolateCtrl($scope) { var child = { a: 1 }; child.a++; }
注意这个child,它并未绑定到$scope上,如果我们想要在a变化的时候做某些事情,是没有办法做的,因为直到最近的某些浏览器中,才实现了Object.observe这样的对象变更观测方法,之前某些浏览器中要做这些,会比较麻烦。
但是我们的$watch和$eval之类的方法,其实都是实现在作用域对象上的,也就是说,任何一个作用域,即使没有与界面产生关联,也是能够使用这些方法的。
function IsolateCtrl($scope) { var child = $scope.$new(); child.a = 1; child.$watch("a", function(newValue) { alert(newValue); }); $scope.change = function() { child.a++; }; }
这时候child里面a的变更就可以被观测到,并且,这个child只有本作用域可以访问到,相当于是一个增强版的数据模型。如果我们要做一个小型流程引擎之类的东西,作用域对象上提供的这些方法会很有用。
作用域上的事件
我们刚才提到使用$parent来处理上下级的通讯,但其实这不是一种好的方式,尤其是在不同控制器之间,这会增加它们的耦合,对组件复用很不利。那怎样才能更好地解耦呢?我们可以使用事件。
提到事件,可能很多人想到的都是DOM事件,其实DOM事件只存在于上层,而且没有业务含义,如果我们想要传递一个明确的业务消息,就需要使用业务事件。这种所谓的业务事件,其实就是一种消息的传递。
假设有如图所示的应用:
这张图中有一个应用,下面存在两个视图块A和B,它们分别又有两个子视图。这时候,如果子视图A1想要发出一个业务事件,使得B1和B2能够得到通知,过程就会是:
- 沿着父作用域一路往上到达双方共同的祖先作用域
- 从祖先作用域一级一级往下进行广播,直到到达需要的地方
刚才的图形体现了界面的包含关系,如果把这个图再立体化,就会是下面这样:
对于这种事件的传播方式,可以有个类似的比喻:
比如说,某军队中,1营1连1排长想要给1营2连下属的三个排发个警戒通知,他的通知方向是一级一级向上汇报,直到双方共同的上级,也就是1营指挥人员这里,然后再沿着二连这个路线向下去通知。
- 从作用域往上发送事件,使用scope.$emit
$scope.$emit("someEvent", {});
- 从作用域往下发送事件,使用scope.$broadcast
$scope.$broadcast("someEvent", {});
这两个方法的第二个参数是要随事件带出的数据。
注意,这两种方式传播事件,事件的发送方自己也会收到一份。
使用事件的主要作用是消除模块间的耦合,发送方是不需要知道接收方的状况的,接收方也不需要知道发送方的状况,双方只需要传送必要的业务数据即可。
事件的接收与阻止
无论是$emit还是$broadcast发送的事件,都可以被接收,接收这两种事件的方式是一样的:
$scope.$on("someEvent", function(e) { // 这里从e上可以取到发送过来的数据 });
注意,事件被接收了,并不代表它就中止了,它仍然会沿着原来的方向继续传播,也就是:
- $emit的事件将继续向上传播
- $broadcast的事件将继续向下传播
有时候,我们希望某一级收到事件之后,就让它停下来,不再传播,可以把事件中止。这时候,两种事件的区别就体现出来了,只有$emit发出的事件是可以被中止的,$broadcast发出的不可以。
如果想要阻止$emit事件的继续传播,可以调用事件对象的stopPropagation()方法。
$scope.$on("someEvent", function(e) { e.stopPropagation(); });
但是,想要阻止$broadcast事件的传播,就麻烦了,我们只能通过变通的方式:
首先,调用事件对象的preventDefault()方法,然后,在收取这个事件对象的时候,判断它的defaultPrevented属性,如果为true,就忽略此事件。这个过程比较麻烦,其实我们一般是不需要管的,只要不监听对应的事件就可以了。在实际使用过程中,也应当尽量少使用事件的广播,尤其是从较高的层级进行广播。
上级作用域
$scope.$on("someEvent", function(e) { e.preventDefault(); });
下级作用域
$scope.$on("someEvent", function(e) { if (e.defaultPrevented) { return; } });
事件总线
在Angular中,不同层级作用域之间的数据通信有多种方式,可以通过原型继承的一些特征,也可以收发事件,还可以使用服务来构造单例对象进行通信。
前面提到的这个军队的例子,有些时候沟通效率比较低,特别是层级多的时候。想象一下,刚才这个只有三层,如果更复杂,一个排长的消息都一定要报告到军长那边再下发到其他基层主官,必定贻误军情,更何况有很多下级根本不需要知道这个消息。
那怎么办呢,难道是直接打电话沟通吗?这个效率高是高,就是容易乱,这也就相当于界面块之间的直接通过id调用。
Angular的作用域树类似于传统的组织架构树,一个大型企业,一般都会有若干层级,近年来有很多管理的方法论,比如说组织架构的扁平化。
我们能不能这样:搞一个专门负责通讯的机构,大家的消息都发给它,然后由它发给相关人员,其他人员在理念上都是平级关系。
这就是一个很典型的订阅发布模式,接收方在这里订阅消息,发布方在这里发布消息。这个过程可以用这样的图形来表示:
代码写起来也很简单,把它做成一个公共模块,就可以被各种业务方调用了:
app.factory("EventBus", function() { var eventMap = {}; var EventBus = { on : function(eventType, handler) { //multiple event listener if (!eventMap[eventType]) { eventMap[eventType] = []; } eventMap[eventType].push(handler); }, off : function(eventType, handler) { for (var i = 0; i < eventMap[eventType].length; i++) { if (eventMap[eventType][i] === handler) { eventMap[eventType].splice(i, 1); break; } } }, fire : function(event) { var eventType = event.type; if (eventMap && eventMap[eventType]) { for (var i = 0; i < eventMap[eventType].length; i++) { eventMap[eventType][i](event); } } } }; return EventBus; });
事件订阅代码:
EventBus.on("someEvent", function(event) { // 这里处理事件 var c = event.data.a + event.data.b; });
事件发布代码:
EventBus.fire({ type: "someEvent", data: { aaa: 1, bbb: 2 } });
注意,如果在复杂的应用中使用事件总线,需要慎重规划事件名,推荐使用业务路径,比如:"portal.menu.selectedMenuChange",以避免事件冲突。
小结
在本章,我们学习了作用域相关的知识,以及它们之间传递数据的方式。作用域在整个Angular应用中形成了一棵树,以$rootScope为根部,开枝散叶。这棵树独立于DOM而存在,又与DOM相关联。事件在整个树上传播,如蜂飞蝶舞。
总体来说,使用AngularJS对JavaScript的基本功是有一定要求的,因为这里面大部分实现都依赖于纯JavaScript语法,比如原型继承的使用。如果对这一块有充分的认识,理解Angular的作用域就会比较容易。
一个大型单页应用,需要对部件的整合方式和通信机制作良好的规划,为它们建立良好的秩序,这对于确保整个应用的稳定性是非常必要的。
首要问题不是自由,而是建立合法的公共秩序。人类可以无自由而有秩序,但不能无秩序而有自由。——缪尔·亨廷顿
本章所涉及的所有Demo,参见在线演示地址
演讲幻灯片下载:点这里