getalog

console.log geta6

Reactで動的に出現する要素をサイズを抑えながら実装する

Reactで動的に出現する要素をDOM量抑えながら実装する。たとえばドロップダウン。

画面内に一個だけしか出現しないユニークな要素であれば、stateとcssでなんとかなると思う。

handleDropdownToggle = () => {
  this.setState({ dropdownVisible: !this.state.dropdownVisible });
};

render = () => (
  <div>
    <button onClick={this.handleDropdownToggle}>
      俺をクリックすると
    </button>
    <div className={this.state.dropdownVisible ? 'visible' : ''}>
      俺がみえるぞ!
    </div>
  </div>
);

ただ下記のような要件が出てくると、この実装だとマズそうな気配が強くなる。

  • 複数のモジュールから使いたい
  • 同じモジュール内で違う種類のドロップダウンを実装したい
  • 最初に100個くらいレンダリングされる要素の中で使用したい

こういう需要が出た時にどうするか、という話。

切り出し方は「機能で切り出す場合」と「ビューで切り出す場合」の二つのパターンに大別できる、それぞれまとめる。

パターン1: 機能で切り出す場合

機能単位で切り出されて、内包するコンテンツが実装箇所それぞれで全く異なるタイプのもの。

平たく言えば「パラメーター渡しでレンダリングする仕組みの設計が困難」なもの、例えばドロップダウンとか。

  • ある場所ではul,liなメニューが不定数あるドロップダウン
  • ある場所ではスクローラブルな(たとえば通知一覧とか)ドロップダウン

これだけの要件でも、実装しようとしたらパラメーター渡しでは設計が無理そうな気配が強く感じられる。

なので、利用先の各モジュールで内部ビューの実装ができるよう設計する。

動作イメージ

利用イメージ

下記三つのモジュールを実装して使う。

  • 見た目に何も影響を及ぼさないラッパーとなる <Dropdown>
  • 実装する機能のトリガーとなるビューを実装する <DropdownTrigger>
  • トリガーされた結果表示されるビューを実装する <DropdownContent>
render = () => (
  <Dropdown>
    <DropdownTrigger>
      俺をクリックすると
    </DropdownTrigger>
    <DropdownContent>
      俺がみえるぞ!
    </DropdownContent>
  </Dropdown>
);

ビューに関することは利用先で実装することができ、「動的に出現する」という機能だけを<Dropdown>で担当することができる。

パラメータ渡しでDOMを構築させるよりもカスタマイズしやすく、見通しもよい。

ドロップダウンのようにコンテンツの表示非表示を切り替えるという『機能』単位で切り出されるモジュールは、ビューがその時々によって異なるため上記のように記述したい需要が特に高いと思う。

実装イメージ

React.cloneElementというメソッドがある。propsやchildrenを拡張する目的で、特定のReactElementをクローンすることができる。

これを利用して、<Dropdown>に渡されたchildrenの中から<DropdownTrigger><DropdownContent>を探してきて、<Dropdown>が管理するpropsを知っている状態のTrigger,Contentへ加工する。

加工した結果はstateに保持しておき、このstateをレンダリングするようにする。

import DropdownTrigger from './DropdownTrigger';
import DropdownContent from './DropdownContent';

class Dropdown extends PureComponent {
  constructor(props, context) {
    super(props, context);
    this.state = { active: false };
    this.state.children = this.getChildren();
  }

  componentDidUpdate = (prevProps, prevState) => {
    if (this.state.active && prevState.active !== this.state.active) {
      this.setState({ children: this.getChildren() });
    }
    if (!isEqual(prevProps.children, this.props.children)) {
      this.setState({ children: this.getChildren() });
    }
  };

  // ここでprops.childrenを加工している
  getChildren = () => React.Children.map(this.props.children, (child) => {
    if (child.type === DropdownTrigger) {
      return React.cloneElement(child, { onClick: this.handleToggle });
    }
    if (child.type === DropdownContent) {
      return React.cloneElement(child, { active: this.state.active });
    }
    return child;
  });

  handleToggle = () => {
    this.setState({ active: !this.state.active })
  };

  // state.childrenをレンダリングしている
  render = () => (
    <div className={classNames('Dropdown', { active: this.state.active })}>
      {this.state.children}
    </div>
  );
}

export { Dropdown, DropdownTrigger, DropdownContent };

<DropdownTrigger><DropdownContent>は、props.onClickをDOMにアサインしたり、props.activeにしたがって表示非表示を切り替えたりするような実装になっていればよい。

export default class DropdownTrigger extends PureComponent {
  render = () => (
    <div className='DropdownTrigger' onClick={this.props.onClick}>
      {this.props.children}
    </div>
  );
}
export default class DropdownContent extends PureComponent {
  render = () => (
    <div className='DropdownContent'>
      {this.props.active && this.props.children}
    </div>
  );
}

<DropdownContent>にactive状態を渡してprops.childrenの表示非表示を切り替えているのは、アクティブじゃない状態の時にDOMをレンダリングさせないため。

アクティブじゃない時のDOMを最初からレンダリングしてしまうと、例えばイテレートされるモジュールの中で使用した時に初回のレンダリングコストや、サーバーサイドレンダリングをした時の配信サイズが大きくなってしまう。

DOMを最初からレンダリングしないようにしておけば、心置きなく巨大なドロップダウンを実装することができる(メモリのことを無視すればだけど..)。

パターン2: ビューで切り出す場合

ある見た目を実現するために切り出されて、内包するビューが画一的でよいタイプのもの。

たとえばアラートモーダル。見た目は統一されていてほしいので、パラメーター渡しでいい感じにコンテンツを構築してくれるものがほしい。

動作イメージ

利用イメージ

下記二つのモジュールとインターフェースを実装して使う。

  • ビューを構築する <Dialog>
  • 実行すると<Dialog>レンダリングする dialog
handleDialog = async () => {
  const ok = await dialog('これでよいですか?'); // これ
  console.log(ok ? 'おk' : 'だめ..');
};

render = () => (
  <button onClick={this.handleDialog}>
    削除するぞ!
  </button>
);

メソッドの実行前に画面上にDOMを保持しないため、ある程度ファットな実装になっても安心してイテレータの中で利用することができる。また、シングルトン制約もないので使い方には応用が効きやすい。

実装イメージ

dialogを呼び出すとdocument.createElementで空divを作ってbodyにappendし、ReactDOM.render<Dialog>の実装をそのdivにマウントするようになっている。

<Dialog>の中ではhandleFinalizeという終了メソッドを実装していて、その中で自分自身をunmountしつつparentNodeを削除する。そうしてしまえば、このモジュールに関するものはDOM上から一掃することができる。

export const dialog = (message) => new Promise(async (done) => {
  ReactDOM.render(<Dialog
    message={message}
    onDone={done}
  />, document.body.appendChild(document.createElement('div')));
});

class Dialog extends PureComponent {
  constructor(props) {
    super(props);
  }

  handleFinalize = () => {
    const wrapper = this.node.parentNode;
    ReactDOM.unmountComponentAtNode(wrapper);
    setTimeout(() => wrapper.parentNode.removeChild(wrapper), 0);
  };

  handleAccept = () => {
    this.props.onDone(true);
    this.handleFinalize();
  }

  handleCancel = () => {
    this.props.onDone(false);
    this.handleFinalize();
  }

  render = () => (
    <div className='Dialog'>
      {this.props.message}
      <button onClick={this.handleAccept}>OK</button>
      <button onClick={this.handleCancel}>Cancel</button>
    </div>
  );
}