# 你會需要的 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)
×
Sign in
Email
Password
Forgot password
or
By clicking below, you agree to our
terms of service
.
Sign in via Facebook
Sign in via Twitter
Sign in via GitHub
Sign in via Dropbox
Sign in with Wallet
Wallet (
)
Connect another wallet
New to HackMD?
Sign up