《AngularJS深度剖析与最佳实践》一1.5 实现更多功能:主题

简介:

本节书摘来自华章出版社《AngularJS深度剖析与最佳实践》一书中的第1章,第1.5节,作者 雪狼 破狼 彭洪伟,更多章节内容可以访问云栖社区“华章计算机”公众号查看

1.5 实现更多功能:主题

1.5.1 实现主题列表

我们还没有实现发帖和取帖子列表的后端功能,甚至连这个API该设计成什么样都还不清楚。不过没关系,我们可以先在前端做一些模拟数据,等到前端所需的数据结构确定下来,API也就基本成型了,这也是敏捷方法的一种应用。
我们先做些准备工作。在router.js中增加两个路由定义:

$stateProvider.state('thread', {
    url: '/thread',
    template: '<div ui-view></div>',
    abstract: true
});
$stateProvider.state('thread.list', {
    url: '/list',
    templateUrl: 'controllers/thread/list.html',
    controller: 'ThreadListCtrl as vm'
});

然后创建两个空文件:app/controllers/thread/list.js和app/controllers/thread/list.html。
接下来我们就正式开始实现。
最明显,我们需要几个字段:标题(title)、发帖人(poster)、发帖时间(dateCreated)。于是我们得出了第一个控制器:

angular.module('com.ngnice.app').controller('ThreadListCtrl', function ThreadListCtrl() {
    var vm = this;
    vm.items = [
        {
            title: '这是第一个主贴',
            poster: '雪狼',
            dateCreated: '2015-02-19T00:00:00'
        },
        {
            title: '这是第二个主贴,含有字母abcd和数字1234',
            poster: '破狼',
            dateCreated: '2015-02-19T15:00:00'
        }
    ];
    for (var i = 0; i < 10; ++i) {
        vm.items.push({
            title: '主题' + i,
            poster: 'user' + i,
            dateCreated: '2015-02-18T15:00:00'
        });
    }
});
和第一个模板:
<table class="table table-hover table-striped">
    <thead>
    <tr>
        <th>主题</th>
        <th>作者</th>
        <th>发帖时间</th>
    </tr>
    </thead>
    <tbody>
    <tr ng-repeat="item in vm.items">
        <td>{{item.title}}</td>
        <td>{{item.poster}}</td>
        <td>{{item.dateCreated}}</td>
    </tr>
    </tbody>
</table>

这里起主要作用的是我们的老朋友:ng-repeat指令。
现在我们已经可以通过在地址栏输入http://localhost:5000/#/thread/list来预览效果了。
但是这里面的日期显示的仍然是ISO-8601格式的日期,对用户来说不够友好,接下来我们把它改成可读性更好些的格式。我们前面提到过,在Angular中,负责对数据进行格式化的是“过滤器”(filter),于是我们查阅Angular的API文档,发现在filter分类下有一个date过滤器,顾名思义,它应该是用来格式化日期的,于是我们把它加到dateCreated上:{{item.dateCreated|date}}。
不过,预览的时候我们发现它被格式化成了英文格式,对中文用户来说太不友好了。针对不同国家、语种的用户进行特殊处理的工作,叫作i18n,它是internationalization(国际化)的缩写,因为它中间正好有18个字符。好在,Angular已经内置了一组i18n文件,中文简体的文件名叫作angular-locale_zh-cn.js。我们把它从github的Angular项目中下载下来,然后复制到app/libraries目录里就可以了。当然,放到app的其他子目录中也可以,不过我们的约定是把除bower之外的第三方库都放到app/libraries目录下,遵循这个约定有助于提高程序的可维护性。
date过滤器还可以带参数,如果我们要把它显示成“年-月-日时:分”的自定义格式,那么就要这么写:{{item.dateCreated:date:'yyyy-MM-dd HH:mm'}}。
如果要进一步提高用户友好性,最好把时间显示成“刚才”、“一分钟前”、“昨天”等形式。这种情况下,我们可以自定义一个新的过滤器,来完成转换工作。由于这个过滤器不涉及新的知识点,就不再赘述了,你可以自己尝试实现,还可以借助一个第三方时间库momentjs来降低复杂度。

1.5.2 实现过滤功能

接下来,我们给读者提供一个搜索框,可以用它对主贴标题、作者、发帖时间进行过滤。为了展示Angular的威力,我们先不借助后端,直接在前端实现过滤功能。即使在现实项目中,前端过滤也非常有用:在数据条数较少时,前端过滤可以大幅简化代码。在做原型的时候,它也具有出色的编程速度和表现力。
前几节我们说过,过滤器实际上应该叫转换器,常见的用法为:格式化和过滤。我们已经展示过它的格式化功能,现在我们就要用到它的过滤功能了。
我们先来看代码:

<div>
    <label for="_keyword">
        过滤条件:
    </label>
    <!-- 把用户输入的结果存到vm.filter.$变量中,供后面使用 -->
    <input id="_keyword" type="text" placeholder="请输入主题、作者或发帖时间的任意部分进行筛选" ng-model="vm.filter.$"/>
</div>
<table class="table table-hover table-striped">
    <thead>
    <tr>
        <th>主题</th>
        <th>作者</th>
        <th>发帖时间</th>
    </tr>
    </thead>
    <tbody>
    <!-- 使用Angular内置的名为filter的过滤器来实现过滤 -->
    <tr ng-repeat="item in vm.items | filter:vm.filter">
        <td>{{item.title}}</td>
        <td>{{item.poster}}</td>
        <td>{{item.dateCreated|date:'yyyy-MM-dd HH:mm'}}</td>
    </tr>
    </tbody>
</table>

可以看到,为了实现前端过滤,我并没有写任何JavaScript代码,只在HTML中通过很简单的几步就实现了。
加入一个输入框,用于接收关键字,并且存入vm.filter对象上名为$的属性中。
把这个vm.filter对象作为filter过滤器的参数,filter过滤器会根据这个参数对vm.items数组进行筛选。把过滤的结果传给ng-repeat指令,Angular会据此更新显示内容。
现在,是否已经体验到了“出色的编程速度”?我们再来看“表现力”:当你输入任何字符或汉字时,它都会对每个对象中的数据进行部分匹配,并且立即更新列表。如果不考虑大数据量下的性能问题,这显然是一种相当不错的体验。
在实际开发中,我一般以2000条普通查询结果作为区分用前端过滤还是用后端过滤的界限,即:当预期的最大数据条数小于2000时,用前端过滤,否则用后端过滤。如果后端逻辑复杂,查询代价高,或者单条数据量大,传输代价高,可以适当减小这个限额。当然,如果要实现基于语义分析的模糊匹配,显然就该用后端过滤来实现了。
如果要实现后端过滤功能该怎么写呢?

<div>
    <label for="_keyword">
        过滤条件:
    </label>
    <!-- 把用户输入的结果存到vm.filter.$变量中,通过vm.search函数发起请求,把结果存入vm.items变量中 -->
    <input id="_keyword" type="text" placeholder="请输入主题、作者或发帖时间的任意部分进行筛选" ng-model="vm.filter.$" ng-change="vm.search()"/>
</div>
<table class="table table-hover table-striped">
...
    <!-- vm.items是经过后端过滤的数据 -->
    <tr ng-repeat="item in vm.items">
...
    </tr>
    </tbody>
</table>

其工作原理是这样的:
当用户进行输入时,ng-change事件会被触发,并且把过滤条件作为参数传给后端。
后端解释这些过滤条件,然后返回符合条件的数据。
前端收到这些数据,就把它赋值给vm.items。
Angular检测到vm.items变化了,就会据此更新界面。
如果要进一步改进,还可以修改ng-change的处理函数vm.search,让它在用户连续输入的时候不发起网络请求,直到用户的输入告一段落时再发起。这个看起来酷炫的功能其实不必自己写,直接用第三方库lodash的debounce函数来完成就可以了,代码如下:

var search = function() {
    // 发起网络请求,把vm.filter作为过滤条件传给后端
};
// 包装后的结果供模板调用
vm.search = _.debounce(search, 500);

就这么简单!
lodash是个很强大的JavaScript算法库,值得深入学习。

1.5.3 实现分页功能

分页和排序也是列表中常用的功能,它们同样有前端实现和后端实现两种方式。过滤、分页、排序的实现原理大同小异,在本节中,我们将一起实现前端分页功能,同时作为对上一节的复习。而后端分页功能和排序功能,我们不再讲解,读者朋友可以尝试自己实现。
我们先按照“模型驱动开发”的方式来思考分页:
我们有一大堆数据。
我们需要从中截取出一部分,显示给用户。
假设当前页码为page(我们从0开始编号),每页条目数为pageSize,则我们要截取的范围是:page pageSize到(page + 1) pageSize。
抛开形形色色的表现形式,分页的核心逻辑就是这么简单。
那么,我们如何做数据截取呢?我们首先要明白数据截取的本质就是一种过滤,其过滤条件是“起始索引号”和“截止索引号”。想通了这一点,答案就呼之欲出了:过滤器,我们的老朋友。
于是,我们设计了一个叫作page的过滤器:

angular.module('com.ngnice.app').filter('page', function () {
    return function (input, page, pageSize) {
        if (!input) {
            return input;
        }
        if (page < 0 || pageSize <= 0) {
            return [];
        }
        var start = page * pageSize;
        var end = (page + 1) * pageSize;
        return input.slice(start, end);
    };
});

由于这里涉及边界问题,容易出错,我们还要写一个单元测试来保障它的行为不会超出预期。

describe('filter > page >', function () {
    beforeEach(module('com.ngnice.app'));
    var pageFilter;
    // 注入器会忽略变量名前后的下划线
    // 每个filter其实都是一个nameFilter的服务,我们可以用这种方式快速注入,不再引用$filter服务,这样表意性也更强
    beforeEach(inject(function (_pageFilter_) {
        pageFilter = _pageFilter_;
    }));

    var items;
    beforeEach(function () {
        items = [1, 2, 3, 4, 5, 6];
    });
    it('第一页为满页时', function() {
        expect(pageFilter(items, 0, 3)).toEqual([1,2,3]);
    });
    it('第一页为不满页时', function() {
        expect(pageFilter(items, 0, 7)).toEqual([1,2,3,4,5,6]);
    });
    it('最后一页为满页时', function() {
        expect(pageFilter(items, 1, 3)).toEqual([4,5,6]);
    });
    it('最后一页为不满页时', function() {
        expect(pageFilter(items, 1, 4)).toEqual([5,6]);
    });
    it('大于最大页码时', function() {
        expect(pageFilter(items, 2, 4)).toEqual([]);
    });
    it('小于最小页码时', function() {
        expect(pageFilter(items, -2, 4)).toEqual([]);
    });
});

这里唯一需要注意的是引用filter的一个小技巧,它有助于我们理解Angular的工作原理。
官方推荐的方式是注入$filter,然后调用$filter(憄age?来获得page过滤器。事实上,在Angular中,每个filter都是一个service,只是它的名字加上了Filter后缀。比如page过滤器对应的服务名就是pageFilter。这两者是等价的,但由于我讨厌在代码中硬编码字符串,所以一般使用直接注入service的方式。
使用这个过滤器就很简单了,我们先在界面中通过一个输入框来输入页码,并且规定每页大小为5条,然后我们再想办法美化它。这也是在遵循敏捷方法:每一步只做一件事。

<table class="table table-hover table-striped">
...
    <tr ng-repeat="item in vm.items | filter:vm.filter | page:vm.page.index:5">
...
    </tr>
</table>
<label>
    页号:
    <input type="number" ng-model="vm.page.index"/>
</label>

剩下的工作就是分页标签的美化,最简单的方式是使用第三方库:angular-bootstrap中的pagination指令,它已经实现得相当不错了。如果想要自己写,这个指令也可以作为一个复杂度适中的练习题,但是要记住以下几点:
面向模型编程。
测试驱动开发。
先保障交互逻辑,再调整细节。
做完练习之后可以与angular-bootstrap的pagination指令(注意,它的起始页码是1而不是0),以及我随书源码中所实现的分页标签作对比。如果对我的实现方式感到惊讶,不用困惑,也不用着急,在下一节中,我会详细讲解这种实现思路。

1.5.4 实现主题树

把主题和回复按照回复关系组织起来,就构成了一棵“主题树”。相比传统论坛列表形式的组织方式,主题树可以更加直观地展现出回复关系,并且更方便按照分组进行操作,比如把一个回复及其后续回复收藏起来或删除。这些特性不太适合人气火爆的论坛,但是比较适合我们这个小型的读者交流社区。
树在各种前端框架中都不是一个小话题,为了避免一次引入大量新概念,我们拆成两步来实现它:首先,做一个固定层数,带有折叠和级联选择功能的树;然后,把它扩展成不限层数,可以任意递归的树。
我们先来构造模拟数据:

angular.module('com.ngnice.app').controller('ThreadTreeCtrl', function ThreadTreeCtrl() {
    var vm = this;
    vm.items = [
        {
            id: 1,
            title: '这是第一个主题',
            poster: '雪狼',
            dateCreated: '2015-02-19T00:00:00',
            items: [
                {
                    id: 11,
                    title: '这是第一个回复',
                    poster: '雪狼',
                    dateCreated: '2015-02-19T00:00:01',
                    items: [
                        {
                            id: 111,
                            title: '回复1.1',
                            poster: '破狼',
                            dateCreated: '2015-02-19T00:01:00'
                        },
                        {
                            id: 112,
                            title: '回复1.2',
                            poster: '破狼',
                            dateCreated: '2015-02-19T00:01:30'
                        }
                    ]
                },
                {
                    id: 12,
                    title: '这是第二个回复',
                    poster: '雪狼',
                    dateCreated: '2015-02-19T00:00:03'
                }
            ]
        },
        {
            id: 2,
            title: '这是第二个主题,含有字母abcd和数字1234',
            poster: '破狼',
            dateCreated: '2015-02-19T15:00:00'
        }
    ];
});
对于我们的目标来说,三层数据已经有了足够的代表性。接下来,我们就把它展示到界面上:
<ul ng-if="vm.items">
    <li ng-repeat="item1 in vm.items">
        {{item1.title}}
        <ul ng-if="item1.items">
            <li ng-repeat="item2 in item1.items">
                {{item2.title}}
                <ul ng-if="item2.items">
                    <li ng-repeat="item3 in item2.items">
                        {{item3.title}}
                    </li>
                </ul>
            </li>
        </ul>
    </li>
</ul>

显然,它是个递归结构,但是由于Angular的一些限制,我们没法直接用指令递归的方式实现它,而要借助一些特殊的技巧,这些技巧涉及一些Angular高级知识,我们把它留到后面再讲。
接下来,我们要实现折叠功能。所谓折叠,就是把子节点隐藏起来。但这是一个面向DOM的思维方式,我们把它换成面向模型的思维方式:下级子节点的“显示与否”取决于当前节点的一个Model,即“是否折叠”($folded)。
现在我们用代码来表达这个思路:

<ul ng-if="vm.items">
    <li ng-repeat="item1 in vm.items">
        <!-- $folded是表示当前节点是否折叠的Model,点击是切换它 -->
        <div ng-click="item1.$folded = !item1.$folded">
            <!-- 有子节点时才显示加减号 -->
            <span ng-if="item1.items">
                <!-- 未折叠的显示减号 -->
                <span ng-if="!item1.$folded">-</span>
                <!-- 已折叠的显示加号 -->
                <span ng-if="item1.$folded">+</span>
            </span>
            {{item1.title}}
        </div>
        <!-- 子节点的显示与否,取决于当前节点的Model:$folded -->
        <ul ng-if="item1.items && !item1.$folded">
            <li ng-repeat="item2 in item1.items">
                <div ng-click="item2.$folded = !item2.$folded">
                    <span ng-if="item2.items">
                        <span ng-if="!item2.$folded">-</span>
                        <span ng-if="item2.$folded">+</span>
                    </span>
                    {{item2.title}}
                </div>
                <ul ng-if="item2.items && !item2.$folded">
                    <li ng-repeat="item3 in item2.items">
                        {{item3.title}}
                    </li>
                </ul>
            </li>
        </ul>
    </li>
</ul>

我们没有写一句JavaScript代码,只修改了一下HTML模板就实现了折叠功能。这里之所以用$开头,主要有两个目的,一是减小和原有数据冲突的可能,二是如果这些数据通过$http或$resource提交给服务器,它们所调用的angular.toJson()函数会忽略所有以$开头的属性,这样我们扩展出来的属性就不会被提交了。这带来一个额外的小便利:阅读的时候一看到是$开头的,也就知道是扩展出来的属性了。
但是把item1.$folded = !item1.$folded这类代码混合在HTML中是不够干净的,我们要把它重构出去。重构的方式有多种。最明显的一种是把整棵树作成一个指令,但这也是最坏的一种,事实上,这是在用写jQuery组件的思路写Angular,它会将视图和模型紧密地混合在一起,难以复用和测试。这是一种坏习惯,我就不再演示了。
好一些的是把切换的代码封装成函数,放在controller中:vm.toggle = function(item) {item.$folded = !item.$folded; },在视图中直接调用vm.toggle(item1)。这种情况下,HTML中倒是“干净”了,但控制器又“脏”了。而且一旦我们加入比折叠更复杂的代码,那么这些代码将不得不写很多份。
更好的方式是作成一个服务。我们真正要实现的逻辑,其实是对模型中的$folded属性进行操作,然后通过一些指令来告诉Angular如何让视图和模型保持同步。所以,我们只要给模型加上$foldToggle()之类的操作方法和$isFolded()之类的判断函数就可以了。我们理想的使用方式是这样的:

<ul ng-if="vm.$hasChildren()">
    <li ng-repeat="item1 in vm.items">
        <!-- 点击时调用切换折叠状态的函数 -->
        <div ng-click="item1.$foldToggle()">
            <!-- 判断是否有子节点 -->
            <span ng-if="item1.$hasChildren()">
                <!-- 隐藏私有变量$folded,改用$isFolded函数 -->
                <span ng-if="!item1.$isFolded()">-</span>
                <span ng-if="item1.$isFolded()">+</span>
            </span>
            {{item1.title}}
        </div>
        <ul ng-if="item1.$hasChildren() && !item1.$isFolded()">
        ...
        </ul>
    </li>
</ul>

显然,item1本身是不存在这三个函数的,但是我们可以加上它:

angular.module('com.ngnice.app').controller('ThreadTreeCtrl', function ThreadTreeCtrl(tree) {
    var vm = this;
    vm.items = ...
    tree.enhance(vm.items);
});

这里的tree是个我们所期望的一个工具类服务,它负责对数据进行强化,给它添加这些函数。
接下来,我们就来实现这个工具类服务:

angular.module('com.ngnice.app').service('tree', function Tree() {
    var self = this;
    // 强化条目
    var enhanceItem = function (item, childrenName) {
        item.$hasChildren = function() {
            var subItems = this[childrenName];
            return angular.isArray(subItems) && subItems.length;
        };
        item.$foldToggle = function () {
            this.$folded = !this.$folded;
        };
        item.$isFolded = function () {
            return this.$folded;
        };
    };
    // 对传进来的数据进行强化
    this.enhance = function (items, childrenName) {
        if (angular.isUndefined(childrenName)) {
            childrenName = 'items';
        }
        angular.forEach(items, function (item) {
            enhanceItem(item, childrenName);
            // 如果有子节点,则递归处理
            if (item.$hasChildren()) {
                self.enhance(item[childrenName], childrenName);
            }
        });
        return items;
    };
});

这样用起来还是不够方便,而且把强化数据的代码也都放进了控制器中,还是不够干净。
还有什么方式能让它更简单点呢?有请我们的老朋友—过滤器:

<ul ng-if="vm.$hasChildren()">
    <li ng-repeat="item1 in vm.items|tree">
        ...
    </li>
</ul>

我们记得过滤器的本质是数据转换,并且示范过它的两大主要用法:格式化和筛选。现在则是一个不常用但很有用的用法:强化,也就是为数据添加成员和方法。
我们期望有这样一个过滤器,接着就来实现它,但是我们不用从头写代码,直接使用已经写好的tree服务就可以了:

angular.module('com.ngnice.app').filter('tree', function (tree) {
    return function (items, childrenName) {
        tree.enhance(items, childrenName);
        return items;
    };
});

这是在编程中常用的一种方式:先想好准备怎么用,然后再逐步去实现它。这其实是“测试驱动开发”的一个退化变种。由于折叠的逻辑非常简单,所以我们直接用这种方式就够用了。
接下来,我们要实现一个更复杂的功能,这个功能我们就要用“测试驱动开发”的方式来实现了。
我们先来详细描述一下“级联选择”这个需求:
每个节点前有一个复选框。
选中父节点时,自动选中所有子节点。
选中所有子节点时,自动选中父节点。
取消选中所有子节点时,自动取消选中父节点。
部分选中子节点时,父节点显示为“待定”状态。
这次,我们不再像以前那样先写视图,而是直接来设计模型。“以模型为核心的思维方式”是你从新手成为Angular专家的一个重要特征。
像我们设计折叠功能时看到的一样,每个节点需要有两个方法:$checkToggle()和$isChecked()。接着,为了支持待定状态,我们还需要一个方法:$isIndeterminate()。由于$checkToggle()对当前状态的依赖比较大,测试的时候很不方便,和checkbox配合的时候也不理想。为了提供更高的可控性,我们再增加$check()、$unCheck()和$setCheckState函数。
供视图中使用的接口先定下这6个就足够了,其他的方法在我们编程的过程中自然会显现出来,现在不用急。
接下来,我们就要来实现它了。我们要在前面所写的tree服务的基础上改进一下,实现“级联选择”功能。
级联选择的逻辑远比折叠的逻辑复杂,那么,是时候请出我们的必杀器:“测试驱动开发”(TDD)了!
在FrontJet的支持下,TDD是一种享受。我们的fj serve命令会同步开启TDD模式,只要留意下它的控制台窗口,就可以开始我们的TDD之旅了。
首先,我们写一个针对tree service的单元测试文件,按照约定,我们把它命名为tree.test.js,.test.js是我们给单元测试文件加的后缀,用于和程序文件相对应的同时区分开文件名。文件内容如下:

// 用describe函数对测试进行分组
describe('tree service >', function () {
    // 每次执行前加载app模块,它依赖的模块也会被级联加载
    beforeEach(module('com.ngnice.app'));
    var tree;
    // 这里可以注入服务或控制器,注入器会忽略名称两端的下划线
    beforeEach(inject(function (_tree_) {
        tree = _tree_;
    }));
    // 把我们前面写的需求规约逐个加进来
    it('选中父节点时,自动选中所有各级子节点', function () {
    });
});

当我们保存这个文件时,FrontJet中集成的Karma会自动跑一遍单元测试,并且在控制台中给出反馈:
INFO [watcher]: Changed file "/Users/.../services/utils/tree.test.js".
PhantomJS 1.9.8 (Mac OS X): Executed 1 of 1 SUCCESS (0.001 secs / 0.016 secs)
我们看到,测试执行成功了,这是测试驱动开发的“绿灯”状态。接下来,我们先构造数据,并把它们都变为“红灯”状态,以免将来遗漏了需求。

describe('tree service >', function () {
    beforeEach(module('com.ngnice.app'));
    var tree;
    // 这里可以注入服务或控制器,注入器会忽略名称两端的下划线
    beforeEach(inject(function (_tree_) {
        tree = _tree_;
    }));
    var treeData;
    // 定义两个供测试用的工具函数
    var getFlattenData = function (items) {
        var result = items || [];
        angular.forEach(items, function (item) {
            result = result.concat(getFlattenData(item.items));
        });
        return result;
    };
    var findById = function (id) {
        return _.findWhere(getFlattenData(treeData), {id: id});
    };
    // 构造尽量少但足够有代表性的模拟数据
    beforeEach(function () {
        treeData = [
            {
                id: 1,
                items: [
                    {
                        id: 11,
                        items: [
                            {
                                id: 111
                            },
                            {
                                id: 112
                            }
                        ]
                    },
                {
                    id: 12
                    }
                ]
            }
        ];
        tree.enhance(treeData);
    });
    it('选中父节点时,自动选中直接子节点', function () {
        // 动作:选中中间节点
        findById(11).$check();
        expect(findById(111).$isChecked()).toBeTruthy();
        expect(findById(112).$isChecked()).toBeTruthy();
    });
    it('选中祖先节点时,自动选中间接子节点', function() {
        // 动作:选中根节点
        findById(1).$check();
        // 期待:子节点被选中
        expect(findById(11).$isChecked()).toBeTruthy();
        expect(findById(12).$isChecked()).toBeTruthy();
        // 期待:孙子节点被选中
        expect(findById(111).$isChecked()).toBeTruthy();
        expect(findById(112).$isChecked()).toBeTruthy();
    });
});

这段代码很长,不过做的事情并不是很多。我们先定义了测试数据,然后用代码定义了一系列断言来落实需求。每个测试的组成大致是:做一个动作,检查所期待的状态。
注意,我们把这条需求拆分成了两个:选中父节点时自动选中“直接子节点”,选中祖先节点时自动选中“间接子节点”。把它们合在一个测试中当然也是可以的,不过,作为单元测试的一条重要原则,我们应该尽可能让每个单元测试只做一件事。
我们定义完了这一系列规约,但是还没有实现tree服务,所以我们看到如下错误:

INFO [watcher]: Changed file "/Users/.../services/utils/tree.test.js".
PhantomJS 1.9.8 (Mac OS X) tree service > 选中父节点时,自动选中直接子节点 FAILED
    TypeError: 'undefined' is not a function (evaluating 'findById(11).$check()')
        at /Users/.../services/utils/tree.test.js:45
        ...
PhantomJS 1.9.8 (Mac OS X) tree service > 选中祖先节点时,自动选中间接子节点 FAILED
    TypeError: 'undefined' is not a function (evaluating 'findById(1).$check()')
        at /Users/.../services/utils/tree.test.js:51
        ...
PhantomJS 1.9.8 (Mac OS X): Executed 2 of 2 (2 FAILED) ERROR (0.001 secs / 0.014 secs)

现在两个测试全部失败了,这是测试驱动开发中的下一个状态:红灯。测试驱动开发过程中会期待红绿灯交替出现。接下来,我们就把它变为绿灯状态。
我们先修改下tree服务:

var enhanceItem = function (item, childrenName) {
    ...
    // 递归设置
    var setCheckState = function(node, checked) {
        node.$checked = checked;
        if (node.$hasChildren()) {
            angular.forEach(node[childrenName], function(subNode) {
                setCheckState(subNode, checked);
            });
        }
    };
    item.$check = function() {
        setCheckState(item, true);
    };
    item.$isChecked = function() {
        return this.$checked;
    };
};

当程序文件发生变化时,单元测试也会被自动执行。控制台中会出现下列输出:

INFO [watcher]: Changed file "/Users/.../app/services/utils/tree.js".
PhantomJS 1.9.8 (Mac OS X): Executed 2 of 2 SUCCESS (0.001 secs / 0.011 secs)

于是,我们又回到了“绿灯”状态,这就是一个TDD工作循环。对于逻辑比较复杂的功能,我们可能还要经过多个红绿灯才能完成一个工作循环。
接下来,我们按照上面的工作方式,把另外三项需求也逐步加进来,我不再把代码摘录到书中,只把最终的单元测试列在这里,大家可以尝试自己实现。

it('选中所有子节点时,自动选中父节点', function () {
    findById(111).$check();
    findById(112).$check();
    expect(findById(11).$isChecked()).toBeTruthy();
    findById(12).$check();
    expect(findById(1).$isChecked()).toBeTruthy();
});
it('取消选中所有子节点时,自动取消选中父节点', function () {
    // 先全部选中
    findById(1).$check();
    expect(findById(112).$isChecked()).toBeTruthy();
    // 逐个反选
    findById(111).$unCheck();
    findById(112).$unCheck();
    expect(findById(11).$isChecked()).toBeFalsy();
    findById(12).$unCheck();
    expect(findById(1).$isChecked()).toBeFalsy();
});
it('部分选中子节点时,父节点显示为“待定”状态', function () {
    findById(111).$check();
    expect(findById(11).$isIndeterminate()).toBeTruthy();
    expect(findById(1).$isIndeterminate()).toBeTruthy();
    // 子节点本身和没有子节点的节点不受影响
    expect(findById(111).$isIndeterminate()).toBeFalsy();
    expect(findById(12).$isIndeterminate()).toBeFalsy();
});
it('全部选中子节点时,父节点为“非待定”状态', function() {
    findById(111).$check();
    expect(findById(11).$isIndeterminate()).toBeTruthy();
    findById(112).$check();
    expect(findById(11).$isIndeterminate()).toBeFalsy();
});
it('$checkToggle会切换当前节点的选中状态', function() {
    var node = findById(111);
    node.$checkToggle();
    expect(node.$isChecked()).toBeTruthy();
    node.$checkToggle();
    expect(node.$isChecked()).toBeFalsy();
});
我们实现完服务之后,再把它应用到页面中:
<ul ng-if="vm.items">
    <li ng-repeat="item1 in vm.items">
        <div>
            <span ng-if="item1.$hasChildren()" ng-click="item1.$foldToggle()">
                <span ng-if="!item1.$isFolded()">-</span>
                <span ng-if="item1.$isFolded()">+</span>
            </span>
            <label>
            <!-- 在数据变化时,调用服务中的相应函数来更新父节点、子节点的状态 -->
            <!-- 通过自定义指令bf-check-indeterminate来把模型更新到界面上 -->
<input type="checkbox" bf-check-indeterminate="item1.$isIndeterminate()" ng-model="item1.$checked" ng-change="item1.$setCheckState (item1.$checked)"/>
                {{item1.title}}
            </label>
        </div>
        <ul ng-if="item1.$hasChildren() && !item1.$isFolded()">
            <li ng-repeat="item2 in item1.items">
                <div>
                    <span ng-if="item2.$hasChildren()" ng-click="item2.$foldToggle()">
                        <span ng-if="!item2.$isFolded()">-</span>
                        <span ng-if="item2.$isFolded()">+</span>
                    </span>
                    <label>
                        <input type="checkbox" bf-check-indeterminate="item2.$isIndeterminate()" ng-model="item2.$checked" ng-change= "item2.$setCheckState(item2.$checked)"/>
                        {{item2.title}}
                    </label>
                </div>
                <ul ng-if="item2.$hasChildren() && !item2.$isFolded()">
                    <li ng-repeat="item3 in item2.items">
                        <label>
                            <input type="checkbox" bf-check-indeterminate="item3.$isIndeterminate()" ng-model="item3.$checked" ng-change="item3.$setCheckState(item3.$checked)"/>
                            {{item3.title}}
                        </label>
                    </li>
                </ul>
            </li>
        </ul>
    </li>
</ul>

现在我们还缺一个bf-check-indeterminate指令,它的实现代码如下:

angular.module('com.ngnice.app').directive('bfCheckIndeterminate', function bfCheck-Indeterminate() {
    return {
        restrict: 'A',
        link: function (scope, element, attrs) {
            scope.$watch(
                function () {
                    return scope.$eval(attrs.bfCheckIndeterminate);
                },
                function (value) {
                    angular.forEach(element, function (DOM) {
                        DOM.indeterminate = value;
                    });
               }
           );
        }
    };
});

测试代码如下:

describe('checkIndeterminate指令 >', function () {
    beforeEach(module('com.ngnice.app'));
    var $compile;
    var $rootScope;
    beforeEach(inject(function (_$compile_, _$rootScope_) {
        $compile = _$compile_;
        $rootScope = _$rootScope_;
    }));
    it('使用常量作为默认值', function () {
        var scope = $rootScope.$new(true);
        var dom = $compile('<input type="checkbox" bf-check-indeterminate="true" />')(scope);
        scope.$digest();
        expect(dom[0].indeterminate).toBeTruthy();
    });
    it('通过代码修改变量', function() {
        var scope = $rootScope.$new(true);
        scope.checked = true;
        var dom = $compile('<input type="checkbox" bf-check-indeterminate= "checked" />')(scope);
        scope.$digest();
        expect(dom[0].indeterminate).toBeTruthy();
        scope.checked = false;
        scope.$digest();
        expect(dom[0].indeterminate).toBeFalsy();
    })
});

可以看到,我们只响应了一个事件、写了一个很简单的自定义指令,就完成了模型和视图的绑定。
在我们实现整个功能的过程中,用TDD的方式覆盖了绝大部分交互逻辑,对这些复杂度较高、不太有把握的部分,建立了信心。然后,通过非常简单的几句代码,就把模型和视图绑定在一起了。
同时,可以看出,通过TDD方式写复杂逻辑的效率比传统方式高多了:如果是传统方式,我们得写一些代码,然后切换到浏览器中,操作几下,看结果是否符合预期,还要留意我们的新改动是否破坏了已经“完成”的部分工作逻辑;在TDD方式下,我们写好测试,然后写程序代码,随时保存、随时看测试结果,等所有测试都通过的时候,我们就知道核心逻辑已经完成了。
回忆我们曾经写过的这些指令,会发现一个共同的特点:它们都非常简单,只有少数几行语句,这些语句的作用仅仅是把一个很简单的Model的变更情况表现到视图中去。曾经有很多人问我如何对指令进行单元测试,我给出的答案是:不要测试指令。当然,这只是一个“当头棒喝”式的答案,并不意味着无法或没必要对指令进行单元测试。我要表达的核心意思是:用服务代替指令,让指令尽可能保持简约。理解这一点,也是转换到Angular“模型驱动”思维的重要一步。

1.5.5 实现递归主题树

接下来,我们就要进入Angular指令的“深水区”了。在这一节,我们会开始使用一些高级技巧来实现递归树。
指令递归嵌套自身会导致停止响应,这是我们要解决的核心问题。具体的分析过程我将在第3章“背后的原理”中讲解。这里我直接给出答案:指令的解析分成两个阶段:编译(compile)和链接(link),如果指令嵌套了,那么编译过程会被不断执行,刚执行完一次又触发一次,相当于出现了无限循环。
原因找到了,大体的解决思路也就有了:只要我们不直接嵌套自身,而是通过其他指令间接嵌套一次。当然,通过其他指令间接嵌套也同样是有问题的,除非我们能错开执行时间,比如把间接嵌套的过程放到链接阶段:生成一个live DOM,然后把生成的live DOM动态插入到期望的位置。这种方式,我们曾在“错误信息提示”指令中使用过,还记得吧?不过和上次不同的是,这次还有几个新问题:
准备如何使用?
如何取得模板?
如何取得数据?
如何生成子节点?
我们先来设想预期的用法。
首先,我们肯定需要两个指令来配合:一个上级指令,用来取得模板和数据;一个下级指令,用来渲染子节点。

<ul>
    <!-- 取得数据源,并且赋给$dataSource变量 -->
    <li bf-template="vm.items" ng-repeat="node in $dataSource">
        {{node.title}}
        <ul>
            <li bf-recurse="node.items"></li>
        </ul>
    </li>
</ul>

然后,我们来解决“如何取得模板”的问题:
对于递归指令,顾名思义,上下级的模板是完全相同的。所以,我们只要把上级节点的模板保存起来,将来用它来编译下级节点就行了。所以,我们写一个指令,其作用就是把所在的节点的HTML取下来,存到一个scope变量里。不过还有一个问题:在链接阶段,所在的节点已经变成了live DOM,我们取不到原始的HTML模板。解决方案很简单,那就是在编译阶段取模板,这个阶段的节点还是静态的。代码如下:

angular.module('com.ngnice.app').directive('bfTemplate', function bfTemplate() {
    return {
        restrict: 'A',
        compile: function (element) {
            var template = element[0].outerHTML;
            return function (scope) {
                scope.$template = template;
            };
        }
    };
});

接下来,是解决“如何取得数据”的问题。
递归指令的数据也同样是递归的。这就要求指令所依赖的scope结构也是递归的,显然,如果直接把controller的scope用于递归指令,那么将对controller产生严重的限制.我们需要一个独立的scope结构,它的数据来源于controller,但是最终我们要把它赋值给我们定义的内部数据结构。从controller中取数据的方式我们也同样在“错误信息提示”指令中使用过:scope.$eval。
于是,我们的指令修改为:

angular.module('com.ngnice.app').directive('bfTemplate', function bfTemplate() {
    return {
        restrict: 'A',
        // 提高优先级,原因稍后讲解
        priority: 2000,
        compile: function (element) {
            var template = element[0].outerHTML;
            return function (scope, element, attrs) {
                scope.$template = template;
                if (!scope.$dataSource) {
                    scope.$dataSource = scope.$eval(attrs.bfTemplate);
                }
            };
        }
    };
});

不过,还有一个问题,ng-repeat等指令具有很高的优先级,所以它会被首先执行,而这个时候bf-template指令还没有机会执行,所以$dataSource变量是空值。我们必须让bf-template指令在ng-repeat之前执行,在Angular中,这个机制叫作优先级(priority)。即,优先级高的指令会先执行。而ng-repeat的优先级是1000,于是,我们把bf-template的优先级指定为2000,确保它先执行。
最后,我们再来解决“如何生成子节点”的问题。

angular.module('com.ngnice.app').directive('bfRecurse', function bfRecurse ($compile) {
    return {
        restrict: 'A',
        link: function (scope, element, attrs) {
            // 建立一个独立作用域
            var subScope = scope.$new(true);
            // 取得本节点的数据,这个数据将被传给模板
            subScope.$dataSource = scope.$eval(attrs.bfRecurse);
            // 生成live DOM
            var dom = $compile(scope.$template)(subScope);
            // 用live DOM替换当前节点
            element.replaceWith(dom);
        }
    };
});

实现类似功能的还有一个第三方指令angular-tree-repeat,当初我第一次看到这个指令,就为它巧妙而漂亮的实现方式而惊艳,本书的实现方式也是受其启发改进来的。有兴趣的也可以对照两者的代码,体会一下我的改进思路。

1.5.6 实现“查看详情”功能

接下来,我们把递归主题树的功能扩展一下,给每个节点加一个“查看详情”链接。用户点击这个链接的时候,就跳转到“查看详情”页面。
本节很简短,主要着眼于示范如下特性:
在路由中使用参数。
通过ui-sref指令构建链接。
我们首先要实现“查看详情”页面,这包括三步:在router.js中添加路由定义,实现controller,实现模板。
新增的路由定义如下:

$stateProvider.state('thread.show', {
    // 声明一个id参数和一个title参数
    url: '/:id/show?title&poster',
    templateUrl: 'controllers/thread/show.html',
    controller: 'ThreadShowCtrl as vm'
});

可以看到,这个路由定义中声明了两个路由参数,一个是行内的id参数,一个是query参数title。
接下来就是在controller中使用了,具体代码如下:

angular.module('com.ngnice.app').controller('ThreadShowCtrl',function Thread-ShowCtrl($stateParams) {
    var vm = this;
    vm.id = $stateParams.id;
    vm.title = $stateParams.title;
    vm.poster = $stateParams.poster;
});

这里的关键是$stateParams服务。凡是从路由中传过来的参数都可以通过这个服务访问,但是这里有一个常见的坑是:只有在路由定义中声明过的参数才能使用,比如这里只声明了两个query参数:title和poster。对于URL:/thread/1/show?title=abc&poster=wzc&dateCreated=2015-03-01,接收到的参数中,title和poster都正常,而dateCreated参数为空,这就是因为前面没有声明它。
详情页中我们只实现一个最简单的界面:

编号:{{vm.id}}<br/>
标题:{{vm.title}}<br/>
作者:{{vm.poster}}

接下来,我们要做的就是从主题树中链接到详情页:

<a ui-sref="thread.show({id: node.id, title: node.title, poster: node.poster})">查看</a>

ui-sref是个angular-ui-router提供的指令,其参数是一个路由的名字,而括号中的参数是一个由各个路由参数组成的对象。如果使用系统内置的ngRoute作为路由库,那么就要自己拼接ng-href了,如:查看。虽然也不错,但从可读性和灵活性方面,要比前者差一些。

相关文章
|
6月前
|
设计模式 JavaScript 前端开发
12分布式电商项目 - AngularJS快速入门
12分布式电商项目 - AngularJS快速入门
20 0
|
6月前
|
设计模式 前端开发 JavaScript
11分布式电商项目 - AngularJS简介
11分布式电商项目 - AngularJS简介
28 0
|
Web App开发 前端开发 JavaScript
|
安全 前端开发 测试技术
《AngularJS深度剖析与最佳实践》一导读
现在,软件不但运行在PC上,还要运行在智能手机上,运行在各种Pad上,屏幕分辨率更是多到让研发和测试工程师发怵的地步。
1401 0