まずはじめに
Reactはユーザインターフェース構築のためのJavaScriptライブラリです。
React は、インタラクティブなユーザインターフェイスの作成にともなう苦痛を取り除きます。アプリケーションの各状態に対応するシンプルな View を設計するだけで、React はデータの変更を検知し、関連するコンポーネントだけを効率的に更新、描画します。
- React公式より
Reactのプロジェクトである程度規模が大きくなっていくと問題になっていくのは
きちんと設計しないとビジネスロジック、コンポーネントのステート、表示
これらが入り混じって数百行の巨大なコンポーネント(モノリシックなコンポーネント)ができてしまう場合があることです。
確かにReactはユーザインタラクティブなViewの作成には強力な力を発揮しますが、
綺麗なコンポーネント設計に関しては利用者に委ねられています。
(Reactが提供しているのはMVCモデルのViewControllerの部分です)
プロジェクトが大規模になるほど、モノリシックなコンポーネントの保守拡張は厄介になるため、
そもそもモノリシックにならないようにコンポーネントを設計する必要があります。
本記事では綺麗なコンポーネントを設計するためのテクニックを幾つか紹介します。
- 流用しやすいViewコンポーネント設計
- Presentation(ビジネスロジック)とViewを分離する
- Compound Componentで分岐を綺麗にする
Gitサンプルは簡易のためParcelで構築しています。
環境構築に関しては割愛とさせていただきます。
サンプルは以下で起動できます。(typescript+eslint+prettier設定済み)
$ yarn
$ yarn start
流用しやすいViewコンポーネント設計
UIコンポーネント表示の粒度として概念的にわかりやすいものでAtomic Designがあります。
- 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コンポーネントから分離しています。
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や処理を拡張する手法です。
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拡張する手法もあります。
注意点としては文字列や複数の子が入る場合もあるのでその対応も必要になります。
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つだけなのと文字列を入れる想定がないため実装がシンプルです。
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パターンを導入することで
次のように条件分岐が隠蔽化されて、表示部分のみが可視化され非常に直感的になります。
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カスタムフックに分離しています。(後述のテストに使う)
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の各種コンポーネントです。
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の設定を記載します。
{
"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
でカスタムフックのシミュレーションを行うことが出来ます。
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のショーケースを作成しています。
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
作成したショーケースが閲覧できます。
Visual Regression Test(表示回帰テスト)
storybookにショーケースを作成しておくことで表示のデグレが起きていないかテスト(特にMaterial-UIなどのUI系のライブラリを導入している場合はライブラリバージョンを上げた際の確認用にやっておいたほうが良い)
storybook公式ではChromaticを推しています。
GitHubなどのリポジトリ単位で連携しかつstorybookの全ショーケースに対して
Visual Regression Testを行ってくれます。
$ yarn add --dev chromatic
$ npx chromatic --project-token {Chromaticのトークン}
以下はfontSize変更した際に検出された表示差分です。
Github連携するとIntegrationsにChromaticが追加されます。
Github Actions、CircleCI、Travis各種CIへ導入することももちろんできます。
ただ、Github Actionsでのactionsも用意されているのですが、試してみたところ上手く動かなかったので直接chromatic-cliのコマンドを実行しています。
.github/workflows/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にて以下のようなコマンドです。
"scripts": {
"chromatic": "npx chromatic --project-token"
},
secrets.CHROMATIC_TOKEN
はchromaticより払い出しされたトークンでGitHubのSecrets項目に設定している想定です。
設定完了するとGithub Actionsが動くようになります。
Github連携が完了しているとBranch protection rulesにUI ReviewとUI Testsが現れます。
これらの項目をPR merge条件として必須化させることもできます。
Chromaticは5000 snapshot/monthまで無料で、個人用プロジェクトレベルならあまり問題ないですが、
料金が気になるという場合はBackStopJSライブラリなどを使って自前でVisual Regression Testをする方法もあります。(その際、比較元の環境が必要ですが・・・)