TypeScript 實踐:自定義裝飾器將 Angular Input 轉化為 Observable
What
如題,實現一個將 Angular 組件 Input
自動轉化為 Observable
的自定義攔截器:
@Component({})
export class DemoComponent {
@ObservableInput()
@Input(name)
name$$: Observable<string>;
}
通過上面的 ObservableInput
裝飾器,我們將父組件傳遞的 Input
name
自動轉化成了一個 Observable
對象。
Why
Angular 組件中我們使用 @Input
獲取父組件傳遞的上下文數據,類似 React/Vue 中 props
的概念。通常我們為了支持 Input
動態變化並做出一些相關操作的情況,會將 @Input
定義為 setter
的方式,同時我們為了取到最新的 Input
值又需要定義一個內部私有變數和一個對應的 getter
:
@Component({})
export class DemoComponent {
private _name: string;
@Input()
get name() {
return this._name;
}
set name(name: string) {
this._name = name;
// do something
}
}
很明顯,如果項目里的組件的 Input
越來越多且我們都需要支持動態 Input
的話可能會有很多這樣的模板代碼,且類似 _name
這樣的中間變數放在代碼里既顯得醜陋又影響代碼閱讀體驗,而實際上 Angular 社區對 ObservableInput
的需求已經由來已久:Proposal: Input as Observable,但官方一直未提供相應的實現。
How
目前社區里類似的 ObservableInput 實現也都是通過自定義 getter/settter
劫持的方案來完成數據的轉換,但是依然存在一些問題:
- 轉化成
Observable
對象後無法直接獲取原來Input
的值了 - 無法給原始
Input
設置默認值了
解決一下:
// 使用方式一
@Component({})
export class DemoComponent {
@ObservableInput(true) // 自動綁定 name 值,即去除 `name$$` 末尾的 `$` 符號
@Input(name)
name$$: Observable<string>;
name: string;
}
// 使用方式二
@Component({})
export class DemoComponent {
@ObservableInput(true, Hello World) // 自動綁定 name Input 的值並設置默認值為 Hello World
name$$: Observable<string>;
@Input()
name: string;
}
// 使用方式三
@Component({})
export class DemoComponent {
@ObservableInput(nameValue) // 自動綁定 nameValue Input 的值
name$$: Observable<string>;
@Input()
nameValue: string;
}
即我們提供更加靈活的 ObservableInput
使用方式滿足相對更多的使用需求。
本質上實現這樣的數據劫持並不是什麼黑魔法,只需要 ES5 環境支持(Symbol 可以換成其他實現):
基本實現
export function ObservableInput<
T = any,
SK extends keyof T = any,
K extends keyof T = any
>(propertyKey?: K | boolean, initialValue?: SubjectType<T[SK]>) {
return (target: T, sPropertyKey: SK) => {
const symbol = Symbol();
type ST = SubjectType<T[SK]>;
type Mixed = T & {
[symbol]: BehaviorSubject<ST>;
} & Record<SK, BehaviorSubject<ST>>;
Object.defineProperty(target, sPropertyKey, {
enumerable: true,
configurable: true,
get(this: Mixed) {
return (
this[symbol] || (this[symbol] = new BehaviorSubject<ST>(initialValue))
);
},
set(this: Mixed, value: ST) {
this[sPropertyKey].next(value);
},
});
if (!propertyKey) {
return;
}
if (propertyKey === true) {
propertyKey = (sPropertyKey as string).replace(/$+$/, ) as K;
}
Object.defineProperty(target, propertyKey, {
enumerable: true,
configurable: true,
get(this: Mixed) {
return this[sPropertyKey].getValue();
},
set(this: Mixed, value: ST) {
this[sPropertyKey].next(value);
},
});
};
}
One more thing
使用類似的方案我們可以實現一個 ValueHook
裝飾器來實現不需要多增加私有變數而自定義 Input
的 settter
和 getter
:
@Component({})
export class DemoComponent {
@ValueHook(function(name) {
// do something
})
@Input()
name: string;
}
如果只是為了攔截 setter
,ValueHook
的使用似乎更加有效。
基本實現
const checkDescriptor = <T, K extends keyof T>(target: T, propertyKey: K) => {
const descriptor = Object.getOwnPropertyDescriptor(target, propertyKey);
if (descriptor && !descriptor.configurable) {
throw new TypeError(`property ${propertyKey} is not configurable`);
}
return {
oGetter: descriptor && descriptor.get,
oSetter: descriptor && descriptor.set,
};
};
export function ValueHook<T = any, K extends keyof T = any>(
setter?: (this: T, value?: T[K]) => boolean | void,
getter?: (this: T, value?: T[K]) => T[K],
) {
return (target: T, propertyKey: K) => {
const { oGetter, oSetter } = checkDescriptor(target, propertyKey);
const symbol = Symbol();
type Mixed = T & {
[symbol]: T[K];
};
Object.defineProperty(target, propertyKey, {
enumerable: true,
configurable: true,
get(this: Mixed) {
return getter
? getter.call(this, this[symbol])
: oGetter
? oGetter.call(this)
: this[symbol];
},
set(this: Mixed, value: T[K]) {
if (
value === this[propertyKey] ||
(setter && setter.call(this, value) === false)
) {
return;
}
if (oSetter) {
oSetter.call(this, value);
}
this[symbol] = value;
},
});
};
}
Last But Not Least
@ObservableInput
和 @ValueHook
實際上可以組合使用,但大部分情況下你沒必要也不應該這麼做,如果你有這種需求,可能你更應該重構一下代碼了。:)
推薦閱讀: