Uzabase Tech Blog

SPEEDA, NewsPicks, FORCASなどを開発するユーザベースの技術チームブログです。

フロントエンドのコンポーネント設計で気をつけているn個のこと

はじめまして、昨年の12月に入社しました根岸です。

UZABASEに入社する前はフロントエンドエンジニアとして働いており、ここ1年間くらいはReactとTypeScriptの開発ばかりやっていました。 今回はフロントエンドのコンポーネントを設計するときに気をつけていることについてまとめます。

対象読者

  • ReactやVueなどフロントエンドフレームワークで開発している人
  • コンポーネントの設計に自信がない人
  • 拡張性の高いコンポーネントを作りたいと思っている人

Propsの名前に一貫性をもたせる

例えばページ内でスタイルをあわせるためにButtonコンポーネントを作ったとしましょう。

Buttonコンポーネントがクリックされたときに関数を呼び出したいとき、どうやって関数をpropsに渡しますか? 多くのひとがonClick propsに渡そうするでしょう。

もしclick propsに渡す必要があったら驚くでしょう。 なぜならば標準DOM要素<button>の場合はonClickに渡せばよいから。

標準DOM要素と同じようなことをしたい場合ときは、標準DOM要素のprops名に合わせましょう。

  • クリックしたときに関数を実行 -> onClick
  • フォームの送信時に関数を実行 -> onSubmit
  • link先のurl -> href (toが使われる場合も多い)
// 良い例
const Button = ({onClick, children}) => (
  <button onClick={onClick}>{children}</button>
)
// アンチパターン
const Button = ({click, children}) => (
  <button onClick={click}>{children}</button>
)

標準DOM要素と同様の役割を持つコンポーネントのpropsは標準DOM要素に合わせる

atom層のコンポーネントは標準DOM要素と同じ役割をもつケースが多く、標準DOM要素をラップするだけのことがよくあります。 その場合は標準DOM要素のpropsをすべて使えるようにしましょう。

どうやって実装するかというとすべてのpropsをラップした標準DOM要素に渡せばよいです。 スプレッド演算子を使うことでまとめてpropsを渡すことができます。

// 良い例
const Button = ({color, ...props}) => {
  // propsで受け取ったものをすべて<button>に渡す
    return <button style={{background: color}} {...props} />
}

const Example = () => {
  const handleClick = () => alert('click')
  return <Button type="button" disabled={false} name="example">ボタン</Button>
}
// アンチパターン
const Button = ({color, children}) => {
    return <button style={{background: color}}>{children}</button>
}

const Example = () => {
  const handleClick = () => alert('click')
  // typeやdisabledをButtonは<button>に渡していない
  // Buttonにtypeやdisabledを渡しても使えない
  return <Button type="button" disabled={false} name="example">ボタン</Button>
}

TypeScriptの場合はJSX.IntrinsicElementsから標準DOM要素のpropsの型を取得してReact.FCに渡せば良いです。

// 良い例
const Button: React.FC<JSX.IntrinsicElements['button']> = props => {
    return <button style={{background: 'red'}} {...props} />
}

styled-componentやemotionなどで使えるstyledを使うとより簡単です。

// 良い例

// Buttonはbuttonのpropsを継承する
const Button = styled.button`
  background: red;
`

レイアウトをコンポーネントに切り出す

f:id:t-NeGI:20200329164536p:plain
2カラムページ

上の図のようなページのコーディングを考えます。

図は左側にメニュー、右側にメインのコンテンツを表示する2カラムのレイアウトを持っています。 このようなレイアウトは他のページでも再利用されることが考えられます。 再利用される場合はレイアウトに関してもコンポーネントに切り出してコードの共通化をしましょう。

下の例ではレイアウトに関することはTwoColumnLayoutに切り出しています。 レイアウトをコンポーネント化する場合はpropsにReactElementを受け取るときれいに切り出すことができます。 レイアウトをTwoColumnLayoutに切り出したことによって、Pageコンポーネントは左側と右側のカラムにMenuとMainを表示することだけに関心を持てばよいコードになっています。

// 良い例
const Page = () => (
  <>
    <Header />
    <TwoColumnLayout>
      <Menu />
      <Main />
    </TwoColumnLayout>
  </>
)

const TwoColumnLayout = ({ children }) => (
  <div style={{display: 'flex'}}>
    <div style={{flex: '1 1 30%'}}>{children[0]}</div>
    <div style={{flex: '1 1 70%'}}>{children[1]}</div>
  </div>
)
// 良い例 (TypeScript版)
const TwoColumnLayout:React.FC<Props> = ({ children }) => (
  <div style={{display: 'flex'}}>
    <div style={{flex: '1 1 30%'}}>{children[0]}</div>
    <div style={{flex: '1 1 70%'}}>{children[1]}</div>
  </div>
)

interface Props {
  children: [React.ReactElement, React.ReactElement];
}

下のコードのPageコンポーネントはレイアウトの情報を持っています。 このように特定のコンポーネントがレイアウトの情報を持つと、共通のレイアウトを1つのコードで管理できなくなってしまいます。 また、コードの見通しも悪くなります。

// アンチパターン
const Page = () => (
  <>
    <Header />
    <div style={{display: 'flex'}}>
      <div style={{flex: '1 1 30%'}}>
        <Menu />
      </div>
      <div style={{flex: '1 1 70%'}}>
        <Main />
      </div>
    </div>
  </>
)

インタラクティブな部分もコンポーネント切り出す

f:id:t-NeGI:20200329164443g:plain
クリックすると開閉するメニュー

上の図のようなアイコンをクリックすると開くメニューのコーディングを考えます。

このメニューはアイコンをクリックすると開いたり閉じたりできます。 またメニューにはフェードイン・フェードアウトのアニメーションがついています。

「アイコンをクリックしたら要素がフェードイン・フェードアウトする」という動作は、メニュー以外でも使うことがあるでしょう。 その場合はレイアウトと同様にコンポーネントに切り出して再利用できるようにしましょう。

クリック時の動作をコンポーネントへ切り出さずにコーディングすると下のようになります。 ToggleMenuは表示する要素に加えて、開閉の状態やフェードイン・フェードアウトに関するスタイルなどクリック時の動作に関する役割も持っています。

// クリック時の動作をコンポーネントに切り出す前のコード
const ToggleMenu = ({ menuItems }) => {
  const [isOpen, setIsOpen] = useState(false);
  const handleClickButton = useCallback(() => setIsOpen(isOpen => !isOpen), [
    setIsOpen
  ]);

  const opacityStyle = isOpen ? { opacity: "1" } : { opacity: "0" };
  return (
    <>
      <FiMenu size="30" onClick={handleClickButton}/>
      <ul
        style={{
          ...opacityStyle,
          transition: "opacity 0.2s",
          border: "1px solid",
          width: "170px",
        }}
      >
        {menuItems.map(item => (
          <li>{item}</li>
        ))}
      </ul>
    </>
  );
};

下の例ではクリックしたときの動作に関することはToggleElementWrapperに切り出しています。 ToggleElementWrapperはクリックされる要素(clickableElement)と表示・非表示される要素(toggledElement)をpropsで受け取っています。 prosで受け取ったclickableElementtoggledElementにonClickとstyleをそれぞれのpropsに渡すことでクリック時に要素を表示・非表示する動作を実現しています。

また、ToggleElementWrapperを利用する側のToggleMenuは表示する要素だけに関心を持てば良いコードになっています。 開閉の状態やアニメーションに関してToggleMenuは一切考える必要がありません。

// 良い例
const ToggleMenu = ({ menuItems }) => {
  const clickableElement = <FiMenu size="30" />;
  const toggledElement = (
    <ul
      style={{
        border: "1px solid",
        width: "170px"
      }}
    >
      {menuItems.map(item => (
        <li>{item}</li>
      ))}
    </ul>
  );

  return (
    <ToggleElementWrapper
      clickableElement={clickableElement}
      toggledElement={toggledElement}
    />
  );
};

const ToggleElementWrapper = ({ clickableElement, toggledElement }) => {
  const [isOpen, setIsOpen] = useState(false);
  const handleClickButton = useCallback(() => setIsOpen(isOpen => !isOpen), [
    setIsOpen
  ]);

  const opacityStyle = isOpen ? { opacity: "1" } : { opacity: "0" };

  // clickableElementにonClick propsを追加している
  const clickableElementAddedOnClickProps = React.cloneElement(
    clickableElement,
    { onClick: handleClickButton }
  );

  // toggledElementにスタイルに関するpropsを追加している
  const toggledElementAddedOpacityStyle = React.cloneElement(toggledElement, {
    style: {
      ...toggledElement.props.style,
      ...opacityStyle,
      transition: "opacity 0.2s"
    }
  });
  return (
    <>
      {clickableElementAddedOnClickProps}
      {toggledElementAddedOpacityStyle}
    </>
  );
};

単一責任を意識してコンポーネントを作る

オブジェクト指向プログラミングでは単一責任の原則という設計の考え方があります。 Reactのコンポーネントを作るときも単一責任の原則を意識しましょう。

例としてレイアウトのコードを再掲してそれぞれのコンポーネントの役割について考えてみます。

const Page1 = () => (
  <>
    <Header />
    <TwoColumnLayout>
      <Menu />
      <Main />
    </TwoColumnLayout>
  </>
)

const TwoColumnLayout = ({ children }) => (
  <div style={{display: 'flex'}}>
    <div style={{flex: '1 1 30%'}}>{children[0]}</div>
    <div style={{flex: '1 1 70%'}}>{children[1]}</div>
  </div>
)

const Page2 = () => (
  <>
    <Header />
    <div style={{display: 'flex'}}>
      <div style={{flex: '1 1 30%'}}>
        <Menu />
      </div>
      <div style={{flex: '1 1 70%'}}>
        <Main />
      </div>
    </div>
  </>
)

Page1Header,Menu,MainTwoColumnlayoutというコンポーネントで表示するという役割だけを持っていて具体的なスタイルについて何も知りません。 またTwoColumnLayoutは具体的なスタイルの情報だけを持っています。 Page1TwoColumnLayoutはそれぞれ表示する要素をまとめるという役割と具体的なスタイルを定義する役割だけを持っており、単一責任の原則が守られています。

一方、Page2は表示するコンポーネントの情報とスタイルの情報の2つを持っています。 これは単一責任の原則違反であり、Page1TwoColumnLayoutのようにコンポーネントを分割すべきです。

propsを増やすことでコンポーネントのバリエーションを増やさない

f:id:t-NeGI:20200329164557p:plain

上の図のようなECサイトの商品紹介用のボックスについて考えます。 このボックスのコンポーネントを下のコードのように作ったとします。 Productは画像のsrc,商品名,価格をpropsから受け取って表示します。

const Product= ({image, name, price}) => {
  return (
    <Box>
      <Image src={image} />
      <Name>{name}</Name>
      <Price>価格:{price}円</Price>
    </Box>
  )
}

後日、セール時の価格を下の図のように表示したくなったとしましょう。

f:id:t-NeGI:20200329164608p:plain

このときどうやってコーディングすべきでしょうか。 安直に考えるとProductを拡張して、セールのときはセール価格をpropsで受け取り表記を変えれば実装できそうです。

const Product= ({image, name, price, salePrice}) => {
  return (
    <Box>
      <Image src={image} />
      <Name>{name}</Name>
      {/* salePriceがあれば打ち消し線を引く */}
      <Price style={!!salePrice ? {textDecoration: "line-through" } : {}}>
        価格:{price}円
      </Price>
      {/* salePriceがあるときだけ表示する*/}
      {!!salePrice && (
        <Price>セール価格:
          <Text color={"red"}>{salePrice}円</Text>
        </Price>
      )}
    </Box>
  )
}

しかし、salePriceをpropsに追加したおかげでProductのなかでセールのときとそうではないときの制御をしないといけなくなってしまいました。 また、さらに電子版や中古の商品があるときの価格を追加したい要件があったらどうなるでしょう。 Productにpropsを追加していくとどんどんカオスになっていってしまいます。

このようなコンポーネントのバリエーションを増やしたいときは、propsを増やすことで対応するのではなくて別のコンポーネントに分けることで対応しましょう。

const Product= ({image, name, price}) => {
  return (
    <Box>
      <Image src={image} />
      <Name>{name}</Name>
      <Price>価格:{price}円</Price>
    </Box>
  )
}

const SaleProduct= ({image, name, price, salePrice}) => {
  return (
    <Box>
      <Image src={image} />
      <Name>{name}</Name>
      <Price style={{textDecoration: "line-through" }}>価格:{price}円</Price>
      <Price>セール価格:
        <Text color={"red"}>{salePrice}円</Text>
      </Price>
    </Box>
  )
}

ProductSaleProductに分けることで、セールとそうでない場合の制御がなくなりシンプルなコードになりました。

ProductSaleProductを見るとコードの重複が気になる人もいるかも知れません。 しかし無理に1つのコンポーネントにまとめてコードを複雑にすることよりも、別のコンポーネントにわけてコードの重複を許容しつつシンプルなコードにしたほうが長期的に見て得だと私なら考えます。

まとめ

コンポーネントの設計に関しては資料もすくないため、初めはどうするのがよいか悩む人も多いと思います。 この記事を読んで、コンポーネント作るときに少しでも参考にしていただけると嬉しいです。