细说 Angular 2+ 的表单(一):模板驱动型表单

细说 Angular 2+ 的表单(二):响应式表单javascript

摘要

在企业应用开发时,表单是一个躲不过去的事情,和面向消费者的应用不一样,企业领域的开发中,表单的使用量是惊人的。这些表单的处理实际上是一个挺复杂的事情,好比有的是涉及到多个 Tab 的表单,有的是向导形式多个步骤的,各类复杂的验证逻辑和时不时须要弹出的对话框等等。笔者试图在这一系列文章中对 Angular 中的表单处理作一个相对完整的梳理。html

Angular 中提供两种类型的表单处理机制,一种叫模版驱动型(Template Driven)的表单,另外一种叫模型驱动型表单( Model Driven ),这后一种也叫响应式表单 ( Reactive Forms ),因为模版驱动中有一个 ngModel 的指令,容易和这里说的模型驱动混淆,因此在咱们的文章中叫后一种说法:响应式表单。前端

第一篇主要介绍模版驱动型的表单。java

号外

本文评论区会抽出5位童鞋,赠送笔者的 《Angular 从零到一》纸书,机不可失,你们踊跃发言哦。git

模版驱动的表单

模版驱动的表单和 AngularJS 对于表单的处理相似,把一些指令(好比 ngModel )、数据值和行为约束(好比 requireminlength 等等)绑定到模版中(模版就是组件元数据 @Component 中定义的那个 template ),这也是模版驱动这个叫法的来源。整体来讲,这种类型的表单经过绑定把不少工做交给了模版。github

模版驱动的例子

仍是用例子来讲话,好比咱们有一个用户注册的表单,用户名就是 email ,还须要填的信息有:住址、密码和重复密码。这个应该是比较常见的一个注册时须要的信息了。那么咱们第一步来创建领域模型:正则表达式

// src/app/domain/index.ts
export interface User {
  // 新的用户id通常由服务器自动生成,因此能够为空,用 ? 标示
  id?: string; 
  email: string;
  password: string;
  repeat: string;
  address: Address;
}

export interface Address {
  province: string; // 省份
  city: string; // 城市
  area: string; // 区县
  addr: string; // 详细地址
}复制代码

接下来咱们创建模版文件,一个最简单的 HTML 模版,先不增长任何的绑定或事件处理:typescript

<!-- template-driven.component.html -->
<form novalidate>
  <label>
    <span>电子邮件地址</span>
    <input type="text" name="email" placeholder="请输入您的 email 地址">
  </label>
  <div>
    <label>
      <span>密码</span>
      <input type="password" name="password" placeholder="请输入您的密码">
    </label>
    <label>
      <span>确认密码</span>
      <input type="password" name="repeat" placeholder="请再次输入密码">
    </label>
  </div>
  <div >
    <label>
      <span>省份</span>
      <select name="province">
        <option value="">请选择省份</option>
      </select>
    </label>
    <label>
      <span>城市</span>
      <select name="city">
        <option value="">请选择城市</option>
      </select>
    </label>
    <label>
      <span>区县</span>
      <select name="area">
        <option value="">请选择区县</option>
      </select>
    </label>
    <label>
      <span>地址</span>
      <input type="text" name="addr">
    </label>
  </div>
  <button type="submit">注册</button>
</form>复制代码

渲染以后的效果就像下面这样:编程

简单的Form

数据绑定

对于模版驱动型的表单处理,咱们首先须要在对应的模块中引入 FormsModule ,这一点千万不要忘记了。json

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from "@angular/forms";
import { TemplateDrivenComponent } from './template-driven/template-driven.component';

@NgModule({
  imports: [
    CommonModule,
    FormsModule
  ],
  exports: [TemplateDrivenComponent],
  declarations: [TemplateDrivenComponent]
})
export class FormDemoModule { }复制代码

进行模版驱动类型的表单处理的一个必要步骤就是创建数据的双向绑定,那么咱们须要在组件中创建一个类型为 User 的成员变量并赋初始值。

// template-driven.component.ts
// 省略元数据和导入的类库信息
export class TemplateDrivenComponent implements OnInit {

  user: User = {
    email: '',
    password: '',
    repeat: '',
    address: {
      province: '',
      city: '',
      area: '',
      addr: ''
    }
  };
  // 省略其余部分
}复制代码

有了这样一个成员变量以后,咱们在组件模版中就可使用 ngModel 进行绑定了。

使人困惑的 ngModel

咱们在 Angular 中可使用三种形式的 ngModel 表达式: ngModel , [ngModel][(ngModel)]。但不管那种形式,若是你要使用 ngModel 就必须为该控件(好比下面的 input )指定一个 name 属性,若是你忘记添加 name 的话,多半你会看到下面这样的错误:

ERROR Error: Uncaught (in promise): Error: If ngModel is used within a form tag, either the name attribute must be set or the form control must be defined as 'standalone' in ngModelOptions.复制代码

ngModel 和 FormControl

假如咱们使用的是 ngModel ,没有任何中括号小括号的话,这表明着咱们建立了一个 FormControl 的实例,这个实例将会跟踪值的变化、用户的交互、验证状态以及保持视图和领域对象的同步等工做。

<input type="text" name="email" placeholder="请输入您的 email 地址" ngModel>复制代码

若是咱们将这个控件放在一个 Form 表单中, ngModel 会自动将这个 FormControl 注册为 Form 的子控件。下面的例子中咱们在 <form> 中加上了 ngForm 指令,声明这是一个 Angular 可识别的表单,而 ngModel 会将 <input> 注册成表单的子控件,这个子控件的名字就是 email,并且 ngModel 会基于这个子控件的值去绑定表单的的值,这也是为何须要显式声明 name 的缘由。

其实在咱们导入 FormsModule 的时候,全部的 <form> 标签都会默认的被认为是一个 NgForm ,所以咱们并不须要显式的在标签中写 ngForm 这个指令。

<!-- ngForm 并不须要显示声明,任何 <form> 标签默认都是 ngForm -->
<form novalidate ngForm>
  <input type="text" name="email" placeholder="请输入您的 email 地址" ngModel>
</form>复制代码

这一切如今都是不可见的,因此你们可能仍是有些困惑,那么下面咱们将其“可视化”,这须要咱们引用一下表单对象,因此咱们使用 #f="ngForm" 以便咱们能够在模版中输出表单的一些特性。

<!-- 使用 # 把表单对象导出到 f 这个可引用变量中 -->
<form novalidate #f="ngForm">
  ...
</form>
<!-- 将表单的值以 JSON 形式输出 -->
{{f.value | json}}复制代码

这时若是咱们在 email 中输入 sss ,能够看到下图的以 JSON 形式出现的表单值:

控件的输入值同步到了表单的值中

单向数据绑定

那么接下来,咱们看看 [ngModel] 有什么用?若是咱们想给控件设置一个初始值怎么办呢,这时就须要进行一个单向绑定,方向是从组件到视图。咱们能够作的是在初始化 User 的时候,将 email 属性设置成 wang@163.com

user: User = {
    email: 'wang@163.com',
    ...
  };复制代码

并且在模版中使用 [ngModel]="user.email" 进行单向绑定,这个语法其实和普通的属性绑定是同样的,用中括号标示这是一个要进行数据绑定的属性,等号右边是须要绑定的值(这里是 user.email )。那么咱们就能够获得下面这样的输出了, email 的初始值被绑定成功!

单向数据绑定

双向数据绑定

但上面的例子存在一个问题,数据的绑定是单向的,也就是说,在输入框进行输入的时候,咱们的 user 的值不会随之改变的。为了更好的说明,咱们将 user 和 表单的值同时输出

<div>
  <span>user: </span> {{user | json}}
</div>
<div>
  <span>表单:</span> {{f.value | json}}
</div>复制代码

此时咱们将默认的电子邮件改为 wang@gmail.com 的话,表单的值是改变了,但 user 并未改变。

输入的值影响了表单,但不会影响领域对象

若是咱们但愿的是在输入时,这个输入的值也反向的影响咱们的 user 对象的值的话,那就须要用到双向绑定了,也就是 [(ngModel)] 须要上场了。

表单和领域对象的值保持了同步

不管如何,这个 [()] 表达真是很奇怪的样子,其实这个表达是一个语法糖。只要咱们知道下面的两种写法是等价的,咱们就会很清楚的理解了:用这个语法糖你就不用既写数据绑定又写事件绑定了。

<input [(ngModel)]="user.email">
<input [ngModel]="user.email"` (ngModelChange)="user.email = $event">复制代码

ngModelGroup 是什么鬼?

若是咱们仔细观察上面的输出的话,会发现一个问题: user 中是有一个嵌套对象 address 的,而表单中没有嵌套对象的。若是要实现表单中的结构和领域对象的结构一致的话,咱们就得请出 ngModelGroup 了。ngModelGroup 会建立并绑定一个 FormGroup 到该 DOM 元素。 FormGroup 又是什么呢?简单来讲,是一组 FormControl。

<!-- 使用 ngModelGroup 来建立并绑定 FormGroup -->
  <div ngModelGroup="address">
    <label>
      <span>省份</span>
      <select name="province" (change)="onProvinceChange()" [(ngModel)]="user.address.province">
        <option value="">请选择省份</option>
        <option [value]="province" *ngFor="let province of provinces">{{province}}</option>
      </select>
    </label>
    <!-- 省略其余部分 -->
  </div>复制代码

这样的话,咱们再来看一下输出,如今就彻底一致了:

表单和领域对象的结构也彻底一致了

数据验证

模版驱动型的表单的验证也是主要由模版来处理的,在看怎么使用以前,须要界定一下验证规则:

  • 三个必填项: email, passwordrepeat
  • email 的形式须要符合电子邮件的标准
  • passwordrepeat 必须一致

固然除了这几个规则,咱们还但愿在表单未验证经过时提交按钮是不可用的。

<form novalidate #f="ngForm">
  <label>
    <span>电子邮件地址</span>
    <input type="text" name="email" placeholder="请输入您的 email 地址" [ngModel]="user.email" required pattern="([a-zA-Z0-9]+[_|_|.]?)*[a-zA-Z0-9]+@([a-zA-Z0-9]+[_|_|.]?)*[a-zA-Z0-9]+.[a-zA-Z]{2,4}">
  </label>
  <div>
    <label>
      <span>密码</span>
      <input type="password" name="password" placeholder="请输入您的密码" [(ngModel)]="user.password" required minlength="8">
    </label>
    <label>
      <span>确认密码</span>
      <input type="password" name="repeat" placeholder="请再次输入密码" [(ngModel)]="user.repeat" required minlength="8">
    </label>
  </div>
  <!-- 省略其余部分 -->
  <button type="submit" [disabled]="f.invalid">注册</button>
</form>
<div>复制代码

Angular 中有几种内建支持的验证器( Validators )

  • required - 须要 FormControl 有非空值
  • minlength - 须要 FormControl 有最小长度的值
  • maxlength - 须要 FormControl 有最大长度的值
  • pattern - 须要 FormControl 的值能够匹配正则表达式

若是咱们想看到结果的话,咱们能够在模版中加上下面的代码,将错误以 JSON 形式输出便可。

<div>
  <span>email 验证:</span> {{f.controls.email?.errors | json}}
</div>复制代码

咱们看到,若是不填电子邮件的话,错误的 JSON 是 {"required": true} ,这告诉咱们目前有一个 required 的规则没有被知足。

验证结果

当咱们输入一个字母 w 以后,就会发现错误变成了下面的样子。这是由于咱们对于 email 应用了多个规则,当必填项知足后,系统会继续检查其余验证结果。

{ 
"pattern": 
    { 
        "requiredPattern": "^([a-zA-Z0-9]+[_|_|.]?)*[a-zA-Z0-9]+@([a-zA-Z0-9]+[_|_|.]?)*[a-zA-Z0-9]+.[a-zA-Z]{2,4}$", 
        "actualValue": "w" 
    } 
}复制代码

经过几回实验,咱们应该能够得出结论,当验证未经过时,验证器返回的是一个对象, key 为验证的规则(好比 required, minlength 等),value 为验证结果。若是验证经过,返回的是一个 null

知道这一点后,咱们其实就能够作出验证出错的提示了,为了方便引用,咱们仍是导出 ngModel 到一个 email 引用,而后就能够访问这个 FormControl 的各个属性了:验证的状态( valid/invalid )、控件的状态(是否得到过焦点 -- touched/untouched,是否更改过内容 -- pristine/dirty 等)

<label>
  <span>电子邮件地址</span>
  <input ... [ngModel]="user.email" #email="ngModel">
</label>
<div *ngIf="email.errors?.required && email.touched" class="error">
  email 是必填项
</div>
<div *ngIf="email.errors?.pattern && email.touched" class="error">
  email 格式不正确
</div>复制代码

自定义验证

内建的验证器对于两个密码比较的这种验证是不够的,那么这就须要咱们本身定义一个验证器。对于响应式表单来讲,会比较简单一些,但对于模版驱动的表单,这须要咱们实现一个指令来使这个验证器更通用和更一致。由于咱们但愿实现的样子应该是和 requiredminlength 等差很少的形式,好比下面这个样子 validateEqual="repeat"

<div>
    <label>
      <span>密码</span>
      <input type="password" name="password" placeholder="请输入您的密码" [(ngModel)]="user.password" required minlength="8" validateEqual="repeat">
    </label>
    <label>
      <span>确认密码</span>
      <input type="password" name="repeat" placeholder="请再次输入密码" [(ngModel)]="user.repeat" required minlength="8">
    </label>
  </div>复制代码

那么要实现这种形式的验证的话,咱们须要创建一个指令,并且这个指令应该实现 Validator 接口。一个基础的框架以下:

import { Directive, forwardRef } from '@angular/core';
import { NG_VALIDATORS, Validator, AbstractControl } from '@angular/forms';

@Directive({
  selector: '[validateEqual][ngModel]',
  providers: [
    { 
      provide: NG_VALIDATORS, 
      useExisting: forwardRef(()=>RepeatValidatorDirective), 
      multi: true 
    }
  ]
})
export class RepeatValidatorDirective implements Validator{
  constructor() { }
  validate(c: AbstractControl): { [key: string]: any } {
    return null;
  }
}复制代码

咱们尚未开始正式的写验证逻辑,但上面的框架已经出现了几个有意思的点:

  1. Validator 接口要求必须实现的一个方法是 validate(c: AbstractControl): ValidationErrors | null; 。这个也就是咱们前面提到的验证正确返回 null 不然返回一个对象,虽然没有严格的约束,但其 key 通常用于表示这个验证器的名字或者验证的规则名字,value 通常是失败的缘由或验证结果。
  2. 和组件相似,指令也有 selector 这个元数据,用于选择那个元素应用该指令,那么咱们这里除了要求 DOM 元素应用 validateEqual 以外,还须要它是一个 ngModel 元素,这样它才是一个 FormControl,咱们在 validate 的时候才是合法的。
  3. 那么那个 providers 里面那些面目可憎的家伙又是干什么的呢? Angular 对于在一个 FormControl 上执行验证器有一个内部机制: Angular 维护一个令牌为 NG_VALIDATORSmulti provider(简单来讲,Angular 为一个单一令牌注入多个值的这种形式叫 multi provider )。全部的内建验证器都是加到这个 NG_VALIDATORS 的令牌上的,所以在作验证时,Angular 是注入了 NG_VALIDATORS 的依赖,也就是全部的验证器,而后一个个的按顺序执行。所以咱们这里也把本身加到这个 NG_VALIDATORS 中去。
  4. 但若是咱们直接写成 useExisting: RepeatValidatorDirective 会出现一个问题, RepeatValidatorDirective 尚未生成,你怎么能在元数据中使用呢?这就须要使用 forwardRef 来解决这个问题,它接受一个返回一个类的函数做为参数,但这个函数不会当即被调用,而是在该类声明后被调用,也就避免了 undefined 的情况。

下面咱们就来实现这个验证逻辑,因为密码和确认密码有主从关系,并不是彻底的平行关系。也就是说,密码是一个基准对比对象,当密码改变时,咱们不该该提示密码和确认密码不符,而是应该将错误放在确认密码中。因此咱们给出另外一个属性 reverse

export class RepeatValidatorDirective implements Validator{
  constructor(
    @Attribute('validateEqual') public validateEqual: string,
    @Attribute('reverse') public reverse: string) { }

  private get isReverse() {
    if (!this.reverse) return false;
    return this.reverse === 'true' ? true: false;
  }

  validate(c: AbstractControl): { [key: string]: any } {
    // 控件自身值
    let self = c.value;

    // 要对比的值,也就是在 validateEqual=“ctrlname” 的那个控件的值
    let target = c.root.get(this.validateEqual);

    // 不反向查询且值不相等
    if (target && self !== target.value && !this.isReverse) {
      return {
        validateEqual: true
      }
    }

    // 反向查询且值相等
    if (target && self === target.value && this.isReverse) {
        delete target.errors['validateEqual'];
        if (!Object.keys(target.errors).length) target.setErrors(null);
    }

    // 反向查询且值不相等
    if (target && self !== target.value && this.isReverse) {
        target.setErrors({
            validateEqual: true
        })
    }

    return null;
  }
}复制代码

这样改造后,咱们的模版文件中对于密码和确认密码的验证器以下:

<input type="password" name="password" placeholder="请输入您的密码" [(ngModel)]="user.password" #password="ngModel" required minlength="8" validateEqual="repeat" reverse="true">
<!-- 省略其余部分 -->
<input type="password" name="repeat" placeholder="请再次输入密码" [(ngModel)]="user.repeat" #repeat="ngModel" required minlength="8" validateEqual="password" reverse="false">复制代码

完成后的验证错误提示

表单的提交

表单的提交比较简单,绑定表单的 ngSubmit 事件便可

<form novalidate #f="ngForm" (ngSubmit)="onSubmit(f, $event)">复制代码

但须要注意的一点是,button若是不指定类型的话,会被当作 type="submit",因此当按钮不是进行提交表单的话,须要显式指定 type="button" 。并且若是遇到点击提交按钮页面刷新的状况的话,意味着默认的表单提交事件引发了浏览器的刷新,这种时候须要阻止事件冒泡。

onSubmit({value, valid}, event: Event){ 
  if(valid){
    console.log(value);
  }
  event.preventDefault();
}复制代码

对于模板驱动的表单,咱们就先总结到这里,下一篇文章咱们会一块儿讨论响应式表单。

本文代码:github.com/wpcfan/ng-f…

最后再提一下,本文评论区会抽出5人赠送个人 《Angular 从零到一》纸书,欢迎你们围观、订购、提出宝贵意见。

下面是书籍的内容简介:

本书系统介绍Angular的基础知识与开发技巧,可帮助前端开发者快速入门。共有9章,第1章介绍Angular的基本概念,第2~7章从零开始搭建一个待办事项应用,而后逐步增长功能,如增长登陆验证、将应用模块化、多用户版本的实现、使用第三方样式库、动态效果制做等。第8章介绍响应式编程的概念和Rx在Angular中的应用。第9章介绍在React中很是流行的Redux状态管理机制,这种机制的引入可让代码和逻辑隔离得更好,在团队工做中强烈建议采用这种方案。本书不只讲解Angular的基本概念和最佳实践,并且分享了做者解决问题的过程和逻辑,讲解细腻,风趣幽默,适合有面向对象编程基础的读者阅读。

京东连接:item.m.jd.com/product/120…

Angular从零到一
相关文章
相关标签/搜索