1 Dec 2016

Angular 2.x 从0到1(二)


第二章:用Form表单做一个登录控件

对于login组件的小改造

hello-angular\src\app\login\login.component.ts 中更改其模板为下面的样子

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-login',
  template: `
    <div>
      <input type="text">
      <button>Login</button>
    </div>
  `,
  styles: []
})
export class LoginComponent implements OnInit {

  constructor() { }

  ngOnInit() {
  }

}

我们增加了一个文本输入框和一个按钮,保存后返回浏览器可以看到结果

第一个组件

接下来我们尝试给Login按钮添加一个处理方法 <button (click)="onClick()">Login</button>(click)表示我们要处理这个button的click事件,圆括号是说发生此事件时,调用等号后面的表达式或函数。等号后面的onClick()是我们自己定义在LoginComponent中的函数,这个名称你可以随便定成什么,不一定叫onClick()

下面我们就来定义这个函数,在LoginComponent中写一个叫onClick()的方法,内容很简单就是把“button was clicked”输出到Console。

  onClick() {
    console.log('button was clicked');
  }

返回浏览器,并按F12调出开发者工具。当你点击Login时,会发现Console窗口输出了我们期待的文字。

Chrome开发者工具

那么如果要在onClick中传递一个参数,比如是上面的文本输入框输入的值怎么处理呢?我们可以在文本输入框标签内加一个#usernameRef,这个叫引用(reference)。注意这个引用是的input对象,我们如果想传递input的值,可以用usernameRef.value,然后就可以把onClick()方法改成onClick(usernameRef.value)

<div>
  <input #usernameRef type="text">
  <button (click)="onClick(usernameRef.value)">Login</button>
</div>

在Component内部的onClick方法也要随之改写成一个接受username的方法

  onClick(username) {
    console.log(username);
  }

现在我们再看看结果是什么样子,在文本输入框中键入“hello”,点击Login按钮,观察Console窗口:hello被输出了。

Console窗口

好了,现在我们再加一个密码输入框,然后改写onClick方法可以同时接收2个参数:用户名和密码。代码如下:

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-login',
  template: `
    <div>
      <input #usernameRef type="text">
      <input #passwordRef type="password">
      <button (click)="onClick(usernameRef.value, passwordRef.value)">Login</button>
    </div>
  `,
  styles: []
})
export class LoginComponent implements OnInit {

  constructor() { }

  ngOnInit() {
  }

  onClick(username, password) {
    console.log('username:' + username + "\n\r" + "password:" + password);
  }

}

看看结果吧,在浏览器中第一个输入框输入“wang”,第二个输入框输入“1234567”,观察Console窗口,Bingo!

元素引用的使用

建立一个服务去完成业务逻辑

如果我们把登录的业务逻辑在onClick方法中完成,当然也可以,但是这样做的耦合性太强了。设想一下,如果我们增加了微信登录、微博登录等,业务逻辑会越来越复杂,显然我们需要把这个业务逻辑分离出去。

那么我们接下来创建一个AuthService吧, 首先我们在src\app下建立一个core的子文件夹(src\app\core),然后命令行中输入 ng g s core\auth (s这里是service的缩写,core\auth是说在core的目录下建立auth服务相关文件)。auth.service.tsauth.service.spec.ts这个两个文件应该已经出现在你的目录里了。

下面我们为这个service添加一个方法,你可能注意到这里我们为这个方法指定了返回类型和参数类型。这就是TypeScript带来的好处,有了类型约束,你在别处调用这个方法时,如果给出的参数类型或返回类型不正确,IDE就可以直接告诉你错了。

import { Injectable } from '@angular/core';

@Injectable()
export class AuthService {

  constructor() { }

  loginWithCredentials(username: string, password: string): boolean {
    if(username === 'wangpeng')
      return true;
    return false;
  }

}

等一下,这个service虽然被创建了,但仍然无法在Component中使用。当然你可以在Component中import这个服务,然后实例化后使用,但是这样做并不好,仍然时一个紧耦合的模式,Angular2提供了一种依赖性注入(Dependency Injection)的方法。

什么是依赖性注入?

如果不使用DI(依赖性注入)的时候,我们自然的想法是这样的,在login.component.ts中import引入AuthService,在构造中初始化service,在onClick中调用service。

import { Component, OnInit } from '@angular/core';
//引入AuthService
import { AuthService } from '../core/auth.service';

@Component({
  selector: 'app-login',
  template: `
    <div>
      <input #usernameRef type="text">
      <input #passwordRef type="password">
      <button (click)="onClick(usernameRef.value, passwordRef.value)">Login</button>
    </div>
  `,
  styles: []
})
export class LoginComponent implements OnInit {

  //声明成员变量,其类型为AuthService
  service: AuthService;

  constructor() {
    this.service = new AuthService();
  }

  ngOnInit() {
  }

  onClick(username, password) {
    //调用service的方法
    console.log('auth result is: ' + this.service.loginWithCredentials(username, password));
  }

}

这么做呢也可以跑起来,但存在几个问题:

  • 由于实例化是在组件中进行的,意味着我们如果更改service的构造函数的话,组件也需要更改。
  • 如果我们以后需要开发、测试和生产环境配置不同的AuthService,以这种方式实现会非常不方便。

下面我们看看如果使用DI是什么样子的,首先我们需要在组件的修饰器中配置AuthService,然后在组件的构造函数中使用参数进行依赖注入。

import { Component, OnInit } from '@angular/core';
import { AuthService } from '../core/auth.service';

@Component({
  selector: 'app-login',
  template: `
    <div>
      <input #usernameRef type="text">
      <input #passwordRef type="password">
      <button (click)="onClick(usernameRef.value, passwordRef.value)">Login</button>
    </div>
  `,
  styles: [],
  //在providers中配置AuthService
  providers:[AuthService]
})
export class LoginComponent implements OnInit {
  //在构造函数中将AuthService示例注入到成员变量service中
  //而且我们不需要显式声明成员变量service了
  constructor(private service: AuthService) {
  }

  ngOnInit() {
  }

  onClick(username, password) {
    console.log('auth result is: ' + this.service.loginWithCredentials(username, password));
  }

}

看到这里你会发现我们仍然需要import相关的服务,这是import是要将类型引入进来,而provider里面会配置这个类型的实例。当然即使这样还是不太爽,可不可以不引入AuthService呢?答案是可以。

我们看一下app.module.ts,这个根模块文件中我们发现也有个providers,根模块中的这个providers是配置在模块中全局可用的service或参数的。

providers: [
    {provide: 'auth',  useClass: AuthService}
    ]

providers是一个数组,这个数组呢其实是把你想要注入到其他组件中的服务配置在这里。大家注意到我们这里的写法和上面优点区别,没有直接写成

providers:[AuthService]

而是给出了一个对象,里面有两个属性,provide和useClass,provide定义了这个服务的名称,有需要注入这个服务的就引用这个名称就好。useClass指明这个名称对应的服务是一个类,本例中就是AuthService了。这样定义好之后,我们就可以在任意组件中注入这个依赖了。

下面我们改动一下login.component.ts,去掉头部的import { AuthService } from '../core/auth.service';和组件修饰器中的providers,更改其构造函数为

constructor(@Inject('auth') private service) {
  }

我们去掉了service的类型声明,但加了一个修饰符@Inject('auth'),这个修饰符的意思是请到系统配置中找到名称为auth的那个依赖注入到我修饰的变量中。当然这样改完后你会发现Inject这个修饰符系统不识别,我们需要在@angular/core中引用这个修饰符,现在login.component.ts看起来应该是下面这个样子

import { Component, OnInit, Inject } from '@angular/core';

@Component({
  selector: 'app-login',
  template: `
    <div>
      <input #usernameRef type="text">
      <input #passwordRef type="password">
      <button (click)="onClick(usernameRef.value, passwordRef.value)">Login</button>
    </div>
  `,
  styles: []
})
export class LoginComponent implements OnInit {

  constructor(@Inject('auth') private service) {
  }

  ngOnInit() {
  }

  onClick(username, password) {
    console.log('auth result is: ' + this.service.loginWithCredentials(username, password));
  }

}

注意依赖性注入不是仅仅为Service服务的,任何的类都可以通过这种方式提供和注入,它提供了一种解耦的方式,通过Providers提供,通过constructor注入。:

constructor(userService: UserService) {
  userService.addUser({username: 'wang', password:'1234'});
}

注入器从哪得到的依赖?它可能在自己内部容器里已经有该依赖了。如果它没有,也能在提供商的帮助下新建一个。提供商就是一个用于交付服务的配方,它被关联到一个令牌。Angular会使用一些自带的提供商来初始化这些注入器。我们必须自行注册属于自己的提供商,通常用组件或者指令元数据中的providers数组进行注册。简单的类提供商是最典型的例子。只要在providers数值里面提到该类就可以了。

providers: [ AuthService, UserService ]

除了上面那种最简单的提供方式之外,我们还可以以令牌方式提供。我们通常在构造函数里面,为参数指定类型,让Angular来处理依赖注入。该参数类型就是依赖注入器所需的令牌。Angular把该令牌传给注入器,然后把得到的结果赋给参数。下面是一个典型的例子

providers: [
    { provide: 'auth', useClass: AuthService },
    { provide: 'user', useClass: UserService },
    { provide: BASE_URL,  useValue:   'http://localhost:3000/todos' },
    AuthGuardService
    ]

我们发现providers数组是由一系列的provide对象构成的,这个对象是{provide: ..., useClass: ...}或者{provide: ..., useValue: ...}形式的。我们把第一个属性叫令牌,第二个属性叫定义对象。这两种形式分别对应类供应商值供应商

值供应商通常用来进行运行期常量设置,比如网站的基础地址和功能标志等。那么最简单那种情形是怎么回事呢?比如:providers: [ AuthGuardService ],其实这是一个语法糖,等价于{provide: AuthGuardService, useClass: AuthGuardService}

{ provide: BASE_URL,  useValue:   'http://localhost:3000/todos' }

这个例子和其他的好像还是不太一样,BASE_URL不是个字符串对象也不是一个类对象。这是我们创建的一个令牌,这样创建的令牌拥有一个友好的名字,但不会与其它的同名令牌发生冲突。

import { OpaqueToken } from '@angular/core';

export const BASE_URL = new OpaqueToken('BASE_URL');

当然还有另外两种情形,一种叫别名提供商,我们为同一个对象起了不同的别名。

{ provide: MinimalLogger, useExisting: LoggerService },

另一种叫工厂提供商,提供商通过调用工厂函数来新建一个依赖对象,如下例所示。

{ provide: HELLO, useFactory:  helloFactory(2), deps: [Greeting, HelloService] }

使用这项技术,可以用包含了一些依赖服务和本地状态输入的工厂函数来建立一个依赖对象。helloFactory自身不是提供商工厂函数。真正的提供商工厂函数是helloFactory返回的函数。

export function helloFactory(take: number) {
  return (greeting: Greeting, helloService: HelloService): string => {
    /* ... */
  };
};

双向数据绑定

接下来的问题是我们是否只能通过这种方式进行表现层和逻辑之间的数据交换呢?如果我们希望在组件内对数据进行操作后再反馈到界面怎么处理呢?Angular2提供了一个双向数据绑定的机制。这个机制是这样的,在组件中提供成员数据变量,然后在模板中引用这个数据变量。我们来改造一下login.component.ts,首先在class中声明2个数据变量username和password。

  username = "";
  password = "";

然后去掉onClick方法的参数,并将内部的语句改造成如下样子:

console.log('auth result is: '
      + this.service.loginWithCredentials(this.username, this.password));

去掉参数的原因是双向绑定后,我们通过数据成员变量就可以知道用户名和密码了,不需要在传递参数了。而成员变量的引用方式是this.成员变量。 然后我们来改造模板:

    <div>
      <input type="text"
        [(ngModel)]="username"
        />
      <input type="password"
        [(ngModel)]="password"
        />
      <button (click)="onClick()">Login</button>
    </div>

[(ngModel)]="username"这个看起来很别扭,稍微解释一下,方括号[]的作用是说把等号后面当成表达式来解析而不是当成字符串,如果我们去掉方括号那就等于说是直接给这个ngModel赋值成“username”这个字符串了。方括号的含义是单向绑定,就是说我们在组件中给model赋的值会设置到HTML的input控件中。

[()]是双向绑定的意思,就是说HTML对应控件的状态的改变会反射设置到组件的model中。ngModel是FormModule中提供的指令,它负责从Domain Model(这里就是username或password,以后我们可用绑定更复杂的对象)中创建一个FormControl的实例,并将这个实例和表单的控件绑定起来。

同样的对于click事件的处理,我们不需要传入参数了,因为其调用的是刚刚我们改造的组件中的onClick方法。现在我们保存文件后打开浏览器看一下,效果和上一节的应该一样的。本节的完整代码如下:

//login.component.ts
import { Component, OnInit, Inject } from '@angular/core';

@Component({
  selector: 'app-login',
  template: `
    <div>
      <input type="text"
        [(ngModel)]="username"
        />
      <input type="password"
        [(ngModel)]="password"
        />
      <button (click)="onClick()">Login</button>
    </div>
  `,
  styles: []
})
export class LoginComponent implements OnInit {

  username = '';
  password = '';

  constructor(@Inject('auth') private service) {
  }

  ngOnInit() {
  }

  onClick() {
    console.log('auth result is: '
      + this.service.loginWithCredentials(this.username, this.password));
  }

}

表单数据的验证

通常情况下,表单的数据是有一定的规则的,我们需要依照其规则对输入的数据做验证以及反馈验证结果。Angular2中对表单验证有非常完善的支持,我们继续上面的例子,在login组件中,我们定义了一个用户名和密码的输入框,现在我们来为它们加上规则。首先我们定义一下规则,用户名和密码都是必须输入的,也就是不能为空。更改login.component.ts中的模板为下面的样子

    <div>
      <input required type="text"
        [(ngModel)]="username"
        #usernameRef="ngModel"
        />
        
      <input required type="password"
        [(ngModel)]="password"
        #passwordRef="ngModel"
        />
        
      <button (click)="onClick()">Login</button>
    </div>

注意到我们只是为username和password两个控件加上了required这个属性,表明这两个控件为必填项。通过#usernameRef="ngModel"我们重新又加入了引用,这次的引用指向了ngModel,这个引用是要在模板中使用的,所以才加入这个引用如果不需要在模板中使用,可以不要这句。``双花括号表示解析括号中的表达式,并把这个值输出到模板中。

这里我们为了可以显性的看到控件的验证状态,直接在对应控件后输出了验证的状态。初始状态可以看到2个控件的验证状态都是false,试着填写一些字符在两个输入框中,看看状态变化吧。

表单验证状态

我们是知道了验证的状态是什么,但是如果我们想知道验证失败的原因怎么办呢?我们只需要将替换成|是管道操作符,用于将前面的结果通过管道输出成另一种格式,这里就是把errors对象输出成json格式的意思。看一下结果吧,返回的结果如下

管道输出

如果除了不能为空,我们为username再添加一个规则试试看呢,比如字符数不能少于3。

      <input type="text"
        [(ngModel)]="username"
        #usernameRef="ngModel"
        required 
        minlength="3"
        />

多规则验证

现在我们试着把 \{\{表达式}} 替换成友好的错误提示,我们想在有错误发生时显示错误的提示信息。那么我们来改造一下template。

    <div>
      <input type="text"
        [(ngModel)]="username"
        #usernameRef="ngModel"
        required
        minlength="3"
        />
        
        <div *ngIf="usernameRef.errors?.required">this is required</div>
        <div *ngIf="usernameRef.errors?.minlength">should be at least 3 charactors</div>
      <input required type="password"
        [(ngModel)]="password"
        #passwordRef="ngModel"
        />
        <div *ngIf="passwordRef.errors?.required">this is required</div>
      <button (click)="onClick()">Login</button>
    </div>

ngIf也是一个Angular2的指令,顾名思义,是用于做条件判断的。*ngIf="usernameRef.errors?.required"的意思是当usernameRef.errors.requiredtrue时显示div标签。那么那个?是干嘛的呢?因为errors可能是个null,如果这个时候调用errorsrequired属性肯定会引发异常,那么?就是标明errors可能为空,在其为空时就不用调用后面的属性了。

如果我们把用户名和密码整个看成一个表单的话,我们应该把它们放在一对<form></form>标签中,类似的加入一个表单的引用formRef

    <div>
      <form #formRef="ngForm">
        <input type="text"
          [(ngModel)]="username"
          #usernameRef="ngModel"
          required
          minlength="3"
          />
          <div *ngIf="usernameRef.errors?.required">this is required</div>
          <div *ngIf="usernameRef.errors?.minlength">should be at least 3 charactors</div>
        <input type="password"
          [(ngModel)]="password"
          #passwordRef="ngModel"
          required
          />
          <div *ngIf="passwordRef.errors?.required">this is required</div>
        <button (click)="onClick()">Login</button>
      </form>
    </div>

这时运行后会发现原本好用的代码出错了,这是由于如果在一个大的表单中,ngModel会注册成Form的一个子控件,注册子控件需要一个name,这要求我们显式的指定对应控件的name,因此我们需要为input增加name属性

    <div>
      <form #formRef="ngForm">
        <input type="text"
          name="username"
          [(ngModel)]="username"
          #usernameRef="ngModel"
          required
          minlength="3"
          />
          <div *ngIf="usernameRef.errors?.required">this is required</div>
          <div *ngIf="usernameRef.errors?.minlength">should be at least 3 charactors</div>
        <input type="password"
          name="password"
          [(ngModel)]="password"
          #passwordRef="ngModel"
          required
          />
          <div *ngIf="passwordRef.errors?.required">this is required</div>
        <button (click)="onClick()">Login</button>
        <button type="submit">Submit</button>
      </form>
    </div>

既然我们增加了一个formRef,我们就看看formRef.value有什么吧。 首先为form增加一个表单提交事件的处理 <form #formRef="ngForm" (ngSubmit)="onSubmit(formRef.value)">。 然后在组件中增加一个onSubmit方法

  onSubmit(formValue) {
    console.log(formValue);
  }

你会发现formRef.value中包括了表单所有填写项的值。

表单引用

有时候在表单项过多时我们需要对表单项进行分组,HTML中提供了fieldset标签用来处理。那么我们看看怎么和Angular2结合吧:

    <div>
      <form #formRef="ngForm" (ngSubmit)="onSubmit(formRef.value)">
        <fieldset ngModelGroup="login">
          <input type="text"
            name="username"
            [(ngModel)]="username"
            #usernameRef="ngModel"
            required
            minlength="3"
            />
            <div *ngIf="usernameRef.errors?.required">this is required</div>
            <div *ngIf="usernameRef.errors?.minlength">should be at least 3 charactors</div>
          <input type="password"
            name="password"
            [(ngModel)]="password"
            #passwordRef="ngModel"
            required
            />
            <div *ngIf="passwordRef.errors?.required">this is required</div>
          <button (click)="onClick()">Login</button>
          <button type="submit">Submit</button>
        </fieldset>
      </form>
    </div>

<fieldset ngModelGroup="login">意味着我们对于fieldset之内的数据都分组到了login对象中。

表单验证

接下来我们改写onSubmit方法用来替代onClick,因为看起来这两个按钮重复了,我们需要去掉onClick。首先去掉template中的<button (click)="onClick()">Login</button>,然后把<button type="submit">标签后的Submit文本替换成Login,最后改写onSubmit方法。

  onSubmit(formValue) {
    console.log('auth result is: '
      + this.service.loginWithCredentials(formValue.login.username, formValue.login.password));
  }

在浏览器中试验一下吧,所有功能正常工作。

验证结果的样式自定义

如果我们在开发工具中查看网页源码,可以看到

验证的样式

用户名控件的HTML代码是下面的样子:在验证结果为false时input的样式是ng-invalid

<input 
    name="username" 
    class="ng-pristine ng-invalid ng-touched" 
    required="" 
    type="text" 
    minlength="3" 
    ng-reflect-minlength="3" 
    ng-reflect-name="username">

类似的可以实验一下,填入一些字符满足验证要求之后,看input的HTML是下面的样子:在验证结果为true时input的样式是ng-valid

<input 
    name="username" 
    class="ng-touched ng-dirty ng-valid" 
    required="" 
    type="text" 
    ng-reflect-model="ssdsds" 
    minlength="3" 
    ng-reflect-minlength="3" 
    ng-reflect-name="username">

知道这个后,我们可以自定义不同验证状态下的控件样式。在组件的修饰符中把styles数组改写一下:

  styles: [`
    .ng-invalid{
      border: 3px solid red;
    }
    .ng-valid{
      border: 3px solid green;
    }
  `]

保存一下,返回浏览器可以看到,验证不通过时

验证失败的样式

验证通过时是这样的:

验证通过的样式

最后说一下,我们看到这样设置完样式后连form和fieldset都一起设置了,这是由于form和fieldset也在样式中应用了.ng-valid.ng-valid,那怎么解决呢?只需要在.ng-valid加上input即可,它表明的是应用于input类型控件并且class引用了ng-invalid的元素。

  styles: [`
    input.ng-invalid{
      border: 3px solid red;
    }
    input.ng-valid{
      border: 3px solid green;
    }
  `]

很多开发人员不太了解CSS,其实CSS还是比较简单的,我建议先从Selector开始看,Selector的概念弄懂后Angular2的开发CSS就会顺畅很多。具体可见W3School中对于CSS Selctor的参考和https://css-tricks.com/multiple-class-id-selectors/。

组件样式

刚刚我们其实已经使用了组件样式,这里简单介绍一下什么是组件样式。对于我们写的每个Angular组件来说,除了定义HTML模板之外,我们还要定义用于模板的 CSS 样式、指定任意的选择器、规则和媒体查询。

实现方式之一,是在组件的元数据中设置styles属性。styles属性可以接受一个包含 CSS 代码的字符串数组。通常我们只给它一个字符串就行了,就像我们在LoginComponent中做的那样。

@Component({
  selector: 'app-login',
  template: `
    <div>
      <input type="text"
        [(ngModel)]="username"
        />
      <input type="password"
        [(ngModel)]="password"
        />
      <button (click)="onClick()">Login</button>
    </div>
  `,
  styles: [`
    input.ng-invalid{
      border: 3px solid red;
    }
    input.ng-valid{
      border: 3px solid green;
    }
  `]
})

组件样式在很多方面都不同于传统的全局性样式。我们放在组件样式中的选择器,只会应用在组件自身的模板中。上面这个例子中的input选择器只会对 LoginComponent模板中的<input>标签生效,而对应用中其它地方的<input>元素毫无影响。

这种模块化相对于CSS的传统工作方式有如下优点:

  1. CSS 类名和选择器是控件范围的。属于组件内部的,它不会和应用中其它地方的类名和选择器出现冲突。
  2. 我们组件的样式不会因为别的地方修改了样式而被意外改变。
  3. 我们可以让每个组件的CSS代码和它的TypeScript、HTML代码放在一起,这将促成清爽整洁的项目结构。
  4. 修改或移除组件的CSS代码时,不用搜索整个应用来看它有没有被别处用到。

本节代码: https://github.com/wpcfan/awesome-tutorials/tree/chap02/angular2/ng2-tut

进一步的练习

  • 练习1:如果我们想给username和password输入框设置默认值。比如“请输入用户名”和“请输入密码”,自己动手试一下吧。
  • 练习2:如果我们想在输入框聚焦时把默认文字清除掉,该怎么做?
  • 练习3:如果我们想把默认文字颜色设置成浅灰色该怎么做?

Tags:
Stats:
comments


Share:


Comments: