DEV Community

tumit
tumit

Posted on

Angular - Sub/Unsub ยังไงดี

ตอนเริ่มเขียน Angular จะมี moment โดนขู่เสมอว่า

subscribe แล้วต้อง unsubscribe นะ เดี๋ยว memory จะ leak

ก็เชื่ออะนะว่ามันจะ leak แต่อีกใจก็อยากเห็นกับตาว่ามัน leak จริงไหม ว่าแล้วก็มาลองกัน

Garbage collection

ก่อนจะไปเรื่อง memory leak ไม่ leak เรามารู้จัก Garbage collection กันก่อน ในภาษาที่มีการ จอง/คืน Memory ให้อัตโนนาโถ มักจะมี concept ที่เรียกว่า Garbage collection อยู่

ตาม concept ก็คือจะมี ระบบเล็กๆ ที่แอบทำงานอยู่ เรียกว่า Garbage Collector จะค่อยทำงานอยู่เงียบ ๆ คอยเช็คว่ามี memory ส่วนไหนที่ไม่ได้ใช้แล้ว ถ้ามีก็จะทำการคืน memory ที่ไม่ได้ใช้แล้วคืนสู่ระบบ เช่น


// (A)
const STOCKS = ['BBL', 'KBANK', 'KTB']

class StockComponent {

  // (B)
  filtered = STOCKS;

  search(q: string): void {

    // (C)
    this.filtered = STOCKS.filter(v => v.include(q))
  }
}
Enter fullscreen mode Exit fullscreen mode

จาก code ท่อนนี้ ถ้าจำลองการทำงานประมาณนี้

  1. ที่ step (A) จะมี value ['BBL', 'KBANK', 'KTB'] เก็บไว้ใน memory โดยมี var STOCKS reference ไปที่ value ['BBL', 'KBANK', 'KTB']
  2. ที่ step (B) มี var filtered ก็ชี้ไปที่เดียวกับ STOCKS ก็คือ value ['BBL', 'KBANK', 'KTB'] นั้นเอง
  3. เมื่อมีการ search เช่นส่ง 'K' เข้ามาทำงานที่ step (C) และ STOCK.filter จะสร้าง value ที่เป็น array ตัวใหม่จาก value ['BBL', 'KBANK', 'KTB']
  4. แปลว่าจะมี value 2 ตัวเรียบร้อย คือ value ['BBL', 'KBANK', 'KTB'] กับ value ['KBANK', 'KTB']
  5. และใน step (C) var filtered ก็จะเปลี่ยน reference ไปที่ value ['KBANK', 'KTB'] แทน
  6. ต่อมาถ้า search ถูกเรียกทำงานอีกครั้งด้วย 'L' ก็จะเข้ามาทำงานที่ step (C) อีกครั้ง ครั้งก็จะสร้าง array ใหม่ขึ้นมาอีกเป็น value ['BBL']
  7. แปลว่ามี value 3 ตัวละ คือ value ['BBL', 'KBANK', 'KTB'], value ['KBANK', 'KTB'], และ value ['BBL']
  8. แต่มีแค่ value 2 ตัวเท่านั้นที่มี reference คือ value ['BBL', 'KBANK', 'KTB'] <= STOCKS และ value ['BBL'] <= filtered
  9. มาถึงจังหวะนี้แล้ว ถ้า Garbage Collector ตื่นมาทำงาน value ['KBANK', 'KTB'] ที่ไม่มีใคร reference จะสามารถคืน memory ของ value นี้สู่ระบบได้

เครื่องไม้เครื่องมือในการดู memory

ใน Chrome จะมีเครื่องมือที่ช่วยดู Memory สามารถกด F12 หรือ Ctrl+Shirt+I หรือ ... -> More tools > Developer Tool พอเปิดมา เลือก tab Memory จะมีปุ่มให้เรา snapshot memory ได้ หน้าตาประมาณนี้

Memory tab in Chrome Dev Tool

เราจะทดสอบด้วยวิธีง่ายๆ คือ

  1. เราจะ snapshot memory ก่อนที่จะมีการ subscribe
  2. เรียก code ส่วนที่มีการ subscribe แล้ว snapshot อีกครั้ง
  3. จากนั้นเราจะ destroy code ในส่วนที่มีการ subscribe ออกไป แล้ว snapshot อีกที เพื่อดูความต่างกันระหว่าง memory

ถ้า snapshot 1 = 3 แปลว่า "ไม่ leak" เพราะได้ memory กลับมา
แต่ถ้า snapshot 2 = 3 แปลว่า "leak" เพราะทำลายแล้ว memory ไม่กลับมา

Subscribe & Unsubscribe

Angular ใช้ RxJS ในหลายส่วนเช่น Route, Router, HttpClient, Reactive Form และอื่นๆ และเพื่อสามารถทำงานแบบ async ได้ จึงต้องทำการ subscribe เพื่อ define callback function ว่า หลังจากทำงานที่เป็น async เสร็จให้ทำอะไรต่อ

แต่การ subscribe ก็ใช้ memory เหมือนกัน แปลว่าถ้าเรา subscribe แล้วไม่ unsubscribe ก็มีโอกาสที่จะทำให้ memory ส่วนนั้นยัง reference อยู่และ Garbage Collector เลยไม่สามารถทวงคืน memory สู่ระบบได้ เป็นสาเหตุที่ทำให้ memory leak นั้นเอง

มาลองดูว่าถ้าไม่ unsubscribe จะ leak จริงไหมกัน

โอเคเข้าเรื่องที่เราอยากรู้ เรามาลองทำตัวอย่างง่าย ๆ โดยมีใช้ button + @if เป็น toggle เพื่อสร้าง/ลบ child component ประมาณนี้

Show/Hide

import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { OtpComponent } from './shared/components/otp/otp.component';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, RouterOutlet, OtpComponent],
  template: `
    <main class="container mt-3">
      <button (click)="toggleOtp()" class="btn btn-primary">
        Show/Hide OTP
      </button>
      <hr />
      @if (isShowOtp) {
      <app-otp />
      }
    </main>
  `,
  styles: '',
})
export class AppComponent {
  isShowOtp = false;

  toggleOtp(): void {
    this.isShowOtp = !this.isShowOtp;
  }
}
Enter fullscreen mode Exit fullscreen mode
import { Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-otp',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <label class="form-label" for="otp">OTP</label>
    <input class="form-control" type="text" id="otp" />
    <button class="btn btn-secondary mt-3">Send</button>
  `,
  styles: ''
})
export class OtpComponent {
}

Enter fullscreen mode Exit fullscreen mode

ตัวอย่างแรก Route & Router

ใน Angular ปกติเราจะใช้ Route & Router ผ่าน dependency inject แปลว่า reference ของ 2 ตัวนี้อยู่ที่ object pool ตรงกลาง

เพราะฉะนั้นตามทฤษฎีอันนี้จำเป็นต้อง unsubscribe แน่นอน เพราะให้เห็นได้ชัดเราจัด subscribe ไปเลย อย่างละ 10,000 เพื่อง่ายต่อการสังเกตุ

import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';

@Component({
  selector: 'app-otp',
  standalone: true,
  imports: [],
  template: `
    <label class="form-label" for="otp">OTP</label>
    <input class="form-control" type="text" id="otp" />
    <button class="btn btn-secondary mt-3">Send</button>
  `,
  styles: ''
})
export class OtpComponent {

  route = inject(ActivatedRoute)
  router = inject(Router)

  constructor() {
    for(let i = 0; i < 10000 ; i++) {
      this.route.params.subscribe()
      this.route.url.subscribe()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Route&Router no unsubscribe

สังเกตุว่า memory มีค่า 4 > 9 > 9 ไม่ได้จางหายไปไหนและถ้าเราเทียบ snapshot 1 - 3 ก็จะเห็นว่า

Delta of subscribe

ค่า delta ของการ subscribe ยัง +20,000 แม้จะลบ OTP component ไปแล้ว

งั้นเรามาลอง unsubscribe กัน ตั้งแต่ v16 เรามี takeUntilDestroyed ที่อยู่ใน rxjs-interop มาช่วยให้ unsubscribe ได้ง่ายขึ้น มาลองดูว่าจะเป็นไง

import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-otp',
  standalone: true,
  imports: [],
  template: `
    <label class="form-label" for="otp">OTP</label>
    <input class="form-control" type="text" id="otp" />
    <button class="btn btn-secondary mt-3">Send</button>
  `,
  styles: ''
})
export class OtpComponent {

  route = inject(ActivatedRoute)
  router = inject(Router)

  constructor() {
    for(let i = 0; i < 10000 ; i++) {
      this.route.params.pipe(takeUntilDestroyed()).subscribe()
      this.route.url.pipe(takeUntilDestroyed()).subscribe()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Route&Router unsubscribe

memory มีค่า 4 > 20 > 4 memory ใช้มากขึ้นแต่พอ destroy ก็เอากลับมาได้ แปลว่าการถ้าเป็น route & router ควรจะ unsubscribe จริง ๆ

ลอง HttpClient

การทำงานอีกประเภทที่ใช้ในการ subscribe บ่อย ๆ คือพวก HttpClient เรามาดูว่าถ้า subscribe ต้อง unscribe ไหม
ปล. เนื่องจากไม่อยากให้ API นานขอยิงแค่ 100 พอ

import { HttpClient } from '@angular/common/http';
import { Component, inject } from '@angular/core';

@Component({
  selector: 'app-otp',
  standalone: true,
  imports: [],
  template: `
    <label class="form-label" for="otp">OTP</label>
    <input class="form-control" type="text" id="otp" />
    <button class="btn btn-secondary mt-3">Send</button>
  `,
  styles: ''
})
export class OtpComponent {

  httpClient = inject(HttpClient)

  constructor() {
    const url = 'http://localhost:3000/otp'
    for(let i = 0; i < 100 ; i++) {
      this.httpClient.get<any>(url).subscribe(v => console.log)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

HttpClient no unsubscribe

รอบนี้เรา snapshot 1 เทียบกับ 2 พอแล้ว filter หา Subscriber ปรากฏว่า "ไม่มี" เพราะการ subscribe จาก HttpClient เป็น "Finite Observable" แปลว่า มันจะทำลายตัวเองหลังจบการทำงาน เราจึงไม่ค่อย unsubscribe กัน

Reactive Form

อีกอันที่เราใช้ subscribe บ่อยๆ คือ valueChange ของพวก FormGroup FormControl เรามาลองกันว่ารอดไหม

import { HttpClient } from '@angular/common/http';
import { Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-otp',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    debug: pin={{ pin.value }}
    <label class="form-label" for="otp">OTP</label>
    <input [formControl]="pin" class="form-control" type="text" id="otp" />
    <button class="btn btn-secondary mt-3">Send</button>
  `,
  styles: ''
})
export class OtpComponent {

  httpClient = inject(HttpClient)

  pin = new FormControl('')

  constructor() {
    for(let i = 0; i < 10000 ; i++) {
      this.pin.valueChanges.subscribe(v => console.log)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Reactive Form no unsubscribe

รอด ! เหมือนที่บอก เพราะ FormControl ที่เรา subscribe อยู่บน OTP Component เมื่อ instance ของ OTP Component ถูกทำลาย reference ของการ subscribe ก็หายไป Garbage collector จึงสามารถเข้ามาเก็บ memory ไปคืนระบบได้นั้นเอง แต่ถ้า...

Reactive Form จาก Parent

บางครั้ง เราจะมีท่าที่ @Input จาก Parent เข้ามาที่ Child แทน และเนื่องจากเราจะใช้งาน ตัวแปรที่เป็น @Input เราจะต้องย้ายการ subscribe มาไว้ที่ ngOnInit แทน หน้าจะประมาณนี้

import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { OtpComponent } from './shared/components/otp/otp.component';
import { FormControl, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, RouterOutlet, OtpComponent, ReactiveFormsModule],
  template: `
    <main class="container mt-3">
      <button (click)="toggleOtp()" class="btn btn-primary">
        Show/Hide OTP
      </button>
      <hr />
      @if (isShowOtp) {
      <app-otp [pin]="pinParent" />
      }
    </main>
  `,
  styles: '',
})
export class AppComponent {

  pinParent = new FormControl('')

  isShowOtp = false;

  toggleOtp(): void {
    this.isShowOtp = !this.isShowOtp;
  }
}

Enter fullscreen mode Exit fullscreen mode
import { HttpClient } from '@angular/common/http';
import { Component, Input, OnInit, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-otp',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    debug: pin={{ pin.value }}
    <label class="form-label" for="otp">OTP</label>
    <input [formControl]="pin" class="form-control" type="text" id="otp" />
    <button class="btn btn-secondary mt-3">Send</button>
  `,
  styles: ''
})
export class OtpComponent implements OnInit {

  httpClient = inject(HttpClient)

  @Input({ required: true })
  pin!: FormControl

  ngOnInit(): void {
    for(let i = 0; i < 10000 ; i++) {
      this.pin.valueChanges.subscribe(v => console.log)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Reactive Form Parent

แน่นอน "ไม่รอด" เพราะ reference ของการ subscribe อยู่ที่ Parent ไม่ได้ถูกทำลายไปด้วยตอน Child ถูกทำลาย ดังนั้นถ้าใช้ pattern นี้ต้อง unsubscribe ด้วย

การใช้ takeUntilDestroyed นอก constructor

เนื่องจากการ takeUntilDestroyed ต้องการ context ในการรู้ว่าต้อง destroy object ไหน ดังนั้นการจะใช้ นอก context อย่าง ngOnInit จำเป็นต้อง pass destroyRef ให้ด้วย หน้าตาจะประมาณนี้

import { Component, DestroyRef, Input, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-otp',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    debug: pin={{ pin.value }}
    <label class="form-label" for="otp">OTP</label>
    <input [formControl]="pin" class="form-control" type="text" id="otp" />
    <button class="btn btn-secondary mt-3">Send</button>
  `,
  styles: '',
})
export class OtpComponent implements OnInit {
  destroyRef = inject(DestroyRef);

  @Input({ required: true })
  pin!: FormControl;

  ngOnInit(): void {
    for (let i = 0; i < 10000; i++) {
      this.pin.valueChanges
        .pipe(takeUntilDestroyed(this.destroyRef))
        .subscribe((v) => console.log);
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

React Form Parent unsubscribe

Unscubsribe ด้วยวิธีอื่น

นอกจากการใช้ takeUntilDestroyed เรายังมีวิธี unsubscribe แบบอื่น ๆ ได้อีกเช่น การไป subscribe ที template ด้วย Async Pipe หรือใน v16 Angular ใส่ concept ที่เรียกว่า Signal เข้ามา เราสามารถย้ายวิธีการเขียนแบบ Reactive ไปที่ Signal แทนและ Signal ก็จะ auto unsubscribe ให้แทนได้ด้วยเช่นกัน

import {
  Component,
  DestroyRef,
  Injector,
  Input,
  OnInit,
  Signal,
  inject,
  runInInjectionContext,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-otp',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <label class="form-label" for="otp">OTP</label>
    <input [formControl]="pin" class="form-control" type="text" id="otp" />
    <button class="btn btn-secondary mt-3">Send</button>
  `,
  styles: '',
})
export class OtpComponent implements OnInit {
  destroyRef = inject(DestroyRef);
  injector = inject(Injector);

  @Input({ required: true })
  pin!: FormControl;

  pinValueChangeSignals: Signal<any>[] = [];

  ngOnInit(): void {
    runInInjectionContext(this.injector, () => {
      for (let i = 0; i < 10000; i++) {
        this.pinValueChangeSignals.push(toSignal(this.pin.valueChanges));
      }
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

Signal

สรุป

ถ้าพวกที่เป็น Finite Observable หรือ Reference มี life-cycle ที่จะโดนทำลายอยู่แล้ว เพื่อลดความซับซ้อน เราไม่จำเป็นต้อง unsubscribe ได้

แต่ถ้า reference ที่ subscribe เป็น Observable ที่อยู่ในระบบตลอดกลางเช่น ActiveRoute, Router, Renderer2, Observable.interval, Observable.fromEvent หรือพวก State manement Libreary พวกนี้ต้อง unsubscribe อาจจะด้วย takeUntilDestroyed, Async Pipe หรือโยนใส่ signal เพื่อหลีกเลี่ยง memory leak นั้นเอง

จบปิ๊ง ~

Top comments (0)