# 為什麼不應該在 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 範本表達式中使用了函式呼叫,請友善地將這篇文章的連結寄給他們,與他們分享的快樂勝過獨自擁有。
祝美好的一天。