AngularのControlValueAccessor

Suzuki Aki
odds.team
Published in
5 min readJul 13, 2018

วันเสาร์นี้จะมีคอร์ส Angular เลยจะมาแนะนำการใช้ ControlValueAccessor ใน Angular หน่อย แต่ก่อนอื่นมาอธิบายกันก่อนว่า ControlValueAccessor คืออะไร และมันดียังไง

ControlValueAccessor เป็น interface ที่เป็นตัวกลางในการคุยกันระหว่าง DOM และ model ใน Angular input ประเภทต่างๆ ล้วน implement ControlValueAccessor ทั้งหมด นั่นคือเหตุผลว่าทำไมเราถึงใช้ ngModel หรือ FormControl กับ element พวกนี้เพื่อ sync data จาก input ได้

ControlValueAccessor มี Method อยู่ 4 method ด้วยกันดังนี้

  1. writeValue(value)
    คือ method สำหรับรับ value เข้ามาเพื่อทำอะไรบางอย่างใน component ด้วย ngModel หรือ formControl ถ้าเป็น input ก็ไปแสดงใน attribute value
  2. registerOnChange(fn)
    รับ function callback เข้ามาเพื่อรับบางอย่างจากใน component function ที่ถูก register ใน event (change) จะถูกส่งเข้ามาที่นี่
  3. registerOnTouched(fn)
    เช่นเดียวกันกับ registerOnTouched แต่อันนี้เป็น event (blur)
  4. setDisabledState?(isDisabled)
    เป็น method ที่จะถูกเรียก เมื่อ component มีการถูก set directive [disabled] เข้ามา ก็จะส่ง ค่า boolean เข้ามา method เป็น optional ดังนี้ไม่ต้องมีก็ได้

ในการที่จะสร้าง custom component ที่มีความสามารถเหมือน form input จำเป็นจะต้อง implement interface ControlValueAccessor ทฤษฎีจบแล้วไปดูวิธีการเขียนกัน (หวังว่าผู้อ่านจะเขียน Angular เป็นนะ ขอข้ามไปตอนสร้าง Component เลยแล้วกัน)

ตัวอย่างคือ อยากจะสร้าง input date range ที่มี input date 2 อัน แล้วให้ output เป็น start date และ end date

สร้าง component ก่อน

$ ng generate component date-range

เมื่อได้ component แล้ว เปิดไฟล์ html สร้าง input date มา 2 อัน

<input type="date">
-
<input type="date">

เอา Component ไปแปะ ที่ app component

<div>
<app-date-range></app-date-range>
</div>

ต้องไป import FormsModule ที่ app.module.ts ก่อน

import { FormsModule } from '@angular/forms';...@NgModule({
declarations: [
AppComponent,
FileInputComponent
],
imports: [
BrowserModule,
FormsModule
],
providers: [],
bootstrap: [AppComponent]
})
...

ลอง render ดู จะได้หน้าตาแบบนี้

ที่ file date-range.component.ts สร้าง attribute เพื่อ handle value ของ input date (input date รับส่งค่าเป็น string format ‘yyyy-MM-dd’ แต่เราอยากเก็บค่าเป็น
Date Object เลยต้องมา handle ด้วยวิธีนี้)

export class DateRangeComponent {  startDate: Date;
endDate: Date;
onChangeStartDate(date) {
this.startDate = new Date(date);
}
onChangeEndDate(date) {
this.endDate = new Date(date);
}
}

ใน template ก็ไป bind value

<input type="date" 
[ngModel]="startDate | date:'yyyy-MM-dd'"
(ngModelChange)="onChangeStartDate($event)" />
-
<input type="date"
[ngModel]="endDate | date:'yyyy-MM-dd'"
(ngModelChange)="onChangeEndDate($event)" />

implement interface ControlValueAccessor

export class DateRangeComponent implements ControlValueAccessor {
startDate: Date;
endDate: Date;
writeValue(value: any): void {} registerOnChange(fn: any): void {} registerOnTouched(fn: any): void {} setDisabledState?(isDisabled: boolean): void {}}

เราจะกำหนดให้ data ที่จะ bind กับ component date-range หน้าตาแบบนี้

interface DateRange {
startDate: Date;
endDate: Date;
}

ใน method writeValue implement handle value ที่ถูกส่งเข้ามา แล้วนำมาเก็บไว้ที่ properties ของ DateRangeComponent เพื่อ bind เข้ากับ input date ทั้ง 2 ตัว

...  writeValue(value: DateRange): void {
this.startDate = value.startDate;
this.endDate = value.endDate;
}...

implement method registerOnChange และ registerOnTouched
registerOnChange จะส่ง function สำหรับ handle value change เข้ามา ซึ่งจะไปเกี่ยวกับตอนที่เรียกใช้ component แล้วใส่ event (change) แต่ในกรณีนี้เราไม่ได้ใช้ event (change) ดังนี้ function ที่ถูกส่งเข้ามาทาง registerOnChange จะเป็น function default ของ value accessor ซึ่งจะมาทำหน้าที่ส่ง output value ออกไปทาง ngModel

สำหรับ onTouched จะถูกใช้งานก็ต่อเมื่อมีการกำหนด event (blur) แต่วันนี้ยังไม่ใช้

...onChange = (_) => {}
onTouched = () => {}
registerOnChange(fn: any): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
...

เสร็จแล้วไปที่ app.component.ts ไปประกาศตัวแปรที่จะเป็น model ของ date range

export class AppComponent {
range: DateRange = {
startDate: null,
endDate: null
};
}

ใน template ของ app component ก็ bind model เข้ากับ date range component พร้อมกับแสดง value เพื่อจะทดสอบดูว่า model ถูก bind อย่างถูกต้องหรือไม่

<div>
<app-date-range
[(ngModel)]="range"
name="range"
#dates="ngModel">
</app-date-range>
</div>
<div>{{dates.value | json}}</div>

เมื่อ refresh browser จะพบว่ามี error แบบนี้

ERROR Error: No value accessor for form control with name: 'range'

หมายถึงว่า component ที่เราไป bind model นั้น ไม่มี value accessor นั่นเป็นเพราะ DateRangeComponent ยังไม่ได้ถูกคนอื่นมองว่าเป็น ValueAccessor ทำให้ไม่สามารถใช้ ngModel กับ DateRangeComponent ได้ สิ่งที่ต้องทำคือ ไปกำหนด provider ให้กับ DateRangeComponent ให้เป็น ValueAccessor

ที่ decorator ของ DateRangeComponent เพิ่ม section providers เข้าไปแบบนี้

@Component({
selector: 'app-date-range',
templateUrl: './date-range.component.html',
styleUrls: ['./date-range.component.css'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: DateRangeComponent
}
]
})

เมื่อ refresh browser จะพบว่า error หายไปแล้ว แล้วหน้าจอแสดงผลแบบนี้

value ของ startDate ของ endDate เป็น null เป็นเพราะเราไปกำหนดให้เป็น null ใน model ของ DateRange ก่อนหน้านี้

ถ้าลองเลือกวันที่ใน input date แล้ว json ข้างล่างแสดงวันที่ที่เลือก ไม่ต้องตกใจไป นั่นเป็นเพราะ value ที่เราเลือกจาก input date ใน DateRangeComponent ยังไม่ได้ถูกส่งออกมาเป็น output ดังนั้นต้องไปเพิ่มโค้ดที่ DateRangeComponent เรียก onChange ด้วย value ที่เราอยากจะให้ส่งเป็น output ออกไป


onChangeStartDate(date) {
this.startDate = new Date(date);
this.onChange({
startDate: this.startDate,
endDate: this.endDate
});
}onChangeEndDate(date) {
this.endDate = new Date(date);
this.onChange({
startDate: this.startDate,
endDate: this.endDate
});
}

เมื่อลองไปเล่นดูอีกครั้ง วันที่จะแสดงค่าใน json อย่างถูกต้อง

ที่นี้ลองไปใช้กับ form ดูบ้าง สร้าง tag form มาครอบ date-range พร้อมกับ render value ของทั้ง form

<form #f="ngForm">
<div>
<app-date-range
[(ngModel)]="range"
name="range">
</app-date-range>
</div>
<div>
Value: {{ f.value | json }}
</div>
</form>

หน้าจอก็แสดงผลแบบนี้ จะเห็นได้ว่า value ของ form จะมี field “range” อยู่ ซึ่งมาจาก attribute name ที่เรากำหนดใน tag app-date-range ซึ่งตอนนี้ app-date-range มีคุณสมบัติเหมือน form control อื่นๆแล้ว ทำให้สามารถใช้ร่วมกับ form ได้อย่างแนบเนียนเหมือนกับ input ประเภทอื่นๆ

ยัง ยังไม่จบ ลองมาทำ disable ดูบ้าง เราสามารถกำหนด disabled ที่ app-date-range แบบนี้ได้เลย

<app-date-range
[(ngModel)]="range"
name="range"
disabled>
</app-date-range>

เพียงแต่ว่าใน DateRangeComponent จะต้อง handle ค่า disabled ที่ส่งมาเพื่อนำไป disable input date ทั้ง 2 ตัว ที่ method setDisabledState

...disabled = false;setDisabledState?(isDisabled: boolean): void {
this.disabled = isDisabled;
}..

จากนั้นเอา disabled ไปแปะที่ input date

<input type="date" 
[ngModel]="startDate | date:'yyyy-MM-dd'"
(ngModelChange)="onChangeStartDate($event)"
[disabled]="disabled" />
-
<input type="date"
[ngModel]="endDate | date:'yyyy-MM-dd'"
(ngModelChange)="onChangeEndDate($event)"
[disabled]="disabled" />

input ก็จะมีสีเทาเล็กน้อย และเลือกวันที่ไม่ได้

ข้อที่ดีกว่าการใช้ Component แบบ emit output ออกมา

  1. เนียนกว่า เราไม่ต้องสร้าง method เพื่อรับ output event แล้วนำ value มาเก็บ เราสามารถใช้ ngModel กับ component นั้นได้เลย เสมือนว่าเป็น input ตัวหนึ่ง ทำให้โค้ดสะอาดขึ้นมาก
  2. NgForm หรือ FormGroup ที่ครอบไว้สามารถเห็น Custom component เป็น field หนึ่งและกวาด value มาได้ อัตโนมัติ และสามารถใช้งาน Feature ของ Form ได้เต็มที่ไม่ว่าจะเป็น valid, dirty และ disable
  3. Encapsulate อย่างแท้จริง Component แม่ที่เรียกใช้ Custom component นี้อยู่ ไม่ต้องรู้อะไรเกี่ยวกับ implementation ภายในเลย แค่กำหนดตัวแปรที่จะ bind value ก็พอ

ControlValueAccessor เหมาะกับการใช้สร้าง Custom component ที่เราอยากให้มีความสามารถเหมือน form control แล้วใช้ร่วมกับ Form ได้

คราวหน้าจะมาต่อเรื่องการทำ Validationให้ DateRangeComponent สามารถ provide ความเป็น valid/invalid ได้

Docs: https://angular.io/api/forms/ControlValueAccessor

--

--