Angular 表单 2

217 阅读15分钟

这是 Angular 表单相关知识的进阶版本。通过这篇文章,希望能够帮助您彻底掌握 Angular form 相关的知识点。本文所列的知识和技巧足以支持您应对复杂场景下的表单交互制作。

1. Module 1

1.1 Angular 中的两种表单模式

image.png

1.2 两种模式对比

image.png

image.png

1.3 深入了解 Angular Reactive Form

本文按照下面的步骤逐渐深入理解 Angular 中的模型表单:

  1. 与模板表单的对比
  2. 建造一个模型表单
  3. 模型表单验证
  4. 模型表单相应表单项变化
  5. 动态表单元素
  6. 模型表单的上下文
  7. 表单网络请求和增删改查

2. Module 2

2.1 选择 Reactive Form 的原因

让我们先看看模板表单相关的一些相关知识:

  • Form Group
  • Form Control
  • 何为模板表单
  • 复杂场景

2.2 Angular 表单元素的状态

从三个方面看,Angular 中的表单元素有着不同的状态,这些状态有助于我们更好的了解这个表单元素上发生了什么。

image.png

从表单元素有没有被修改过可以分为:pristine 和 dirty 两种状态。从表单元素有没有通过校验可分为:valid 和 errors 两种状态。从表单元素有没有被点击过分为:touched 和 untouched 两种状态。

而如何能够获取这些表单元素的状态呢,这就要用到 FormGroup 和 FormControl 了。

2.3 最外层的 FormGroup

整个表单的最外层是一个 FormGroup 实例,又称为 Form Modal. 在这个示例上可以读取的内容有:

  • 表单的状态(正如上文所述)
  • 表单值
  • 内嵌的其它 Form Group 实例
  • 所有的 Form Control 实例

2.4 模板表单要素

  • 模板
    • form 元素
    • 表单元素
    • 数据绑定
    • 数据校验规则(属性方式)
    • 校验失败信息
    • 根据模板自动生成表单对象
  • class
    • 对应数据绑定的属性
    • 操作方法,如表单提交函数

2.5 模型表单要素

  • class
    • from model
    • 数据校验函数
    • 校验失败信息
    • 管理表单数据的属性
    • 操作表单的方法
  • 模板
    • form 元素
    • 表单元素
    • 将模型和数据绑定起来

2.6 两种表单使用的指令对比

  1. 模板表单使用的指令:ngForm ngModel ngModelGroup

image.png

image.png

典型的模板表单代码:

image.png

  1. 模型表单使用的指令:formGroup formControl formControlName formGroupName formArrayName

相应的模型表单代码:

image.png

相比较,Reactive Form 的优势在于:不用给每一个表单元素都绑定一大堆指令,我们可以通过 Form Model 很快找到这些表达元素或者更新这些元素的值。

2.7 Reactive Form 能够实现的复杂场景

  1. 更加精细的控制校验过程,如校验节流或者异步校验。
  2. 动态的修改校验规则,增加,改变或者减少。
  3. 动态的增加或者减少表单元素,或者拖动排序这样的高级功能。
  4. 更多场景如下图所示:

image.png

3. Module 3 -- 创建模型表单

本节是对 Reactive Form 的一个概览,主要内容有:

  • 在 class 中创建 Form Module
  • Angular 支持模型表单的 Module
  • 模型表单的 html 应该怎么写
  • 使用 setValue 和 patchValue 回填表单值
  • 使用 FormBuilder 服务来简化表单模型创建的过程

3.1 创建一个 Form Model

一个 Form Model 的组成成分:

image.png

创建一个 Form Model 的代码:

image.png

image.png

创建 model 的时机是 Oninit:

image.png

将 Form model 绑定到 html 模板上:

image.png

获取表单元素的几种方法:

image.png

  1. 通过 controls 等属性一层层往下找。
  2. 通过 getName 来找。
  3. 在 class 中事先预留好 handler.
  4. 通过 getter:
get firstName () { return customerForm.get('firstName'); }

将模板表单变成模型表单的方法:

image.png

3.2 更新表单值

使用模型表单不适用数据双向绑定,所以更新/设置表单值要通过特定的 Api 完成。setValue patchValue

image.png

3.3 使用 FormBuilder 创建表单模型

使用 Form Builder 创建表单模型需要先引入 FormBuilder 再注入到 class 中。创建表单元素的方法如下所示:

image.png

4. Module 4 -- 表单验证

关于表单验证,需要掌握的知识点有:

  1. 使用内置的表单验证函数
  2. 在运行的时候动态修改验证规则
  3. 自定义表单验证函数
  4. 带参数的自定义表单验证函数
  5. 跨字段验证解决方案

4.1 内置表单验证函数

image.png

在数组的第二个参数中设置校验函数,第二个参数可以是单个校验函数或者校验函数数组。

4.2 动态修改校验规则

如果想要动态修改校验规则,那么可以使用下图所示的 Api:

image.png

  • setValidators 是用来设置某个表单元素校验规则的,可以传入一个校验函数或者一组校验函数。
  • clearValidators 可以清除所有的校验规则。
  • updateValueAndValidity 是必须的,因为设置完新的校验规则之后并不是马上生效的,需要一个启用的契机,这个契机就是 updateValueAndValidity 函数调用的时候。

对于像 radio 这样的表单元素,属于同一组的 radio 的 FormControlName 是相同的。

image.png

4.3 自定义表单校验规则

校验函数不仅能够作用在表单元素上还可以作用在 FormGroup 上

这部分内容详见:Angular 表单 - 掘金 (juejin.cn)

使用工厂模式让自定义校验函数接受参数:

image.png

4.4 跨字段校验

有时候,校验能否通过可能会参考表单中已有的数据,比如日期跨度组件中,后面的日期就不能小于前面的日期。这个时候的表单验证成为跨字段验证。进行跨字段验证有三种常见的方法:

  1. 将相关表单元素成组,放在一个 FormGroup 中,然后对这个 Group 进行校验,而不是对其中的某个元素校验。
  2. 通过被校验的元素一层层寻找,直到找到相关的所有表单元素,然后提取值之后一起决定校验是否通过。
  3. 使用 ValidatorFactory 并在调用的时候将表单模型对象当成参数传递进来,根据表单根对象可以获取表单任意元素的值。

image.png

从上图中不难看出来,给 FormGroup 设定校验规则的时候在形式上和 FormControl 有些许不同。

image.png

在定义 Validator 的时候,我们用到了 pristine 这个属性,表示在表单没有被填写或者聚焦的时候不去进行校验,这是一个非常好的样例。

5. Module 5 -- 表单修改之后

在用户修改表单中的值之后,我们一般会做这些事情:

  • 监听表单值的改变。
  • 根据改变之后的值做出响应。
  • Reactive Transformation.

5.1 监听表单元素

你可以监听表单元素的值改变或者状态改变。

image.png

可以监听的对象有:

  1. 表单元素
  2. 表单元素组
  3. 整个表单

如下所示:

image.png

监听表单元素变化的时机一般是在 Oninit 中:

image.png

5.2 根据变化做出响应

如果我们监听到了表单元素的变化,接下来我们可以做什么呢?

image.png

比如说:展示校验失败的信息:

image.png

image.png

也就是说,通过监听表单元素的值的改变,我们甚至可以强行介入表单验证过程,而不使用内置的 validate 流程。

5.3 对校验的触发进行节流

显然,我们不希望校验规则一直触发,这样的用户体验并不好,所以对校验过程进行节流是必要的。这就要求我们通过监听的方式强行介入封装起来的校验过程以实现节流的目的。

实现的过程是非常简洁的,如下所示:

image.png

代码简洁的原因在于 valueChanges 返回的是一个 Observable 对象。

6. Module 6 -- 动态表单元素

提出一个问题:如何复制现有表单中的元素?

核心代码如下所示:

image.png

image.png

这部分内容详见:Angular 表单 - 掘金 (juejin.cn)

注意!在 ngFor 中渲染带 id 元素的时候,一定要将 index 作为其一部分保证其唯一性!

image.png

动态表单的实现需要一定的技巧,并且有很多小细节需要注意。核心是 FormArray 与 ngFor 以及 FormGroupName.

7. Module 7 -- 表单和路由

在实际开发中,往往需要从路由中读取如 id 等信息,再根据 id 信息去请求对象的数据。最后再将数据渲染/回填到表单中。因此,表单和路由是有着密切关系的,具体来说:

  • 在渲染表单的组件中获取路由
  • 然后从路由中读取信息
  • 设置路由守卫(离开时检查是否保存,进来时检查id是否合法等)

7.1 架构

一个典型的表单页代码结构。

image.png

对应的模块图:

image.png

7.2 典型的路由结构

image.png

注意上面的路由守卫,当我们要进入到 /:id 中的时候,PeoductDetailComponent 这个路由守卫会检验合法性。而从 /:id/edit 出来的时候,ProductEditGuard 路由守卫又会检测当前表单是否做了保存。

上面的路由对应的触发代码为:

image.png

或者,

image.png

7.3 读取路由信息

我们需要从路由中读取 id,因此需要用到一些内置的 services:

image.png

上图提供了两种获取路由参数的方法,第一种是一次性的,适合从别的路由跳转过来之后不会在本页上跳转的情况,也就是基本上相当于id不会改变。后面那种情况是针对本页跳本页的。

本页跳本页的情况: 本页跳本页可能是从一个商品的主页跳到另外一个;也有可能是从编辑页跳到新增页。这是现实存在的需求,并且是常见的。

使用后一种,则需要配合两个生命周期函数,分别是:ngOninitngOnDestroy:

image.png

7.4 提示保存的路由守卫

一个典型的路由守卫:

image.png

8. Module 8 -- 表单和网络请求

本节的内容是创建数据处理服务,并通过网络请求完成对数据的 增删改查 操作。

8.1 数据服务

首先解释一下为什么需要一个单独的服务来完成上述操作。

image.png

建立一个数据服务的过程:

image.png

一个典型的数据服务大概如下所示:

image.png

使用方式如下:

image.png

8.2 模拟后端数据

常见的模拟后端,提供数据方法有下面4种:

image.png

  • 使用硬编码的数据假装是后端返回的数据。
  • 使用网络请求本来就是代码一部分的 JSON 文件内容。
  • 使用 Angular 提供的 Mock 功能。
  • 使用第三方库 angular-in-memory-web-api.

而前三种方法的问题在于:

  1. 根本不走网络,只能模拟数据,不能模拟网络通信过程。
  2. 具有了请求过程,但是只支持 get 不支持 post 或者其它方式。
  3. Mock 太过重量级,繁琐。

所以我们必须首先安装这个库:npm install angular-in-memory-web-api --save-dev

然后参考下面的过程引用、创建服务、使用数据。

8.3 第三方库介绍:angular-in-memory-web-api

Angular In-Memory Web API 是一个由 Angular 官方提供的开发用的第三方库,它提供了一个模拟的HTTP客户端来替代真实的后端服务。这对于在开发过程中没有可用后端服务时非常有用,它允许开发者专注于前端开发而无需担心后端逻辑。

8.3.1 特点:

  • 内存中的数据存储:数据存储在内存中,查询速度快。
  • HTTP API 模拟:模拟了 Angular 的 HttpClient,可以用于测试和原型设计。
  • 可配置性:可以配置生成数据的延迟、数据集等。

8.3.2 使用场景:

  • 前端开发:在后端API尚未完成时,快速开发前端。
  • 单元测试:为测试提供可预测的响应数据。
  • 原型设计:快速搭建原型,验证UI/UX设计。

8.3.3 安装

要使用 angular-in-memory-web-api,首先需要通过npm安装它:

npm install angular-in-memory-web-api --save

8.3.4 基本使用

以下是如何在Angular应用中使用 angular-in-memory-web-api 的基本步骤:

1. 导入 InMemoryWebApiModule

在应用的根模块(通常是 AppModule)中导入 InMemoryWebApiModule

// app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { InMemoryWebApiModule } from 'angular-in-memory-web-api';
import { InMemoryDataService } from './in-memory-data.service';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    InMemoryWebApiModule.forRoot(InMemoryDataService) // 配置模拟数据服务
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

2. 创建模拟数据服务

创建一个服务来提供模拟数据。

// in-memory-data.service.ts
import { InMemoryDbService } from 'angular-in-memory-web-api';
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class InMemoryDataService implements InMemoryDbService {
  db = { heroes: [] };

  constructor() {
    this.initializeDb();
  }

  initializeDb() {
    const heroes = [
      { id: 1, name: 'John Doe' },
      { id: 2, name: 'Jane Doe' },
      // 更多数据...
    ];
    this.db.heroes = heroes;
  }

  createDb() {
    // 这个方法实际上可能不需要,除非你需要在应用启动时重新创建数据库
    return this.db;
  }

  // 获取所有数据
  get(collection: string, id?: number): Observable<any[]> {
    if (id) {
      return of(this.db[collection].find(item => item.id === id)).pipe(delay(100)); // 模拟网络延迟
    }
    return of(this.db[collection]).pipe(delay(100)); // 模拟网络延迟
  }

  // 添加新数据
  post(collection: string, data: any): Observable<any> {
    const item = { ...data, id: Date.now() }; // 为新数据生成一个唯一的id
    this.db[collection].push(item);
    return of(item).pipe(delay(100)); // 模拟网络延迟
  }

  // 更新数据
  put(collection: string, id: number, data: any): Observable<any> {
    const index = this.db[collection].findIndex(item => item.id === id);
    if (index >= 0) {
      this.db[collection][index] = { ...this.db[collection][index], ...data };
    }
    return of(this.db[collection].find(item => item.id === id)).pipe(delay(100)); // 模拟网络延迟
  }

  // 删除数据
  delete(collection: string, id: number): Observable<any> {
    const oldArray = this.db[collection];
    this.db[collection] = this.db[collection].filter(item => item.id !== id);
    return of(oldArray.find(item => item.id === id)).pipe(delay(100)); // 模拟网络延迟
  }
}

3. 使用 HttpClient 与模拟API交互

使用 Angular 的 HttpClient 来获取数据。

// app.component.ts
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-root',
  template: `
    <ul>
      <li *ngFor="let hero of heroes | async">{{ hero.name }}</li>
    </ul>
  `,
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
  heroes$: Observable<any[]>;

  constructor(private http: HttpClient) {}

  ngOnInit() {
    this.heroes$ = this.http.get<any[]>('/api/heroes');
  }
}

4. 配置代理(可选)

如果你的应用与后端API使用不同的端口,可能需要配置代理。

angular.json 文件中添加代理配置:

{
  "serve": {
    "proxyConfig": "proxy.conf.json"
  }
}

创建 proxy.conf.json 文件并定义路由规则:

{
  "/api": {
    "target": "http://localhost:3000",
    "secure": false
  }
}

8.3.5 高级配置

angular-in-memory-web-api 还支持更高级的配置,如延迟响应、自定义路由等。

8.3.6 延迟响应

InMemoryDbService 中,你可以为数据操作添加延迟:

get(id: number): Observable<any> {
  return of({ items: this.db['items'] }).pipe(
    delay(1000) // 延迟1秒
  );
}

8.3.7 自定义路由

你可以在服务中定义自定义路由:

constructor(private http: HttpClient) {}

post(req: Request): Observable<any> {
  // 自定义逻辑...
}

8.3.9 总结

angular-in-memory-web-api 是一个强大的工具,可以帮助开发者在没有后端服务的情况下开发和测试Angular应用。通过模拟HTTP请求和响应,它提供了一个简单而有效的方式来构建前端应用,同时等待后端API的完成。

8.4 网络通信细节

下面介绍几个小技巧。

8.4.1 使用不影响流的 tap 做调试,使用 catchError 截取错误

image.png

8.4.2 post 和 put 请求的不同

简单来说,它们的根本不同在于 put 是幂等的,而 post 不是。反映在 put 请求总是会带一个身份信息,例如 id 号。所以 post 一般用来创建新的对象,而 put 一般用来修改对象。

需要注意的是,put 也可以用来创建新的对象,只不过是已经被用户指定的 id.

8.4.3 使用 HttpHeaders 创建网络请求时候的请求头

这里我们使用了专门用来创建请求头的 api,如下图所示:

image.png

8.4.4 表单编辑完成之后在跳转之前 reset 表单信息

重置表单是一个好习惯,因为有的时候你可能只关系了 value 得正确性而忽视 status 的正确性。

image.png

8.4.5 新增/编辑一体化

下面是一个示例代码,展示如何将新增和编辑做在同一个函数中。

image.png

上图中的 this.product 表示的就是初始化的表单值。

9. Module 9 -- 总结

两种表单模式分别是:Template-drivenReactive

优缺点对比:

image.png

此外,这里着重强调其动态性,包括视当前表单状态和内容动态修改验证规则的动态性和视当前表单状态和内容动态修改表单元素的动态性。