+1

Angular Forms: Useful Tips

Bài viết này cung cấp cho bạn một số lời khuyên hữu ích khi làm việc với các Angular Forms.

Angular Forms là một phần quan trọng của hầu hết mọi Angular apps, thậm chí còn hơn thế đối với các — dashboards, live data apps, thu thập dữ liệu, v.v. Điều rất quan trọng là phải linh hoạt thiết lập càng tốt, bởi vì các requirements cho forms có thể thay đổi rất nhiều trong một khoảnh khắc.

Có một số điều quan trọng cần biết và ghi nhớ về Angular forms.

Forms are not type-safe

Forms không phải là loại an toàn, vì vậy không thể dựa vào TypeScript để bắt lỗi, code dễ bị lỗi và lỗi chính tả, điều đó có nghĩa là phải thận trọng hơn khi xử lý forms. Nhưng tất nhiên chỉ cần thận trọng không phải là điều hữu ích duy nhất - có một số điều chúng ta có thể làm để làm cho cuộc sống của chúng ta dễ dàng hơn.

  1. Hạn chế việc sử dụng Formgroup.get khi referring đến các nested control trong Formgroup. Thay vào đó, lưu trữ các tham chiếu đến các nested control trong các component propertie.
  2. Sử dụng DTO pattern khi thay đổi mô hình dữ liệu của Forms thành pattern được require bởi một data service.
  3. Abstract away harder form controls to custom components implementing ControlValueAccessor.

Phần này thực sự khó hiểu với những người mới tiếp cận với Angular vì vậy tôi sẽ làm rõ từng phần phía trên bằng những ví dụ cụ thể nhé.

1. Hạn chế việc sử dụng Formgroup.get

Hãy xem xét đoạn mã này:

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.css' ]
})
export class AppComponent  {
  form = this.formBuilder.group({
    firstName: [''],
    lastName: [''],
    age: [''],
  });

  constructor(private formBuilder: FormBuilder) {}
}

Đây là một component có Formgroup đơn giản. Bây giờ hãy xem template:

<form [formGroup]="form">
  <div>
    <label for="firstName">First Name</label>
    <input formControlName="firstName" id="firstName"/>
    <span *ngIf="form.get('firstName').touched && form.get('firstName').hasError('required')" class="errors">
      Field is required
    </span>
  </div>

  <div>
    <label for="lastName">Last Name</label>
    <input formControlName="lastName" id="lastName"/>
    <span *ngIf="form.get('lastName').touched && form.get('larstName').hasError('required')" class="errors">
      Field is required
    </span>
  </div>
</form>

Trông giống như một thiết lập form khá thông thường, không có gì quá khó. Nhưng bạn đã thấy vấn đề?

Hãy cùng xem xét nhé

Ở dòng

<span *ngIf="form.get('lastName').touched && form.get('larstName').hasError('required')" class="errors">

tôi đã viết nhầm ‘larstName, thay vì‘ lastName, và nó có thể đã gây ra lỗi và tối có thể dễ dàng phát hiện ra nó trong console log,

Lỗi thì cũng là chuyện bình thường lúc mình code phải không những lỗi như vậy bạn mở console log ra là thấy ngay, nhưng còn một chuyện khác, đó là: bạn có đủ kiên nhẫn để ngồi ngõ form.get(‘firstName’) tất nhiên form.get thì nhiều IDE hay plugin của text-editor nó vẫn tự nhắc lệnh nhưng firstName chẳng hạn thì sẽ không, bạn phải tự gõ đúng không. Và đó cũng là nguyên nhân dẫn đến bug chỉ vì bạn gõ sai chính tả. Vậy có cách nào khác không? Hãy xem ở dưới nhé

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
})
export class AppComponent  {
  form = this.formBuilder.group({
    firstName: [''],
    lastName: [''],
    age: [''],
  });

  controls = {
    firstName: this.form.get('firstName'),
    lastName: this.form.get('lastName'),
  }

  constructor(private formBuilder: FormBuilder) {}
}

Điều này khá đơn giản: tôi chỉ lưu trữ cá creferences đến form controls của tôi trong một đối tượng đặc biệt gọi là controls để tôi có thể sử dụng chúng dễ dàng trong form của tôi, giờ đây sẽ trông như thế này:

<form [formGroup]="form">
  <div>
    <label for="firstName">First Name</label>
    <input formControlName="firstName" id="firstName"/>
    <span *ngIf="controls.lastName.touched && controls.firstName.hasError('required')" class="errors">
      Field is required
    </span>
  </div>

  <div>
    <label for="lastName">First Name</label>
    <input formControlName="lastName" id="lastName"/>
    <span *ngIf="controls.lastName.touched && controls.lastName.hasError('required')" class="errors">
      Field is required
    </span>
  </div>
</form>

Thoạt nhiên, điều này có vẻ như là một cải tiến nhỏ, nhưng hãy xem nó mang lại được những gì nhé

  1. FormControl.get là một function và gọi nó bên trong một template có nghĩa là nó sẽ được gọi trên mỗi change detection cycle (rất nhiều), nhưng bằng cách sử dụng phương thức này, tôi cũng giải quyết được vấn đề đó
  2. Tôi chỉ lưu trữ references cho các control sẽ được tham chiếu trong template
  3. Nếu chúng ta cần những controls bên trong component class’ code, chúng ta cũng có thể truy cập chúng thông qua các references này và tránh lặp lại nhiều lần

Vì vậy, một trong những vấn đề trên đã được giải quyết, nhưng còn vềtype-safety? Giá trị các controls của tôi vẫn được TypeScript xử lý vì chúng thuộc loại bất kỳ, đây không phải là một điều tốt. Vậy chúng ta nên làm thế nào với nó đây?

2. Use Data Transfer Object pattern

Hãy tưởng tượng một tình huống mà chúng ta phải điền vào một biểu mẫu phức tạp và gửi nó đến máy chủ thông qua HTTP request. Tất nhiên, chúng ta có thể lấy Formgroup.value và chỉ cần gửi nó, nhưng rất có thể là server API gateway đang chờ mô hình dữ liệu hơi khác. Ví dụ: nó có thể nhận Date ở định dạng ISO thay vì chuỗi JS bình thường. Tất nhiên, chúng ta có thể thay đổi hình dạng của Formgroup, nhưng mặt khác, các control mà chúng ta sử dụng trong template có thể chỉ hoạt động tốt hơn với các loại dữ liệu khác với các loại máy chủ mong. Vậy chúng ta có thể làm gì?

DTO(data transfer object là một đối tượng đặc biệt, chịu trách nhiệm mang thông tin từ một subsystem (Angular app) sang một hệ thống khác (backend). Mục đích chính và ban đầu của nó là để giảm lượng dữ liệu được gửi qua mạng. Nhưng nó cũng đảm bảo rằng các subsystem nói chuyện với nhau bằng cùng một ngôn ngữ (cùng loại dữ liệu). Vậy làm thế nào chúng ta sẽ được hưởng lợi từ mô hình này?

Bằng cách viết một class đơn giản, sẽ nhận giá trị form trong constructor của nó và tạo ra một đối tượng khớp chính xác với API signature của máy chủ, xử lý tất cả các khác biệt giữa giá trị trong form của tôi và máy chủ. Điều rất quan trọng là lớp của chúng không có bất kỳ behaviors, getters, setters nào khác - đó là những thứ sẽ không serialized với JSON.stringify, và class của chúng ta sẽ có một trách nhiệm duy nhất - tạo ra một data transfer object. Đây là một ví dụ về DTO:

export class ArticleDTO {
  title: string;
  tags: string;
  date: string;
  referenceIds: number[];

  constructor(formValue: RawFormValue) {
    this.title = formValue.title;
    this.tags = formValue.tags.join(',');
    this.date = formValue.date.toISOString();
    if (formValue.referenceIds && formValue.referenceIds.length > 0) {
      this.referenceIds = formValue.referenceIds;
    }
  }
}

export interface RawFormValue {
  title: string;
  tags: string[];
  date: Date;
  referenceIds?: number[];
}

Class này khá đơn giản: nó xử lý việc chuyển đổi một đối tượng (giá trị dạng thô) thành một đối tượng khác (sẽ chuyển sang phần backend). Logic hoàn toàn được gói gọn bên trong constructor. Nếu có vấn đề, constructor là nơi duy nhất ở codebase có thể xảy ra; nếu có vấn đề với loại, RawFormValue interface là nơi chúng ta nên tìm kiếm. Dưới đây là một ví dụ về cách chúng ta có thể sử dụng mẫu này:

export class AppComponent  {
  // rest of the component implementation is ommitted for brevity

  submit() {
    if (this.form.valid) {
      const article = new ArticleDTO(this.form.value as RawFormValue);
      // now send the article DTO to the backend using one of your services
    }
  }
}

Viết thế này có lợi ở một số điểm như:

  1. Business logic của ứng dụng với các thao tác và xử lý dữ liệu được chuyển ra khỏi component
  2. Logic đó được chứa ở một nơi duy nhất trong ứng dụng của tôi, và nó cũng chỉ chịu trách nhiệm duy nhất cho logic đó (dễ debug hơn)
  3. Điều này khá hay ho với bất kỳ cách thao tác dữ liệu nào bạn sử dụng, từ các đối tượng JS cũ đơn giản đến quản lý trạng thái và, ví dụ, chuẩn hóa NGRX.

Custom Angular Controls

Điểm cuối cùng tôi đã đề cập về việc làm cho form đơn giản hơn là tạo các custom controls. Implementing ControlValueAccessor interface trong một component và đăng ký nó với NG_VALUE_ACCESSOR provider cho phép tôi tạo custom Angular Form Control. Điều này có nghĩa là <custom-component></custom-component> của tôi giờ đây có thể lấy FormControl và ngModel như thế này:

<custom-component formControlName="controlName"></custom-component>

Điều này giúp chúng tôi trừu tượng hóa complex behavior trên các form và làm cho nó có thể tái sử dụng. Bất cứ khi nào bạn thấy mình thực hiện nhiều logic nặng tùy chỉnh trên một control duy nhất trong Formgroup, hãy nghĩ xem liệu có thể biến nó thành một component khác không.

setValue vs patchValue

Có hai cách để thay đổi giá trị của FormControl trong Angular: setValuepatchValue. Chủ yếu chúng giống nhau, nhưng có một điểm khác biệt quan trọng: nếu chúng ta gọi setValue trên Formgroup với một đối tượng thiếu một số keys từ form signature, nó sẽ báo lỗi. Điều này rất hữu ích, vì nó cho phép một số loại an toàn trong dynamic world của Reactive Form. Nhưng có một vấn đề: nó cũng đưa ra một lỗi bất cứ khi nào đối tượng chúng ta chuyển đến nó có một thuộc tính mà biểu mẫu của chúng ta không có.

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
})
export class AppComponent  {
  form = this.formBuilder.group({
    firstName: [''],
    lastName: [''],
    age: [''],
  });

  ngOnInit() {
    // throws an error
    this.form.setValue({
      firstName: 'Armen',
      lastName: 'Vardanyan',
      age: 25,
      occupation: 'Software developer, writer',
    });
  }

  constructor(private formBuilder: FormBuilder) {}
}

Điều này sẽ đưa ra một lỗi “Error: Cannot find form control with name: occupation”. Điều này khá hữu ích, nhưng đi kèm với một nhược điểm nghiêm trọng chúng ta phải quan tâm. Xem xét đoạn code này:

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
})
export class AppComponent  {
  form = this.formBuilder.group({
    firstName: [''],
    lastName: [''],
    age: [''],
  });

  async ngOnInit() {
    const userData = await this.userService.getUserById(/*some id*/);
    this.form.setValue(userData);
  }

  constructor(
    private formBuilder: FormBuilder,
    private userService: UserService,
  ) {}
}

Trong trường hợp này, tôi nhận được một số dữ liệu từ server và đặt nó làm giá trị form - không có gì đặc biệt kỳ lạ. Vì vậy, vấn đề là gì?

Hiện tại, mọi thứ đều hoạt động tốt, server sẽ gửi chính xác cùng một dữ liệu cần thiết. Nhưng hãy tưởng tượng điều này - cùng một lệnh gọi API mà chúng ta sử dụng để truy xuất dữ liệu người dùng này cũng được sử dụng ở một nơi khác trong ứng dụng, trong một thành phần không được tôi xây dựng. Và ngay tại đó, UI cần thêm một chút dữ liệu về người dùng - vì vậy, các API dev đã thêm một trường occupation, và nghĩ rằng “when we added something, why would anything stop working?”.Bây giờ nó ngừng hoạt động, bởi vì có một thuộc tính mới trên đối tượng mà chúng ta cố gắng setValue. Vậy làm thế nào để chúng ta giải quyết điều này? Có hai cách tiếp cận:

  1. Sử dụng patchValue khi cài đặt giá trị từ các external API-s. Điều này giải quyết vấn đề trước mắt, nhưng có thể tạo ra các vấn đề khác trong tương lai - điều gì xảy ra nếu thiết kế API có thay đổi thực sự và một số trường bị thiếu trong response? Thay vì nhìn thấy màn hình trống trên mỗi input fields tôi phải thấy thông báo lỗi và patchValue không throw.
  2. Giải pháp hoàn chỉnh: viết một functio trung gian (hoặc có thể là một class) để chuyển đổi response server thành một cái gì đó tương thích với signature form của tôi (giống như những gì chúng ta đã làm khi gửi form value đến server). Ví dụ:
interface RawFormValue {
  firstName: string;
  lastName: string;
  age: number;
}

function toRawFormValue<T extends RawFormValue>(serverData: T): RawFormValue {
  return {
    firstName: serverData.firstName,
    lastName: serverData.lastName,
    age: serverData.age,
  }
}

Ta có thể thấy: Nếu thiết kế API thay đổi, điều duy nhất chúng ta sẽ phải làm là thay đổi function này.

Forms and events

Một tính năng quan trọng của ReactiveForms là chúng emit và đọc các event khác nhau - control has been touched, control became dirty, value has changed, validity has changed, v.v. Tôi sử dụng điều đó trong rất nhiều hình thức, nhưng phổ biến nhất là subscrib đến FormControl.valueChanges Observable. Observable này cung cấp một luồng các thay đổi trên control, được kích hoạt bởi người dùng hoặc programmatically.

Programmatically nghĩa là sao? Điều này có nghĩa là khi gọi FormControl.setValue sẽ trigger một emission đến những subscriber valueChanges của nó. Điều này đôi khi có thể dẫn đến kết quả bất ngờ. Hãy tưởng tượng một case sau đây: một directive binds đến mọi [formControl], injects một reference tới NgControl, đọc valueChanges0, xóa tất cả các dấu cách từ nó và set giá trị trên control. Ví dụ:

@Directive({
  selector: '[formControl]'
})
export class TrimDirective implements OnInit {

  constructor(
    private ngControl: NgControl,
  ) { }

  ngOnInit() {
    this.ngControl.valueChanges.subscribe(
      (value: string) => this.ngControl.control.setValue(value.trim())
    );
  }
}

Directive này thực hiện chính xác điều đó - xóa hết dấu cách trong inputs. Nhưng có một điều đó là: Ngay khi người dùng nhập bất cứ thứ gì, nó sẽ kích directive, sẽ set giá trị mới. Vậy chuyện gì sẽ xảy ra? chúng ta có thể gặp lỗi ngay sau đó?

Điều này có thể dễ dàng tránh được với một option được gọi là emitEvent, mặc định nó sẽ là 'true'. Khi nó là 'false', về cơ bản, nó nói với FormControl rằng giá trị phải được thay đổi, nhưng subscribers không được thông báo về sự thay đổi đó. Đây là cách thức hoạt động của nó:

(value: string) => this.ngControl.control.setValue(value.trim(), {emitEvent: false}),

Note: Hãy cẩn thận với điều này - hãy xem xét rằng nếu bạn value một giá trị trong khi emitEvent: false subscriber sẽ không nhận được thông báo.

Kết luận

Angular Forms là những công cụ rất mạnh có thể được sử dụng để thực hiện các hoạt động rất phức tạp. Nhưng để kiểm soát và làm chủ nó thì thật sự rất khó, và bài viết này giúp giải quyết một số vấn đề phổ biến liên quan đến chúng. Hi vọng nó sẽ giúp ích cho bạn một chút trong quá trình làm việc với Angular. Cảm ơn đã đọc bài viết

Bài viết tham khảo : Angular Forms: Useful Tips


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.