Try   HackMD

Angular 中的那些預期外的行為

2020.06.21
tags: 自主學習 翻譯 Angular

原始文章
Angular: The Unexpected



當你最愛的框架不像你想的那樣運作。

每個 Angular 開發者開發時都曾經碰過一些詭異甚至是荒謬的事情。本篇文章會探討一些這樣的案例並解釋為什麼框架的運作方式是如此。


FormControl.disable 觸發 valueChanges

問題

使用 Reactive Forms 要禁用 FormControl 時,通常會使用 enabledisable 方法 :

@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" 的FormControl 綁到 input 上,並監聽 valueChangesObservable。有一個切換"啟用/禁用"的按鈕。

這有什麼問題 ?

如果我們按下按鈕並且看控制台,會發現每次按下按鈕時都印出 "Default Value",即使值其實沒變。

如果表單很複雜並且會根據使用者設定或權限來啟用/禁用某些巢狀表單元件時,監聽 valueChanges 有時候會造成混淆。在 ngOnInit 中訂閱 valueChanges,但是啟用/禁用依據的資料是透過 HTTP 呼叫取得,並且回應是在訂閱行為之後時,就會造成我們不希望的觸發。

原因

雖然控制項本身的值並沒有改變,但是控制項如果是 FormGroupFormArray 的一部分,當控制項被啟用/禁用時會改變父層 FormGroup 的值,FormGroupvalueChanges 是 子層控制項的 valueChanges 的組合,因此子層控制項必須發射 valueChanges

解決方式

呼叫 enable/disable 時帶上設定的參數 :

this.control.disable({emitEvent: false});

繼承 Input/Output 屬性

問題

在作者另外一篇文章(在 Angular 中使用 Typescript Mixins)中提到,有時候繼承 base 元件是一件有用的事情。但有個情況是,Angular 的一個特別的功能並沒有像我們預期的那樣 :

export class BaseComponent { @Input() something = ''; } @Component({ selector: 'my-selector', template: 'Empty', }) export class InheritedComponent extends BaseComponent { // actual class implementation }

下面的元件繼承了上面的 base 元件,而 base 元件有一個 Input 的屬性,當運行 app 時沒有問題,但是當使用 production 設定建置時會失敗,會說下面的元件沒有叫做 "something" 的 Input 屬性。

原因

這是個 bug,參考GitHub issue

解決方式

在子元件中特別宣告 inputs :

export class BaseComponent { @Input() something = ''; } @Component({ selector: 'my-selector', template: 'Empty', inputs: ['something'], }) export class InheritedComponent extends BaseComponent { // actual class implementation }

告訴 Angular 編譯器,繼承的屬性是個 Input。這是在 Angular 團隊修正問題前的暫時解法。

注意 : TSLint 會警告 inputs 這個屬性,可以用 tslint:disable:use-output-property-decorator 註解來取消警告


ngOnChanges vs ngOnInit

問題

從字面上看,我們會覺得 ngOnInit 會是最先被觸發的生命週期方法,但有時候 ngOnChanges 會比 ngOnInit 早被呼叫。

原因

ngOnInit 是在 view 開始渲染時呼叫,而 ngOnChanges 是在 Input 改變時呼叫,而 Input 改變不是必須等 ngOnInit 執行後才會發生。常常從父元件塞入的 Input 會在子元件開始渲染前就改變,因此 ngOnChanges 會在 ngOnChanges 前觸發。

解決方式

如果 ngOnInit 中的程式邏輯會依據某些資料,而這些資料會在 ngOnChanges 中被處理時,就需要特別小心,特別是在監聽 FormControl.valueChanges 並且會根據元件的 Input 執行某些程式邏輯時。


Reactive Forms 的 "非型別安全"

問題

在作者另外一篇文章(Angular Forms: Useful Tips)中提到,使用 Reactive Form 的一個問題是它並不保證是正確型別,而正確型別在使用 Typescript 的專案中是很必要的。非型別安全會導致不好的 IDE 體驗,型別無法被正確獲取,並導致一些重複的程式碼。

原因

主要問題是 Reactive Form 是非常可客製化的,所以可能因此無法保持完整的型別安全。

解決方式

可以參考上面提到的作者文章的建議。


NGRX Action types are (not) unique strings

問題

NGRX 的每個 Action 都有由使用者提供的獨特識別。

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, ) {} }

上面的程式在做這件事 : loadData 這個Action發送時,呼叫 dataService API 取得資料,取得成功後,發送 loadDataSuccess 的Action,把收到的資料放進 store 中。

打開 app 時看起來沒問題,但是打開開發者工具的 Network標籤,會發現 API 被呼叫近乎無限次,甚至導致 Effect 卡在無窮迴圈。

造成無限呼叫的原因是,建立 loadDataloadDataSuccess 呼叫 createAction 時用了相同的字串。兩個不同的 Action 使用同樣的字串識別,當Action 被發送時會觸發 Effect,然後又發送同樣的 Action。

原因

用使用者提供的值作為 Action 的識別,比起做整個識別名稱系統來的容易,所以 NGRX 團隊用這種方式。但是馬上會改變,參見解決方式。

解決方式

自己實作一個 ActionNames 的服務來避免產生 Action 時不小心使用到相同的識別名稱 :

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 的最新版有他們團隊自己的解決方式實作,在 runtime 檢查識別的獨特性,當重複時會丟出錯誤。也可以使用這些TSLint規則 來檢查 Action 識別的獨特性


結論

Angular 的功能相當廣大,有時候我們可能會誤以為自己完全了解他的運作方式,但總是會碰到驚喜。從錯誤中學習並了解其他開發者碰過的問題,能讓我們避免寫出 bug,也節省時間。