Angular กับ Canceled HTTP Requests

Phai Panda
7 min readFeb 16, 2020

--

พวกเรารู้จัก routing ใช่ไหม เคย navigate ระหว่าง pages เวลาส่ง request ไป API รู้ใช่ไหมว่ามันมีเวลาอยู่ตรงนั้นเสมอ จะช้าหรือเร็วก็ควรพิจารณาการยกเลิก request เพื่อหยุดยั้งงานที่สูญเปล่านั่น

Angular ใช้กลไกที่เรียกว่า component จัดการกับ UI และออกแบบการไหลของ data ไปพร้อมกัน ซึ่งในขณะที่ data กำลังเดินทางมาถึงผ่าน life cycle hooks เช่น ngOnInit ก็มีความเป็นไปได้ที่จะเกิดการเปลี่ยน page หรือแม้แต่เปลี่ยน component ที่แสดงผลอยู่ นี่จึงเป็นที่มาของบทความนี้ หัวใจคือการสั่งยกเลิก HTTP requests

แต่เราจะเริ่มต้นตั้งแต่สร้างโปรเจกต์เพื่อให้มือใหม่ได้เห็นการพัฒนาและต่อยอดทีละเล็กทีละน้อย แบ่งได้ 4 ส่วนใหญ่ๆดังนี้

  1. ภาพรวมและเตรียมโปรเจกต์
  2. จำลอง data จาก observable
  3. จำลอง data จาก fake APIs
  4. กลไก canceled http requests

ภาพรวมและเตรียมโปรเจกต์

สมมติเรามี component อยู่ 3 ชิ้น ได้แก่ NoteComponent, MapComponent และ NoteDetailsComponent พฤติกรรมคือให้มันอยู่กันคนละหน้า ให้ NoteComponent เป็นศูนย์กลาง (หน้าแรก) มีปุ่มอยู่ในแต่ละรายการเพื่อ navigate ไปหน้า MapComponent และ NoteDetailsComponent ดังนี้

we note project idea

เริ่มแรกคือ NoteComponent ร้องขอ data จาก API ทันทีที่เห็นปุ่ม “แผนที่” และ “รายละเอียด” เขาอาจกดปุ่มใดก็ได้ สมมติว่าเป็นปุ่มรายละเอียด ทว่าไม่ทันที่หน้า NoteDetailsComponent จะได้แสดงผลเขาก็เปลี่ยนใจกดปุ่ม “ย้อนกลับ” เพื่อกลับไปเลือกรายการใหม่ ณ เวลาตรงนี้ที่เราต้องการให้เกิดการยกเลิก http request เหตุเพราะ NoteComponent จะร้องขอ data จาก API ใหม่เสมอ เป็นผลให้มี requests สะสมไว้ใน stack และรอคอย (pending state) จนกระทั่ง data นั้นจะมาถึง เหตุการณ์เช่นนี้แหละครับคืองานสูญเปล่าแท้จริง

แล้วเราจะจัดการอย่างไร?

คำตอบ เราจะใช้สิ่งที่เรียกว่า Interceptor กับ RxJS เพราะเมื่อใดก็ตามที่เปลี่ยน page ใหม่หรือ component ใหม่ในหน้าเดียวกันได้ร้องขอ data จาก API เราสามารถยกเลิกการร้องขอนี้ได้ทันที

มาลองดูกันครับ

เตรียมพร้อม

ขอดูเวอร์ชัน Angular CLI ก่อน

ng --version

Angular CLI: 9.0.2
Node: 10.16.0
OS: darwin x64

@angular-devkit/architect 0.900.2
@angular-devkit/core 9.0.2
@angular-devkit/schematics 9.0.2
@schematics/angular 9.0.2
@schematics/update 0.900.2
rxjs 6.5.3

เริ่มสร้าง project

เราจะสร้างโปรเจกต์ชื่อ we-note

ng new we-note

มันถามว่า Would you like to add Angular routing? ตอบ y

มันถามว่า Which stylesheet format would you like to use? เลือก css

สร้าง NoteComponent จากคำสั่ง generate file ซึ่งเป็นการ generate component เมื่อ g ย่อจาก generate, c ย่อจาก component และ components/note คือให้สร้างไว้ใน folder ชื่อ components (ตั้งอะไรก็ได้)

ng g c components/note

ผลจะได้ 4 ไฟล์อยู่ภายใน components folder ดังนี้

  • note.component.css
  • note.component.html
  • note.component.spec.ts
  • note.component.ts

ถัดไปให้สร้าง NoteDetailsComponent

ng g c components/note-details

ถ้าไปให้สร้าง MapComponent

ng g c components/map

จากนั้นเปิดไฟล์ app-routing.module.ts เพื่อสร้าง routing ระหว่าง page

const routes: Routes = [  {    path: 'notes', children: [      { path: ':id/details', component: NoteDetailsComponent },      { path: ':id/map', component: MapComponent },      { path: '', component: NoteComponent },    ],  },  { path: '**', redirectTo: '/notes', pathMatch: 'full' }];

รัน

ng s

เปิดไฟล์ app.component.html ทำให้เหลือแค่ router-outlet ก็พอครับ

<router-outlet></router-outlet>

ทดสอบ routing ด้วยการพิมพ์บน address bar ของ browser ดังนี้

พิมพ์

http://localhost:4200

แล้ว enter ผลคือ

มัน redirect มาหน้า /notes ถือว่าถูกต้อง

พิพม์

http://localhost:4200/notes/123/details

ผล

แสดงหน้า note details ถูกต้อง

พิมพ์

http://localhost:4200/notes/123/map

ผล

แสดง map ถูกต้อง

NoteComponent Linking

ตกแต่งและเพิ่ม link เพื่อไปยังหน้า note details กับ map

เปิดไฟล์ note.component.html

<h1>Notes</h1><div>  <a routerLink="123/details">รายละเอียด</a>  <a routerLink="123/map">แผนที่</a></div>

ไฟล์ note.component.css

div {  background-color: gainsboro;}div a {  margin-right: 8px;}a {  display: inline-flex;  background-color: gray;  padding: 0 10px;  height: 44px;  text-decoration: none;  color: #f5f5f7;  opacity: .8;  border-radius: 4px;  align-items: center;}

ผล

รายการ notes

จำลอง data จาก observable

สำหรับมือใหม่ที่ยังไม่รู้จัก observable ขอให้อ่านบทความที่เกี่ยวข้องก่อน เช่น

NoteService

มาถึงคราวของ data กันบ้าง เราจะสร้าง service ชื่อว่า NoteService ทำหน้าที่ response data แก่ประดา components ที่ต้องการข้างต้นโดยจำลองการสุ่มค่า delay time ระหว่าง 1 ถึง 5 วินาทีด้วย observable

ng g s services/note

เปิดไฟล์ note.service.ts จะได้

import { Injectable } from '@angular/core';@Injectable({ providedIn: 'root' })export class NoteService { }

จำลอง note data ชื่อ notes เป็น array

...export class NoteService {  notes: any[] = [    { id: 123, title: 'พัทยากลาง', date: new Date(2020, 2, 8, 12), node: 'พัทยากลางแหล่งอาบแดด คลื่นแรงและน้ำทะเลขุ่น' },    { id: 124, title: 'พัทยากลาง ร้านอาหาร', date: new Date(2020, 2, 8, 13), node: 'ร้านอาหารคนไทยแบบกันเอง มีฝรั่งสองคนชายหญิงเข้าใจว่าพวกเราเป็นคนจีน' },    { id: 125, title: 'alcazar show', date: new Date(2020, 2, 9, 20), node: 'เป็นโชว์ของสาวประเภทสองที่สนุกสนานมาก อลังการ บัตรไม่แพงแค่ 500 บาทต่อที่นั่ง'},  ];}

สร้างเมธอด findAll ส่งค่ากลับไปแบบ observable

...import { of } from 'rxjs';export class NoteService {...  findAll(): Observable<any[]> {    return of(this.notes);  }}

จากนั้นเพิ่ม delay สุ่มระหว่าง 1 ถึง 5 วินาที (เพื่อนๆจะสุ่มมากหรือน้อยกว่านี้ก็ได้)

import { delay } from 'rxjs/operators';...findAll(): Observable<any[]> {  return of(this.notes).pipe(delay(this.random(1, 5) * 1000));}

สร้างเมธอด random

...export class NoteService {...  random(min: number, max: number):number {    return Math.floor(Math.random() * max) + min;  }}

NoteComponent Data is Coming

เราจะให้ NoteComponent ร้องขอ data จาก NoteService แล้วนำไปแสดงผล

เปิดไฟล์ note.component.ts ประกาศตัวแปรชื่อ notes$ ให้มีชนิดเป็น Observable

...export class NoteComponent implements OnInit {  notes$: Observable<any[]>;...}

ที่ constructor ของคลาส inject NoteService เข้ามา

constructor(  private noteService: NoteService,) { }

ที่เมธอด ngOnInit ร้องขอ data

ngOnInit(): void {  this.notes$ = this.noteService.findAll();}

เปิดไฟล์ note.component.html วาด data ที่ได้รับมา

...<div *ngFor="let note of notes$ | async">  <span>{{note.title}}</span>  <a routerLink="123/details">รายละเอียด</a>  <a routerLink="123/map">แผนที่</a></div>

ผล

รายการ notes

ระหว่างที่คอย data มาถึงเราจะพบว่ามันมีเวลาที่เห็นเป็นหน้าหรือส่วนว่างเปล่า ตรงนี้สามารถทดแทนด้วยข้อความหรือ progress spinner ได้

ปรับปรุงไฟล์ note.component.html

<h1>Notes</h1><div *ngIf="notes$ | async as notes; else loading">  <div *ngFor="let note of notes">    <span>{{note.title}}</span>    <a routerLink="123/details">รายละเอียด</a>    <a routerLink="123/map">แผนที่</a>  </div></div><ng-template #loading>  Loading...</ng-template>
loading screen

จำลอง data จาก fake APIs

data ที่ได้จาก observable จะถูกยกเลิกอัตโนมัติเมื่อมีการเปลี่ยน page เราจะแก้ไขโค้ดของ NoteService เล็กน้อยเพื่อพิมพ์ log ออกมาชมดู

เปิดไฟล์ note.service.ts แก้ไขเมธอด findAll

findAll(): Observable<any[]> {  return of(this.notes).pipe(    delay(this.random(1, 5) * 1000),    map((note: any) => {      console.log(note);      return note;    })  );}

ผล

| async จะ unsubscribe ให้เอง

โค้ดข้างต้นแสดงให้เห็นว่าทุกครั้งที่หน้า NoteComponent จะถูก render นั้น observable จะยังคงทำงานเป็นปกติ กระทั่งเกิดการเปลี่ยนหน้า observable นี้จะถูก unsubscribe โดยอัตโนมัติตราบใดที่เรายังใช้ | async (อ่านเพิ่มเติม)

คำว่า fake หรือ mock ในที่นี้คือความหมายเดียวกัน กล่าวคือ data นี้ไม่ใช่ของจริง (จำลอง)

Fake Backend

ด้วย HTTP Client เรียกไปยัง APIs เราจะสร้างกลไกแทรกแซงเพื่อปั้น data (จำลอง) เป็น response กลับไปโดยอาศัย HttpInterceptor

สร้าง Interceptor ชื่อ FakeBackendInterceptor

ng g interceptor interceptors/fake-backend

สิ่งที่ได้ไม่ต่างจาก ng generate อื่นๆ กล่าวคือได้ folder ชื่อ interceptors และมีคลาส FakeBackendInterceptor ดังที่หวังอยู่ภายใน

...@Injectable()export class FakeBackendInterceptor implements HttpInterceptor {  constructor() {}  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {    return next.handle(request);  }}

เราจะ handle request ตามที่ต้องการ ในกรณีนี้สมมติเป็น path ที่มีหน้าตาแบบนี้

/notes

จึงแก้ไขเมธอด intercept ได้ว่า

intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {  const { url, method } = request;  switch(true) {    case url.endsWith('/notes') && method === 'GET':      return this.findAllNotes();    default:      return next.handle(request);  }}

ในคลาสเดียวกันนี้เพิ่มเมธอด findAllNotes และเราจะหยิบเอา logic ทั้งหมดจาก NoteService.findAll มาไว้ที่นี่

findAllNotes(): Observable<HttpEvent<any>> {  const notes: any[] = [    { id: 123, title: 'พัทยากลาง', date: new Date(2020, 2, 8, 12), node: 'พัทยากลางแหล่งอาบแดด คลื่นแรงและน้ำทะเลขุ่น' },    { id: 124, title: 'พัทยากลาง ร้านอาหาร', date: new Date(2020, 2, 8, 13), node: 'ร้านอาหารคนไทยแบบกันเอง มีฝรั่งสองคนชายหญิงเข้าใจว่าพวกเราเป็นคนจีน' },    { id: 125, title: 'alcazar show', date: new Date(2020, 2, 9, 20), node: 'เป็นโชว์ของสาวประเภทสองที่สนุกสนานมาก อลังการ บัตรไม่แพงแค่ 500 บาทต่อที่นั่ง' },  ];  return of(new HttpResponse({ status: 200, body: notes}));}

เพิ่ม delay และ log เข้าไปด้วย

findAllNotes(): Observable<HttpEvent<any>> {...  return of(new HttpResponse({ status: 200, body: notes })).pipe(    delay(this.random(1, 5) * 1000),    map((note: any) => {      console.log(note);      return note;    })  );}

และอย่าได้ลืมย้ายเมธอด random

random(min: number, max: number): number {  return Math.floor(Math.random() * max) + min;}

กลับมาดูไฟล์ note.service.ts จะเหลือเท่านี้

import { HttpClient } from '@angular/common/http';import { Injectable } from '@angular/core';import { Observable } from 'rxjs';@Injectable({ providedIn: 'root' })export class NoteService {  constructor(    private http: HttpClient,  ) { }  findAll(): Observable<any[]> {    const url = `http://localhost:4200/notes`;    return this.http.get<any[]>(url);  }}

แต่ก่อนที่ FakeBackendInterceptor จะทำงานได้เราก็ต้องนำไปลงทะเบียนกับ providers ก่อนครับ

เปิดไฟล์ app.module.ts มองหาส่วนที่เขียนว่า providers: [ ] แล้วเพิ่ม interceptor ของเราเข้าไป

import { HTTP_INTERCEPTORS } from '@angular/common/http';import { FakeBackendInterceptor } from './interceptors/fake-backend.interceptor';...providers: [  { provide: HTTP_INTERCEPTORS, useClass: FakeBackendInterceptor, multi: true }],

Angular อนุญาตให้ใช้ HTTP_INTERCEPTORS ร่วมกันได้มากกว่าหนึ่งคลาสโดยการระบุ multi ค่าเป็น true ผลคือ useClass ทั้งหมดจะถูกทำงาน หากไม่แล้ว (multi: false) ผมเข้าใจว่า HTTP_INTERCEPTORS ล่าสุด (ลำดับท้าย) เท่านั้นจะถูกทำงาน

สืบเนื่องจากเราใช้งาน HttpClient ก็อย่าลืม import HttpClientModule ที่ไฟล์เดียวกันนี้ในส่วนของ imports

import { HttpClientModule } from '@angular/common/http';...imports: [...  HttpClientModule,],

ทดสอบสิ ทุกอย่างต้องยังคงปกติสุขนะครับ

| async จะ unsubscribe ให้เอง

กลไก canceled http requests

เพราะโดยปกติเราใช้ HttpClient ร้องขอ data จาก APIs หรือ backend ซึ่ง data ที่กลับมาจะอยู่ในรูปของ observable กลไก canceled http นี้ก็คือการ unsubscribe การทำงานของ observable นั้น

สร้าง service ใหม่ชื่อ CanceledHttpService

ng g s services/canceled-http

ให้มีบริการสำคัญ 2 บริการ ได้แก่

  • cancelPendingRequests
  • onCancelPendingRequests
...export class CanceledHttpService {  private subject = new Subject()  cancelPendingRequests() {    this.subject.next();  }  onCancelPendingRequests() {    return this.subject.asObservable();  }}

ทุกครั้งที่มีการ routing จะเกิด event ขึ้น Angular เรียกมันว่า Router events เราจะใช้ event นี้ใน interceptor เพื่อทำงาน 2 อย่าง ได้แก่

  • เมื่อ routing สำเร็จด้วยดี (ActivationEnd) จะเรียก cancelPendingRequests
  • หาไม่แล้วขณะอยู่ระหว่างการร้องขอ data จะใช้ RxJS ชื่อ takeUntil ตรวจสอบ observable นั้นมีค่าส่งมาหรือไม่ (ส่งจาก next) เมื่อใดที่มีค่าส่งมาก็จะเรียก complete ถือเป็นการ unsubscribe การทำงานของ observable นั้น

สร้าง interceptor ตัวใหม่ ชื่อ CanceledHttpInterceptor

ng g interceptor interceptors/canceled-http

เปิดไฟล์ canceled-http.interceptor.ts ส่วนของ constructor

...import { CanceledHttpService } from '../services/canceled-http.service';import { Router, ActivationEnd } from '@angular/router';...constructor(  private router: Router,  private canceledHttpService: CanceledHttpService,) {  this.router.events.subscribe(event => {    if (event instanceof ActivationEnd) {      this.canceledHttpService.cancelPendingRequests();    }  })}...

ส่วนของเมธอด intercept

...import { takeUntil } from 'rxjs/operators';...intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {  return next.handle(request).pipe(    takeUntil(this.canceledHttpService.onCancelPendingRequests())  );}...

นำไปลงทะเบียนใน providers เปิดไฟล์ app.module.ts

...import { CanceledHttpInterceptor } from './interceptors/canceled-http.interceptor';...providers: [  { provide: HTTP_INTERCEPTORS, useClass: CanceledHttpInterceptor, multi: true },  { provide: HTTP_INTERCEPTORS, useClass: FakeBackendInterceptor, multi: true }],...

ทดสอบ

เพื่อทดสอบผลการทำงาน เราจะใช้บริการ fake JSON data ของเว็บไซต์ https://jsonplaceholder.typicode.com มองหา /photos ซึ่งมีปริมาณ data ถึง 5000 photos

แก้ไขไฟล์ note.service.ts ให้ร้องขอไปดังนี้

findAll(): Observable<any[]> {  // const url = `http://localhost:4200/notes`;  const url = `https://jsonplaceholder.typicode.com/photos`;  return this.http.get<any[]>(url);}

ไปยัง browser ปรับระดับความเร็วอินเตอร์เน็ตลง

เลือก Fast 3G หรือ Slow 3G ก็ได้

จากนั้นเรียกหน้าแรก NoteComponent สลับกับ MapComponent หรือ NoteDetailsComponent แบบไวๆครับ

ผล

canceled http requests!

หวังว่าบทความนี้จะเป็นประโยชน์แก่นักพัฒนา Angular ทุกท่าน สวัสดีครับ

อ้างอิง

https://rxjs.dev/api/operators/takeUntil

https://jasonwatmore.com/post/2019/05/02/angular-7-mock-backend-example-for-backendless-development

https://medium.com/angular-in-depth/angular-show-loading-indicator-when-obs-async-is-not-yet-resolved-9d8e5497dd8

https://www.freakyjolly.com/angular-how-to-cancel-http-calls-on-router-change/#more-2919

--

--

No responses yet