详谈Angular 2+ 的表单(一)之模板驱动型表单


Posted in Javascript onApril 25, 2017

摘要

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

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

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

号外

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

模版驱动的表单

模版驱动的表单和 AngularJS 对于表单的处理类似,把一些指令(比如 ngModel )、数据值和行为约束(比如 require 、 minlength 等等)绑定到模版中(模版就是组件元数据 @Component 中定义的那个 template ),这也是模版驱动这个叫法的来源。总体来说,这种类型的表单通过绑定把很多工作交给了模版。

模版驱动的例子

还是用例子来说话,比如我们有一个用户注册的表单,用户名就是 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 模版,先不增加任何的绑定或事件处理:

<!-- 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>

渲染之后的效果就像下面这样:

详谈Angular 2+ 的表单(一)之模板驱动型表单 

简单的Form

数据绑定

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

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 形式出现的表单值:

详谈Angular 2+ 的表单(一)之模板驱动型表单 

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

单向数据绑定

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

user: User = {
 email: 'wang@163.com',
 ...
 };

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

单向数据绑定

详谈Angular 2+ 的表单(一)之模板驱动型表单 

双向数据绑定

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

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

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

详谈Angular 2+ 的表单(一)之模板驱动型表单 

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

如果我们希望的是在输入时,这个输入的值也反向的影响我们的 user 对象的值的话,那就需要用到双向绑定了,也就是 [(ngModel)] 需要上场了。

详谈Angular 2+ 的表单(一)之模板驱动型表单 

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

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

<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>

这样的话,我们再来看一下输出,现在就完全一致了:

详谈Angular 2+ 的表单(一)之模板驱动型表单 

表单和领域对象的结构也完全一致了

数据验证

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

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

当然除了这几个规则,我们还希望在表单未验证通过时提交按钮是不可用的。

<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 的规则没有被满足。

详谈Angular 2+ 的表单(一)之模板驱动型表单 

验证结果

当我们输入一个字母 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>

自定义验证

内建的验证器对于两个密码比较的这种验证是不够的,那么这就需要我们自己定义一个验证器。对于响应式表单来说,会比较简单一些,但对于模版驱动的表单,这需要我们实现一个指令来使这个验证器更通用和更一致。因为我们希望实现的样子应该是和 required 、 minlength 等差不多的形式,比如下面这个样子 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_VALIDATORS 的 multi 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">

完成后的验证错误提示

详谈Angular 2+ 的表单(一)之模板驱动型表单 

表单的提交

表单的提交比较简单,绑定表单的 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();
}

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

本文代码: https://github.com/wpcfan/ng-features.git

以上所述是小编给大家介绍的Angular 2+ 的表单(一)之模板驱动型表单,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对三水点靠木网站的支持!

Javascript 相关文章推荐
关于IE、Firefox、Opera页面呈现异同 写脚本很痛苦
Aug 28 Javascript
Javascript基础知识(二)事件
Sep 29 Javascript
node.js中的fs.symlinkSync方法使用说明
Dec 15 Javascript
JS实现在网页中弹出一个输入框的方法
Mar 03 Javascript
深入JavaScript高级程序设计之对象、数组(栈方法,队列方法,重排序方法,迭代方法)
Dec 01 Javascript
jquery实现页面常用的返回顶部效果
Mar 04 Javascript
利用node.js搭建简单web服务器的方法教程
Feb 20 Javascript
AngularJS 防止页面闪烁的方法
Mar 09 Javascript
微信小程序数据存储与取值详解
Jan 30 Javascript
在vue项目中,将juery设置为全局变量的方法
Sep 25 Javascript
微信小程序设置滚动条过程详解
Jul 25 Javascript
vue移动端模态框(可传参)的实现
Nov 20 Javascript
详解Vue 动态添加模板的几种方法
Apr 25 #Javascript
详解vue-cli + webpack 多页面实例应用
Apr 25 #Javascript
基于Vue实现timepicker
Apr 25 #Javascript
VueJS如何引入css或者less文件的一些坑
Apr 25 #Javascript
详解Angular 4.x 动态创建组件
Apr 25 #Javascript
Angular 4.x中表单Reactive Forms详解
Apr 25 #Javascript
Angular 4.x 动态创建表单实例
Apr 25 #Javascript
You might like
PHP中一个控制字符串输出的函数
2006/10/09 PHP
php+mysql分页代码详解
2008/03/27 PHP
Php注入点构造代码
2008/06/14 PHP
php5.4以下版本json不支持不转义内容中文的解决方法
2015/01/13 PHP
[企业公众号]升级到[企业微信]之后发送消息失败的解决方法
2017/06/30 PHP
PHP实现的日历功能示例
2018/09/01 PHP
jquery的ajax()函数传值中文乱码解决方法介绍
2012/11/08 Javascript
含有CKEditor的表单如何提交
2014/01/09 Javascript
IE6/IE7中JavaScript json提示缺少标识符、字符串或数字问题处理
2014/12/16 Javascript
JavaScript对数字的判断与处理实例分析
2015/02/02 Javascript
JavaScript中setUTCMilliseconds()方法的使用详解
2015/06/12 Javascript
JS与jQuery遍历Table所有单元格内容的方法
2015/12/07 Javascript
JavaScript实现移动端滑动选择日期功能
2016/06/21 Javascript
一个极为简单的requirejs实现方法
2016/10/20 Javascript
工厂模式在JS中的实践
2017/01/18 Javascript
vue.js的提示组件
2017/03/02 Javascript
jQuery插件echarts实现的循环生成图效果示例【附demo源码下载】
2017/03/04 Javascript
node中koa中间件机制详解
2017/08/22 Javascript
vuejs 切换导航条高亮(路由菜单高亮)的方法示例
2018/05/29 Javascript
js实现全选反选不选功能代码详解
2019/04/24 Javascript
简单了解JavaScript中常见的反模式
2019/06/21 Javascript
layui使用数据表格实现购物车功能
2019/07/26 Javascript
JS如何实现网站中PC端和手机端自动识别并跳转对应的代码
2020/01/08 Javascript
vue 限制input只能输入正数的操作
2020/08/05 Javascript
python实现忽略大小写对字符串列表排序的方法
2014/09/25 Python
详解Django的model查询操作与查询性能优化
2018/10/16 Python
详解如何从TensorFlow的mnist数据集导出手写体数字图片
2019/08/05 Python
如何在python开发工具PyCharm中搭建QtPy环境(教程详解)
2020/02/04 Python
详解scrapy内置中间件的顺序
2020/09/28 Python
pycharm 多行批量缩进和反向缩进快捷键介绍
2021/01/15 Python
html5的新增的标签和废除的标签简要概述
2013/02/20 HTML / CSS
英文求职信写作小建议
2014/02/16 职场文书
科技之星事迹材料
2014/06/02 职场文书
服装设计师求职信
2014/06/04 职场文书
2016教师廉洁从教心得体会
2016/01/13 职场文书
如何利用Python实现一个论文降重工具
2021/07/09 Python