React + ReduxのプロジェクトにAtomic DesignとImmutable.jsを使ったらいい感じになった話

React + ReduxのプロジェクトにAtomic DesignとImmutable.jsを使ったらいい感じになった話

ちょうど一年前くらいなんですが、React + Reduxのサービスを作りました。んでさらに今回また新たにReact + Reduxでサービスを作り始めているのですが、前回の反省を活かして今回につなげられればと思います。まだサービス事態は作っている状態執筆しているので今後修正等も入りそうですが、とりあえず基本的な方針ということでまとめてみました。

Ads

前回の反省点 & 改善点

一年前はチームのみんながReact初めてでかなり手探り状態かつリリースまでの期間があまりなかったという言い訳もあるんですが、大きな反省点としては

  • componentを作り込めなかった
  • どこまでをcomponent or containersにするか明確な定義がなかった
  • reducer周りが記述量が増える

componentを作り込めなかった

言い訳になってしまいますが、リリースまで時間がなくスピード重視だった為にcomponentを作り込めなかった為にcontainersにhtmlを書くことが多くなってしまいました。
結果としてcontainersが機能もりもりで1ファイルに対してメソッドがたくさんある状態になっていました。さらにcomponentWillReceivePropsやcomponentDidUpdateとかでif文の嵐になってしまいデバッグが大変でしたね。

どこまでをcomponent or containersにするか明確な定義がなかった

Reduxを使っているので、Reduxにコネクトするかしないかの違いだけなのですが、どこまでの粒度でcomponentを作るべきか、containersにどこまでの機能を持たせるかなどの明確な定義がない状態でした。もちろんリリース後にリファクタの対象として上がっていましたが、どのように棲み分けするか大事だと痛感しました。

reducer周りが記述量が増える

API側の設計とUI側のデータの持ち方にどうしても差異が生まれてしまうことがありました。僕がやったので一番差異が多かったのが、グラフなどを使ったときでした。グラフにはライブラリを使ったのですが、フォーマットが多少違くてreducerでAPIから受け取ったデータを元に成形しなければならない状態だったんですね。

そうなるともはやReducerの役割の範疇超えてない?ってことでどうにかならないのかなというのが運用時に考えていました。

改善点

改善点としては以下のようなことに気をつけて新たにReact + Reduxでサービスを作り始めています。

  • componentの作り込み
  • 機能ごとにcontainersを作る
  • component or containersの棲み分けの定義と共有
  • reducerを見通しよくする

今回のサービスで取り入れたこと

  • Atomic Design
  • Immutable.js

Atomic Design

Atomic Design | Brad Frost

Atomic Designでは5つのステージに分けて管理します。

  • Atoms(アトム) – 原子
  • Molecules(モルキュール) – 分子
  • Organisms(オルガニズム) – 有機体
  • Templates(テンプレート) – テンプレート (今回は使っていない)
  • Pages(ページ) – ページ

これらのステージは上から下に行くにつれて、粒度は大きくなり、抽象度は下がります。 抽象度の高いコンポーネント(たとえば原子)を合体させて、繰り返し可能なコンポーネント(分子)やテンプレートを構築できるようにデザインを考えていきます。 ページをデザインするのではなく、コンポーネントで構成するデザインシステムです。

詳しくは後述しますが、React と Reduxにうまく落とし込めそうということで採用しました。

Immutable.js

Immutable.js

React使い必見! Immutable.jsでReactはもっと良くなる | Wantedly Engineer Blog

Immutable.jsとは、Facebookが開発している、不変データ構造を扱うJavaScriptのライブラリです。

前回の反省点でReducerの肥大化がありました。Immutable.jsを使うことでロジックを別のとこで行うことができるようになります。Model的な役割をしてくれます。

ディレクトリ構成

実際にやってみたことをご紹介します。まずはディレクトリ構成ですが、一部省略している部分もありますが、概ねこんな感じです。

├── public // コンパイル済み各公開ファイル
│   └── assets
│   └── index.html
└── src
    ├── components // Reduxに依存しないReactのコンポーネントディレクトリ
    │   ├── atoms
    │   └── molecules
    ├── containers // Reduxとコネクトするコンポーネント
    │   ├── organisms
    │   └── pages
    ├── redux // ReduxのAction,Reducer,Storeを扱うディレクトリ
    │   ├── middlewares // 主にAPI処理をまとめたディレクトリ(ReduxでいうとAction発行時にReducerの前でインターセプトする処理)
    │   ├── modules
    │   └── records
    ├── styles // 基本的なスタイルを定義する
    │   ├── core
    │   ├── foundation
    │   └── utility
    └── utils // 汎用的な処理をするUtil用ディレクトリ

srcディレクトリの中身をじっくりみてみます。

components

Reduxに依存しないReactのコンポーネントディレクトリです。Atomic DesignでいうとAtomsとMoleculesが該当します。

Atoms

  • 一番小さい単位のComponent
  • これ以上小さくできない単位で作成する

例えば、アイコンとかボタンなどです。汎用的に使えるように設計する必要があります。なので、{this.props.children}を使ってテキストなどの表示をするのがいいのかなと思います。

ちなみに、atoms/buttonなどのようにそれぞれディレクトリを作った方がいいかなと思います。以下のように同じディレクトリにstyle.cssにcssを書いてmoduleとしてIndex.js内でimportできるようにしておくと、ローカルのクラスとして使用できるので、スタイルの重複や破綻がしにくくなるかと思います。

atoms
  ├── button
  │   └── Index.js
  │   └── style.css

Buttonの例

<Button onClickAction={() => console.log('hoge')} size={'Large'} color={'Primary'}>Modal Open</Button>
import React, {Component} from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'

import styles from './style.pcss'

export default class Button extends Component{
  static propTypes = {
    classes: PropTypes.array,
    children: PropTypes.any,
    size: PropTypes.string,
    display: PropTypes.string,
    onClickAction: PropTypes.func,
    type: PropTypes.string,
    color: PropTypes.string,
    disabled: PropTypes.bool
  }

  render(){
    return(
      <button
        type={this.props.type}
        onClick={this.props.onClickAction}
        disabled={this.props.disabled}
        className={classNames(
          styles.Btn,
          this.props.color && styles[this.props.color],
          this.props.size && styles[this.props.size],
          this.props.display && styles[this.props.display],
          this.props.classes)}
      >
        {this.props.children}
      </button>
    )
  }
}

style.pcss

※例ではpost-cssを使っているので、若干普通のcssとは違います。

クラス名については先頭を大文字にしています。これについても色々悩んだんですが、htmlの属性を混同させない & css modulesであるというのを明確にする為に大文字にしました。

@import "../../../styles/core/variables.pcss";

/*
* Block
* ----------------------------------------------------
*/
.Btn {
  display: inline-block;
  border: 0;
  text-align: center;
  vertical-align: middle;
  touch-action: manipulation;
  cursor: pointer;
  padding: 0 1.6rem;
  line-height: 3.9rem;
  font-size: var(--fontSizeH4);
  outline: none;
  position: relative;
  white-space: nowrap;
  background: color(var(--colorDefault) lightness(94%));

  &:link,
  &:active,
  &:hover,
  &:visited {
    color: var(--fontColorLight);
    text-decoration: none;
  }

  &:hover {
    opacity: .7;
  }

  &.disabled,
  &[disabled] {
    cursor: not-allowed;
    opacity: .7;
  }
}

/*
* size
* ----------------------------------------------------
*/
.Jumbo {
  font-size: 2.4rem;
  line-height: 8.4rem;
  padding: 0 3.4rem;
}

.Large {
  font-size: 2.0rem;
  line-height: 6.4rem;
  padding: 0 2.6rem;
}

.Small {
  font-size: 1rem;
  line-height: 2.8rem;
  padding: 0 1rem;
}

.Tiny {
  font-size: 1rem;
  line-height: 1.6rem;
  padding: 0 0.5rem;
}

.Block {
  display: block;
  width: 100%;
}

/*
* color
* ----------------------------------------------------
*/
.Primary,
.Secondary,
.Action,
.Info,
.Highlight {
  color: var(--colorWhite);

  &:link,
  &:active,
  &:hover,
  &:visited {
    color: var(--colorWhite);
  }
}

.Primary {
  background: var(--colorPrimary);
}

.Secondary {
  background: var(--colorSecondary);
}

.Action {
  background: var(--colorAction);
}

.Info {
  background: var(--colorInfo);
}

.Highlight {
  background: var(--colorHighlight);
}

Molecules

  • 基本的にAtomsの組み合わせで作る
  • 再利用可能なAtomsの集合体

結構定義に悩むとこかもしれません。なので、今回のルールとしては

  • State管理しない
  • {this.props.children}を使う

この2点が当てはまるならMoleculesです。

ModalやTabなどがここに該当します。

modalの例

ButtonやPanelなどatomsを組み合わせて一つのコンポーネントを作っています。もちろん必要であればstyleも用意します。

import React, {Component} from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import styles from './style.pcss'

import Button from '~components/atoms/button'

import Panel from '~components/atoms/modal/Panel'
import Content from '~components/atoms/modal/Content'
import Header from '~components/atoms/modal/Header'
import Overlay from '~components/atoms/modal/Overlay'
import Bottom from '~components/atoms/modal/Bottom'

export default class Modal extends Component{

  static propTypes = {
    classes: PropTypes.array,
    children: PropTypes.any,
    title: PropTypes.string,
    name: PropTypes.string,
    showModal: PropTypes.any,
    onClickAction: PropTypes.func
  }

  render(){
    return(
      <div
        className={classNames(
          styles.Modal,
          this.props.name === this.props.showModal && styles.FadeIn,
          this.props.classes)}
      >
        <Panel>
          <Header>{this.props.title}</Header>
          <Content>
            {this.props.children}
          </Content>
          <Bottom>
            <Button type="button" onClickAction={this.props.onClickAction}>閉じる</Button>
          </Bottom>
        </Panel>
        <Overlay onClickAction={this.props.onClickAction}/>
      </div>
    )
  }
}

containers

Reduxとコネクトするコンポーネントディレクトリです。Atomic DesignでいうとOrganismsとPagesが該当します。

Organisms

基本的にAtomsもしくはMoleculesで作られたpagesを構成する機能を分割したComponent(Containers)です。
言い換えると各Pagesで使う機能やUIを機能ごとにまとめたものと言えます。

HeaderやFooter、LoginForm、SideMenuなどが該当します。

Headerの例

以下のようにReduxとコネクトしていてstateを使ってごにょごにょします。必要であればここでエンドポイントを叩いたりもします。
あとは、よほどのことがない限り{this.props.children}を使うことはないです。というか極力使わないように設計した方が、pagesのcontainerが綺麗になります!!

import styles from '~styles/app.pcss'

import { bindActionCreators } from 'redux'
import React, { Component } from 'react'
import { connect } from 'react-redux'
import {Link, withRouter} from 'react-router-dom'
import PropTypes from 'prop-types'

import Bar from '~components/atoms/bar'
import Grid from '~components/atoms/grid'
import GridCol from '~components/atoms/grid/GridCol'
import Nav from '~components/atoms/nav'
import NavItem from '~components/atoms/nav/NavItem'
import Text from '~components/atoms/text'

class Header extends Component {
  static propTypes = {
    auth: PropTypes.any,
    location: PropTypes.object
  }

  constructor(props) {
    super(props)
  }

  render() {
    return (
      <header>
        <Bar shadow="Bottom" size='Medium' fixed="Top">
          <Grid gutters={true}>
            <GridCol size={4}>
              <Text color="Primary">Logo</Text>
            </GridCol>
            {this.props.auth.token && Object.keys(this.props.auth.token).length > 0 &&
              <GridCol size={8}>
                <Nav size='Medium' classes={[styles[`u-ta-r`]]}>
                  <NavItem path={this.props.location.pathname} name="/">
                    <Link to="/">Home</Link>
                  </NavItem>
                  <NavItem path={this.props.location.pathname} name="/user">
                    <Link to="/user">User</Link>
                  </NavItem>
                  <NavItem path={this.props.location.pathname} name="/signout">
                    <Link to="/signout">SignOut</Link>
                  </NavItem>
                </Nav>
              </GridCol>
            }
          </Grid>
        </Bar>
      </header>
    )
  }
}

function mapStateToProps(state) {
  return {
    auth: state.auth
  }
}

function mapDispatchToProps(dispatch) {
  return bindActionCreators(Object.assign({}, {}), dispatch)
}

export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Header))

pages

User一覧ページやログインページなど各ページを表す、一番大きいComponentです。render内では基本的にorganismsコンポーネントのみで構成するようにします。

Homeの例

こんな感じで機能ごとにorganismsを作り、pagesでは当て込むだけにしていきます。

import { bindActionCreators } from 'redux'
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'

import LocalMenu from '../organisms/home/LocalMenu'
import BreadCrumbs from '../organisms/home/BreadCrumbs'
import UserList from '../organisms/home/UserList'

class Home extends Component {
  static propTypes = {
    showModal: PropTypes.any,
    changeModal: PropTypes.func
  }

  constructor(props) {
    super(props)
  }


  render() {
    return (
      <div>
        <LocalMenu/>
        <BreadCrumbs/>
        <UserList/>
      </div>
    )
  }
}

function mapStateToProps(state) {
  return {}
}

function mapDispatchToProps(dispatch) {
  return bindActionCreators(Object.assign({}, {}), dispatch)
}

export default connect(mapStateToProps, mapDispatchToProps)(Home)

Redux

ReduxのAction,Reducer,Storeを扱うディレクトリです。一般的なReduxのお作法だとAction,Reducer,Storeはそれぞれディレクトリが違うのですが、ActionとReducerをmodulesにまとめました。個人的にはどのactionで何が起きるか1ファイル見ればわかるので気に入っています。

middlewares

ここではAPI処理をまとめています。今回は割愛。

modules

ここではActionとReduceをまとめています。例えばModalだったらこんな感じでAction types、Action、Reducerを1ファイルにまとめています。

import Modal from '../records/Modal'

// Action types
export const CHANGE_MODAL = 'CHANGE_MODAL'

// Action
export function changeModal(name) {
  return {
    type: CHANGE_MODAL,
    showModal: name
  }
}

// Reducer
export function modalReducer(state = new Modal, action) {
  const {type} = action
  switch (type) {
    case CHANGE_MODAL:
      return state.change(action.showModal)
    default:
      return state
  }
}

records

ここはImmutable.jsを使ってロジックを書きます。ちなみに、ディレクトリ名は最初modelだったんですがmodulesとかmodalとか似たような単語が多いのでrecordsにしました。

Modalの例

import { Record } from 'immutable'

const ModalRecord = Record({showModal: false})

export default class Modal extends ModalRecord {

  change(data) {
    return this.set('showModal', data)
  }
}

これだとかなりシンプルですが、こんな感じでごにょごにょしてもいいです。

import { Record } from 'immutable'

const SumRecord = Record({sum: '', prevSum: '' })

export default class ModelSummary extends SumRecord {

  calcSum(books) {
    return this.set('sum', this.mapToSummary(books))
  }

  calcPrevSum(books) {
    return this.set('prevSum', this.mapToSummary(books))
  }

  mapToSummary(data) {
    let expense = 0
    let income = 0

    if(data && data.length > 0){

      data.map((item) => {
        if(item.budget) {
          expense += item.price
        } else {
          income += item.price
        }
      })
    }

    return income - expense
  }

}

styles

ここでは基本的なスタイルをまとめています。reset.cssやbase.cssやpost-cssなどを使っているならvariablesとかを定義しておきます。
ただ、utilityに関してはcontainersなどで使うこともあります。使い方ですが、大元のcssないしpcssを呼び出してclasses={[styles['u-ta-r']]}でクラスを付与することができます。というか、atomsの段階でclassesが追加されるように作らなければいけません。

import styles from '~styles/app.pcss'

<Button size='Large' classes={[styles[`u-mb-32`]]}>
  //
</Button>
<button
  type={this.props.type}
  onClick={this.props.onClickAction}
  disabled={this.props.disabled}
  className={classNames(
    styles.Btn,
    this.props.color && styles[this.props.color],
    this.props.size && styles[this.props.size],
    this.props.display && styles[this.props.display],
    this.props.classes)}
>
  {this.props.children}
</button>

utils

ここでは汎用的な処理をするファイルをまとめています。enumとか、validateなどです。

styleについて

余談ですが、atoms内などでstyle.cssでスタイルを書きますが、クラス名についてはBEM的な書き方はあまり好ましくないかもしれません。
理由としては

  • module cssの時点でローカルである
  • atomsなどcomponent内でのstyleの定義がめんどくさくなる

そもそもBEMはできる限りローカルクラスっぽくになるように命名を工夫しようってことだと思うんです。moduleにする時点でこれは必要ないですよね。
あとcomponent作成時にめんどくさくなります。

<button
  type={this.props.type}
  onClick={this.props.onClickAction}
  disabled={this.props.disabled}
  className={classNames(
    styles.Btn,
    this.props.color && styles[`--${this.props.color}`]
    this.props.classes)}
>
  {this.props.children}
</button>

こんな感じで記述が増えるのでおすすめしません。

styles[`--${this.props.color}`]

componentsとcontainers

今回Atomic Designを採用しました。だったらディレクトリ構成でcomponentsとcontainersは必要なさそうですよね。
僕も最初いらないかなーと思ったんですけど、componentsとcontainersを明示的にすることができるので、Reduxのお作法に乗っかることにしました。

まとめ

新規サービスということでAtomic DesignとImmutable.jsを採用しました。実際にサービスをリリースした段階での記事ではないので、まだ修正の余地はありそうです。その辺は今後また記事にするとして、今の所設計としては結構気に入っています。理由としては、componentsとcontainersの棲み分けが綺麗だしわかりやすくなりました。さらに、Immutable.jsを使いreducerのロジック部分を分けることができmodules内のファイルの肥大化を防ぐことができました。

今後また修正するかと思いますが、一旦まとめてみました。React使っている方の何かの参考になればと思います。

いいなと思ったらシェアお願いします

Ads
ページの先頭へ