15
15

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 5 years have passed since last update.

ExpressサーバーからReactまでのフロントエンドハンズオン 第2章〜フロントエンド編〜

Last updated at Posted at 2019-01-05

概要

今時のフロントエンドってどうやって実装すればいいのか、実際に作りながら説明する
ところどころ省略しているものもあるが、業務運用に耐えうる設計を前提に書いたつもりである

以下の構成で紹介する。この投稿は第2章

  • 第1章 UIサーバー編
  • 第2章 フロントエンド編(ここ)
  • 第3章 フロントエンド-redux編 (まだない)

フロントエンド編で扱う、webpackやbabelの設定ファイルは、バージョンが変われば大きく書き方が変わってしまう。インターネットで調べても古いバージョンだとコピペしても動かなかったりするので、公式のドキュメントを参考にするのが一番いい。

主要なパッケージについてここでは以下のバージョンを想定している。

babel ^7.1.0
webpack ^4.25.1
eslint ^5.11.0
react ^16.7.0
redux ^4.0.1
@material-ui/core ^3.7.1

第1章で作ったUIサーバーが返すJavaScriptファイルを作成するのが目的となる。
1つのHTMLで、SPAページを実装するJavaScriptを作成する

作るもの

第1章で作ったUIサーバーが返すHTMLページに埋め込まれる、以下2つのjsファイル(webpackでの出力先)

ui/public/js/login.js
ui/public_authenticated/js/app.js

github
https://github.com/yas-tyoukan/frontend-basic-sample

jsがビルドされるまでの流れ

わかってる人は読み飛ばしてよい

JavaScriptファイルは適切に分割して書いたり、パッケージを読み込んだり、ブラウザでは実行できない新しい仕様に基づいた構文で書いたりする。それをブラウザで実行できるjsファイルにするためにビルドする。CSSもjsの中に出力する。

ビルドにはwebpackを使う。
webpackが色々よしなにやってくれる。
(よしなにやってくれるよう設定を書く)

だいたいこんな感じの流れ
スクリーンショット 2018-12-18 16.53.51.png

公式のおしゃれな図: https://webpack.js.org/
スクリーンショット 2018-12-22 22.52.44.png

準備

ディレクトリ構成は前回作成したファイルはそのままで、以下のように srcディレクトリを追加

frontend-sample/
 ├ backend/
 └ ui/
   └ src/

必要なパッケージのインストール

以下のpackage.jsonを参考に必要なパッケージをinstallする。
https://github.com/yas-tyoukan/frontend-basic-sample/blob/master/ui/package.json

上記package.jsonファイルをui/package.jsonに配置して、

cd ui/
npm i

すればOK

eslintやテストパッケージ(今回はないが)は、開発環境でだけ必要なのでdevDependenciesに追加する。
ビルドや本番環境で必要なパッケージはdependenciesに追加する。

babelの設定

フロントの実装では新しい構文でJavaScriptを書いたりするが、それをブラウザでも実行できるようにするために変換が必要。
babelはその変換をするためのツール。
構文変換のことを、トランスパイルという。 口語で"バベる"と言うこともある。

どんな風に変換されるかはここで試せる
Babel · try it out
スクリーンショット 2019-01-05 18.43.38.png

ここではそのbabelの設定をbabel.config.jsに記述する
参考: https://babeljs.io/docs/en/configuration

// ui/babel.config.js
module.exports = function (api) {
  const presets = [
    [
      '@babel/preset-env',
      {
        targets: {
          ie: 11,
        },
        useBuiltIns: 'entry',
      },
    ],
    [
      '@babel/preset-react',
      {
        development: !api.env('production'),
      },
    ],

  ];

  const plugins = [
    '@babel/plugin-proposal-function-bind',
    '@babel/plugin-proposal-export-default-from',
    '@babel/plugin-proposal-export-namespace-from',
    '@babel/plugin-proposal-class-properties',
  ];

  return {
    presets,
    plugins,
  };
};

babel.config.jsというファイル名にしたが、.babelrcでもbabel.config.jsでも.baberc.jsでもいい。

参考:
Configure Babel
@babel/preset-env
@babel/preset-react

以下のような設定を書いている

Polyfill とは、 String.padStartArray.prototype.forEachなど、古いブラウザにはない実装を補完してくれるもの。口語で "ポリフィる" ともいう。

ここではIE11向けにトランスパイルするように設定しているので、IE11にないものだけを補完してくれるようになる。(つまり、IE10以下は切っている)

IE11で動けば他のモダンブラウザでも動くので、chromeやfirefox向けの設定は書いていない。

useBuiltInsはポリフィる時の方法の設定。usage にすると、実際に使っている必要なものだけにしてくれる。usageなら未使用の機能についてはポリフィられないのでビルド後のファイルサイズもわずかに小さくなる。なのでusageが一番良さそうではあるものの、私が使っていて補完されないケースがあった(補間の必要なメソッド呼び出しをしている変数の型が推定できなくて補完が必要なのかbabelが判断できないケースがあるんじゃないかと推測している)。そのため全部補完するように"entry"にした。

eslintの設定

フロントの実装をするにあたって、linterとしてeslintを使う。その設定を書く。
airbnbの設定を使用する。

公式: ESLint
eslintでエラーが出て内容がわからない時は、公式で検索するのが速くて正確

ここではついでにUIサーバー側のjsのコードにもeslintの設定を適用できるように書く(UIサーバー側のコードをlintする仕組みはここでは作らないが、eslintrc.jsを作っておけばIDEによってはライブでlintが掛かる)

UIサーバー側のコードに適用するものと、フロント側に適用するもので分けたいので、ui/.eslintrc.jsui/src/.eslintrc.jsを作成する。

// ui/.eslintrc.js
module.exports = {
  extends: 'airbnb',
};
// ui/src/.eslintrc.js
module.exports = {
  env: {
    browser: true,
  },
  parser: 'babel-eslint',
  settings: {
    'import/resolver': {
      webpack: {
        config: 'webpack.config.babel.js',
      }
    },
    react: {
      pragma: 'React',
      version: '16.6'
    },
  },
};

eslintの設定ファイルは、プロジェクトルートまでの親ディレクトリをたどるので、ui/.eslintrc.jsairbnbの設定ファイルを使用することを書いておけば、ui/src/.eslintrc.js側には書かなくてもaibnbの設定ファイルが使用される。

ui/src/.eslintrc.jsでは、ブラウザで使用するコード向けであること、babelを使うこと、importする時の解決方法はwebpack.config.babel.js(後述)の設定を使うこと、reactのバージョンは16.6であること、を設定している

Webpackの設定

Webpackのビルド設定を、webpack.config.babel.jsに記述する。
Webpackがファイルをビルドする際に、どのファイルをどうやってどこに出力するかの設定を書く。

// ui/webpack.config.babel.js
import path from 'path';
import webpack from 'webpack';

export default (env, args) => {
  const isProduction = args && args.mode === 'production';
  const devtool = !isProduction && 'inline-source-map';
  const sourceMap = !isProduction;
  const rules = [{
    test: /\.jsx?$/,
    exclude: /node_modules/,
    use: 'babel-loader',
  }, {
    test: /\.less$/,
    use: [
      'style-loader',
      {
        loader: 'css-loader',
        options: { sourceMap },
      }, {
        loader: 'less-loader',
        options: {
          sourceMap,
          paths: [
            path.resolve(path.join(__dirname, './src/styles')),
            path.resolve(path.join(__dirname, './src/components')),
          ],
        },
      },
    ],
  }, {
    test: /\.(eot|woff|woff2|ttf|svg|png|jpe?g|gif)(\?\S*)?$/,
    use: [{ loader: 'url-loader', options: { limit: 100000 } }],
  }];

  if (!isProduction) {
    rules.unshift({
      enforce: 'pre',
      test: /\.jsx?$/,
      exclude: /node_modules/,
      loader: 'eslint-loader',
      options: { configFile: path.join(__dirname, './src/.eslintrc.js') },
    });
  }

  return {
    devtool,
    entry: {
      'public/js/login': ['./src/entries/login.jsx'],
      'public_authenticated/js/app': ['./src/entries/app.jsx'],
    },
    output: {
      path: path.join(__dirname, './'),
      filename: '[name].js',
    },
    module: { rules },
    watchOptions: {
      ignored: /node_modules/,
    },
    resolve: {
      modules: ['node_modules'],
      alias: {
        '~': path.join(__dirname, './src'),
      },
      extensions: ['.js', '.jsx', '.css', '.less'],
    },
  };
};

src/entries/login.js, src/entries/app.js をそれぞれ public/js/login.js, public_authenticated/js/app.js に出力する設定を書いている。

Webpackの設定は関数で記述することができ、実行時の引数を第2引数argsから取得できる。
mode引数の値によって、ソースマップを出力するかを決めたり、環境変数を変えたりしている。

また、 ~./src/を表すエイリアスとして登録している。これによって、階層の深いjsファイルのimport文で../../../のように書かずに、~/components/...のように書けるようにしている。

詳しい内容は、以下参考
基本設定について https://webpack.js.org/concepts/
各loaderについて https://webpack.js.org/loaders/

開発で使うscriptを登録

これから、実装を書いていくが、ビルドやウォッチを npm run ... で実行できるように、scriptを登録しておく。

package.json に以下追記する

// package.json (一部) 
  "scripts": {
    /* ... */
    "build": "webpack --mode production",
    "build-dev": "webpack --mode development",
    "watch": "webpack --mode development --watch --progress --colors",
    "server": "node --inspect .",
    "start": "npm run build && node ."
  }

フロントエンドのコード実装時には、 npm run watchを走らせておき、エラー個所がすぐにリアルタイムでわかるようにしておく

npm run watch

storybookの設定

storybookはコンポーネントのカタログを作るものであり、開発時のコンポーネント実装のコンポーネント単位で確認するために使う。後から導入してもいいが、コンポーネントの実装をする上で、各コンポーネントの動作確認を簡単にするために最初からあった方が良い。

参考: https://storybook.js.org/

動作イメージは以下のような感じ。
スクリーンショット 2019-01-05 14.52.26.png

インストール

addonも合わせてインストールする(すでにpackage.jsonを使ってinstallした場合は入っているので不要)

npm i -S @storybook/addon-actions @storybook/addon-notes @storybook/addon-viewport @storybook/cli @storybook/react

storybookの設定

ui/.storybookディレクトリを作成し、以下のファイルを作成する

// ui/.storybook/addons.js
import '@storybook/addon-notes/register';
import '@storybook/addon-actions/register';
import '@storybook/addon-viewport/register';
// ui/.storybook/config.jsx
import React from 'react';
import {
  addDecorator,
  configure,
  storiesOf,
} from '@storybook/react';
import Wrapper from '~/components/routings/Wrapper';

const WrapperDecorator = storyFn => <Wrapper>{storyFn()}</Wrapper>;

// automatically import all files ending in *.stories.js or *.stories.jsx
const context = require.context('../src/components', true, /\.stories.jsx?$/);

function getDirs(path) {
  return path.replace(/..?\//, '').split('/').reverse().slice(1).reverse();
}

function loadStories() {
  addDecorator(WrapperDecorator);
  context.keys().sort().forEach((c) => {
    const dirs = getDirs(c);

    if (!dirs.length) return;

    const stories = storiesOf(dirs.join('/'), module);
    context(c).default(stories);
  });
}

configure(loadStories, module);

細かい説明は省くが、material-uireduxreact-routerを使ってコンポーネントを作成するため、コンポーネントを表示するときに、コンポーネントによっては何かにラップされていないと表示できない・表示が崩れるものがある。(Linkタグなどはreact-routerConnectedRouterにラップされていないとエラーになる)

そのため、addDecoratorを使って表示するコンポーネントをラップする要素Wrapperを指定している。Wrapperの実装はstorybookのためだけではなく、実装でも使用するため後述する。

また、コンポーネント(ui/src/components)以下の*.stories.jsxを読み込んで、storybookに載せる設定をしている。

// ui/.storybook/webpack.config.js (一部)
require('@babel/register');
const config = require('../webpack.config.babel.js');

module.exports = config.default(process.env, { mode: process.env.NODE_ENV });

ui/.storybook/static にstorybookで静的にホストしたいファイルなどがあれば置いておく(画像など)
ここでは画像を使うコンポーネントを作らないので、適当にfaviconでも置いておく

スクリーンショット 2019-01-05 19.06.19.png

scriptの登録

storybookをnpm runを使って立ち上げられるようにpackage.jsonにscriptを登録しておく

// ui/package.json (一部)
    "storybook": "start-storybook -s ./.storybook/static -p 6006",
    "build-storybook": "build-storybook -s ./.storybook/static"

build-storybookindex.html含めた静的ファイルを出力する。storybookはサーバーが立ち上がる。

npm run storybookでサーバーを立ち上がると、ブラウザにstorybookのページが開く。

ログイン画面の作成

各コンポーネントはatmic designに則って作成する。データバインドや状態管理はredux, react-reduxを使用する

参考 コンポーネント指向でreact-reduxで画面を作るための考え方

ディレクトリ構成はこんな感じ
スクリーンショット 2019-01-05 15.18.39.png

よく紹介されているreact-reduxの構成を少し自分でカスタマイズしたものである。
参考: A Better File Structure For React/Redux Applications
上の日本語紹介記事: React+Reduxのディレクトリ構成検討

以下の用途を意図している

  • ui/src以下にwebpackでビルドする対象のコードを配置
  • ui/src/components以下にコンポーネントを配置
  • ui/src/components/routings以下に、ルーティングを担うコンポーネント実装の配置
  • ui/src/form以下にフォーム(redux-form)で使うバリデーション実装やsubmit処理実装のファイル配置
  • ui/src/entries以下に、ビルドの起点となるファイルを配置

各ファイルの実装

以下、必要なパッケージのinstallコマンドの記載は省く。先に紹介したpackage.jsonを使ってinstallした場合は入っているので都度のインストールは不要。必要なものだけ入れたい場合はコード中のimport文を見て足りないパッケージをinstallするなどで対応すること。

// ui/src/entries/login.jsx
import '@babel/polyfill';
import React from 'react';
import ReactDOM from 'react-dom';

import Wrapper from '~/components/routings/Wrapper';
import * as rootReducer from '~/reducers/login';
import Login from '~/components/routings/Login';

ReactDOM.render(
  <Wrapper rootReducer={rootReducer}>
    <Login />
  </Wrapper>,
  document.getElementById('root'),
);

ログインページのreducerの記述。ログイン後と分けるために、ui/src/reducers/loginディレクトリを作成して、その中に実装する

// ui/src/reducers/login/index.js
// ログイン画面で使用するreducer
export login from './login';
// ui/src/reducers/login/login.js
import { actionTypes } from 'redux-form';

const initialState = {
  error: null,
  succeed: false,
};

export default function login(state = initialState, action) {
  switch (action.type) {
    case actionTypes.SET_SUBMIT_SUCCEEDED:
      return { ...state, succeed: true };
    case actionTypes.SET_SUBMIT_FAILED:
      return { ...state, error: action.error };
    default:
      return state;
  }
}

ログイン前のページのルーティングが入ったコンポーネントをレンダリングするのがui/src/entries/login.jsxの役割である。ここで使っているWrapperは、ログイン後も共通のものとなる、ルーティング周りの実装を書いている。

// ui/components/routings/Wrapper/index.jsx
import React from 'react';
import PropTypes from 'prop-types';
import { Provider } from 'react-redux';
import { createBrowserHistory } from 'history';
import { ConnectedRouter } from 'connected-react-router';
import { MuiThemeProvider } from '@material-ui/core';

import '~/styles/main.less';
import theme from '~/styles/theme';
import init from '~/utils/init';
import configureStore from '~/configureStore';

const Wrapper = ({ rootReducer, children }) => {
  const history = createBrowserHistory();
  const store = configureStore({}, rootReducer, history);
  init(store.dispatch);
  return (
    <MuiThemeProvider theme={theme}>
      <Provider store={store}>
        <ConnectedRouter history={history}>
          {children}
        </ConnectedRouter>
      </Provider>
    </MuiThemeProvider>
  );
};

Wrapper.propTypes = {
  rootReducer: PropTypes.shape({}),
  children: PropTypes.node,
};

Wrapper.defaultProps = {
  rootReducer: {},
  children: null,
};

export default Wrapper;

import '~/styles/main.less'; としているのは、ここでコンポーネントによらない共通のスタイル定義を読み込んでいる。 html,body{width:100%;height:100%;}のような共通のスタイルや、変数宣言などが含まれる。この後作成する、ログイン後の画面でも共通としている。

import theme from '~/styles/theme'; で読み込んでいるテーマファイルは、MUIThemeProviderに渡しているテーマを設定している。material-uiの部品やスタイルを使うため、MUIThemeProviderでコンポーネント全体をラップしている。

参考: Material-UI https://material-ui.com/
参考: MUIThemeProvider API https://material-ui.com/api/mui-theme-provider/

ui/src/styles以下の実装は、ここでは省略とする。リポジトリを参照のこと。
https://github.com/yas-tyoukan/frontend-basic-sample/tree/master/ui/src/styles

次に初期設定の処理の記述

// ui/src/utils/init.js
import axios from 'axios';

import { ajaxError } from '~/actions/common';

/**
 * 初期設定
 * @param {Function} dispatch dispatch関数
 */
export default (dispatch) => {
  /* ---------- axiosの設定 ---------- */
  axios.defaults.responseType = 'json';
  axios.interceptors.request.use((config) => {
    if (!(config.data && config.data.headers && config.data.headers['csrf-token'])) {
      // すでに設定されている場合をのぞいて、csrf-tokenをmetaタグから取得して設定する
      // eslint-disable-next-line no-param-reassign
      config.headers['csrf-token'] = document.querySelector('meta[name=csrf-token]').getAttribute('content');
    }
    return config;
  });
  // 共通のエラーハンドラ定義
  axios.interceptors.response.use(
    res => res,
    (error) => {
      dispatch(ajaxError(error));
      return Promise.reject(error);
    },
  );
};
// ui/src/actions/common/index.js
import {
  CLEAR_ERROR,
  ERR_AJAX,
} from '../actionTypes/common';

export const ajaxError = error => ({
  type: ERR_AJAX,
  error,
});

export const clearError = () => ({
  type: CLEAR_ERROR,
});
// ui/src/actionTypes/common/index.js
export const ERR_AJAX = 'ERR_AJAX';
export const CLEAR_ERROR = 'CLEAR_ERROR';

ui/src/utils/init.jsでは初期設定をしている。ここではaxiosを使った時の初期設定と、失敗時にactionを実行する設定を行なっている。

次にreduxのconfigureStoreの設定

// ui/src/configureStore.js
import {
  applyMiddleware,
  combineReducers,
  createStore,
} from 'redux';
import { reducer as formReducer } from 'redux-form';
import { routerMiddleware } from 'react-router-redux';
import { connectRouter } from 'connected-react-router';
import thunkMiddleware from 'redux-thunk';

const middleware = [thunkMiddleware];

if (process.env.NODE_ENV !== 'production') {
  // eslint-disable-next-line global-require, import/no-extraneous-dependencies
  const reduxLogger = require('redux-logger');
  middleware.push(reduxLogger.createLogger());
}

export default function configureStore(initialState, reducers, history) {
  middleware.push(routerMiddleware(history));

  if (history) {
    middleware.push(routerMiddleware(history));
  }
  return createStore(
    combineReducers({ ...reducers, form: formReducer, router: connectRouter(history) }),
    initialState,
    applyMiddleware(...middleware),
  );
}

参考: Configuring Your Store

redux-loggerは開発時だけ使いたくて、プロダクションビルド時は含めないように実装している。

if (process.env.NODE_ENV !== 'production') {としているのは、知らないと違和感を感じるかもしれない。なぜならこのコードはフロントエンドのコードで、ビルドするといろんなブラウザが実行するものなのに、process.env.NODE_ENVはビルド時の環境変数を参照しているような実装だからである。

これは、webpackでビルドする時にwebpackが設定してくれる値を参照している。webpackを使ってビルドするときに、指定したmodeによって、process.env.NODE_ENVの値が変わる。

参考: https://webpack.js.org/concepts/mode/#mode-development

これによって、webpackをmode=productionで実行すると、process.env.NODE_ENV"production"になり、該当の部分は、if('production'!=='production') { となり、minifyされるので、if(false){}と解釈され、if文内の処理はビルドするコードに含まれない、結果、productionの場合はredux-loggerは適用もされず、requireもされない、という仕組みである。

次に、ログインページのルーティングの設定

// ui/src/components/routings/Login
import React from 'react';
import {
  Redirect,
  Route,
  Switch,
} from 'react-router';
import { Link } from 'react-router-dom';
import Login from '~/components/pages/Login';

export default () => (
  <Switch>
    <Redirect exact from="/" to="/login" />
    <Route exact path="/login">
      <Login />
    </Route>
    <Route exact path="/login/password-reminder">
      <>
        <div>id/pass = user1/p</div>
        <Link to={{ pathname: 'login' }}>ログインに戻る</Link>
      </>
    </Route>
    <Redirect from="*" to="/login" />
  </Switch>
);

/login/login/password-reminderのルーティングを設定している。ルーティングの動作確認のためにパスワードリマインダーを作っているだけなので、実装は簡易的なものにしている。

ここからコンポーネントの実装。

// ui/src/components/Login/index.jsx
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';

import submitLogin from '~/form/submit/login';
import CenteringTemplate from '~/components/templates/CenteringTemplate';
import LoginForm from '~/components/organisms/LoginForm';

const onSubmitSuccessDefault = () => {
  window.location.href = '/';
};

export default class Login extends React.PureComponent {
  static propTypes = {
    onSubmit: PropTypes.func,
    onSubmitSuccess: PropTypes.func,
  };

  static defaultProps = {
    onSubmit: submitLogin,
    onSubmitSuccess: onSubmitSuccessDefault,
  };

  constructor() {
    super();
    this.onSubmit = ::this.onSubmit;
    this.onSubmitSuccess = ::this.onSubmitSuccess;
  }

  onSubmit(values) {
    const { onSubmit } = this.props;
    if (onSubmit) {
      return onSubmit(values);
    }
    return null;
  }

  onSubmitSuccess() {
    const { onSubmitSuccess } = this.props;
    if (onSubmitSuccess) {
      return onSubmitSuccess();
    }
    return null;
  }

  render() {
    const contentsEl = (
      <>
        <h1>ログイン</h1>
        <LoginForm
          onSubmit={this.onSubmit}
          onSubmitSuccess={this.onSubmitSuccess}
        />
        <Link to={{ pathname: 'login/password-reminder' }}>パスワードを忘れた方はこちら</Link>
      </>
    );
    return (
      <CenteringTemplate
        vertical
        horizontal
        className="p_login"
        contents={contentsEl}
      />
    );
  }
}

storybookに載せるstoryも作成する

// ui/src/componens/pages/Login/index.stories.jsx
import React from 'react';
import { action } from '@storybook/addon-actions';
import Login from '.';

export default stories => stories
  .add('default', () => (
    <Login
      onSubmit={action('onSubmit')}
      onSubmitSuccess={action('onSubmitSuccess')}
    />
  ));

submit関数

// ui/src/form/submit/login.js
import axios from 'axios';
import { SubmissionError } from 'redux-form';

export default function onSubmit(values) {
  // CSRF対策トークンを取得してからログインのPOSTを行う
  return axios.get('/csrf-token')
    .then((res) => {
      document.querySelector('meta[name=csrf-token]').setAttribute('content', res.data.token);
    })
    .then(() => axios.post('/api/login', values))
    .catch(() => {
      throw new SubmissionError({ _error: 'authenticated failure' });
    });
}

テンプレートコンポーネントの作成。中央に1つのコンポーネントを配置するテンプレートとして作成している。propsで左右中央か、上下中央か(その両方か)を指定できるように作っている

// ui/src/components/templates/CenteringTemplate/index.jsx
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import './style.less';

const CenteringTemplate = ({
  className,
  contents,
  horizontal,
  vertical,
}) => (
  <div className={classNames('t_centering-template', className, { horizontal, vertical })}>
    <div className="contents">{contents}</div>
  </div>
);

CenteringTemplate.propTypes = {
  className: PropTypes.string,
  contents: PropTypes.node,
  horizontal: PropTypes.bool,
  vertical: PropTypes.bool,
};

CenteringTemplate.defaultProps = {
  className: '',
  contents: '',
  horizontal: false,
  vertical: false,
};

export default CenteringTemplate;

中央寄せのためのスタイル定義

// ui/src/components/templates/CenteringTemplate/style.less
.t_centering-template {
  height: 100%;
  display: flex;
  flex-direction: column;

  &.vertical {
    justify-content: center;
  }

  &.horizontal {
    align-items: center;
  }
}

story

import React from 'react';
import CenteringTemplate from '.';

const contentsSampleEl = (
  <>
    <h1>問題</h1>
    <p>『エビフライ』にあって、『カキフライ』にないもの、なぁ〜んだ?</p>
    <p>答え:エビ</p>
  </>
);

export default stories => stories
  .add('horizontal', () => <CenteringTemplate horizontal contents={contentsSampleEl} />)
  .add('vertical', () => <CenteringTemplate vertical contents={contentsSampleEl} />)
  .add('horizontal and vertical', () => <CenteringTemplate horizontal vertical contents={contentsSampleEl} />);

LoginForm

// ui/src/components/organisms/LoginForm/index.jsx
import React from 'react';
import PropTypes from 'prop-types';
import {
  Field,
  propTypes as reduxFormPropTypes,
  reduxForm,
} from 'redux-form';
import './style.less';
import validate from '~/form/validates/login';
import TextField from '~/components/atoms/TextField';
import Button from '~/components/atoms/Button';

export const Form = ({
  onSubmit,
  handleSubmit,
  submitFailed,
  submitting,
  error,
}) => (
  <form
    onSubmit={handleSubmit(onSubmit)}
    className="o_login-form"
  >
    {(submitFailed && error)
      ? (
        <div className="error-box">
          <div className="messageWrapper">
            <p className="message">
              ログインIDまたはパスワードが違います。
            </p>
            <p className="message">
              入力内容に誤りがないか ご確認下さい。
            </p>
          </div>
        </div>
      )
      : ''
    }
    <Field
      name="id"
      type="text"
      component={TextField}
      placeholder="ログインID"
      fullWidth
    />
    <Field
      name="password"
      type="password"
      component={TextField}
      placeholder="パスワード"
      fullWidth
    />
    <Button type="submit" size="large" disabled={submitting} fullWidth>
      ログイン
    </Button>
  </form>
);

Form.propTypes = {
  ...reduxFormPropTypes,
  onSubmit: PropTypes.func.isRequired,
};

export default reduxForm({
  form: 'login',
  validate,
})(Form);

storyの作成

// ui/src/components/organisms/LoginForm/index.stories.jsx
import React from 'react';
import { createDecoratedForm } from '~/components/story_utils';
import { Form } from '.';

export default stories => stories
  .add('default', () => {
    const LoginForm = createDecoratedForm('login', Form);
    return <LoginForm />;
  })
  .add('ログイン失敗', () => {
    const LoginForm = createDecoratedForm('login', Form, { submitFailed: true, error: { _error: 'error' } });
    return <LoginForm />;
  });

フォーム内で使っているFieldコンポーネントは、reduxと繋がった部品が親にないとエラーになるため、フォームのコンポーネントをよしなにして、Fieldを含むフォームを表示できるようにするutilsを作る。storybookでコンポーネントを表示したいだけなのに、redux-formを使っているためにreduxのstateのことも考えなくてはいけないのは嫌な感じがするので、別案があれば改善したいところであるが、一旦utilsを作って対応している。

// ui/src/components/story_utils.jsx
import React from 'react';
import {
  combineReducers,
  createStore,
} from 'redux';
import { Provider } from 'react-redux';
import {
  reducer,
  reduxForm,
} from 'redux-form';
import { action } from '@storybook/addon-actions';

export const actions = {
  onSubmit(e) {
    e.preventDefault();
    action('onSubmit')(e);
  },
  handleSubmit(arg) {
    if (typeof arg.preventDefault === 'function') {
      arg.preventDefault();
    }
    return arg;
  },
};

export function createDecoratedForm(form, FormComponent, formState) {
  const Decorated = reduxForm({ form })(FormComponent);
  const mockStore = createStore(
    combineReducers({ form: reducer }),
    { form: { [form]: formState } },
  );
  return props => (
    <Provider store={mockStore}>
      <Decorated {...actions} {...props} />
    </Provider>
  );
}

export const TestForm = createDecoratedForm('sample', 'form');

validationの実装

// ui/src/form/validates/login.js
export default (values) => {
  const errors = {};
  if (!values.id) {
    errors.id = '必須項目です';
  }
  if (!values.password) {
    errors.password = '必須項目です';
  }
  return errors;
};

styleの実装

// ui/src/components/organisms/LoginForm/style.less
.o_login-form {
  > .a_text-field,
  > .a_button {
    display: inline-block;
    margin-top: 2em;
  }

  > .a_button {
    height: 5em;
  }

  > .error-box {
    padding-top: 1em;
  }
}

TextFieldの実装(Material-UIのラップ)

// ui/src/components/atoms/TextField/index.jsx
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import MUITextField from '@material-ui/core/TextField';

export const TextFieldContainer = ({
  error,
  meta = {},
  helperText,
  presenter,
  input: inputProps,
  className,
  ...remainProps
}) => {
  const hasError = !!(error || (meta && meta.error && meta.touched));
  const helperTextOrErrorText = hasError && (meta.error || helperText);
  return presenter({
    ...remainProps,
    inputProps,
    className: classNames('a_text-field', className),
    error: hasError,
    helperText: helperTextOrErrorText,
  });
};

export const TextFieldPresenter = ({ ...props }) => <MUITextField {...props} />;

/**
 * https://material-ui.com/api/text-field/
 * @param props
 * @returns {*}
 * @constructor
 */
const TextField = ({ ...props }) => (
  <TextFieldContainer {...props} presenter={TextFieldPresenter} />
);

TextField.propTypes = {
  variant: PropTypes.oneOf(['standard', 'outlined', 'filled']),
};

TextField.defaultProps = {
  variant: 'standard',
};

export default TextField;

TextFieldredux-formFieldcomponentプロパティの値として使用するため、redux-formmetaなどの固有のプロパティを渡す。それをよしなに変換するロジックを実装している。ロジック部分を、TextFieldContainer、表示部分をTextFieldPresenterにして、それらを合わせたものをTextFieldとして作成している。

こうすることで、例えばredux-formを使うのをやめたり、redux-formの仕様が変わった場合でも、表示するコンポーネントは変えずにロジック部分=Containerコンポーネントの変更に留まるというメリットがある。ここでは作らないが、例えばチェックボックスを考えると見た目は2種類しかないのに、状態管理などは複雑になるため、見た目だけのPresenterコンポーネントと、ロジックを実装するContainerコンポーネントに分けるメリットが大きい。ContainerコンポーネントはReact関係ない純粋なjsなので、テストも書きやすい。

story

// ui/src/components/atoms/TextField/index.stories.jsx
import React from 'react';
import { action } from '@storybook/addon-actions';
import TextField from '.';

const onChange = action('onChange');

export default stories => stories
  .add('default', () => (
    <TextField
      placeholder="ログインID"
      label="ログインID"
      input={{ onChange }}
      meta={{}}
    />
  ))
  .add('error', () => (
    <TextField
      placeholder="ログインID"
      label="ログインID"
      input={{ onChange }}
      error
      helperText="入力に間違いがあります"
    />
  ))
  .add('meta.error (redux-form想定)', () => (
    <TextField
      placeholder="ログインID"
      label="ログインID"
      input={{ onChange }}
      error
      meta={{ error: '入力に間違いがあります' }}
    />
  ));

次にButtonコンポーネント

// ui/src/components/atoms/Button/index.jsx
import React from 'react';
import classNames from 'classnames';
import MUIButton from '@material-ui/core/Button';

export default ({ className, ...props }) => (
  <MUIButton className={classNames('a_button', className)} {...props} />
);

story

// ui/src/components/atoms/Button/index.stories.jsx
import React from 'react';
import { action } from '@storybook/addon-actions';
import Button from '.';

export default (stories) => {
  const props = {
    onClick: action('onClick'),
  };
  const colors = [
    'default',
    'inherit',
    'primary',
    'secondary',
  ];

  const sizes = [
    'small',
    'medium',
    'large',
  ];

  const variants = [
    'text',
    'outlined',
    'contained',
  ];

  colors.forEach(color => stories
    .add(`color: ${color}`, () => (<Button color={color} {...props}>ボタン</Button>)));

  sizes.forEach(size => stories
    .add(`size: ${size}`, () => (<Button size={size} color="primary" {...props}>ボタン</Button>)));

  variants.forEach(variant => stories
    .add(`variant: ${variant}`, () => (<Button variant={variant} color="primary" {...props}>ボタン</Button>)));

  stories
    .add('multi-line', () => (
      <Button
        color="primary"
        {...props}
      >
        <p>
          利用規約に同意して
          <br />
          登録する
        </p>
      </Button>
    ));

  stories
    .add('fullWidth', () => (<Button color="primary" fullWidth>ボタン</Button>));

  stories
    .add('disabled', () => (<Button color="primary" disabled>ボタン</Button>));

  return stories;
};

ここまでできたらログイン画面の完成である。すなわち、ui/public/js/login.jsへビルドするファイルが揃い、localhost:3000へアクセスすると第1章で作成したexpressサーバーが、ビルド後のファイルui/public/js/login.jsをホストし、ログイン画面が表示される。

動作確認

動作確認のためにログイン後の画面も一旦ダミーで作成しておこう

// ui/src/components/entries/app.jsx
import '@babel/polyfill';
import React from 'react';
import ReactDOM from 'react-dom';

import Wrapper from '~/components/routings/Wrapper';

ReactDOM.render(
  <Wrapper rootReducer={{}}>
    <span>ようこそ</span>
  </Wrapper>,
  document.getElementById('root'),
);

コンポーネントの確認

作ったコンポーネントが正しく表示されるかをstorybookを立ち上げて確認する

cd ui/
npm run storybook

localhost:6006
スクリーンショット 2019-01-05 19.16.35.png

ログイン画面の確認

第1章で作成した、ダミーのバックエンドと、expressサーバー(UIサーバー)を立ち上げる

cd backend/
npm run start
cd ui/
npm run server

フロント側のjsのビルド(watch)

cd ui/
npm run watch

localhost:3000にアクセス

スクリーンショット 2019-01-05 17.57.22.png

パスワードリマインダーページ

スクリーンショット 2019-01-05 17.57.30.png

ログイン成功時のページ(ダミー)

スクリーンショット 2019-01-05 17.56.53.png

書いてある通りにやったのに動かない場合

説明に漏れがあるかもしれません。コメントいただければレスします。

終わりに

この後は、ログイン成功時に表示されるページで、主にreduxredux-thunkを使った、非同期通信、データバインドの実装をやっていく予定。

githubのリポジトリでは実装済みなので、待ちきれない方はそちらを参考にしてください。

github
https://github.com/yas-tyoukan/frontend-basic-sample

15
15
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
15
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?