389
351

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

綺麗なReactコンポーネント設計でモノリシックなコンポーネントを爆殺する

Last updated at Posted at 2020-09-22

まずはじめに

Reactはユーザインターフェース構築のためのJavaScriptライブラリです。
React は、インタラクティブなユーザインターフェイスの作成にともなう苦痛を取り除きます。アプリケーションの各状態に対応するシンプルな View を設計するだけで、React はデータの変更を検知し、関連するコンポーネントだけを効率的に更新、描画します。

  • React公式より

Reactのプロジェクトである程度規模が大きくなっていくと問題になっていくのは
きちんと設計しないとビジネスロジック、コンポーネントのステート、表示
これらが入り混じって数百行の巨大なコンポーネント(モノリシックなコンポーネント)ができてしまう場合があることです。
確かにReactはユーザインタラクティブなViewの作成には強力な力を発揮しますが、
綺麗なコンポーネント設計に関しては利用者に委ねられています。
(Reactが提供しているのはMVCモデルのViewControllerの部分です)

端的に言ってしまえば、 ビジネスロジックと表示の部分の分離の面倒まではReactは見てくれないので 気をつけないとビジネスロジックと表示が密結合になり、 著しく流用性、保守性が下がるコンポーネントになっていきます。

プロジェクトが大規模になるほど、モノリシックなコンポーネントの保守拡張は厄介になるため、
そもそもモノリシックにならないようにコンポーネントを設計する必要があります。
本記事では綺麗なコンポーネントを設計するためのテクニックを幾つか紹介します。

  • 流用しやすいViewコンポーネント設計
  • Presentation(ビジネスロジック)とViewを分離する
  • Compound Componentで分岐を綺麗にする

Gitサンプルは簡易のためParcelで構築しています。
環境構築に関しては割愛とさせていただきます。

サンプルは以下で起動できます。(typescript+eslint+prettier設定済み)

$ yarn
$ yarn start

流用しやすいViewコンポーネント設計

UIコンポーネント表示の粒度として概念的にわかりやすいものでAtomic Designがあります。

https___qiita-image-store.s3.amazonaws.com_0_150569_73ffc43a-a181-fad9-a16e-f9b894475f85.png

  • ATOMS(原子): UIコンポーネントの最小単位、単一のボタン、単一のテキストボックスなど
  • MOLECULES(分子): 原子を組み合わせたもの、性別のラジオグループなど
  • ORGANISMS(組織): フォーム、リスト、グリッドなどのコレクションなど複数の分子を格納している粒度
  • TEMPLATES(雛形): ページのスケルトン(ページにデータを流し込む前の状態)、概念としてはあるけどあんまり使われない
  • PAGES(ページ): ページそのものの単位の粒度

流用可用性としてせいぜいATOMSとMOLECULESまでくらいでしょう。
ORGANISMSまでいくとレイアウトそのものにもプロジェクトの色が濃く反映され、他のプロジェクトなどへの流用は難しくなります。

流用性が高いコンポーネントの特徴としては

  • props名にドメイン名を含めず、抽象化されている(NG:profileName→OK:name)
  • margin,top,left,bottom,rightなどのレイアウトの外側の余白、位置(できればサイズも)を決定するスタイルを持たない&props経由でスタイルを上書きすることができる

参考:スタイルクローズドの原則

要は、ORGANISMS以下でページ内の位置を決定するのはおかしなことで、ページの方で使用するコンポーネントの位置を決定するmarginなどを割り振りできるようにすべし

逆に流用性が低くならざる得ない場合も当然あると思います。その場合は割り切って利用用途(ドメイン)に応じてコンポーネントをフォルダ分けしたほうがいいでしょう

Presentation(ビジネスロジック)とViewを分離する

表示とビジネスロジック(表示条件、表示データなど)を分離するにはいくつか方法があります。

  • 高階コンポーネント(HOC)
  • children propsの拡張
  • componentのprops渡し

いずれもビジネスロジックハンドリング用のPresentationコンポーネントと表示専用のViewコンポーネントを分離するためのパターンです。
LogicPage.tsxに実際の使用例をまとめてあります。
いずれもLogを出力するというロジックをViewコンポーネントから分離しています。

LogicPage.tsx
import React from 'react'
import withLogger from '../logics/LoggerHOC'
import LoggerChildrenProps from '../logics/LoggerChildrenProps'
import LoggerWithProps from '../logics/LoggerWithProps'
import TextButton from '../moleculars/TextButton'

const LogTextButton = ({ log }: { log?: string }) => (
  <TextButton
    onClick={(text) => {
      console.log(log)
      console.log(text)
    }}
  />
)

const WrapTextButton = withLogger(LogTextButton)

const LogicPage = (): JSX.Element => {
  return (
    <div>
      <h1>HOC</h1>
      <WrapTextButton log="high order components" />
      <h1>childrenのpropsの拡張</h1>
      <LoggerChildrenProps log="with children props">
        <LogTextButton />
        ほげ
      </LoggerChildrenProps>
      <h1>componentのprops渡し</h1>
      <LoggerWithProps log="with props" component={LogTextButton} />
    </div>
  )
}

export default LogicPage

棲み分けとしては次のようなイメージです。

  • レンダリングに必要なpropsの取得やデータ送信(APIコールなど)はPresentationコンポーネントで行う
  • ViewコンポーネントではAPIコールやビジネスロジックを伴う直接的な判定を行わない、自身の表示の状態(state)切り替えなどは持ってもよい

高階コンポーネント(HOC)

High Order Component(HOC)は
高階関数にて既存のコンポーネントをwrapして
propsや処理を拡張する手法です。

LoggerHOC.tsx
import React from 'react'

type InjectProps = { log: string }

function withLogger<T>(Component: React.ComponentType<T & InjectProps>) {
  return function wrap(props: T & InjectProps): JSX.Element {
    const { log } = props
    // ロジックをねじ込む
    React.useEffect(() => {
      console.log(`${log} mount`)

      return () => console.log(`${log} unmount`)
    }, [])

    return <Component {...props} />
  }
}

export default withLogger

使い方は既存のコンポーネントをHOC関数でwrapした上で使います。
wrap元のコンポーネントに影響を与えない反面、呼び出され元がwrapされているものなのか判別が厄介になるデメリットもあります。

const WrapTextButton = withLogger(LogTextButton)

return <WrapTextButton log="high order components" />

children propsの拡張

childrenのコンポーネントをReact.cloneElement関数にてprops拡張する手法もあります。
注意点としては文字列や複数の子が入る場合もあるのでその対応も必要になります。

LoggerChildrenProps.tsx
import React from 'react'

type InjectProps = { log: string }

function LoggerChildrenProps({
  log,
  children,
}: InjectProps & {
  children: React.ReactChild | React.ReactChild[]
}): JSX.Element {
  // ロジックをねじ込む
  React.useEffect(() => {
    console.log(`${log} mount`)

    return () => console.log(`${log} unmount`)
  }, [])

  // childrenだと文字列や複数の子も許容してしまうため対応する
  const childrenWithProps = React.Children.map(children, (child) => {
    switch (typeof child) {
      case 'string':
        return child
      case 'object':
        return React.cloneElement(child as React.ReactElement, { log })
      default:
        return null
    }
  })

  return <>{childrenWithProps}</>
}

export default LoggerChildrenProps

使用側は入れ子にするだけで、拡張されたpropsが子コンポーネントに渡されます。

  <LoggerChildrenProps log="with children props">
     <LogTextButton />
     ほげ
  </LoggerChildrenProps>

componentのprops渡し

これが一番直感的かもしれません。componentという名のpropsにコンポーネントを渡します。
children propsの拡張と違うのは子が必ず1つだけなのと文字列を入れる想定がないため実装がシンプルです。

LoggerWithProps.tsx
import React from 'react'

type InjectProps = { log: string }

function LoggerWithProps({
  log,
  component,
}: {
  log: string
  component: React.ComponentType<InjectProps>
}): React.ReactElement {
  // ロジックをねじ込む
  React.useEffect(() => {
    console.log(`${log} mount`)

    return () => console.log(`${log} unmount`)
  }, [])

  const Component = component
  return <Component log={log} />
}

export default LoggerWithProps

使用側はpropsに渡すだけで、拡張されたpropsが子コンポーネントに渡されます。

<LoggerWithProps log="with props" component={LogTextButton} />

Compound Componentで分岐条件を隠蔽化する

Reactのデザインパターン Compound Componentsを参考にReact Hook化しています。
Hook版Compound Componentの参考:React Hooks: Compound Components

例えば、次のようなif文もしくは?演算子によるレンダリングの分岐が膨れ上がっていくとすると
一体どの条件で何がレンダリングされるのか直感的ではありません。


 render() {
    if (this.state.currentTabType === TAB_TYPES.HOME) {
      return <div>Homeの時の中身</div>;
    } else if (this.state.currentTabType === TAB_TYPES.ABOUT) {
      return <div>Aboutの時の中身</div>;
    } else if (this.state.currentTabType === TAB_TYPES.OTHERS) {
      return <div>OTHERSの時の中身</div>;
    }
    return null;
 }

Compound Componentsパターンを導入することで
次のように条件分岐が隠蔽化されて、表示部分のみが可視化され非常に直感的になります。

MenuPage.tsx
const MenuPage = (): JSX.Element => {
  return (
    <Menu>
      <Menu.Tabs />
      <div
        style={{
          width: 300,
          height: 300,
          border: '1px solid black',
          padding: 10,
        }}
      >
        <Menu.Home>Homeの時の中身</Menu.Home>
        <Menu.About>Aboutの時の中身</Menu.About>
        <Menu.Others>Othersの時の中身</Menu.Others>
      </div>
    </Menu>
  )
}

今回はタブの状態管理をuseMenuカスタムフックに分離しています。(後述のテストに使う)

useMenu.tsx
import React from 'react'

export type ValueOf<T> = T[keyof T]

export const TAB_TYPES = {
  HOME: 'home',
  ABOUT: 'about',
  OTHERS: 'others',
}

export const tabData = [
  {
    text: 'Home',
    type: TAB_TYPES.HOME,
  },
  {
    text: 'About',
    type: TAB_TYPES.ABOUT,
  },
  {
    text: 'Others',
    type: TAB_TYPES.OTHERS,
  },
]

export const useMenu = (): {
  tabType: ValueOf<typeof TAB_TYPES>
  changeTab: (tabType: ValueOf<typeof TAB_TYPES>) => void
} => {
  const [tabType, setTabType] = React.useState<ValueOf<typeof TAB_TYPES>>(
    TAB_TYPES.HOME
  )

  const changeTab = React.useCallback(
    (tabType: ValueOf<typeof TAB_TYPES>) => {
      setTabType(tabType)
    },
    [tabType]
  )

  return { tabType, changeTab }
}

Menu.tsxで具体的にCompound Componentを実装しています。
ポイントなるのがContext API(TabContext.Provider+useContext)で末端の各Tabコンポーネントに親元のMenuコンポーネントの状態を伝えています。
メニュー部を表示しているのがTabsコンポーネントでタブの中身を表示しているのがHome、About、Othersの各種コンポーネントです。

Menu.tsx
import React, { useContext } from 'react'
import { ValueOf, TAB_TYPES, tabData, useMenu } from '../../hooks/useMenu'

const TabContext = React.createContext<{
  tabType: ValueOf<typeof TAB_TYPES>
  changeTab: (tabType: ValueOf<typeof TAB_TYPES>) => void
}>({
  tabType: TAB_TYPES.HOME,
  changeTab: () => null,
})

function Menu({ children }: { children: React.ReactNode }): JSX.Element {
  const { tabType, changeTab } = useMenu()

  return (
    <TabContext.Provider
      value={{
        tabType,
        changeTab,
      }}
    >
      {children}
    </TabContext.Provider>
  )
}

function Home({ children }: { children?: React.ReactNode }) {
  const { tabType } = useContext(TabContext)

  return tabType === TAB_TYPES.HOME ? (children as JSX.Element) : null
}

function About({ children }: { children?: React.ReactNode }) {
  const { tabType } = useContext(TabContext)

  return tabType === TAB_TYPES.ABOUT ? (children as JSX.Element) : null
}

function Others({ children }: { children?: React.ReactNode }) {
  const { tabType } = useContext(TabContext)

  return tabType === TAB_TYPES.OTHERS ? (children as JSX.Element) : null
}

function Tabs() {
  const { tabType, changeTab } = useContext(TabContext)

  return (
    <ul style={{ display: 'flex', padding: 0 }}>
      {tabData.map((tab) => (
        <li
          key={tab.type}
          style={{
            display: 'block',
            color: tabType === tab.type ? 'black' : 'grey',
            marginRight: 5,
            padding: 0,
            cursor: 'pointer',
          }}
          onClick={() => changeTab(tab.type)}
        >
          {tab.text}
        </li>
      ))}
    </ul>
  )
}

Menu.Tabs = Tabs
Menu.Home = Home
Menu.About = About
Menu.Others = Others

export default Menu

カスタムフックのテスト

原則ビジネスロジックをあまりフロントエンドに寄せない方が良いのですが、
(bundleの肥大化、どのみちバックエンドでのAPIでの判定が必要など)
カスタムフックそのもののテストを@testing-library/react-hooksを使うことで行うこともできます。
jestを使ってのテスト環境を構築します。

$ yarn jest ts-jest @types/jest babel-jest react-test-renderer @testing-library/react @testing-library/react-hooks

package.jsonにjestの設定を記載します。

package.json
{
  "scripts": {
    "test": "jest",
  },
  "jest": {
    "moduleFileExtensions": [
      "js",
      "ts",
      "tsx"
    ],
    "transform": {
      "^.+\\.tsx?$": "ts-jest"
    },
    "globals": {
      "ts-jest": {
        "tsConfig": "tsconfig.json"
      }
    },
    "testMatch": [
      "**/__tests__/**/*.test.ts"
    ]
  }
}

src/__tests__以下にカスタムフックのテストを書きます。
renderHookでカスタムフックの戻り値を取得します。
actでカスタムフックのシミュレーションを行うことが出来ます。

useMenu.test.ts
import { act, renderHook } from '@testing-library/react-hooks'
import { useMenu, TAB_TYPES } from '../hooks/useMenu'

it('tab toggle', () => {
  const { result } = renderHook(() => useMenu())
  // 初期状態(Homeタブ)
  expect(result.current.tabType).toBe(TAB_TYPES.HOME)

  // Aboutタブに切り替え
  act(() => {
    result.current.changeTab(TAB_TYPES.ABOUT)
  })
  expect(result.current.tabType).toBe(TAB_TYPES.ABOUT)

  // Othersタブに切り替え
  act(() => {
    result.current.changeTab(TAB_TYPES.OTHERS)
  })
  expect(result.current.tabType).toBe(TAB_TYPES.OTHERS)
})

jestコマンドでカスタムフックの単体テストを行うことが出来ます。

$ yarn jest
yarn run v1.22.4
$ /Users/teradonburi/Desktop/ts-react/node_modules/.bin/jest
 PASS  src/__tests__/useMenu.test.ts
  ✓ tab toggle (13 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.575 s, estimated 3 s
Ran all test suites.
✨  Done in 3.15s.

参考:React Hooks のテストを react-hooks-testing-library で書く

Storybookでショーケースを作っておく

コンポーネントのテストには
enzymeなどのテストライブラリで単体テストをする方法もありますが、
ビジュアル部分に関してテストすることはできません。
またコンポーネントそのものの使い勝手の良さなどは実際にすぐにいじれる環境が必要です。
storybookの導入はsb initコマンドで行います。

$ yarn add --dev @storybook/cli
# プロジェクトのReact、VueなどのフレームワークとTypescript有無などを勝手に判別して適切な設定で初期化してくれる
$ npx @storybook/cli sb init

.storybookフォルダとstoriesフォルダ(+サンプル)が生成されます。
storiesフォルダの自動生成サンプルは一旦ごっそり消して、実際に使用しているコンポーネントのショーケースを作成します。
今回は自作したButtonのショーケースを作成しています。

stories/Button.stories.tsx
import React from 'react'
import { Story, Meta } from '@storybook/react/types-6-0'

import Button, { ButtonProps } from '../components/atoms/Button'

// 表示するコンポーネント
export default {
  title: 'Example/Button',
  component: Button,
} as Meta

const Template: Story<ButtonProps> = (args) => <Button {...args} />

// 表示されるショーケースの名前
export const Normal = Template.bind({})
// コンポーネントのprops
Normal.args = {
  value: '送信',
}

以下のコマンドでstorybookサーバが起動します。

$ npx start-storybook -p 6006

作成したショーケースが閲覧できます。

スクリーンショット 2020-09-23 1.44.08.png

Visual Regression Test(表示回帰テスト)

storybookにショーケースを作成しておくことで表示のデグレが起きていないかテスト(特にMaterial-UIなどのUI系のライブラリを導入している場合はライブラリバージョンを上げた際の確認用にやっておいたほうが良い)
storybook公式ではChromaticを推しています。
GitHubなどのリポジトリ単位で連携しかつstorybookの全ショーケースに対して
Visual Regression Testを行ってくれます。

$ yarn add --dev chromatic
$ npx chromatic --project-token {Chromaticのトークン}

スクリーンショット 2020-09-22 19.50.00.png

以下はfontSize変更した際に検出された表示差分です。

スクリーンショット 2020-09-22 19.54.05.png

Github連携するとIntegrationsにChromaticが追加されます。

スクリーンショット 2020-09-23 0.40.38.png

Github Actions、CircleCI、Travis各種CIへ導入することももちろんできます。
ただ、Github Actionsでのactionsも用意されているのですが、試してみたところ上手く動かなかったので直接chromatic-cliのコマンドを実行しています。

.github/workflows/main.yml

main.yml
# This is a basic workflow to help you get started with Actions

name: CI

# Controls when the action will run. Triggers the workflow on push or pull request
# events but only for the master branch
on:
  push:
    branches: [master]
  pull_request:
    branches: [master]

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  build:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      - uses: actions/checkout@v2
        with:
          fetch-depth: 0
      - name: run
        run: |
          yarn 
          yarn chromatic ${{ secrets.CHROMATIC_TOKEN }}

yarn chromaticのコマンドはpackage.jsonにて以下のようなコマンドです。

package.json
  "scripts": {
    "chromatic": "npx chromatic --project-token"
  },

secrets.CHROMATIC_TOKENはchromaticより払い出しされたトークンでGitHubのSecrets項目に設定している想定です。

スクリーンショット 2020-09-23 0.38.16.png

設定完了するとGithub Actionsが動くようになります。

スクリーンショット 2020-09-23 0.37.03.png

Github連携が完了しているとBranch protection rulesにUI ReviewとUI Testsが現れます。
これらの項目をPR merge条件として必須化させることもできます。

スクリーンショット 2020-09-23 0.40.08.png

Chromaticは5000 snapshot/monthまで無料で、個人用プロジェクトレベルならあまり問題ないですが、
料金が気になるという場合はBackStopJSライブラリなどを使って自前でVisual Regression Testをする方法もあります。(その際、比較元の環境が必要ですが・・・)

389
351
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
389
351

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?