Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[zh] TypeScript 实践:自定义装饰器拦截 Angular Input 转化为 Observable #176

Open
JounQin opened this issue Jun 24, 2019 · 0 comments
Labels

Comments

@JounQin
Copy link
Owner

@JounQin JounQin commented Jun 24, 2019

[zh]

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

@JounQin JounQin added the Angular label Jun 24, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
1 participant
You can’t perform that action at this time.