LoginSignup
1
1

More than 3 years have passed since last update.

Class Componentライクに書けるFunction Component

Last updated at Posted at 2019-08-22

動機

Reactに導入されたHooks APIは色々と便利です。
ところが、FC自体はTypeScriptのクラス機能や型付けとあまり相性が良くないように思います。1
そこで、Class Componentのときに出来た機能をちょっと移植しつつ、FCとして使えるようにするクラスを自作してみました。

コード


import * as React from 'react';

/** 第2引数で指定したプロパティ名を省略可能にします */
type Optional<Original, Names> = { [P in Extract<keyof Original, Names>]?: Original[P] } & Pick<Original, Exclude<keyof Original, Names>>;
/** defaultPropsの戻り値を省略可能にしたPropsを作ります */
type RenderProps<Original, Component extends { defaultProps: { [P in keyof Original]?: Original[P] }; }> = React.PropsWithChildren<Optional<Original, keyof Component['defaultProps']>>;

export abstract class FunctionComponent<Props = {}, State = {}> {
  private _props!: React.PropsWithChildren<Props>;
  private _state!: State;
  private _dispatchers!: { [P in keyof State]: React.Dispatch<State[P]>; };

  /** defaultPropsのチェックで使うので、publicでないといけない */
  public get props() {
    return this._props;
  }

  protected get state() {
    return this._state;
  }

  protected get dispatchers() {
    return this._dispatchers;
  }

  /**
   * 利用側で使用するJSX要素を生成し返します。
   * この部分がFunction Componentです。
   */
  public readonly toJSX = (props: RenderProps<Props, this>) => {
    this._props = props as any;
    this.initializeState();
    return this.render();
  }

  /**
   * Class ComponentやFunction Component標準のdefaultPropsと同じように実装をしてください。
   * この戻り値のプロパティ名は省略可能扱いになります。
   *
   * 戻り値をPartial<Props>にすると、全てのプロパティが省略可能になってしまい旨味がないので、指定していません。
   */
  public get defaultProps() {
    return {};
  }

  /**
   * ここで指定された値がstateの初期値になります。
   * stateを使用する場合、必ず全ての初期値を返すよう実装してください。
   */
  public get defaultState(): State {
    return {} as State;
  }

  /** このメソッドを旧来のrenderと同じように実装してください。 */
  protected abstract render(): ReturnType<React.FC>;

  /** Class ComponentのsetState相当のことをします。 */
  protected setState<T extends { [P in keyof State]?: State[P] }>(newState: T) {
    (Object.keys(newState) as any as (keyof State)[]).forEach(k => this.dispatchers[k](newState[k] as any));
  }

  private initializeState() {
    const state = this.state || this.defaultState;
    this._state = {} as any;
    this._dispatchers = {} as any;
    Object.keys(state).forEach(k => [this._state[k], this._dispatchers[k]] = React.useState(state[k]));
  }

  private __inited = this.__init();
  /** デバッグ用に生成関数にクラス名をつけたり、defaultPropsを繋げます。 */
  private __init() {
    const desc = Object.getOwnPropertyDescriptor(this.toJSX, 'name');
    if (!desc) {
      return;
    }
    desc.value = (this as any).constructor.name;
    Object.defineProperty(this.toJSX, 'name', desc);
    (this.toJSX as any).defaultProps = this.defaultProps;
  }
}

/** 
 * 利用側でクラスのインスタンスを都度生成しなくてもFCにしてくれるサポート関数です。
 * Propsに定義されていないキーが存在する場合や、型に互換性がない場合、ここでエラーが出ます。
 */
export const convertToFC = <
  T extends {
    toJSX: (props: any) => ReturnType<React.FC>,
    defaultProps: { [K2 in K]?: T['props'][K2] },
    props: any;
  },
  K extends (keyof T['defaultProps'] extends keyof T['props'] ? keyof T['defaultProps'] : '__notExistsKey')
>(clazz: new () => T) => {
  const func = (props: Parameters<T['toJSX']>[0]) => {
    const instance = React.useMemo(() => new clazz(), []);
    return <instance.toJSX {...props} />;
  };
  func.displayName = `${clazz.name}_Creator`;
  return func;
};

基本的に、Class Componentと同じようにpropsstateを設定しつつ、renderの戻り値をFCにして返しています。
そのままだとインスタンスを都度生成しないといけないため、convertToFC関数を用意しています。

使い方

試しに基底となるクラスを作成します。
今回はpropsにnametype、Stateにexamined(診察したかどうか)を持たせています。
ついでにHooks APIとしてuseMemoも使ってみました。

継承したクラスではtypeの初期値を返すdefaultTypeを実装させるようにします。

animal_component.tsx


import * as React from 'react';
import { FunctionComponent } from './function_component';

interface IProps {
  name: string;
  type: string;
}

interface IState {
  examined: boolean;
}

export abstract class AnimalComponentClass extends FunctionComponent<IProps, IState> {
  public get defaultProps() {
    return {
      type: this.defaultType(),
    };
  }

  public get defaultState() {
    return {
      examined: false,
    };
  }

  protected render() {
    const { name, type } = this.props;
    const { examined } = this.state;
    return React.useMemo(() => (
      <div onClick={this.onClick}>
        <div>{name}</div>
        <div>{type}</div>
        <div>{examined ? '' : ''}</div>
      </div>
    ), [examined]);
  }

  protected abstract defaultType(): IProps['type'];

  private onClick = () => {
    this.setState({ examined: true });
  }
}

そしてこれを継承したコンポーネントを作成します。
typeの初期値をもたせるほか、メソッドとしてcryを実装しました。

dog_component.tsx

import { AnimalComponentClass } from './animal_component';
import { convertToFC } from './function_component';

export class DogComponentClass extends AnimalComponentClass {
  public cry() {
    console.log('bow');
  }

  protected defaultType() {
    return '';
  }
}

const component = convertToFC(Class);

最後に、これを使ってみましょう。



const Hospital = () => {
  // インスタンスを生成することで、メソッドを通した操作が可能になります。
  const instance = React.useMemo(() => new DogComponentClass(), []);
  React.useEffect(() => instance.cry(), []);

  // convertToFCを通したものか、インスタンスのtoJSXを呼ぶことでJSX形式として扱うことが可能になります。
  // defaultPropsで指定しているtypeは省略可能とみなされています。
  return (
    <div>
      <DogComponent name='太郎' />
      <instance.toJSX name='次郎' type='レトリーバー' />
    </div>
  );
}

これを実行すると、以下のようになります。

image.png

どちらの方法でも正しく描画出来ていること、typeを省略した場合defaultPropsが機能していることが分かると思います。
また、クリックすることでstateの書き換えも行えます。

image.png

下の方について、だった部分がに書き換わりました。こちらも上手く機能しています。

型の警告について

必須のnameプロパティを省略した場合、以下のように警告が表示されます。

image.png

defaultPropsに、Propsに存在しないプロパティtestを指定した場合、以下のように警告が表示されます。

image.png

同様に、defaultPropstypenullを指定するなど、型違反を起こした場合、以下のように警告が表示されます。

image.png


  1. Class Componentのときは出来た、defaultPropsで返しているプロパティを判別してくれる型定義になっていないとか、stateやprops引き連れ回すときに毎回定義書かなきゃいけないので面倒等。 

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1