概要
今時のフロントエンドってどうやって実装すればいいのか、実際に作りながら説明する
ところどころ省略しているものもあるが、業務運用に耐えうる設計を前提に書いたつもりである
以下の構成で紹介する。この投稿は第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が色々よしなにやってくれる。
(よしなにやってくれるよう設定を書く)
公式のおしゃれな図: https://webpack.js.org/
準備
ディレクトリ構成は前回作成したファイルはそのままで、以下のように 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
ここではその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
以下のような設定を書いている
- IE11で動くようにトランスパイルすること
- Polyfill(後述)する方法は、
"useBuiltIns": "entry"
を使うこと - NODE_ENVがproductionでない時は、reactのコードについてdevelopmentモードをオンにする
- プラグインを使って、W3C勧告プロセスで勧告案(proposal)となっているいくつかの記法についてトランスパイルできるようにすること
Polyfill
とは、 String.padStart
やArray.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.js
とui/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.js
にairbnb
の設定ファイルを使用することを書いておけば、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はコンポーネントのカタログを作るものであり、開発時のコンポーネント実装のコンポーネント単位で確認するために使う。後から導入してもいいが、コンポーネントの実装をする上で、各コンポーネントの動作確認を簡単にするために最初からあった方が良い。
インストール
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-ui
やredux
、react-router
を使ってコンポーネントを作成するため、コンポーネントを表示するときに、コンポーネントによっては何かにラップされていないと表示できない・表示が崩れるものがある。(Link
タグなどはreact-router
のConnectedRouter
にラップされていないとエラーになる)
そのため、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でも置いておく
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-storybook
はindex.html
含めた静的ファイルを出力する。storybook
はサーバーが立ち上がる。
npm run storybook
でサーバーを立ち上がると、ブラウザにstorybookのページが開く。
ログイン画面の作成
各コンポーネントはatmic designに則って作成する。データバインドや状態管理はredux
, react-redux
を使用する
参考 コンポーネント指向でreact-reduxで画面を作るための考え方
よく紹介されている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),
);
}
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;
TextField
はredux-form
のField
のcomponent
プロパティの値として使用するため、redux-form
がmeta
などの固有のプロパティを渡す。それをよしなに変換するロジックを実装している。ロジック部分を、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
ログイン画面の確認
第1章で作成した、ダミーのバックエンドと、expressサーバー(UIサーバー)を立ち上げる
cd backend/
npm run start
cd ui/
npm run server
フロント側のjsのビルド(watch)
cd ui/
npm run watch
localhost:3000にアクセス
パスワードリマインダーページ
ログイン成功時のページ(ダミー)
書いてある通りにやったのに動かない場合
説明に漏れがあるかもしれません。コメントいただければレスします。
終わりに
この後は、ログイン成功時に表示されるページで、主にredux
、redux-thunk
を使った、非同期通信、データバインドの実装をやっていく予定。
githubのリポジトリでは実装済みなので、待ちきれない方はそちらを参考にしてください。