你尚未登录,仅允许查看本站部分内容。请登录使用邀请码注册
xufei

AngularJS沉思录(二) 视图模型的层次 4个回复 专栏 @ 框架与库

xufei 发布于 2 年前

视图模型的层次

嵌套作用域的数据继承

在Angular中,存在作用域的继承。所谓作用域的继承,是指:如果两个视图有包含关系,内层视图对应的作用域可以共享外层视图作用域的数据。比如说:

<body ng-app="test">
    <div ng-controller="OuterCtrl">
        <span ng-bind="a"></span>
        <div ng-controller="InnerCtrl">
            <span ng-bind="a"></span>
            <span ng-bind="b"></span>
        </div>
    </div>
</body>
var app = angular.module("test", []);

app.controller("OuterCtrl", function ($scope) {
    $scope.a = 1;
});

app.controller("InnerCtrl", function ($scope) {
    $scope.b = 100;
});

内层的这个div上,一样也可以绑定变量a,因为在Angular内部,InnerCtrl的实例的原型会被设置为OuterCtrl的实例。

我们改变一下这个示例,如果在内层作用域上,对a进行赋值会怎样?

<body ng-app="test">
    <div ng-controller="OuterCtrl">
        <span ng-bind="a"></span>
        <div ng-controller="InnerCtrl">
            <span ng-bind="a"></span>
            <span ng-bind="b"></span>
            <button ng-click="increasea()">increase a</button>
        </div>
    </div>
</body>
var app = angular.module("test", []);

app.controller("OuterCtrl", function ($scope) {
    $scope.a = 1;
});

app.controller("InnerCtrl", function ($scope) {
    $scope.b = 100;

    $scope.increasea = function() {
        $scope.a++;
    };
});

点击这个按钮的时候,发现了一个问题,内层有了a,而且值在增加,外层的不变了。这是为什么呢?

因为它其实是通过原型集成来做到这样的。像上面这样的包含关系,内层scope的prototype被自动设置为外层的scope了,所以,才可以在内层使用这个a。这时候在内层给a赋值,当然就赋到它自己上了,不会赋值到原型的那个对象上。

同理,如果内外两层作用域上存在同名变量,在内层界面赋值的时候只会赋到内层作用域上的那个变量,不会影响到外层的。

那么,除了显式的ng-controller,Angular还会在什么地方引入视图模型的继承呢,主要是这些:

数组和对象属性的迭代

在Angular里面,有ng-repeat指令,可以用于遍历数组元素、对象属性。

<ul>
    <li ng-repeat="member in members">{{member.name}}</li>
</ul>

单从这个片段看,看不出视图继承的意义。我们把这个例子再拓展一下:

<ul>
    <li ng-repeat="member in members">{{member.name}} in {{teamname}}</li>
</ul>

它对应的视图模型是这么个结构:

function TeamCtrl($scope) {
    $scope.teamname = "Disney";

    $scope.members = [
        {name: "Tom Cat"},
        {name: "Jerry Mouse"},
        {name: "Donald Duck"},
        {name: "Micky Mouse"}
    ];
}

好了,注意到这里,teamname跟members里面的成员其实不在一层作用域,因为它给循环的每个元素都建立了单独的作用域,如果不允许视图模型的继承,在li里面是没法访问到teamname的。为了让这段话更容易理解,我作个转换:

var teamname = "Disney";
var members = [
    {name: "Tom Cat"},
    {name: "Jerry Mouse"},
    {name: "Donald Duck"},
    {name: "Micky Mouse"}
];

for (var i=0; i<members.length; i++) {
    var member = members[i];
    console.log(member.name + " in " + teamname);
}

ng-repeat内部给每个循环造了个作用域,如果不这么做,各个member就无法区分开了。在这种情况下,如果没有作用域的继承关系,在循环内,就访问不到这个teamname。

在这里,我觉得不一定非要造子作用域,它搞子作用域的原因无非是为了区分每个循环变量,但其实可以换一种写法,比如,avalon框架里的repeat写法就很好,在属性上指定循环元素变量名,然后给每个元素生成ObjectProxy,包装每个元素的数据,附带$index等有可能在循环过程中访问的东西。

因此,这里其实不必出现Scope的新实例,而是用一个ObjectProxy返回元素数据即可。

很可能我们的场景还有些简单,再来个复杂的:

<div ng-controller="TestCtrl">
    <div ng-repeat="boy in boys">
        <span style="color:red" ng-bind="boy.name"></span>
        <span style="color:green" ng-bind="boy.age"></span>
        <button ng-click="boy.growUP()">grow up</button>
    </div>
</div>
function TestCtrl($scope){
    $scope.boys = [{
        name: "Tom",
        age: 5,
        growUP: function() {
            this.age ++;
        }
    }, {
        name: "Jerry",
        age: 2,
        growUP: function() {
            this.age ++;
        }
    }];
}

这里,每个boy都能自增自己的年龄,原理与上面相同,这里面growUp方法的调用,用ObjectProxy应当也能处理。

动态包含

另外一个造成视图继承的原因是动态引入界面模板,比如说ng-include和ng-view等。

inner.html

<div>
    <span ng-bind="name"></span>
</div>

outer.html

<div ng-controller="OuterCtrl">
    <span ng-bind="name"></span>
    <div ng-include="'inner.html'"></div>
</div>
function OuterCtrl($scope) {
    $scope.name = "outer name";
}

对上面这个例子来说,ng-include会创建一层作用域,如果不允许作用域继承,那么内层的HTML中就拿不到name属性。那么,为什么ng-include一定要创建子作用域呢?在这个例子里,创建子作用域并不一定必要,直接让两层HTML模板对应同一个视图模型的实例,不就可以了?

我感觉他可能是为了省事,否则要判断动态include进来的这个HTML片段中,是否还指定了别的控制器,如果不管三七二十一就创建子作用域,这事就省了。ng-view跟ng-include的情况还不一样,因为ng-view可能会在路由里面指定新的控制器,所以判断起来就更复杂了,基本上只能创建新作用域。

视图模型的继承好不好?

视图模型的继承在很多情况下是很方便,但造成问题的可能性也会非常多。真的需要这样的共享机制吗?

大家都知道,组件化是解决开发效率不高的银弹,但具体如何做组件化,人们的看法是五花八门的。Angular提供的控制器,服务,指令等概念,把不同的东西隔离到各自的地方,这是一种很好的组件化思路,但与此同时,界面模板层非常乱。

我们可以理解它的用意:只把界面模板层当作配置文件来使用,压根就不考虑它的可复用性。是啊,反正只用一次,就算我写得乱,又怎样呢?可是在Angular中,界面模板是跟控制器密切相关的。我很怀疑控制器的可重用性,注意,它虽然叫控制器,但其实更应该算视图模型。

从可重用性角度来看,如果满分5分的话,整个应用的这些部分的得分应当是这样:

  • 服务,比如说,对后端RESTful接口的AJAX调用,对本地存储的访问等,5分
  • 控制器(也就是视图模型),2-3分
  • 指令,这个要看情况,有的指令是当作对HTML元素体系的扩展来用的,有些是其他事情的
    • 纯UI类型的指令,也可以算是控件,比如DatetimePicker,5分
    • 有些用于沟通DOM跟视图模型的指令,2分
  • 界面模板,这个基本就没有重用性了,1分

从这里我们可以看到,以可重用度来排序,最有价值的是服务和控件,服务代表着业务逻辑的基本单元,控件代表了UI层的最小单元,所以它们是最值得重用的。

现在来看看中间层:视图模型值得重用吗?还是值得的。比如说,同一视图模型以不同的界面模板来展现,这就是一种很好的方式。如果说,同一个视图模型要支持多个界面模板,这些界面模板使用的模型字段或者方法有差异,也可以考虑在视图模型中取并集。例如:

function TestCtrl($scope) {
    $scope.counter = 0;

    $scope.increase = function() {
        $scope.counter++;
    };

    $scope.decrease = function() {
        $scope.counter--;
    };
}

1.html

<div ng-controller="TestCtrl">
    <span ng-bind="counter"></span>
    <button ng-click="increase()">increase</button>
</div>

2.html

<div ng-controller="TestCtrl">
    <span ng-bind="counter"></span>
    <button ng-click="decrease()">decrease</button>
</div>

3.html

<div ng-controller="TestCtrl">
    <span ng-bind="counter"></span>
    <button ng-click="increase()">increase</button>
    <button ng-click="decrease()">decrease</button>
</div>

三个视图的内容是有差异的,但它们仍然共用了同一个视图模型,这个视图模型的内容包含三个视图所能用到的所有属性和方法,每个视图各取所需,互不影响。

这时候,我们再来看视图模型的继承会造成什么影响。如果是我们有了视图模型的继承关系,就意味着界面模板的包含关系必须跟视图模型的继承关系完全一致,这个很大程度上是增加了管理成本的,也造成了视图模型的非通用性。

刚开始提到的例子,如果内外层有同名变量,要在内层作用域中显式变更外层的变量,需要从scope.$parent里面去赋值。而一旦在代码中写了$parent这样的东西,就意味着视图模型只能以这样的方式包含了,甚至说,如果不想变更它们的包含关系,只想变更包含层级,也是不可能的,那说不定就要变成$parent.$parent了。

我们看个场景:

<body ng-app="test">
    <div ng-controller="OuterCtrl">
        <span ng-bind="a"></span>
        <div ng-controller="InnerCtrl">
            <span ng-bind="a"></span>
            <button ng-click="increaseOuterA()">increase outer a</button>
        </div>
    </div>
</body>
var app = angular.module("test", []);

app.controller("OuterCtrl", function ($scope) {
    $scope.a = 1;
});

app.controller("InnerCtrl", function ($scope) {
    $scope.a = 100;

    $scope.increaseOuterA = function() {
        $scope.$parent.a++;
    };
});

这里,因为在InnerCtrl中显式调用了$parent,所以它跟OuterCtrl的视图关系就只能非常固定了。如果说,我们这时候把里面这个div提取出来,放在单独的HTML文件中,然后使用ng-view或者ng-include引入它,因为它们本来就要创建一级作用域,所以会导致这个中间又隔了一级,$parent变成了$parent.$parent,非常不好。

代码如下:

<body ng-app="test">
    <div ng-controller="OuterCtrl">
        <span ng-bind="a"></span>
        <div ng-include="'inner.html'"></div>
    </div>
</body>

inner.html

<div ng-controller="InnerCtrl">
    <span ng-bind="a"></span>
    <button ng-click="increaseOuterA()">increase outer a</button>
</div>

个人认为,在AngularJS中,视图模型的继承虽然使得很多时候代码写起来比较方便,但有些时候会造成很多麻烦。当编写视图模型代码的时候,应当尽量避免父子作用域存在同名变量的情况,以防止造成隐含的问题。不了解AngularJS实现原理的朋友很可能在这里踩很多坑。

  • hefangshi

    对AngularJS不熟悉,但是从传统思想来看,为了解耦,不会写

        $scope.increaseOuterA = function() {
            $scope.$parent.a++;
        };
    

    这样的代码,而是会以事件的形式传递,不知道AngularJS中的最佳实践是什么样的

    #1
  • xufei

    @hefangshi 你说得对,是应当写:

    $scope.$emit("needToChangeA", value);
    

    然后,外层:

    $scope.$on("needToChangeA", function(evt) {
        //这里改变a
    });
    

    它也有冒泡机制,如果中间的层级不取消它,会一直向上冒。后续的文章会专门提及这些。

    #2
  • kazaff

    很赞同你的打分,确实有深有感触~

    #3
  • colorpeach

    民工哥说不在图灵发新文,是以后都发这里了么?

    #4
登录后回复,如无账号,请使用邀请码注册