ตอนเริ่มเขียน 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))
}
}
จาก code ท่อนนี้ ถ้าจำลองการทำงานประมาณนี้
- ที่ step (A) จะมี value ['BBL', 'KBANK', 'KTB'] เก็บไว้ใน memory โดยมี var STOCKS reference ไปที่ value ['BBL', 'KBANK', 'KTB']
- ที่ step (B) มี var filtered ก็ชี้ไปที่เดียวกับ STOCKS ก็คือ value ['BBL', 'KBANK', 'KTB'] นั้นเอง
- เมื่อมีการ search เช่นส่ง 'K' เข้ามาทำงานที่ step (C) และ STOCK.filter จะสร้าง value ที่เป็น array ตัวใหม่จาก value ['BBL', 'KBANK', 'KTB']
- แปลว่าจะมี value 2 ตัวเรียบร้อย คือ value ['BBL', 'KBANK', 'KTB'] กับ value ['KBANK', 'KTB']
- และใน step (C) var filtered ก็จะเปลี่ยน reference ไปที่ value ['KBANK', 'KTB'] แทน
- ต่อมาถ้า search ถูกเรียกทำงานอีกครั้งด้วย 'L' ก็จะเข้ามาทำงานที่ step (C) อีกครั้ง ครั้งก็จะสร้าง array ใหม่ขึ้นมาอีกเป็น value ['BBL']
- แปลว่ามี value 3 ตัวละ คือ value ['BBL', 'KBANK', 'KTB'], value ['KBANK', 'KTB'], และ value ['BBL']
- แต่มีแค่ value 2 ตัวเท่านั้นที่มี reference คือ value ['BBL', 'KBANK', 'KTB'] <= STOCKS และ value ['BBL'] <= filtered
- มาถึงจังหวะนี้แล้ว ถ้า Garbage Collector ตื่นมาทำงาน value ['KBANK', 'KTB'] ที่ไม่มีใคร reference จะสามารถคืน memory ของ value นี้สู่ระบบได้
เครื่องไม้เครื่องมือในการดู memory
ใน Chrome จะมีเครื่องมือที่ช่วยดู Memory สามารถกด F12 หรือ Ctrl+Shirt+I หรือ ... -> More tools > Developer Tool พอเปิดมา เลือก tab Memory จะมีปุ่มให้เรา snapshot memory ได้ หน้าตาประมาณนี้
เราจะทดสอบด้วยวิธีง่ายๆ คือ
- เราจะ snapshot memory ก่อนที่จะมีการ subscribe
- เรียก code ส่วนที่มีการ subscribe แล้ว snapshot อีกครั้ง
- จากนั้นเราจะ 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 ประมาณนี้
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;
}
}
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 {
}
ตัวอย่างแรก 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()
}
}
}
สังเกตุว่า memory มีค่า 4 > 9 > 9 ไม่ได้จางหายไปไหนและถ้าเราเทียบ snapshot 1 - 3 ก็จะเห็นว่า
ค่า 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()
}
}
}
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)
}
}
}
รอบนี้เรา 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)
}
}
}
รอด ! เหมือนที่บอก เพราะ 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;
}
}
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)
}
}
}
แน่นอน "ไม่รอด" เพราะ 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);
}
}
}
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));
}
});
}
}
สรุป
ถ้าพวกที่เป็น 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)