Giter Site home page Giter Site logo

future's People

Contributors

xufei avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

future's Issues

视图模型的层次

视图模型的层次

嵌套作用域的数据继承

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

数据的双向绑定

Angular沉思录

接触AngularJS已经两年多了,时常问自己一些问题,如果是我实现它,会在哪些方面选择跟它相同的道路,哪些方面不同。为此,记录了一些思考,给自己回顾,也供他人参考。

初步大致有以下几个方面:

  • 数据双向绑定
  • 视图模型的继承关系
  • 模块和依赖注入的设计
  • 待定

数据的双向绑定

Angular实现了双向绑定机制。所谓的双向绑定,无非是从界面的操作能实时反映到数据,数据的变更能实时展现到界面。

一个最简单的示例就是这样:

<div ng-controller="CounterCtrl">
    <span ng-bind="counter"></span>
    <button ng-click="counter++">increase</button>
</div>
function CounterCtrl($scope) {
    $scope.counter = 1;
}

这个例子很简单,毫无特别之处,每当点击一次按钮,界面上的数字就增加一。

绑定数据是怎样生效的

初学AngularJS的人可能会踩到这样的坑,假设有一个指令:

var app = angular.module("test", []);

app.directive("myclick", function() {
    return function (scope, element, attr) {
        element.on("click", function() {
            scope.counter++;
        });
    };
});

app.controller("CounterCtrl", function($scope) {
    $scope.counter = 0;
});
<body ng-app="test">
    <div ng-controller="CounterCtrl">
        <button myclick>increase</button>
        <span ng-bind="counter"></span>
    </div>
</body>

这个时候,点击按钮,界面上的数字并不会增加。很多人会感到迷惑,因为他查看调试器,发现数据确实已经增加了,Angular不是双向绑定吗,为什么数据变化了,界面没有跟着刷新?

试试在scope.counter++;这句之后加一句scope.digest();再看看是不是好了?

为什么要这么做呢,什么情况下要这么做呢?我们发现第一个例子中并没有digest,而且,如果你写了digest,它还会抛出异常,说正在做其他的digest,这是怎么回事?

我们先想想,假如没有AngularJS,我们想要自己实现这么个功能,应该怎样?

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>two-way binding</title>
    </head>
    <body onload="init()">
        <button ng-click="inc">
            increase 1
        </button>
        <button ng-click="inc2">
            increase 2
        </button>
        <span style="color:red" ng-bind="counter"></span>
        <span style="color:blue" ng-bind="counter"></span>
        <span style="color:green" ng-bind="counter"></span>

        <script type="text/javascript">
            /* 数据模型区开始 */
            var counter = 0;

            function inc() {
                counter++;
            }

            function inc2() {
                counter+=2;
            }
            /* 数据模型区结束 */

            /* 绑定关系区开始 */
            function init() {
                bind();
            }

            function bind() {
                var list = document.querySelectorAll("[ng-click]");
                for (var i=0; i<list.length; i++) {
                    list[i].onclick = (function(index) {
                        return function() {
                            window[list[index].getAttribute("ng-click")]();
                            apply();
                        };
                    })(i);
                }
            }

            function apply() {
                var list = document.querySelectorAll("[ng-bind='counter']");
                for (var i=0; i<list.length; i++) {
                    list[i].innerHTML = counter;
                }
            }
            /* 绑定关系区结束 */
        </script>
    </body>
</html>

可以看到,在这么一个简单的例子中,我们做了一些双向绑定的事情。从两个按钮的点击到数据的变更,这个很好理解,但我们没有直接使用DOM的onclick方法,而是搞了一个ng-click,然后在bind里面把这个ng-click对应的函数拿出来,绑定到onclick的事件处理函数中。为什么要这样呢?因为数据虽然变更了,但是还没有往界面上填充,我们需要在此做一些附加操作。

从另外一个方面看,当数据变更的时候,需要把这个变更应用到界面上,也就是那三个span里。但由于Angular使用的是脏检测,意味着当改变数据之后,你自己要做一些事情来触发脏检测,然后再应用到这个数据对应的DOM元素上。问题就在于,怎样触发脏检测?什么时候触发?

我们知道,一些基于setter的框架,它可以在给数据设值的时候,对DOM元素上的绑定变量作重新赋值。脏检测的机制没有这个阶段,它没有任何途径在数据变更之后立即得到通知,所以只能在每个事件入口中手动调用apply(),把数据的变更应用到界面上。在真正的Angular实现中,这里先进行脏检测,确定数据有变化了,然后才对界面设值。

所以,我们在ng-click里面封装真正的click,最重要的作用是为了在之后追加一次apply(),把数据的变更应用到界面上去。

那么,为什么在ng-click里面调用$digest的话,会报错呢?因为Angular的设计,同一时间只允许一个$digest运行,而ng-click这种内置指令已经触发了$digest,当前的还没有走完,所以就出错了。

$digest和$apply

在Angular中,有$apply和$digest两个函数,我们刚才是通过$digest来让这个数据应用到界面上。但这个时候,也可以不用$digest,而是使用$apply,效果是一样的,那么,它们的差异是什么呢?

最直接的差异是,$apply可以带参数,它可以接受一个函数,然后在应用数据之后,调用这个函数。所以,一般在集成非Angular框架的代码时,可以把代码写在这个里面调用。

var app = angular.module("test", []);

app.directive("myclick", function() {
    return function (scope, element, attr) {
        element.on("click", function() {
            scope.counter++;
            scope.$apply(function() {
                scope.counter++;
            });
        });
    };
});

app.controller("CounterCtrl", function($scope) {
    $scope.counter = 0;
});

除此之外,还有别的区别吗?

在简单的数据模型中,这两者没有本质差别,但是当有层次结构的时候,就不一样了。考虑到有两层作用域,我们可以在父作用域上调用这两个函数,也可以在子作用域上调用,这个时候就能看到差别了。

对于$digest来说,在父作用域和子作用域上调用是有差别的,但是,对于$apply来说,这两者一样。我们来构造一个特殊的示例:

var app = angular.module("test", []);

app.directive("increasea", function() {
    return function (scope, element, attr) {
        element.on("click", function() {
            scope.a++;
            scope.$digest();
        });
    };
});

app.directive("increaseb", function() {
    return function (scope, element, attr) {
        element.on("click", function() {
            scope.b++;
            scope.$digest();    //这个换成$apply即可
        });
    };
});

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

    $scope.$watch("a", function(newVal) {
        console.log("a:" + newVal);
    });

    $scope.$on("test", function(evt) {
        $scope.a++;
    });
}]);

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

    $scope.$watch("b", function(newVal) {
        console.log("b:" + newVal);
        $scope.$emit("test", newVal);
    });
}]);
<div ng-app="test">
    <div ng-controller="OuterCtrl">
        <div ng-controller="InnerCtrl">
            <button increaseb>increase b</button>
            <span ng-bind="b"></span>
        </div>
        <button increasea>increase a</button>
        <span ng-bind="a"></span>
    </div>
</div> 

这时候,我们就能看出差别了,在increase b按钮上点击,这时候,a跟b的值其实都已经变化了,但是界面上的a没有更新,直到点击一次increase a,这时候刚才对a的累加才会一次更新上来。怎么解决这个问题呢?只需在increaseb这个指令的实现中,把$digest换成$apply即可。

当调用$digest的时候,只触发当前作用域和它的子作用域上的监控,但是当调用$apply的时候,会触发作用域树上的所有监控。

因此,从性能上讲,如果能确定自己作的这个数据变更所造成的影响范围,应当尽量调用$digest,只有当无法精确知道数据变更造成的影响范围时,才去用$apply,很暴力地遍历整个作用域树,调用其中所有的监控。

从另外一个角度,我们也可以看到,为什么调用外部框架的时候,是推荐放在$apply中,因为只有这个地方才是对所有数据变更都应用的地方,如果用$digest,有可能临时丢失数据变更。

脏检测的利弊

很多人对Angular的脏检测机制感到不屑,推崇基于setter,getter的观测机制,在我看来,这只是同一个事情的不同实现方式,并没有谁完全胜过谁,两者是各有优劣的。

大家都知道,在循环中批量添加DOM元素的时候,会推荐使用DocumentFragment,为什么呢,因为如果每次都对DOM产生变更,它都要修改DOM树的结构,性能影响大,如果我们能先在文档碎片中把DOM结构创建好,然后整体添加到主文档中,这个DOM树的变更就会一次完成,性能会提高很多。

同理,在Angular框架里,考虑到这样的场景:

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

    var list = [];

    for (var i=0; i<10000; i++) {
        list.push({
            index: i,
            checked: false
        });
    }

    $scope.list = list;

    $scope.toggleChecked = function(flag) {
        for (var i=0; i<list.length; i++) {
            list[i].checked = flag;
            $scope.numOfCheckedItems++;
        }
    };
}

如果界面上某个文本绑定这个numOfCheckedItems,会怎样?在脏检测的机制下,这个过程毫无压力,一次做完所有数据变更,然后整体应用到界面上。这时候,基于setter的机制就惨了,除非它也是像Angular这样把批量操作延时到一次更新,否则性能会更低。

所以说,两种不同的监控方式,各有其优缺点,最好的办法是了解各自使用方式的差异,考虑出它们性能的差异所在,在不同的业务场景中,避开最容易造成性能瓶颈的用法。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.