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
实际上可以组合使用,但大部分情况下你没必要也不应该这么做,如果你有这种需求,可能你更应该重构一下代码了。:)