# 你會需要的 Angular 變更偵測指南
###### 2020.08.09
###### tags: `自主學習` `翻譯` `Angular`
---
原始文章
[The Last Guide For Angular Change Detection You'll Ever Need](https://indepth.dev/the-last-guide-for-angular-change-detection-youll-ever-need/)
---
<br>
## 什麼是變更偵測
Angular 的主要兩個目標是 <code>可預測性</code> 以及 <code>效能</code>。
框架藉由組合狀態 (Model) 以及範本,複製 app 狀態到 UI 上。

如果狀態發生變更,也必須更新 DOM。這個將 HTML 與 資料同步的機制叫做 "變更偵測"。
每個前端框架有自己的變更偵測實作方式,例如 React 使用 Virtual DOM,
Angular 使用變更偵測。關於 JS 框架的變更偵測介紹可以[看這篇文章](https://teropa.info/blog/2015/03/02/change-and-its-detection-in-javascript-frameworks.html)
> 變更偵測:資料改變時更新 DOM 的過程
>
<br>
## 變更偵測如何運作
變更偵測的循環可以分為兩個部分
- 開發者更新 app model
- Angular 藉由重新渲染,將更新的 model 同步到 DOM
更詳細的過程:
1. 開發者更新資料 model,例如更新元件的綁定
2. Angular 偵測變更
3. 變更偵測對元件樹中的"每一個"元件,從上到下,檢查對應的 model 是否有改變
4. 如果資料有新的值,就更新元件的 View (DOM)。
下面這個 GIF 用簡化的方式展現這段過程:

這張圖片展示 Angular 的元件樹和他的變更偵測器。偵測器會比較(元件)屬性當前的值和前一個值,如果值改變了,會將 <code>isChanged</code> 設為 <code>true</code>,可以看[這段程式](https://github.com/angular/angular/blob/885f1af509eb7d9ee049349a2fe5565282fbfefb/packages/core/src/util/comparison.ts#L13),單純用 <code>===</code> 的操作符來比較,除了特殊的 <code>NaN</code>。
> 變更偵測沒有做深層的物件比對,只比較範本中使用到的屬性的當前值和前一個值
>
<br>
## Zone.js
<code>zone</code> 可以保持追蹤並攔截任何非同步的 task。
<code>zone</code> 通常有這些階段:
- 一開始是"穩定( stable )"
- 如果有 task 在 zone 中執行,會變成"不穩定 (unstable )"
- 如果執行的 task 完成,會再度變成"穩定( stable )"
Angular 在啟動時會擴充一些底層的瀏覽器 API,來做到可以偵測 app 中的變更。這是用 <code>zone.js</code> 來做到,<code>zone.js</code> 擴充了像是 <code>EventEmitter</code>、DOM event listener, <code>XMLHttpRequest</code>、Node.js 中的 <code>fs</code> API 以及[其他更多 API](https://github.com/angular/angular/blob/master/packages/zone.js/STANDARD-APIS.md)。
簡短來說,框架會在以下之一事件發生時,觸發變更偵測:
- 任何瀏覽器事件 (click, keyup, etc.)
- <code>setInterval()</code> 和 <code>setTimeout()</code>
- 藉由 <code>XMLHttpRequest</code> 發送的 HTTP requests
Angular 使用的 zone 叫 <code>NgZone</code>。app 只存在一個 <code>NgZone</code>,並且只有在這個 zone 中觸發的非同步操作,才會觸發變更偵測。
<br>
## 效能
> 當範本的值發生改變時,Angular 變更偵測預設會由上到下檢查所有的元件
>
Angular 使用 [inline-caching](http://mrale.ph/blog/2012/06/03/explaining-js-vms-in-js-inline-caches.html) 產生對 VM 優化過的程式碼,從而能夠非常快的檢查每一個元件。更深度的解釋可以參考 [Victor Savkin](https://twitter.com/victorsavkin) 的 [這篇演講](https://www.youtube.com/watch?v=jvKGQSFQf10)。
然而在大一點的 app 中,效能還是會降低,我們可以藉由使用不同變更偵測策略來改善效能。
<br>
## 變更偵測策略
Angular 提供兩個執行變更偵測的策略
- <code>Default</code>
- <code>Onpush</code>
#### Default
Angular 預設使用 <code>ChangeDetectionStrategy.Default</code> 的變更偵測策略。當每一次事件觸發變更偵測時(例如使用者事件、timer、XHR、Promise),會由上到下檢查元件樹中的每一個元件。這種方式稱為 <code>dirty check</code>。在較大的 app 中可能會降低效能,因為含有太多元件。

#### OnPush
我們可以用在元件裝飾器的 metadata 中增加 <code>changeDetection</code> 屬性,並設定為 <code>ChangeDetectionStrategy.OnPush</code>。
這個變更偵測策略可以對這個元件和子元件忽略非必要的檢查。

使用這種策略,Angular 會知道元件只有在以下情形才做更新:
- (元件)輸入的參考改變
- 元件或子元件觸發 event handler
- 手動觸發變更偵測
- 通過 async pipe 連結到範本上的 Observable 發送一個新的值
詳細探討這幾種事件類型:
#### (元件)輸入的參考改變
在預設的變更偵測策略中,Angular 會在 <code>@Input()</code> 的資料改變時執行變更偵測。使用 <code>OnPush</code> 策略,只會在 "屬性的值有新的參考" 時才會觸發。
JS 中都是 pass by reference,但是所有的基礎類型都是不可變( immutable ),並且會指向同樣的基礎類型實體/參考。
因此在一個 <code>OnPush</code> 修改物件屬性或是陣列的入口不會建立新的參考,因此不會觸發變更偵測。如果要觸發變更偵測,必須傳遞一個新的物件或是新的陣列參考。
可以在這個 [demo](https://angular-change-detection-demo.netlify.com/simple-demo):
1. 修改使用 <code>ChangeDetectionStrategy.Default</code> 策略的<code>HeroCardComponent</code> 的 <code>age</code> 屬性
2. 確認使用 <code>ChangeDetectionStrategy.OnPush</code> 策略的<code>HeroCardOnPushComponent</code>,沒有反應 <code>age</code> 的變更
3. 按下 <code>Create new object reference</code> 按鈕
4. 確認 <code>HeroCardOnPushComponent</code> 做了變更偵測

要避免使用 <code>OnPush</code> 時可能造成的變更偵測的 bug,可以只使用不可變物件和 list。不可變物件只能藉由建立新物件參考的方式來修改,所以可以保證:
- <code>OnPush</code> 策略在每次變更時觸發變更偵測
- 沒有忘記建立新物件參考而導致 bug
可以使用 [Immutable.js](https://facebook.github.io/immutable-js/) 中的不可變資料結構物件(<code>Map</code>) 和 list (<code>List</code>)。
#### 觸發 Event Handler
如果 <code>OnPush</code> 元件或子元件觸發 event handler,像是按下按鈕,會觸發變更偵測(元件樹中的所有元件)。
<font color="red">注意</font>,在 <code>PnPush</code> 時下列行為不會觸發變更偵測
- <code>setTimeout</code>
- <code>setInterval</code>
- <code>Promise.resolve/reject().then()</code>
- <code>this.http.get('...').subscribe()</code> (泛指任何 Rxjs 訂閱)
可以用以下 [demo](https://angular-change-detection-demo.netlify.com/simple-demo) 測試:
1. 按下 <code>HeroCardOnPushComponent</code> 元件中的 <code>Change Age</code> 按鈕
2. 確認觸發變更偵測並且檢查所有元件

#### 手動觸發變更偵測
有三個方法可以手動觸發變更偵測:
- <code>ChangeDetectorRef.detectChanges()</code>,檢查這個元件跟子元件,可以搭配 <code>detach()</code> 實作本地變更偵測檢查
- <code>ApplicationRef.tick()</code>,觸發整個 app 所有元件的檢查
- <code>ChangeDetectorRef.markForCheck()</code>,不觸發變更偵測,但將所有 <code>OnPush</code> 標記為檢查一次,不管是在當前或是下一個變更偵測循環中。會對標記的元件執行變更偵測檢查。
> 手動執行變更偵測不是一種 hack,但是要合理使用。
>
下圖用視覺方式展示不同的 <code>ChangeDetectorRef</code> 方法:

可以在這個 [demo](https://angular-change-detection-demo.netlify.com/simple-demo) 使用 "DC" (<code>detectChanges()</code>) 和 "MFC" (<code>markForCheck()</code>) 按鈕來測試。
#### Async Pipe
內建的 [AsyncPipe](https://angular.io/api/common/AsyncPipe) 訂閱一個 Observable 並回傳最新一個發送的值。
<code>AsyncPipe</code> 在每次新值發送時都會呼叫 <code>markForCheck()</code>,參考以下[原始碼](https://github.com/angular/angular/blob/5.2.10/packages/common/src/pipes/async_pipe.ts#L139)。
``` javascript=1
private _updateLatestValue(async: any, value: Object): void {
if (async === this._obj) {
this._latestValue = value;
this._ref.markForCheck();
}
}
```
<code>AsyncPipe</code> 使用 <code>OnPush</code> 策略自動運作。所以建議盡量使用它,並使用 <code>OnPush</code> 策略。
可以在以下 [async demo](https://angular-change-detection-demo.netlify.com/async-pipe-demo) 觀察行為:

第一個元件直接在範本上藉由 <code>AsyncPipe</code> 綁定 Observable:
``` html=1
<mat-card-title>{{ (hero$ | async).name }}</mat-card-title>
```
``` javascript=1
hero$: Observable<Hero>;
ngOnInit(): void {
this.hero$ = interval(1000).pipe(
startWith(createHero()),
map(() => createHero())
);
}
```
第二個元件訂閱並更新資料:
``` html=1
<mat-card-title>{{ hero.name }}</mat-card-title>
```
``` javascript=1
hero: Hero = createHero();
ngOnInit(): void {
interval(1000)
.pipe(map(() => createHero()))
.subscribe(() => {
this.hero = createHero();
console.log(
'HeroCardAsyncPipeComponent new hero without AsyncPipe: ',
this.hero
);
});
}
```
可以看到沒有使用 <code>AsyncPipe</code> 的話就不會觸發變更偵測,因此需要在 Observable 每次發送新值時手動呼叫 <code>detectChanges()</code>。
<br>
## 避免變更偵測 Loop 以及 ExpressionChangedAfterCheckedError
Angular 的變更偵測有一個 loop 機制。在開發模式時,框架會執行兩次變更偵測,檢查第一次執行後,值有沒有變更。在產品模式時,為了效能只會跑一次。
可以在這個 [ExpressionChangedAfterCheckedError demo](https://angular-change-detection-demo.netlify.com/expression-changed-demo),打開瀏覽器控制台,看到以下錯誤:

在 demo 中,藉由在 <code>ngAfterViewInit</code> 生命週期方法中更新 <code>hero</code> 屬性,強制造成這個錯誤。
發生錯誤的原因,先看下圖的變更偵測執行步驟:

<code>AfterViewInit</code> 生命週期方法,在目前已經被渲染的 DOM 更新後被呼叫。如果在這個 hook 中變更值,在第二次執行時的值就與前一次執行時的值不同,因此拋出 <code>ExpressionChangedAfterCheckedError</code> 錯誤。
建議閱讀 [Max Koretskyi](https://twitter.com/maxkoretskyi) 的 [這篇文章](https://blog.angularindepth.com/everything-you-need-to-know-about-change-detection-in-angular-8006c51d206f),更詳細介紹發生此錯誤的原因和使用情境。
<br>
## 執行程式而不進行變更偵測
如果程式碼執行在 <code>NgZone</code> 之外,就不會觸發變更偵測。
``` javascript=1
constructor(private ngZone: NgZone) {}
runWithoutChangeDetection() {
this.ngZone.runOutsideAngular(() => {
// the following setTimeout will not trigger change detection
setTimeout(() => doStuff(), 1000);
});
}
```
demo 提供一個在 Angular Zone 外觸發行為的按鈕

可以看到操作有 log 在控制台,但是 <code>HeroCard</code> 元件沒有進行檢查,邊框沒有變成紅色。
這個機制在使用 [Protractor](https://www.protractortest.org/#/) 進行 E2E 測試時很有用,特別當在測試中使用 <code>browser.waitForAngular</code>。在每個命令送給瀏覽器後,Protractor 會等待,直到 zone 變為穩定。如果在 zone 中使用 <code>setInterval</code>,zone 永遠不會變為穩定,而測試有可能 timeout。
同樣的議題也會發生在 RxJS ,因此需要增加一個 patch 到 polyfill.ts,就像 [Zone.js's support for non-standard APIs](https://github.com/angular/angular/blob/master/packages/zone.js/NON-STANDARD-APIS.md#usage) 文件中描述的:
``` javascript=1
import 'zone.js/dist/zone'; // Included with Angular CLI.
import 'zone.js/dist/zone-patch-rxjs'; // Import RxJS patch to make sure RxJS runs in the correct zone
```
如果沒有這個 patch,你可能在 <code>ngZone.runOutsideAngular</code> 中執行 Observable 程式,但是仍然是在 <code>NgZone</code> 執行。
<br>
## 關閉變更偵測
在一些特殊使用情境,關閉變更偵測是有意義的。
例如,如果使用 WebSocket 接收由資料推送的資料到前端,而對應的前端元件只應該每十秒更新一次。在這個例子中可以藉由呼叫 <code>detach()</code> 並且使用 <code>detectChanges()</code> 手動觸發變更偵測。
``` javascript=1
constructor(private ref: ChangeDetectorRef) {
ref.detach(); // deactivate change detection
setInterval(() => {
this.ref.detectChanges(); // manually trigger change detection
}, 10 * 1000);
}
```
如果要完全關閉 Zone.js,代表完全關閉自動變更偵測,我們就需要手動觸發 UI 變更,例如呼叫 <code>ChangeDetectorRef.detectChanges()</code>。
首先,註解 <code>polyfills.ts</code> 中的 Zone.js。
``` javascript=1
import 'zone.js/dist/zone'; // Included with Angular CLI.
}
```
接著,需要在 <code>main.ts</code> 設定 noop zone:
``` javascript=1
platformBrowserDynamic().bootstrapModule(AppModule, {
ngZone: 'noop';
}).catch(err => console.log(err));
```
關於關閉 Zone.js 的細節,可以參考 [Angular Elements without Zone.Js](https://www.softwarearchitekt.at/aktuelles/angular-elements-part-iii/)。
<br>
## Ivy
Angular 9 之後,使用 [Ivy, Angular's next-generation compilation and rendering pipeline](https://blog.angularindepth.com/all-you-need-to-know-about-ivy-the-new-angular-engine-9cde471f42cf) 作為預設渲染器。
Ivy 仍然以正確的順序處理所有的生命週期方法,因此變更偵測像以往一樣的運作方式,所以還是會看到 app 中出現 <code>ExpressionChangedAfterCheckedError</code>。
建議文章段落中有兩篇 Ivy 相關的文章,可以參考看看。
<br>
## 建議文章
- [Angular Change Detection - How Does It Really Work?](https://blog.angular-university.io/how-does-angular-2-change-detection-really-work/)
- [Angular OnPush Change Detection and Component Design - Avoid Common Pitfalls](https://blog.angular-university.io/onpush-change-detection-how-it-works/)
- [A Comprehensive Guide to Angular onPush Change Detection Strategy](https://netbasal.com/a-comprehensive-guide-to-angular-onpush-change-detection-strategy-5bac493074a4)
- [Angular Change Detection Explained](https://blog.thoughtram.io/angular/2016/02/22/angular-2-change-detection-explained.html)
- [Angular Ivy change detection execution: are you prepared?](https://blog.angularindepth.com/angular-ivy-change-detection-execution-are-you-prepared-ab68d4231f2c)
- [Understanding Angular Ivy: Incremental DOM and Virtual DOM](https://blog.nrwl.io/understanding-angular-ivy-incremental-dom-and-virtual-dom-243be844bf36)