《AngularJS深度剖析与最佳实践》一1.4 实现第一个页面:注册

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

1.4 实现第一个页面:注册

接下来,我们开始实现第一个迭代的第一个功能:10.注册。
我们把能够通过URL独立访问的一项功能简称为“一个路由”,这里为注册功能分配一个叫作/reader/create的路由。
之所以不使用/register的形式,是希望在各个URL之间保持统一,这也是我们在整个项目中将贯穿的一个约定。

1.4.1 约定优于配置

如同后端开发一样,我们将reader称为controller,create称作action,中间还可以有一个id,所以,典型的URL是这样的:/$controller/:id?/$action,其中的id字段是可以省略的,取决于具体的action。
这样,我们在URL和文件所在的路径之间就可以建立一个简单的映射关系:拿到一个URL,如/reader/1/edit,其中reader是$controller,edit是$action,于是我们知道它的代码位于app/controllers/reader目录下,其模板为edit.html,控制器为edit.js,样式为edit.scss。
这有什么好处呢?比如测试人员报告说一个页面存在Bug,其URL是/book/1/preview,我们一看Bug报告,判断出其错误位于控制器中,于是,我们直接开始修改app/controllers/book/preview.js。
如果使用的是IntelliJ/WebStorm,那么我们只要按下Cmd-Shift-N(Navigate|File...,Mac下)组合键,输入preview.js,然后回车,就可以直接开始编辑了。其他IDE也有类似的功能。
不要小看这一点点约定,它可以节省很多不必要的时间浪费,特别是在多人协作开发时,你的代码可能会被很多人修改,如果连这个都需要沟通或者阅读代码,那么浪费的时间和精力也是很可观的。

1.4.2 定义路由

分配完URL,我们还需要把这个URL和控制器、模板对应起来。虽然我们有了一个约定,但是程序并不知道,我们还需要找个地方声明一下,这个地方就是app/configs/router.js。
范例工程会给它生成一个代码骨架,为了方便加注释,我对一些语句进行了额外换行,实际代码中是不需要这么多换行的:

// 声明此JavaScript为严格模式,以回避一些JavaScript的低级错误,后面的代码摘录时将省略这句,但其实每个JavaScript文件中都有这句话
'use strict';

// 引用模块—它已经在app.js中创建过了,所以这里只需要引用
angular.module('com.ngnice.app')
// 声明config函数,它的参数是一个回调函数,这个回调函数将在模块加载时运行,以便对模块进行配置,路由就是配置的一种
.config(
// 声明config回调函数,它需要两个参数,一个叫$stateProvider,一个叫$urlRouterProvider,这两个都是第三方模块angular-ui-router提供的服务。注意,这些参数名都不可随便修改,具体原因我会在第3章中讲解
function ($stateProvider, $urlRouterProvider) {
    // 声明一个state
    $stateProvider.state(
    // 路由名称
    'home',
    // 路由定义对象
    {
        // URL
        url: '/',
        // 模板所在路径,从app起算
        templateUrl: 'controllers/home/index.html',
        // 控制器名称,as vm是一项最佳实践,其原理将在第4章中详细讲解
        controller: 'HomeIndexCtrl as vm'
    });

    // 定义“页面未找到”路由
    $stateProvider.state('notFound', {
        url: '/notFound',
        templateUrl: 'controllers/home/notFound.html',
        controller: 'HomeNotFoundCtrl as vm'
    });
    // 定义默认路由,即遇到未定义过的URL时跳转到那里
    $urlRouterProvider.otherwise('/notFound');

    // 自己的路由写在这里

});
我们自己的路由要如何声明呢?为了保持一致性,我们这里使用angular-ui-router的方式声明为多级路由:
// 定义一个父路由,它只用于提供URL
$urlRouterProvider.state('reader', {
    // 所有子路由都会继承这个URL
    url: '/reader',
    // 父路由中一般只要提供一个这样的template就够了,不必使用templateUrl,页面中公共的部分通过组件型指令去实现会更灵活、更漂亮
    template: '<div ui-view></div>',
    // 抽象路由不能通过URL直接访问,比如直接访问/reader路径会跳转到otherwise中去
    abstract: true
});

// 定义一个子路由
$urlRouterProvider.state(
// 名称,注意,这个名称不是随便取的,angular-ui-router会使用“点”对其进行分割,并且从前往后逐个执行,所以这个名称中的每一段都要存在
'reader.create',
{
    // 子路由的路径,angular-ui-router会把各级父路由与当前路由的URL组合起来,作为最终的访问路径,如:`/reader/create`
    url: '/create',
    // 子路由模板
    templateUrl: 'controllers/reader/create.html',
    // 子路由控制器
    controller: 'ReaderCreateCtrl as vm'
});

定义完路由,我们仍然不能直接通过URL访问它,如果访问则会在浏览器控制台中发现一个错误信息:angular.js:10126 Error: [ng:areq] Argument 'ReaderCreateCtrl' is not a function, got undefined(angular.js:10126 错误:[ng:areq]参数'ReaderCreateCtrl'不是函数,而是未定义。),其原因是没有找到名为ReaderCreateCtrl的控制器。
接下来,我们就对它所引用的模板和控制器进行实现。
首先我们要创建一个app/controllers/reader/create.js文件,和一个app/controllers/reader/create.html文件,我们来看一个空白的create.js文件:

// 引用应用模块
angular.module('com.ngnice.app')
// 注册一个控制器
.controller(
// 控制器名称
'ReaderCreateCtrl',
// 控制器实现
function ReaderCreateCtrl() {
    // 在ReaderCreateCtrl as vm语法下,当前函数的this指针指向的其实是$scope.vm变量,作为一项约定和最佳实践,我们把它赋值给vm变量。我们在程序中不再直接使用this,因为JavaScript中的this很容易给一些不熟悉JavaScript的程序员造成混乱。
    var vm = this;
});

接下来,我们就要开始实现它们了。
如果已经有UX(用户体验设计师)或BA(业务分析师)给出的原型图,那么建议从设计Model的数据结构开始,这样有助于更深入的理解Angular开发中最显著的特点:模型驱动。如果是从零开始,也可以先设计HTML。我们这个项目的开发是单人项目,所以我们先直接设计HTML。到本节的最后,我再来讲解根据原型图做模型驱动开发的过程。
我们要设计一个HTML,但不用设计一个“漂亮的”HTML。注意,在一个项目组中,不同的角色是需要分工的,要把UX擅长的工作留给UX;即使是单人项目,也需要把不同类型的工作分开完成。
在注册页,我们需要一个表单,它具有如下业务意义上的字段:邮箱、昵称、密码、确认密码;还需要一些技术和法律意义上的字段:图形验证码(captcha)、网站服务协议、“同意服务协议”复选框。
由于我们的业务并不需要手机号、年龄之类的字段,那么我们就不要收集它。这种“最小信息”原则,可以帮助你在受到安全攻击的时候把损失控制在最小。同时,把需要填写的内容控制在最小范围内,也有利于提升用户体验。
我们的第一个HTML页面如下:

<!-- 这是一个表单 -->
<form>
    <!-- 邮箱 -->
    <div>
        <!-- for标签用来在label和input之间建立关联:你点击label的时候就相当于点击了for所指向的input -->
        <label for="_email">邮箱</label>
        <!-- 注意type=email -->
        <input id="_email" type="email"/>
    </div>
    <div>
        <label for="_nickname">昵称</label>
        <input id="_nickname" type="text"/>
    </div>
    <!-- 密码型字段 -->
    <div>
        <label for="_password">密码</label>
        <input id="_password" type="password"/>
    </div>
    <div>
        <label for="_retypedPassword">确认密码</label>
        <input id="_retypedPassword" type="password"/>
    </div>
    <div>
        <label for="_captcha">图形验证码</label>
        <input id="_captcha" type="text" />
        <img src="" alt="图形验证码"/>
    </div>
    <div>
        <input id="_accepted" type="checkbox"/>
        <label for="_accepted">
            我已阅读并同意
            <a href="">用户服务协议</a>
        </label>
    </div>
    <div>
        <button type="submit">提交</button>
    </div>
</form>

注意,用来在input和label之间建立关联的id字段都是用下划线开头的,这并不是随意为之,而是要把id留给写“端到端测试”的人员。我们把这些不能不用的id全用下划线开头,有助于防止潜在的冲突。
现在,我们切到浏览器中,会看到一个很难看的表单,如图1-1所示。

《AngularJS深度剖析与最佳实践》一1.4 实现第一个页面:注册

虽然简陋,但已经足够表现我们的HTML骨架了。
接下来,我们需要把它修到可正常交互的级别。
首先,我们需要给每个输入型字段(input/textarea/select等)绑定一个Model变量。仅以邮箱字段为例,我们把它修改为:

<div>
    <label for="_email">邮箱</label>
    <input id="_email" type="email" ng-model="vm.form.email" />
</div>

这里的ng-model就是Angular中一系列“魔法”的关键。它是一个Angular指令,其作用是把所在的input元素和ng-model=""中的表达式建立双向绑定,这种双向绑定意味着,当表达式的值发生变化时,input的value会跟着变化,反过来,当input中的value 由于用户操作而发生变化时,绑定表达式的值也会相应跟着变化。而且这两者的数据格式并不需要保持一致,ng-model指令提供了一系列机制在两者之间进行转换。
ng-model并不限于用在input/textarea/select元素中,事实上,它几乎可以用在任何元素中。但只有这几个元素可以直接使用ng-model,用于其他元素时需要自己写自定义指令来实现双向绑定。这是因为Angular自带了对input/textarea/select的重写指令,重定义了它们的行为,使其可以支持ng-model。在后面的章节,我们会看到如何在自定义指令中支持ng-model。
这里还涉及另一项约定和最佳实践:把当前表单的所有字段绑定到一个叫作vm.form的对象中,这样我们就可以很方便的把表单数据作为一个整体进行处理,比如提交或重置。
除了“确认密码”和“同意协议”之外的其他的字段可以以此类推,不再摘引源码。
“确认密码”字段之所以特殊,是因为它并不需要最终提交给服务端,我们只是为了防止用户输入错误,靠前端来校验就已经足够了。我们设想一下攻击场景,发现对它只做前端校验并不会构成安全漏洞。所以,我们不需要把它绑定到vm.form对象的属性中去,用个独立的vm.retypedPassword变量就可以了。
而“同意协议”字段也同样可以依靠纯前端验证。在法律上,我们只要尽到了提醒和询问的义务即可。如果用户通过非正规手段绕过前端直接访问服务端,不能作为未曾同意协议的借口。
注意,前面我们只是修改了HTML就已经完成了双向绑定,我们并没有在控制器中定义vm.form.email变量,甚至连vm.form对象都没有定义。这是因为在Angular中做了容错处理,发现一个变量没有定义时,它会自动帮我们定义一下,而不会触发错误,这个特性在写模板时非常有用。
“图形验证码”的字段也比较特殊,我们把它留到下一节去处理。现在,我们的界面就已经到了最初的可交互级别。
接下来我们有两个分支可以走:套用Bootstrap类进行初步美化(UX分支),或者开始实现表单提交代码(程序员分支),两者可以同时进行。
为了尽快看到“可行走骨架”,我们选择程序员的分支:实现表单提交代码。要在收集了表单数据的基础上进一步实现表单提交,我们就要借助另一个指令了,这个指令叫作ng-submit。
直觉上,我们可能希望在提交按钮上绑定一个事件来完成表单提交,但更好的方式是在form上绑定一个ng-submit事件。这是因为触发表单提交并不是只有点击“提交”按钮这一种方式,用户还可以在input中敲回车键来直接提交表单,在大多数场景下,这是更为友好的方式。但更重要的是,这样的HTML,其表意性更强一些。
这个步骤对代码的影响很小,在HTML中,我们只要把

改为`javascript
-submit="vm.submit(vm.form)">即可,在JavaScript中增加几句指令即可:
vm.submit = function(form) {
console.log(form);

};

由于还没有写后端代码,因此我们这里只把它要提交的代码打印到控制台中,我们只要打开Chrome控制台就可以验证一下我们要提交的数据是否正确了。
在实现真正的后端提交之前,我们还需要实现一个服务器,由于本书只讲前端开发,所以我们就把后端程序当做一个黑盒子,不再讲解实现原理。下一节我们会简单讲一下如何让它在本机跑起来,以及如何通过设置反向代理来解决跨域问题。
###1.4.3 把后端程序跑起来
我们把书中的后端代码也放在了GitHub上,作为起步,建议只下载BookForumApi.zip文件就可以了。解压之后,会发现一个server文件和一个server.bat文件,如果在Windows下面,直接执行server.bat即可,如果在Linux或Mac下,请执行./server命令。它会启动一个开发服务器,这个开发服务器会内置一个H2内存数据库,每次重新启动服务器就会将其内容清除。它会自动监听5080端口,如果你的机器上5080端口已经被占用,请使用server 5090等命令来重新指定一个端口。
接下来,我们需要修改前端项目中的FrontJet配置文件—项目根目录下的fj.conf.js:

module.exports = function (config) {

// 可以定义多条规则,后面的规则会覆盖前面的
config.rules = [
    {
        url: /^\/api\/(.*)$/, // 要代理的URL,可以是正则表达式,也可以是字符串,如字符串'/api',将被处理成/^\/api\/(.*)$/的形式,两者等价
        rewrite: '$1',  // 可选,默认把原来的URL完全传过来,即:不重写
        proxy: 'http://localhost:5080/BookForumApi/', // 反向代理设置
        cookie: {
            path: '/api',  // 覆盖原服务器的cookie path设置(如果有)
            domain: 'localhost'  // 覆盖原服务器的cookie domain设置(如果有)
        },
        delay: 500  // 延迟毫秒数,可选
    },
    {
        url: '^/api/readers',
        delay: 0
    }
];
// 用户自定义的Middleware将优先于默认的
config.middlewares = [
    function(req, res, next) {
        next();
    }
];

};

现在可以通过POST http://localhost:5000/api/readers来模拟读者注册的后端API了。
至此前端已经配置完毕。如果你只关心前端编程,那么可以跳过本节的剩余部分。
写给要深入后端程序的同学
事实上,后端程序可选用任何语言和框架,只要它可以提供HTTP形式的接口即可。这就给整体的技术架构提供了高度灵活性,甚至可以在不中断服务的情况下逐步把后端程序从一种框架替换为另一种框架,甚至从一种语言替换为另一种语言。
如果要了解后端程序的工作原理或对其进行修改,那么需要把全部源码下载下来。我在这里不展开讲解,只对后端程序的框架写个简介。
这个范例中的后端程序使用的是一个基于Groovy的框架,叫作Grails,Java程序员应该对Groovy不陌生,它就是现在正火爆的Gradle中所使用的编程语言。Groovy的源码和Java的写法兼容程度很高,事实上,大部分Java代码直接拷贝到Groovy中就能编译,不需要做什么修改。
而Grails是一个全栈式的后端框架,它的设计思想是来自Ruby世界中的Rails框架,而它的基础技术栈则是Hibernate、Spring MVC等成熟的Java框架。它可以流畅的运行在JVM上,还可以无缝接入现有的Java框架或库,具有成熟的生态环境。对于讨厌Java传统框架的xml配置的人,Grails是个福音。而且,虽然不推荐,但必要的时候,你仍然可以通过定义xml文件来使用传统风格的配置方式。
Grails中囊括的技术很多,但是在前后端分离的架构下,我们只要使用它的两大特性就行了:GORM、REST,其他的,如GSP、taglib、i18n等特性则可以完全不用管它。
GORM最大的好处就是只要定义领域类就行了,不用写getter/setter,不用配置XML,不用写annotation。
除此之外,它还能直接定义业务规则,如用户名的长度限制等。这些限制会在保存实例的时候自动被检查。
不过,它最大的亮点是支持DSL式的查询,事实上,它的底层就是Hibernate的Criteria特性,这保障了它的成熟度和稳定性。而在Groovy的强力支持下,它被写成了内嵌的DSL语句。IntelliJ甚至可以对这个DSL进行相当细致的智能提示。
REST是另一大亮点,虽然它提供的默认控制器仍然不够成熟,但好在它只是很薄的一层,我们可以通过自定义模板的方式来按我们的方式自动生成REST服务。而JSON解析、回应、数据格式转换等很多REST中必备的特性,它都实现的相当不错。
###1.4.4 连接后端程序
现在,我们已经在5000端口上跑着前端程序,在5080端口上跑着后端程序,并且通过FrontJet的反向代理功能把后端也代理到了5000端口下,从而避免了跨域问题。
万事俱备,我们可以正式向服务端提交请求了。
首先,我们需要写一个服务访问对象(Service Access Object,SAO),这是一个和后端的DAO类似的概念,不过好在我们不用从头实现它。在Angular中,有两个用于支持Ajax请求的服务,一个叫$http,一个叫$resource。$http是一个广义的Ajax服务,可用于支持任何形式的Ajax请求;$resource则是专门用于访问REST服务的对象,它封装了一系列REST最佳实践和规约。
显然,对于我们这样一个新系统,直接使用$resource是最好的选择。对于一些老系统,则可以穿插使用$http和$resource。
不过只要有条件,最好把后端API逐步改造为REST风格,本书中有两处对REST进行详解:一处在第3章“背后的原理”中,对REST原理、概念进行解释;另一处在第4章“最佳实践”中,介绍了REST方面的最佳实践和开发规范。
通过$resource定义一个SAO非常简单,最简单的API只要一句就够了:

angular.module('com.ngnice.app').factory('Reader', function ReaderFactory ($resource) {

return $resource('/api/readers/:id', {id: '@id'});

});

在这里,我只封装了一个API路径,其他的CRUD(创建、读取、更新、删除)操作则直接使用$resource已经提供好的封装。虽然封装很浅,但却很实用,我们来看一下封装前后调用方式的区别。
封装前:

angular.module('com.ngnice.app').controller('ReaderCreateCtrl', function ReaderCreateCtrl($resource) {

var vm = this;
var Reader = $resource('/api/readers/:id', {id: '@id'});
vm.submit = function(form) {
    Reader.save(form);
};

});

封装后:

angular.module('com.ngnice.app').controller('ReaderCreateCtrl', function ReaderCreateCtrl(Reader) {

var vm = this;
vm.submit = function(form) {
    Reader.save(form);
};

});

表面看,封装之后省不了多少代码,但是,我们封装的首要目的可不是节省代码,而是提高可读性,对比一下两者,体会它们在表意性上的差别!
除此之外,我们的封装还获得了额外的灵活性。这得益于由$http服务提供并且被$resource服务继承的拦截器(interceptor)机制。在后面的章节中,我们会根据需求的深化,逐步展示拦截器的用法,这里先不展开。
上面的代码只是提交了请求,并未关心是否创建成功,但这对用户来说显然很重要。接下来,我们增加处理返回结果的功能。

Reader.save(form,

function (reader) {
    console.log(reader);
},
function (resp) {
    console.log(resp);
}

);

$resource的save、update、get、query、delete等都可以带两个回调函数,第一个回调函数是成功回调,第二个是失败回调。成功回调函数会传一个参数过来,就是由服务器返回来的JSON对象,而失败回调函数则传回一个response对象,其中有状态码等信息。
在这里,我只是把它们输出到控制台来演示执行效果。正式项目下,对于save、update或remove的成功回调,它通常是个路由跳转语句;在query或get请求中,则把返回值赋值到vm的成员上,供模板中绑定。失败回调通常不用单独处理,而是通过拦截器(interceptors)进行统一处理,这属于一个略有难度的话题,因此我们把它放在稍后的章节中实现。
我们和后端的对接就先暂时告一段落,过几节之后再加入高级功能。

###1.4.5 添加验证器
至此,我们在注册页面中还缺少一个重要功能:数据验证。
数据验证包括两个主题:一是定义验证规则,用于验证数据的有效性;二是显示验证结果,要把验证的结果用友好的方式显示给用户。
HTML5内置了一些验证功能,并且会显示内置的提示,但是这种提示容易破坏UX设计的整体效果,因此我们要先把它禁用掉。我们只要在form上增加一个属性即可:

我们先来看邮箱字段。它一共有两个验证规则:首先,它必须是格式合法的邮箱;其次,它是必填项。
Angular有一个内置指令改写了input[type=email]元素,它会在接收到输入的时候检查是否符合标准的邮箱格式,并且设置相应的有效性标识,所以我们只要指定了type=email就不用再管了。
验证必填项规则有两种选择:可以使用HTML中标准的required属性,Angular改写的input指令会检测它,并且设置相应的有效性标识;还可以使用Angular的ng-required指令,两者的区别在于后者是可以编程控制的。也就是说后者的值可以是一个scope变量,如而前者是不能通过的形式来控制的,因为在HTML中,像required/disabled之类的属性,只要“出现”就视为有效,而不管它的值是不是空。注意,ng-required的值并不需要使用{{}}包裹起来,详细的原因我们会在第2章“概念介绍”中讲解。现在只需记住:除下列指令外的内置指令都不需要把值用{{}}包裹起来:ng-src、ng-href以及不太常用的ng-bind-template和ng-srcset。
每次用户输入之后,这些验证器就会按照优先级和出现顺序依次被验证。当验证全部成功时,Angular就会把用户的输入转换成Model中的值;如果任何一个验证不成功,那么Model中的值会保持原样,并且在Model上增加一个$error对象,每一个失败的验证器,都会在其中出现一个和验证器同名的属性,其值为true。如,邮箱格式的验证器出错时,$error的内容就是{email: true}。
禁用了内置的验证提示之后,我们还要使用自定义的界面来显示错误信息。
这时,Model变量虽然是存在的,我们却没法在模板中直接引用它。要想引用它,我们还需要给它所属的form指定一个名字,再给它所在的input指定一个名字。例如:

<div>
    <label for="_email">邮箱</label>
    <input id="_email" name="email" type="email" ng-required="true" ng-model="vm.form.email"/>
</div>


这时候,我们的$scope上就多出来一个名为form的变量,它的内容为:

{

$dirty: false
$error: Object
$invalid: true
$name: "form"
$pristine: true
$removeControl: function (control) {...
$setDirty: function () {...
$setPristine: function () {...
$setValidity: function (validationToken, isValid, control) {...
$valid: false
accepted: Constructor
captcha: Constructor
email: Constructor
nickname: Constructor
password: Constructor
retypedPassword: Constructor

}

其中的几个函数我们到第3章“背后的原理”中再详细讲解,我们先看看这几个成员变量:
$dirty表示用户是否在这个表单内任何一个输入框中输入过。
$pristine和$dirty正相反,表示尚未输入过。
$error在前面已经介绍过,不再赘述。
$invalid表示这个表单中的数据是否有无效的。只要任何一个输入框是无效的则将整个表单视为无效的。
$valid和$invalid正相反,表示数据有效。
$name表示这个表单的名字,也就是前面我们指定的name属性。
accepted/captcha/email/nickname/password/retypedPassword是表单中各个输入框的Model。
我们再来看看email字段的Model:

{

$$validityState: ValidityState $dirty: false $error: Object $formatters: Array[2] $invalid: true $isEmpty: function (value) {... $modelValue: undefined $name: "email" $parsers: Array[2] $pristine: true $render: function () {... $setPristine: function () {... $setValidity: function (validationErrorKey, isValid) {... $setViewValue: function (value) {... $valid: false $viewChangeListeners: Array[0] $viewValue: undefined }

它也同样包含了form中的几个属性,其含义也类似,但适用范围是这个Model所在的输入框。这些值全都由Angular的内置指令进行维护。
其他的属性和方法我们也将在第3章“背后的原理”中详细讲解。本节中我们只要记住这几个常用属性:$dirty/$pristine、$valid/$invalid、$error。因为接下来我们就要使用它们了。
看完这些数据结构,我们就可以在模板中通过数据绑定来显示了:

<label for="_email">邮箱</label>
<input id="_email" name="email" type="email" ng-required="true" ng-model="vm.form.email"/>
<!-- 用户输入过,并且无效,则显示 -->
<ul ng-if="form.email.$dirty && form.email.$invalid">
    <!-- 如果“邮箱格式”验证器报错 -->
    <li ng-if="form.email.$error.email">无效的邮箱格式</li>
    <!-- 如果“必填项”验证器报错 -->
    <li ng-if="form.email.$error.required">此项为必填项</li>
</ul>

除了需要绑定到Model变量之外,这就是一段很普通的模板。
显然,这不是一种好办法,除了必须给每个字段取一个名字之外,当字段多的时候,模板中还会出现大量的重复代码。如何解决这个问题呢?我们来写本书的第一个自定义指令吧。

###1.4.6 “错误信息提示”指令
我们之所以添加name属性,是为了能在模板中引用到$error对象,但我们其实还有另一种方式来引用它—那就是“指令”。
我们先来看代码,把一些基础知识插入注释中,然后再解释原理:

angular.module('com.ngnice.app').directive(
// 指令名称,它会按照约定转换成减号分隔的标识符后才能在模板中使用:bf-field-error,这里的bf是book-forum的缩写,用这个前缀来防止和其他指令冲突。
'bfFieldError', function bfFieldError($compile) {

return {
    // 限制为只能通过属性(Attribute)的形式使用,如`<input bf-field-error />`,另一种常用的限制是E,表示通过元素(Element)的形式使用,如`<bf-field-error> </bf-field-error>`,两者也可以组合,如`restrict: 'EA'`
    restrict: 'A',
    // 这个元素上必须有一个ng-model属性,如果没有,就会报错
    require: 'ngModel',
    // link函数会在当前指令初始化的时候被自动执行
    link: function (scope, element, attrs, ngModel/* 将传入require的值 */) {
        // 创建一个`子scope`,`true`参数表示这个子scope是个独立作用域,它不会从父级作用域自动继承属性
        var subScope = scope.$new(true);
        // 在子scope上增加两个函数,供模板中使用
        // 是否有需要显示的错误
        subScope.hasError = function() {
            // 除了判断数据是否无效($invalid)外,还要判断用户是否已经输入过($dirty),否则刚显示表单就会出现一堆错误信息,这显然是不对的
              return ngModel.$invalid && ngModel.$dirty;
        };
        // 错误信息的内容
        subScope.errors = function() {
            // 我们先直接把$error显示到界面上,回头再改进为用户友好的格式
            return ngModel.$error;
        };
        // 把一段HTML编译成“活DOM(Live DOM)”,然后把subScope传给它,这个Live DOM将会跟随subScope的变化自动更新自己。
        var hint = $compile('<ul ng-if="hasError()">{{errors()}}</ul>')(subScope);
        // 把这段Live DOM追加到当前元素后面,好让它显示出来
        element.after(hint);
    }
};

});

这段指令的关键在于require属性。
require的值是另一个指令的名称,但实际上它引用的是那个指令的控制器实例。require机制非常有用,因为有时候多个指令是需要互相配合的,它们必须相互“看到”才能进行协作,require就是让它们相互“看到”的标准化机制。比如,这个指令中声明了require: 'ngModel',那么当Angular初始化它的时候,就会在它所在的元素上寻找一个叫作ng-model的指令,然后取得它的控制器实例。找到之后,就会把这个控制器的实例作为link函数的第四个参数传进来。传进来之后,我们就可以直接访问ngModel上的属性和方法了,这就是所谓“指令之间的协作”。
接下来,我们创建一个模板和一个scope,然后把它们绑定在一起,生成一个所谓的“Live DOM”。Live DOM在使用上和普通的DOM元素没什么区别,只是它们可以感知到scope中数据的变化,并且据此来更新自己。你可能也注意到了,我绑定的数据不是属性,而是一个函数,这是Angular中的一个常见用法。这种神奇的更新机制,其工作原理是什么呢?我们将在第3章“背后的原理”中详细讲解。$compile函数是把它们绑定在一起的关键,事实上,路由中也是用同样的机制来把scope和模板绑定在一起的。把静态DOM和scope变量绑定在一起,使其变成Live DOM的过程,在Angular中被称为编译(compile)。
我们拿到了编译结果之后,还要把它展示给用户并且和用户进行交互,于是我们通过element.after函数把它插入到当前元素的后面。
现在,这个指令已经可以工作了,但仍然是很粗糙的,它只是把$error对象的源码显示出来。接下来,我们要把它简单美化一下,变成一个普通的无符号列表。
我们先把它从一个对象显示成一个名字列表:

var hint = $compile('

  • {{name}}
')(subScope);
这里面涉及一个在Angular中非常重要的内置指令:ng-repeat。它可以对数据项进行枚举,这个数据项既可以是数组,也可以是对象,比如我们的$error就是一个对象。正如上面这段代码所示范的,枚举对象的语法非常简单:(属性名,属性值)in对象,这些属性名和属性值可以被用于当前元素和子元素的绑定表达式中。枚举数组的语法更简单:迭代变量in数组。
不过,ng-repeat虽然语法简单,但仍然有很多高级用法和坑,我们在后面的章节中会陆续提到,不过现在只要先了解这些就够用了。
但是<ul><li>email</li></ul>这样的提示对用户来说仍然太不友好了,我们需要把它转换成更友好的字符串。但是我们又想保证其内聚性,最好不要把生成用户友好的提示信息的逻辑放进来,这就用到了Angular中的另一个重要概念:过滤器。
我们先把过滤器的用法写下来,下一节我们再来实现它。

var hint = $compile('

  • {{name | error}}
')(subScope);
{{name | error}},其中竖线后面的就是过滤器,用于把error的名称,如email,转换成用户友好的提示信息。

###1.4.7 用过滤器生成用户友好的提示信息
在本节中,我们将实现error过滤器。
我们先来看一个空白的过滤器:

angular.module('com.ngnice.app').filter('error', function () {

return function (input) {
    // TODO: 实现转换/格式化逻辑
};

});

大致来说,过滤器就是一个函数,它接收一个输入,然后返回一个输出,函数体负责把输入的变量转换成输出结果。
接下来,我们实现TODO部分的逻辑:

angular.module('com.ngnice.app').filter('error', function () {

var messages = {
    email: '不是有效格式的邮件地址',
    required: '此项不能为空'
};
return function (name) {
    return messages[name] || name;
};

});

我们首先定义了一个(名称,信息)的对照表,然后直接根据名称查阅,如果没有值则返回名称。注意,我们还重构了变量名,从input改成name,其表意性更强。
如果将来错误类型进一步膨胀,那么我们就需要再次重构,这次要把messages提取出去。提取的方式是使用constant,于是将其拆成了两个文件:

1)app/constants/Errors.js:
angular.module('com.ngnice.app').constant('Errors', {

email: '不是有效格式的邮件地址',
required: '此项不能为空'

});

2)app/filters/error.js:
angular.module('com.ngnice.app').filter('error', function (Errors) {

return function (name) {
    return Errors[name] || name;
};

});

我们先把错误信息拆成了一个独立的常量对象(constant),然后把它注入到原来的filters中,代替以前的messages变量。这样拆分出去之后,我们不但可以在过滤器中使用Errors常量,也可以在控制器、指令、服务等任何地方使用它了。
过滤器的实质是数据转换,常见的转换包括格式化和筛选。比如,我们本节实现的过滤器就是个格式化函数,用来把Model层的数据类型转换成View层的用户友好化信息。在本章稍后1.5节中,我们会通过“主题列表”来示范过滤器的筛选功能,最后再把主题列表及其回复扩展成主题树。不过,我们先要回到主线,把注册页面中剩下的功能写完。
###1.4.8 实现自定义验证规则
邮箱字段的验证非常简单,Angular直接内置了验证规则,但是“确认密码”的验证规则和“同意协议”的验证规则就没这么简单了。我们先来实现“确认密码”的验证规则,实现方式同样是写自定义指令(bf-assert-same-as),但是写法又有些不同了:

angular.module('com.ngnice.app').directive('bfAssertSameAs', function bfAssertSameAs() {

return {
    restrict: 'A',
    require: 'ngModel',
    link: function (scope, element, attrs, ngModel) {
        var isSame = function (value) {
            // 取对照值,通过scope.$eval把attrs.bfAssertSameAs作为一个表达式在当前作用域中求值,否则它只是一个固定的字符串
            var anotherValue = scope.$eval(attrs.bfAssertSameAs);
            return value === anotherValue;
        };
        // 1.2.x中只能使用$parsers实现验证,1.3.x中增加了专门的$validators数组,可用更好的方式实现验证。
        ngModel.$parsers.push(function (value) {
            // 调用$setValidity设置验证结果,第一个参数就是名字,和$error中的属性名一致,但是取值相反,因为这里表示的是“有效”,而$error中表示的是“无效”
            ngModel.$setValidity('same', isSame(value));
            // 验证通过则返回转换后的值,否则返回undefined
            return isSame(value) ? value : undefined;
        });
        // 这是assertSameAs验证器所特有的,因为当对照值发生变化时,也要更新有效性状态
        scope.$watch(
            // 对这个函数值进行监控,变化时就触发下一个函数:变化通知
            function () {
                return scope.$eval(attrs.bfAssertSameAs);
            },
            function () {
                // 变化时重新判断并设置验证结果
                ngModel.$setValidity('same', isSame(ngModel.$modelValue));
            }
        );
    }
};

});

使用时的形式如下:

当确认密码和密码不同的时候,bf-field-error指令在检查结果中就会显示“same”。
接下来,我们需要让这个same显示成用户友好的提示信息。这一步就简单了,我们前面已经把错误信息提取到了一个constant里,现在只要把same加进去就行了:

angular.module('com.ngnice.app').constant('Errors', {

email: '不是有效格式的邮件地址',
required: '此项不能为空',
same: '此项必须与上一项相同'

});

因为追求通用性,我们的提示信息还不够友好,还需要一种机制来进一步定制提示信息。定制的方式是给bs-field-error传入一个参数。如:
<input id="_retypedPassword" bf-field-error="{same: '确认密码必须与密码相同'}" type="password" ng-required="true" ng-model="vm.retypedPassword" bf-assert-same-as="vm.form.password"/>
这就需要我们修改bfFieldError指令了,修改后的代码如下:

...

subScope.customMessages = scope.$eval(attrs.bfFieldError);
var hint = $compile('<ul class="bf-field-error" ng-if="hasError()"><li ng-repeat="(name, wrong) in errors()" ng-if="wrong">{{name|error:customMessages}}</li></ul>')(subScope);

...

在这里,我们取了bf-field-error的表达式,并且交给scope.$eval去求值,这样我们就取到了一个自定义的消息对象。然后我们通过error:customMessages语法把这个自定义消息作为参数传给error过滤器。当然,这时候的error过滤器还不能使用这个参数,接下来我们就改一下error过滤器的代码:

angular.module('com.ngnice.app').filter('error', function (Errors) {

return function (name, customMessages) {
    // 把Errors和customMessages合并在一起,作为查阅源,customMessages中的同名属性会覆盖Errors中的
    var errors = angular.extend({}, Errors, customMessages);
    return errors[name] || name;
};

});

这样,我们只修改了几句代码就实现了自定义消息功能。
“同意协议”的验证可以直接借助bf-assert-same-as指令来完成,只使用一个固定值“true”就可以了:

至此,我们把所有的验证都添加完了,接下来我们还需要对表单的提交进行控制,即:只有全部字段都通过了验证的表单才允许提交。它的原理很简单:只要在表单无效时把“提交”按钮禁用掉就行了。换成Angular的语言就是:“视图”中“提交”按钮的禁用状态来自于“模型”中表单的有效性。注意,这种思维的转换对于真正掌握Angular是很重要的—从以过程为中心的思维,转换成以模型为中心的思维。
Angular提供了一个现成的指令:ng-disabled,可以让我们完成这个功能:


...
<button type="submit" class="btn btn-primary" ng-disabled="form.$invalid">立即注册</button>

...

我们首先给form加上了一个name,以便我们可以在视图中引用它,然后我们通过ng-disabled指令把提交按钮的禁用状态关联到form.$invalid属性。当表单变为无效时,按钮被禁用;反之则被启用,这个过程完全是自动的,Angular会负责视图和模型之间的同步。为了能看到更明显的禁用效果,我们给这个按钮加上了来自知名CSS框架Bootstrap的btn和btn-primary类。
至此,前端的验证功能已经全都完成了。
另外,Angular 1.3以上的版本提供了更友好的自定义验证器:ngModel.$validators。如果你的系统没有兼容IE8的负担,那么不妨参考官方API文档,来尝试采用新的方式。
###1.4.9 实现图形验证码
我们在注册表单上的欠账还剩一个图形验证码,不过这个实现起来就简单多了。

我们只要给img元素加上一个ng-src指令,然后把服务端的captcha地址传给它就行了。
可能有人会问,为什么这里我不直接用HTML内置的src属性呢?事实上ng-src确实是通过设置img的src属性来工作的。它们大多数情况下都相同,除了一个场景:假设我们的服务端地址是可以配置的,那么我们绑定的时候就得绑定到一个变量了:<img ng-src="{{api.root}}/captcha.jpg" alt="图形验证码"/>。这时候,如果我们换成src:<img src="{{api.root}}/captcha.jpg" alt="图形验证码"/>会怎样呢? 当页面刚开始加载的时候,由于Angular还没有机会执行,所以花括号中的绑定表达式还没有机会被求值,于是浏览器会尝试读取{{api.root}}/captcha.jpg,显然,这个地址是不存在的,于是浏览器得到一个404错误,这不是我们所预期的。而ng-src是个Angular指令,它在Angular启动完成之后才会被执行,于是得到了正确的值:/api/captcha.jpg,并且赋给src属性,这样就不会出现一个意外的404了。
我们还需要加上一个功能:点击图形验证码的时候自动换一张。为了让注册页的代码内聚性更高、更清洁,我们把captcha功能封装成一个独立的指令:

angular.module('com.ngnice.app').directive('bfCaptcha', function bfCaptcha() {

return {
    restrict: 'A',
    link: function (scope, element) {
        var changeSrc = function() {
            // 通过附加随机码来强制更换图形验证码
            element.attr('src', '/api/captcha.jpg?random=' + new Date().getTime());
        };
        // 启动的时候先更换一次,以便显示出来
        changeSrc();
        // 点击的时候也更换一次
        element.on('click', function() {
            changeSrc();
        });
    }
};

});

使用这个指令的代码就很简单了:<img bf-captcha alt="图形验证码"/>。
至此,注册页的功能部分就全部完成了,美化部分如果有额外的人力就已经可以开始了,不过我们人力不够,所以还是等到最后再统一处理吧。我们先继续完成后面的功能:主题树和登录功能。
上一篇:最全的 Twitter Bootstrap 开发资源清单


下一篇:资源等待类型sys.dm_os_wait_stats