blog icon indicating copy to clipboard operation
blog copied to clipboard

Angular沉思录(二)视图模型的层次

Open xufei opened this issue 9 years ago • 7 comments

视图模型的层次

嵌套作用域的数据继承

在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实现原理的朋友很可能在这里踩很多坑。

xufei avatar Nov 26 '14 10:11 xufei

我也觉得基于prototype继承的scope方式不好。我觉得合理的方式是类似闭包的scope形式。

不过视图嵌套存在一个理论上的问题,如下代码:

<x-tag1 bind="x">
  <x-tag2 bind="y">
    <p>{z}</p>
  </x-tag2>
</x-tag1>

我的问题是那些 bindings 是什么时候发生(处理)的。

有两种可能性:

  1. 都是在最外层发生(处理)的。
  2. 内层的bind是在component里面发生(处理)的。

嵌套视图更接近第二种。

hax avatar Nov 26 '14 14:11 hax

@hax web页面里的mvvm跟其他体系的mvvm,最大差异就在这,因为别的任何体系都是不可能用视图模型的继承来解决视图嵌套问题的,也不需要这么做。尽管我们都觉得用原型来干这事太别扭了,但从实现来说,可能还真是最简单的,配合js这种能动态改原型的语言,一句话解决问题…

所以,2.0里面估计还是这么干。有时候我也想,如果我们不把原型的含义往继承那个方向想,只当作数据组合的一种机制,好像也无所谓,就没什么心理负担了

xufei avatar Nov 26 '14 15:11 xufei

应该不会再这么干,不是都没有$scope了嘛。

hax avatar Nov 26 '14 18:11 hax

@hax 没有$scope,但是控制器本身就承担$scope的职责,也就是直接充当视图模型了啊。

你看看1.2对controller的变更,2应该会默认变成这种:

<div ng-controller="MainController as main">
    {{ main.title }}
</div>
app.controller("MainController", function(){
    this.title = “Hello!”;
});

也就是说,其实1.2就可以没有$scope了

xufei avatar Nov 27 '14 00:11 xufei

所以即使嵌套,你写变量时总是 {{main.title}} 或 {{inner.title}} 来明确指定,所以不存在$scope继承的场景了吧。其实原本它也可以用 {{InnerCtrl::title}} 或者 {{parent::title}} 这样的语法来明确。不知道为什么要搞一个 prototype 继承,只能理解为当初在实现时一拍脑袋觉得这样很cool……

hax avatar Nov 27 '14 06:11 hax

徐大有个无关紧要的错误:

因为它其实是通过原型集成来做到这样的。

应该为

因为它其实是通过原型继承来做到这样的。

然后这里的循环造作用域问题:

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

应该是这样吧?

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

kmCha avatar Feb 19 '16 16:02 kmCha

确实,即便是想重用控制器和视图文件,只要控制器之间变量存在嵌套使用就无法重用了。

lanxiuying avatar Aug 04 '18 09:08 lanxiuying