# Angular 中的那些預期外的行為 ###### 2020.06.21 ###### tags: `自主學習` `翻譯` `Angular` --- 原始文章 [Angular: The Unexpected](https://indepth.dev/angular-the-unexpected/) --- <br/> 當你最愛的框架不像你想的那樣運作。 每個 Angular 開發者開發時都曾經碰過一些詭異甚至是荒謬的事情。本篇文章會探討一些這樣的案例並解釋為什麼框架的運作方式是如此。 <br/> ## <code>FormControl.disable</code> 觸發 <code>valueChanges</code> ### 問題 使用 Reactive Forms 要禁用 <code>FormControl</code> 時,通常會使用 <code>enable</code> 和 <code>disable</code> 方法 : ``` javascript=1 @Component({ selector: 'my-component', template: ` <input [formControl]="control"> <button (click)="toggleEnabledState()">Toggle State</button> `, }) export class MyComponent implements OnInit { control = new FormControl('Default Value'); ngOnInit() { this.control.valueChanges.subscribe(console.log); } toggleEnabledState() { this.control.enabled ? this.control.disable() : this.control.enable(); } } ``` 我們把一個預設值是 "Default Value" 的<code>FormControl</code> 綁到 input 上,並監聽 <code>valueChanges</code> 的 <code>Observable</code>。有一個切換"啟用/禁用"的按鈕。 這有什麼問題 ? 如果我們按下按鈕並且看控制台,會發現每次按下按鈕時都印出 "Default Value",即使值其實沒變。 如果表單很複雜並且會根據使用者設定或權限來啟用/禁用某些巢狀表單元件時,監聽 <code>valueChanges</code> 有時候會造成混淆。在 <code>ngOnInit</code> 中訂閱 <code>valueChanges</code>,但是啟用/禁用依據的資料是透過 HTTP 呼叫取得,並且回應是在訂閱行為之後時,就會造成我們不希望的觸發。 ### 原因 雖然控制項本身的值並沒有改變,但是控制項如果是 <code>FormGroup</code> 或 <code>FormArray</code> 的一部分,當控制項被啟用/禁用時會改變父層 <code>FormGroup</code> 的值,<code>FormGroup</code> 的 <code>valueChanges</code> 是 子層控制項的 <code>valueChanges</code> 的組合,因此子層控制項必須發射 <code>valueChanges</code>。 ### 解決方式 呼叫 <code>enable</code>/<code>disable</code> 時帶上設定的參數 : ``` javascript=1 this.control.disable({emitEvent: false}); ``` <br/> ## 繼承 Input/Output 屬性 ### 問題 在作者另外一篇文章([在 Angular 中使用 Typescript Mixins](https://medium.com/javascript-in-plain-english/harnessing-the-power-of-mixins-in-angular-f2faa432add2))中提到,有時候繼承 base 元件是一件有用的事情。但有個情況是,Angular 的一個特別的功能並沒有像我們預期的那樣 : ``` javascript=1 export class BaseComponent { @Input() something = ''; } @Component({ selector: 'my-selector', template: 'Empty', }) export class InheritedComponent extends BaseComponent { // actual class implementation } ``` 下面的元件繼承了上面的 base 元件,而 base 元件有一個 <code>Input</code> 的屬性,當運行 app 時沒有問題,但是當使用 production 設定建置時會失敗,會說下面的元件沒有叫做 "something" 的 <code>Input</code> 屬性。 ### 原因 這是個 bug,參考[GitHub issue](https://github.com/angular/angular/issues/11606) ### 解決方式 在子元件中特別宣告 <code>inputs</code> : ``` javascript=1 export class BaseComponent { @Input() something = ''; } @Component({ selector: 'my-selector', template: 'Empty', inputs: ['something'], }) export class InheritedComponent extends BaseComponent { // actual class implementation } ``` 告訴 Angular 編譯器,繼承的屬性是個 <code>Input</code>。這是在 Angular 團隊修正問題前的暫時解法。 > 注意 : TSLint 會警告 <code>inputs</code> 這個屬性,可以用 <code>tslint:disable:use-output-property-decorator</code> 註解來取消警告 > <br/> ## <code>ngOnChanges</code> vs <code>ngOnInit</code> ### 問題 從字面上看,我們會覺得 <code>ngOnInit</code> 會是最先被觸發的生命週期方法,但有時候 <code>ngOnChanges</code> 會比 <code>ngOnInit</code> 早被呼叫。 ### 原因 <code>ngOnInit</code> 是在 view 開始渲染時呼叫,而 <code>ngOnChanges</code> 是在 <code>Input</code> 改變時呼叫,而 <code>Input</code> 改變不是必須等 <code>ngOnInit</code> 執行後才會發生。常常從父元件塞入的 <code>Input</code> 會在子元件開始渲染前就改變,因此 <code>ngOnChanges</code> 會在 <code>ngOnChanges</code> 前觸發。 ### 解決方式 如果 <code>ngOnInit</code> 中的程式邏輯會依據某些資料,而這些資料會在 <code>ngOnChanges</code> 中被處理時,就需要特別小心,特別是在監聽 <code>FormControl.valueChanges</code> 並且會根據元件的 <code>Input</code> 執行某些程式邏輯時。 <br/> ## Reactive Forms 的 "非型別安全" ### 問題 在作者另外一篇文章([Angular Forms: Useful Tips](https://indepth.dev/angular-forms-useful-tips/))中提到,使用 Reactive Form 的一個問題是它並不保證是正確型別,而正確型別在使用 Typescript 的專案中是很必要的。非型別安全會導致不好的 IDE 體驗,型別無法被正確獲取,並導致一些重複的程式碼。 ### 原因 主要問題是 Reactive Form 是非常可客製化的,所以可能因此無法保持完整的型別安全。 ### 解決方式 可以參考上面提到的作者文章的建議。 <br/> ## NGRX Action types are (not) unique strings ### 問題 NGRX 的每個 <code>Action</code> 都有由使用者提供的獨特識別。 ``` javascript=1 const loadData = createAction('[Home Page] Load Data'); const loadDataSuccess = createAction( '[Home Page] Load Data', props<{payload: object}>(), ); const _dataReducer = createReducer( {}, on(loadDataSuccess, (state, {payload}) => ({...state, ...payload})), ); @Injectable() export class DataEffects { loadData$ = createEffect(() => this.actions$.pipe( ofType(loadData), mergeMap(() => this.dataService.getData().pipe( map(payload => loadDataSuccess({payload})), )), )); constructor( private readonly actions$: Actions, private readonly dataService: DataService, ) {} } ``` 上面的程式在做這件事 : <code>loadData</code> 這個Action發送時,呼叫 <code>dataService</code> API 取得資料,取得成功後,發送 <code>loadDataSuccess</code> 的Action,把收到的資料放進 store 中。 打開 app 時看起來沒問題,但是打開開發者工具的 <code>Network</code>標籤,會發現 API 被呼叫近乎無限次,甚至導致 <code>Effect</code> 卡在無窮迴圈。 造成無限呼叫的原因是,建立 <code>loadData</code> 和 <code>loadDataSuccess</code> 呼叫 <code>createAction</code> 時用了相同的字串。兩個不同的 Action 使用同樣的字串識別,當Action 被發送時會觸發 <code>Effect</code>,然後又發送同樣的 Action。 ### 原因 用使用者提供的值作為 Action 的識別,比起做整個識別名稱系統來的容易,所以 NGRX 團隊用這種方式。但是馬上會改變,參見解決方式。 ### 解決方式 自己實作一個 ActionNames 的服務來避免產生 Action 時不小心使用到相同的識別名稱 : ``` javascript=1 class ActionNames { private static names = new Set<string>(); static create(name: string): string { if (ActionNames.names.has(name)) { throw new Error('An Action with this type already exists!'); } ActionNames.names.add(name); return name; } } const someAction = createAction(ActionNames.create('[Page] Actions Name')); ``` 上面是可以自己實作的解決方式。 NGRX 的最新版有他們團隊自己的[解決方式實作](https://github.com/ngrx/platform/pull/2520),在 runtime 檢查識別的獨特性,當重複時會丟出錯誤。也可以使用[這些TSLint規則](https://github.com/timdeschryver/ngrx-tslint-rules) 來檢查 Action 識別的獨特性 <br/> ## 結論 Angular 的功能相當廣大,有時候我們可能會誤以為自己完全了解他的運作方式,但總是會碰到驚喜。從錯誤中學習並了解其他開發者碰過的問題,能讓我們避免寫出 bug,也節省時間。