翻译出现错误: TypeScript 实践:自定义装饰器拦截 Angular Input 转化为 Observable

6 months切换至中文

翻译出现错误

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 劫持的方案来完成数据的转换,但是依然存在一些问题:

  1. 转化成 Observable 对象后无法直接获取原来 Input 的值了
  2. 无法给原始 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 装饰器来实现不需要多增加私有变量而自定义 Inputsetttergetter

@Component({})
export class DemoComponent {
  @ValueHook(function(name) {
    // do something
  })
  @Input()
  name: string;
}

如果只是为了拦截 setterValueHook 的使用似乎更加有效。

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