[Angular] NgZone 的應用

昨天在討論區上面有人問了一個問題,「Angular 要如何做到檢查使用是否有在活動,如果一定時間內都沒有任何動作時,要自動登出系統」,這一個需求最直覺的方式是定時去檢查最後一次使用者有動作的時間,但使用 setInterval 會讓 Angular 的效能變差,這時候要怎麼解呢?

首先要先說明為什麼使用 setInterval 會讓 Angular 損失效能,主要的原因是 Angular 預設有使用 zone.js 來監控所有的事件,zone.js 會監測以下的事件,如果有發生時,就會觸發 ChangeDetection,進而更新整個畫面

  1. Events - 使用者的行為,像是 clickchangeinputsubmit
  2. XMLHttpRequests - 像是呼叫 API
  3. Timers - setTimeout()setInterval()

既然 setInterval() 會觸發 ChangeDetection,那就不要讓 Angular 知道有這件事情就好了。

NgZone

Angular 有好心的幫我們包了一個 NgZone 的 class,我們可以透過這一個 class 來進行一些簡單的 zone.js 的操作

An injectable service for executing work inside or outside of the Angular zone.

這是官方描述 NgZone 的功能,而 NgZone 是長這樣的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class NgZone {
static isInAngularZone(): boolean
static assertInAngularZone(): void
static assertNotInAngularZone(): void
constructor(__0)
hasPendingMicrotasks: boolean
hasPendingMacrotasks: boolean
isStable: boolean
onUnstable: EventEmitter<any>
onMicrotaskEmpty: EventEmitter<any>
onStable: EventEmitter<any>
onError: EventEmitter<any>
run<T>(fn: (...args: any[]) => T, applyThis?: any, applyArgs?: any[]): T
runTask<T>(fn: (...args: any[]) => T, applyThis?: any, applyArgs?: any[], name?: string): T
runGuarded<T>(fn: (...args: any[]) => T, applyThis?: any, applyArgs?: any[]): T
runOutsideAngular<T>(fn: (...args: any[]) => T): T
}

而這一篇文章我們就先看 runrunOutsideAngular 這兩個方法就好,在實務上這兩個也是最常用的方法

runOutsideAngular

runOutsideAngular 內所執行的 function 是不會觸發任何 change detection 的,介面如下

1
runOutsideAngular<T>(fn: (...args: any[]) => T): T

使用範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { Component, NgZone } from '@angular/core';

console.clear();

@Component({
selector: 'my-app',
template: `
<p>
<label>Count :</label> {{ num }}
</p>
`
})
export class AppComponent {
num = 0;
constructor(private zone: NgZone) {
this.zone.runOutsideAngular(() => {
let i = 0;
const token = setInterval(() => {
this.num = ++i;
console.log(this.num);
if (i == 10) {
clearInterval(token);
}
}, 1000);
})
}
}

上面的程式碼會每秒更新 num 的變數值,但是真正執行時,會發現畫面並沒有跟著被更新,但實際上 num 的這個變數是有被更新的,如何證明呢? 我們來加一個按鈕來觸發 change detection (程式碼)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { Component, NgZone } from '@angular/core';

console.clear();

@Component({
selector: 'my-app',
template: `
<p>
<label>Count :</label>
{{ num }}
</p>
<button (click)="c()">click</button>
`
})
export class AppComponent {
num = 0;
constructor(private zone: NgZone) {
this.zone.runOutsideAngular(() => {
let i = 0;
const token = setInterval(() => {
this.num = ++i;
console.log(this.num);
if (i == 10) {
clearInterval(token);
}
}, 1000);
})
}

c() { }
}

為什麼會這樣子,還記得在一開始的地方我提到 zone.js 所監控的事件如果發生事件的話,就會觸發 change detection,而 click 事件剛就是在 zone.js 的管轄範圍內,所以當然經過一輪的 change detection,畫面就會顯示出當下應顯示的內容了。

稍微小結一下,根據上面的範例程式,我們可以知道當程式碼寫在 runOutsideAngular 是不會觸發 Angular 的 change detection。接下來延伸的問題是,那我要怎麼手動觸發 change detection 呢?

run

手動觸發 change detection 的方法有幾種,因為這裡我們的主題是 NgZone,所以當然要使用 NgZone 的方法。而 run 這一個方法,其目的就與 runOutsideAngular 是反過來的,這裡是任何方法只要是寫在 run 裡面,就會進入到 Angular zone 的管轄範圍,介面如下

Executes the fn function synchronously within the Angular zone and returns value returned by the function.

1
run<T>(fn: (...args: any[]) => T, applyThis?: any, applyArgs?: any[]): T

而這方法常見的使用情境是使用一些第三方套件,因為一開始就不在 zone.js 的管轄內,所以就必須手動將其包進 run() 內,才可以讓畫面正常的顯示。

而我們也可以透過這個方式將上一小節的問題給解決掉,程式碼如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { Component, NgZone } from '@angular/core';

console.clear();

@Component({
selector: 'my-app',
template: `
<p>
<label>Count :</label>
{{ num }}
</p>
`
})
export class AppComponent {
num = 0;
constructor(private zone: NgZone) {
this.zone.runOutsideAngular(() => {
let i = 0;
const token = setInterval(() => {
this.zone.run(() => {
this.num = ++i;
})
console.log(this.num);
if (i == 10) {
clearInterval(token);
}
}, 1000);
})
}
}

透過 run 的方式就可以簡單的回到 Angular zone 的管轄範圍了。

進階討論

回到一開始提到的問題

Angular 要如何做到檢查使用是否有在活動,如果一定時間內都沒有任何動作時,要自動登出系統

最簡單的解法是在 localStorage 紀錄最後一次使用者動作的時間,然後寫一個 timer 每隔 n 秒檢查目前的時間與最後一次異動時間的間隔是否大於所設定的閒置時間,簡易版本如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export class AppComponent implements OnInit {
notify$ = new Subject();

ngOnInit() {
this.notify$.subscribe(() => {
this.message = 'timeout';
})
}

constructor(private zone: NgZone) {
localStorage.setItem('expiredDate', addMinutes(new Date(), 1).getTime().toString());
this.zone.runOutsideAngular(() => {
const i = setInterval(() => {
const expiredDate = +localStorage.getItem('expiredDate');
console.log(new Date().getTime() - expiredDate);
if (new Date().getTime() - expiredDate > 0) {
this.zone.run(() => {
this.notify$.next();
})
clearInterval(i);
};
}, 1000)
})
}
}

而這樣子的程式碼就只會在符合設定條件時,通知 Angular 要處理之後的動作了。

另外一種解法,不透過 run() 的方式是利用回傳值,不論是 run 或是 runOutsideAngular 都會有回傳值,我們就可以透過回傳 Promise 的方式,來解一樣的問題 (程式碼)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
constructor(private zone: NgZone) {
localStorage.setItem('expiredDate', addMinutes(new Date(), 0.1).getTime().toString());
this.zone.runOutsideAngular(() => {
return new Promise((resolve) => {
const i = setInterval(() => {
const expiredDate = +localStorage.getItem('expiredDate');
console.log(new Date().getTime() - expiredDate);
if (new Date().getTime() - expiredDate > 0) {
resolve(true);
clearInterval(i);
};
}, 1000)
})
}).then(() => {
this.message = 'timeout';
})
}

補充內容

zone.js 的維護者/高手在社群內提出以下說明(連結)

如果要更加提高性能可以使用window[Zone.__symbom__('setInterval')], 這樣會強制使用Native 的Delegate, ngZone.runOutsideAngular 雖然不會觸發ChangeDetection,但是仍然會在默認的RootZone裡,會有一定的性能損耗

解法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export class AppComponent {
num = 0;
constructor(private zone: NgZone) {
const nativeSetInterval = window[Zone.__symbol__('setInterval')]; // 使用這個代替 setInterval
let i = 0;
const token = nativeSetInterval(() => {
this.zone.run(() => {
this.num = ++i;
})
console.log(this.num);
if (i == 10) {
clearInterval(token);
}
}, 1000);
}
}

程式碼

小結

雖然 NgZone 是一個很冷門的主題,但是還是有使用到的機會,了解一下也不是件壞事

延伸閱讀