# 為什麼不應該在 Angular 範本的表達式中使用函式呼叫 ###### 2020.05.22 ###### tags: `自主學習` `翻譯` `Angular` --- 原始文章 [Why you should never use function calls in Angular template expressions](https://medium.com/showpad-engineering/why-you-should-never-use-function-calls-in-angular-template-expressions-e1a50f9c0496) --- <br /> Angular 範本很棒並且極為強大。 藉著使用結構型指令和屬性綁定,我們能夠用非常乾淨的語法創造出非常複雜的畫面。 ``` html=1 <ng-container *ngIf="isLoggedIn"> <h1>Welcome {{ fullName }}!</h1> </ng-container> ``` 因為表達式非常強大,因此當畫面變得複雜時,表達式也很容易變得很複雜。 不知不覺中,我們會在 Angular 範本中使用函式呼叫。 ``` html=1 <ng-container *ngIf="isLoggedIn"> <h1>Welcome {{ fullName }}!</h1> <a href="files" *ngIf="hasAccessTo('files')">Files</a> <a href="photos" *ngIf="hasAccessTo('photos')">Photos</a> </ng-container> ``` 雖然在 Angular 範本中使用函式呼叫,在技術上可行而且超級方便,但可能導致嚴重的效能議題。 本文會針對效能問題的原因進行解釋,並說明如何解決。 <br /> ## 問題所在 用以下例子示範,假設我們有一個 <code>PersonComponent</code> ,並且在他的範本中使用 <code>fullName()</code> 這個函式呼叫,來顯示傳入 <code>person</code> 屬性的這個 person 的全名: ``` javascript=1 @Component({ template: ` <p>Welcome {{ fullName() }}!</p> <button (click)="onClick()">Trigger change detection</button> ` }) export class PersonComponent { @Input() person: { firstName: string, lastName: string }; constructor() { } fullName() { return this.person.firstName + ' ' + this.person.lastName } onClick() { console.log('Button was clicked'; } } ``` 注意了,當每次 Angular 執行變更偵測時, <code>fullName()</code> 函式會被執行,這可能是非常多次! 每當按鈕被點擊時,<code>fullName()</code> 這個函式會被執行。 "按下一個按鈕" 看起來好像不太有害,但假設六個月後我們對 <code>PersonComponent</code> 的需求改變,需要處理更多會觸發變更偵測的事件: ``` javascript=1 @Component({ template: ` <p>Welcome {{ fullName() }}!</p> <div (mousemove)="onMouseMove()">Drop a picture here</div> ` }) export class PersonComponent { @Input() person: { firstName: string, lastName: string }; constructor() { } fullName() { return this.person.firstName + ' ' + this.person.lastName } onMouseMove() { console.log('Mouse was moved'); } } ``` 當我們滑鼠移過 <code>div</code> 時,<code>fullName()</code> 函式突然會被執行好幾百次。 而且因為 <code>fullName()</code> 裡面的程式是我們六個月之前寫的,我們會無法意識到我們新寫的程式帶來的不良影響。 此外,變更偵測會在 <code>PersonComponent</code> 的外面被觸發: ``` html=1 <person [person]="person"></person> <button (click)="onClick()"> Trigger change detection outside of PersonComponent </button> ``` 注意了,每當點擊 <code>PersonComponent</code> 外部的按鈕時,<code>PersonComponent</code> 內的 <code>fullName()</code> 函式都會被執行。 發生的原因是什麼?能夠怎麼處理? <br /> ## 為什麼範本表達式中的函式被呼叫這麼多次? Angular 變更偵測的目標,是找出當改變發生的時候,UI 中的哪些部分需要被重新渲染。 為了決定是否需要重新渲染 <code>\<p>Welcome {{ fullName() }}!\</p></code> 這部分,Angular 需要執行 <code>fullName()</code> 表達式來確認函式所回傳的值是否有改變。 因為 Angular 無法預測 <code>fullName()</code> 的回傳值是否會改變,所以每次執行變更偵測時都必須執行這個函式。 所以如果變更偵測跑了300次,函式就會被呼叫300次,即使函式的回傳值永遠不會變。 根據函式內的程式邏輯,呼叫好幾百次這個函式,有可能會變成一個嚴重的效能議題。 當使用 getter 時,這問題變得會隱藏在範本中: ``` html=1 @Component({ template: ` <p>Welcome {{ fullName }}!</p> ` }) export class PersonComponent { @Input() person: { firstName: string, lastName: string }; constructor() { } get fullName() { return this.person.firstName + ' ' + this.person.lastName } } ``` 注意了,這個範本從視覺上看不出有使用任何函式呼叫,但是這個 <code>get fullName()</code> 的 getter 會在每次變更偵測執行的時候被呼叫。 <br /> ## 用 ChangeDetectionStrategy.OnPush 如何? 當我們對 <code>PersonComponent</code> 啟用 <code>ChangeDetectionStrategy.OnPush</code>,我們告訴 Angular 的變更偵測,忽略那些在 <code>PersonComponent</code> 外部並且不影響input的改變。 啟用之後,<code>PersonComponent</code> 內部的變更偵測只會在他的複數的 input 屬性中任何一個屬性有改變時才會執行,以本例來說就是 <code>person</code> 屬性。 在下面這個例子中: ``` html-1 <person [person]="person"></person> <button (click)="onClick()"> Trigger change detection outside of PersonComponent </button> ``` 當 <code>PersonComponent</code> 外的按鈕被點擊時,<code>PersonComponent</code> 內的 <code>fullName()</code> 函式不再會被執行。 聰明啊!這能解決我們所有潛在的效能議題了吧? 很不幸地,並沒有。 為什麼? 因為每當 <code>PersonComponent</code> 本身內部中的變更偵測開始時,<code>fullName()</code> 函式還是會被執行。 ``` javascript=1 @Component({ template: ` <p>Welcome {{ fullName() }}!</p> <div (mousemove)="onMouseMove()">Drop a picture here</div> ` changeDetection: ChangeDetectionStrategy.OnPush }) export class PersonComponent { @Input() person: { firstName: string, lastName: string }; constructor() { } fullName() { return this.person.firstName + ' ' + this.person.lastName } onMouseMove() { console.log('Mouse was moved'); } } ``` 注意了,即使我們啟用 <code>ChangeDetectionStrategy.OnPush</code>,每當滑鼠移過 <code>div</code> 時,<code>fullName()</code> 函式還是會被執行。 所以就算 <code>ChangeDetectionStrategy.OnPush</code> 能幫我們忽略元件外部的變更偵測週期,元件範本上表達式中的函式,還是會在元件本身中觸發的偵測變更時被執行。 那我們能怎麼樣避免不必要的函式呼叫? <br /> ## 解決方案:如何避免不必要的函式呼叫 #### 方案一: 使用 Pure pipes 第一個減少不必要函式呼叫的方式是使用 pure pipes。 藉著告訴 Angular 某個 pipe 是 pure,Angular 就會知道:如果 pipe 的 input 沒有改變,pipe 的回傳值就不會改變。 在我們剛才的範例中,我們可以建立一個 pure pipe 來計算 person 的全名: ``` javascript=1 @Pipe({ name: 'fullName', pure: true }) export class FullNamePipe implements PipeTransform { transform(person: any, args?: any): any { return person.firstName + ' ' + person.lastName; } } ``` 在 Angular 中,pipe 預設是 pure,所以我們可以省略在 pipe 裝飾器中的 <code>pure: true</code> 這一段: ``` javascript=1 @Pipe({ name: 'fullName' }) export class FullNamePipe implements PipeTransform { transform(person: any, args?: any): any { return person.firstName + ' ' + person.lastName; } } ``` 接著我們可以把 pipe 加入模組的宣告中: ``` javascript=1 @NgModule({ declarations: [ AppComponent, PersonComponent, FullNamePipe ] }) export class AppModule { } ``` 並且在 <code>PersonComponent</code> 的 View 中使用: ``` javascript=1 @Component({ template: ` <p>Welcome {{ person | fullName }}!</p> <div (mousemove)="onMouseMove()">Drop a picture here</div> ` changeDetection: ChangeDetectionStrategy.OnPush }) export class PersonComponent implements OnChanges { @Input() person: { firstName: string, lastName: string }; constructor() { } onMouseMove() { console.log('Mouse was moved'); } } ``` Angular 現在夠聰明能知道,如果 <code>person</code> 沒有改變,那麼 <code>{{ person \| fullName }}</code> 這段表達式就不會改變。 結果就是,如果 person 沒有改變,Angular 會略過執行 pipe 的 <code>transform</code> 方法。 但如果我們的程式邏輯更複雜,很難用 pipe 來處理時該怎麼辦? <br /> #### 方案二: 手動計算值 第二個避免不必要函式呼叫的方案是,在元件的 Controller 中手動計算 View 上面所需要的值。 我們作為程式開發者所具有的優勢就是,我們知道一個表達式的值什麼時候可能改變。 在我們的範例中,我們知道全名只有可能在 <code>person</code> 變更的時候才會改變,所以我們給元件增加一個 <code>fullName</code> 屬性,並且在 <code>ngOnChanges</code> 的生命週期方法中,判斷只有當元件的 <code>person</code> 的 input 變更時,再重新計算全名的值。 這讓我們有了一個簡單屬性,保存有我們需要的值,我們就能夠把在 View 上的 <code>{{ fullName() }} </code> 函式呼叫表達式,替換為 <code>{{ fullName }}</code> 這個屬性表達式。 ``` javascript=1 @Component({ template: ` <p>Welcome {{ fullName }}!</p> <div (mousemove)="onMouseMove()">Drop a picture here</div> ` changeDetection: ChangeDetectionStrategy.OnPush }) export class PersonComponent implements OnChanges { @Input() person: { firstName: string, lastName: string }; fullName = ''; constructor() { } ngOnChanges(changes: SimpleChanges) { if (changes.person) { this.fullName = this.calculateFullName(); } } calculateFullName() { return this.person.firstName + ' ' + this.person.lastName; } onMouseMove() { console.log('Mouse was moved'); } } ``` 結果,<code>fullName</code> 這個屬性只有在元件的 <code>person</code> input 改變時才會被重新計算。 當我們移動滑鼠到 <code>div</code> 上時,變更偵測不再會執行計算全名的邏輯。 看看這個簡單的改變,怎麼讓我們的函式呼叫,在變更五次 person 物件並且觸發 mousemove 事件時,從294次減少成剩下5次: ![https://miro.medium.com/max/1400/1*CGI61ouySrf-YX52KztArg.gif](https://miro.medium.com/max/1400/1*CGI61ouySrf-YX52KztArg.gif) 視覺上來看並沒有什麼不同,但我們的改變在背後造成的影響卻是巨大的。 可以在這裏試試這個 Demo [https://stackblitz.com/edit/angular-hxmb6q](https://stackblitz.com/edit/angular-hxmb6q) <br /> ## 總結 在本文中我們學到,即使啟用 <code>ChangeDetectionStrategy.OnPush</code>,表達式中的函式呼叫仍可能導致嚴重卻沒注意到的效能議題,並且讓 App 變慢。 為了避免這些議題,**強烈建議不要在 Angular 範本表達式中使用函式呼叫**。 替代方式: - **使用 pure pipe** 讓 Angular 知道,如果 pipe 的 input 沒有改變,就可以安全地省略執行 pipe 的程式 - 在元件的 Controller 中**手動計算**需要的值,並且只有當必要的時候才重新計算。 可以在這個 Live Demo 中見證對效能帶來的影響: [https://stackblitz.com/edit/angular-hxmb6q](https://stackblitz.com/edit/angular-hxmb6q) 下次發現自己在範本中寫了一個函式呼叫時,確定有考慮過可能帶來的潛在後果。 如果下次在 Code Review 時發現同事在 Angular 範本表達式中使用了函式呼叫,請友善地將這篇文章的連結寄給他們,與他們分享的快樂勝過獨自擁有。 祝美好的一天。