Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AngularJS实例教程(二)——作用域与事件 #18

Open
xufei opened this issue Feb 3, 2015 · 31 comments
Open

AngularJS实例教程(二)——作用域与事件 #18

xufei opened this issue Feb 3, 2015 · 31 comments
Labels

Comments

@xufei
Copy link
Owner

xufei commented Feb 3, 2015

作用域与事件

学习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,参见在线演示地址

代码库

演讲幻灯片下载:点这里

@xufei xufei added the ng-tutor label Feb 3, 2015
@lichunqiang
Copy link

👍

@zhoufenfens
Copy link

厉害

@mizhdi
Copy link

mizhdi commented Feb 4, 2015

看完点赞:+1:

@dolymood
Copy link

dolymood commented Feb 4, 2015

赞!

@JeffreyWang
Copy link

不错,看完点个赞~

@42thcoder
Copy link

深入浅出, 又很实用

@edwinna
Copy link

edwinna commented Feb 26, 2015

写的很具体 看完很受益 赞!

@paddingme
Copy link

感谢,期待后续。 💯

@mooncakelmn
Copy link

文章写的很细 👍

Angular组件化开发的时候,再用这种类似DOM的event flow有很多痛点。比如文中介绍的stop propagation和prevent default。这种基于scope chain逐级bubble和capture的模式,开发人员手动控制的地方太多,使用起来还不如简单粗暴的使用单一的dispatcher。

@bailinlin
Copy link

棒棒嗒~

@packageman
Copy link

赞一个

@woaixiangbao
Copy link

赞!

@AnnVoV
Copy link

AnnVoV commented Aug 17, 2015

太棒了!

@m1013923728
Copy link

默默的点赞!!!

@Dealize
Copy link

Dealize commented Aug 31, 2015

最后那个订阅发布实在太经典!

@lovelyhui
Copy link

为什么不直接用rootScope.emit & rootScope.on呢?

@huangtengfei
Copy link

再看一遍,受益匪浅,有些之前没太注意的地方现在理解了

@shikelong
Copy link

很详细。受教了。

@Measy
Copy link

Measy commented Nov 11, 2015

发布订阅这个和spring boot事件驱动,CQRS类似简直一样~

@liuqipeng417
Copy link

自己构造的观察者模式确实比较好用

@hongyuGuo
Copy link

这理解的也太透彻了,赞

@cappusd3
Copy link

if (eventMap[eventType][i] === handler) {
eventMap[eventType].splice(i, 1);
break;
}
这段函数我为什么始终都是false,走不到里面,函数相等是如何判断的

@woaixiangbao
Copy link

👍

@pinglikethinking
Copy link

@baipgydx729
Copy link

默默的点了个赞

@chping2125
Copy link

有一个疑问,最后那个订阅者和发布者模式中。接收方只能通过订阅消息把要执行的回调函数传过去,然后发布方触发函数执行,但是如果订阅方想要获取发布方的变量,好像也没法获取呀!那岂不是还是没有完成两个组件之间的通信?

@xufei
Copy link
Owner Author

xufei commented Nov 6, 2016

@chping2125 发布方在发事件的时候,把数据带过去就可以了。直接让订阅方访问发布方的变量是不太好的

@chping2125
Copy link

@xufei 谢谢,明白了。

@justu
Copy link

justu commented Mar 5, 2017

这里的订阅者和发布者模式,是不是跟zookeeper很像了?

@holy-silent
Copy link

大师!谢谢分享!

@lpove
Copy link

lpove commented Mar 9, 2018

写得真好,终于入门 angular 了哈哈

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests