react-boilerplateとは
オフィシャルサイトによると
A highly scalable, offline-first foundation with the best DX and a focus on performance and best practices
パフォーマンスとDX(デベロッパーエクスペリエンス)重視のスケーラブル、オフラインファーストな基盤と言ったところでしょうか。
要はプロダクションレベルのreactアプリケーションを工数かけずに立ち上げできるツールですね。
こちらはReact業界ではかの有名なMax Stoiberさんを筆頭に、多数の開発者による、開発者のための、オープンソース開発ツールです。
Max StoiberさんによるスケーラブルなReact開発についての動画が公開されているので、興味ある方はぜひご覧ください。
特徴
- scaffoldで素早くテンプレを生成できる
- redux/redux-sagaによりステートの管理が楽になる
- オフラインファーストでネット環境が悪くても使用できる
- リンティングとユニットテストがデフォルトでついてくるので初期のセットアップ
が楽
といったところでしょうか。ただし個人的にはルールでガチガチになって後々拡張性に困ってしまうのが心配な方には向いていないと思います。
始める前に
こちらはReactをある程度経験している方向けのツールなので、まずは下記の知識について学んでから使いましょう。React初心者はこちらを参考すると良いでしょう。
コア
ユニットテスト
リント
初めてみる
依存
まずは以下がセットアップされていることを確認しましょう。
- Node.js v8.15.1以上
- npm v5以上
されていない方はこちらを参照してください。
初期設定
git clone --depth=1 https://github.com/react-boilerplate/react-boilerplate.git <プロジェクト名>
cd <プロジェクト名>
npm run setup
npm start
これでhttp://localhost:3000からサンプルアプリを見れます。
ジェネレーター
常用する機能を自動で生成する機能です。こちらを使えば大規模な開発でもフォーマットがある程度保たれます。
生成できる機能はコンテナ、コンポーネントと言語です。
Max Stoiberさんはコンテナ&コンポーネントのアーキテクチャを推していて、コードをタイプ(アクション、スタイルなど)で分けるのではなく、フィーチャーごとに分けるべきという考えです。
大きいカテゴリに分けると
コンポーネント:
- データとの連携はしない見た目重視のパーツ
- DOMマークアップとスタイルを持つ
- 再利用ができる
- 他の部分への依存がない
- Propsでのみデータとインタラクトする
- 例:ページ、ナビゲーションバー、リスト、ボタン
コンテナ:
- 機能(どう動くか)重視のパーツ
- DOMマークアップとスタイルを持たない(divなどは除く)
- コンポーネントのデータの動作の設定
react-boilerplateはi18nが最初からサポートしているので新しい言語の生成、及びメッセージの設定がとても簡単にできます。
新しいコンテナを生成してみる
例えばイベントを宣伝するページを作成するとしましょう。イベントページではおすすめのイベントと全てのイベントのデータを別々にゲットして、ページにレンダーする流れです。
npm run generate container
? What should it be called? EventPage
? Do you want to wrap your component in React.memo? Yes
? Do you want headers? Yes
? Do you want an actions/constants/selectors/reducer tuple for this container? Yes
? Do you want sagas for asynchronous flows? (e.g. fetching data) Yes
? Do you want i18n messages (i.e. will this component use text)? Yes
? Do you want to load resources asynchronously? Yes
これでapp/containers/EventPage/
が作成されて、必要なファイルを自動的に繋げてくれます。
Reduxを設定
コンスタンツの定義
まずはReduxのアクションに必要なコンスタンツを設定します。
// おすすめイベントのアクション
export const LOAD_FEATURED_EVENTS = 'app/EventPage/LOAD_FEATURED_EVENTS';
export const LOAD_FEATURED_EVENTS_SUCCESS = 'app/EventPage/LOAD_FEATURED_EVENTS_SUCCESS';
export const LOAD_FEATURED_EVENTS_FAILURE = 'app/EventPage/LOAD_FEATURED_EVENTS_FAILURE';
// 全てのイベントのアクション
export const LOAD_EVENTS = 'app/EventPage/LOAD_FEATURED_EVENTS';
export const LOAD_EVENTS_SUCCESS = 'app/EventPage/LOAD_FEATURED_EVENTS_SUCCESS';
export const LOAD_EVENTS_FAILURE = 'app/EventPage/LOAD_FEATURED_EVENTS_FAILURE';
アクションの定義
import {
LOAD_EVENTS,
LOAD_EVENTS_SUCCESS,
LOAD_EVENTS_FAILURE,
LOAD_FEATURED_EVENTS,
LOAD_FEATURED_EVENTS_SUCCESS,
LOAD_FEATURED_EVENTS_FAILURE,
} from './constants';
// おすすめイベントのアクション
export function loadFeaturedEvents(userId, skip, take) {
return {
type: LOAD_FEATURED_EVENTS,
payload: {
userId,
skip,
take,
},
};
}
export function loadFeaturedEventsSuccess(featuredEvents) {
return {
type: LOAD_FEATURED_EVENTS_SUCCESS,
payload: {
featuredEvents,
},
};
}
export function loadFeaturedEventsFailure(errors) {
return {
type: LOAD_FEATURED_EVENTS_FAILURE,
payload: {
errors,
},
};
}
// 全てのイベントのアクション
export function loadEvents(userId, skip, take, keyword) {
return {
type: LOAD_EVENTS,
payload: {
userId,
skip,
take,
keyword,
},
};
}
export function loadEventsSuccess(events) {
return {
type: LOAD_EVENTS_SUCCESS,
payload: {
events,
},
};
}
export function loadEventsFailure(errors) {
return {
type: LOAD_EVENTS_FAILURE,
payload: {
errors,
},
};
}
Reducerの定義
import produce from 'immer';
import {
LOAD_EVENTS,
LOAD_EVENTS_SUCCESS,
LOAD_EVENTS_FAILURE,
LOAD_FEATURED_EVENTS,
LOAD_FEATURED_EVENTS_SUCCESS,
LOAD_FEATURED_EVENTS_FAILURE,
} from './constants';
export const initialState = {
// イベントのステート
events: [],
eventsLoading: false,
eventsErrors: false,
// おすすめイベントのステート
featuredEvents: [],
featuredEventsLoading: false,
featuredEventsErrors: false,
};
/* eslint-disable default-case, no-param-reassign */
const eventPageReducer = (state = initialState, action) =>
produce(state, draft => {
switch (action.type) {
case LOAD_EVENTS:
draft.eventsLoading = true;
draft.eventsErrors = false;
break;
case LOAD_EVENTS_SUCCESS:
draft.eventsLoading = false;
draft.eventsErrors = false;
draft.events = action.payload.events;
break;
case LOAD_EVENTS_FAILURE:
draft.eventsLoading = false;
draft.eventsErrors = action.payload.errors;
break;
case LOAD_FEATURED_EVENTS:
draft.featuredEventsLoading = true;
draft.featuredEventsErrors = false;
break;
case LOAD_FEATURED_EVENTS_SUCCESS:
draft.featuredEventsLoading = false;
draft.featuredEventsErrors = false;
draft.featuredEvents = action.payload.featuredEvents;
break;
case LOAD_FEATURED_EVENTS_FAILURE:
draft.featuredEventsLoading = false;
draft.featuredEventsErrors = action.payload.errors;
break;
}
});
export default eventPageReducer;
Selectorの定義
import { createSelector } from 'reselect';
import { initialState } from './reducer';
/**
* Direct selector to the eventPage state domain
*/
const selectEventPageDomain = state => state.eventPage || initialState;
/**
* Featured events selector used by EventPage
*/
const makeFeaturedEventsSelector = () =>
createSelector(
selectEventPageDomain,
substate => substate.get('featuredEvents'),
);
/**
* Events selector used by EventPage
*/
const makeEventsSelector = () =>
createSelector(
selectEventPageDomain,
substate => substate.get('events'),
);
export {
selectEventPageDomain,
makeFeaturedEventsSelector,
makeEventsSelector,
};
sagaを設定
データをasyncで取得する一番肝の部分になります。今回api連携は主ではないので、モックデータを作ってそこから引っ張りたいと思います。
mkdir app/containers/EventPage/mocks
touch app/containers/EventPage/mocks/events.js
touch app/containers/EventPage/mocks/featuredEvents.js
// イベントのデータ
const events = [
{
id: '1',
title: 'event 1',
},
{
id: '2',
title: 'event 2',
},
{
id: '3',
title: 'event 3',
},
];
export default events;
// おすすめイベントのデータ
const featuredEvents = [
{
id: '1',
title: 'featured event 1',
},
{
id: '2',
title: 'featured event 2',
},
];
export default featuredEvents;
それらをsagaにインポートしますが、本番ではfetchの関数内でデータを引っ張ります。
import { all, put, takeLatest } from 'redux-saga/effects';
import { LOAD_EVENTS, LOAD_FEATURED_EVENTS } from './constants';
import {
loadEventsFailure,
loadEventsSuccess,
loadFeaturedEventsFailure,
loadFeaturedEventsSuccess,
} from './actions';
// モックデータを取得
import events from './mocks/events';
import featuredEvents from './mocks/featuredEvents';
function* fetchEvents(action) {
// ここでイベントのapiからデータを取得
try {
yield put(loadEventsSuccess(events));
} catch (e) {
yield put(loadEventsFailure(e));
}
}
function* loadEvents() {
// takeLatestで一番最新のアクションのみに反応する
yield takeLatest(LOAD_EVENTS, fetchEvents);
}
function* fetchFeaturedEvents(action) {
// ここでおすすめイベントのapiからデータを取得
try {
yield put(loadFeaturedEventsSuccess(featuredEvents));
} catch (e) {
yield put(loadFeaturedEventsFailure(e));
}
}
function* loadFeaturedEvents() {
yield takeLatest(LOAD_FEATURED_EVENTS, fetchFeaturedEvents);
}
export default function* eventPageSaga() {
// 多数のアクションをまとめる
yield all([loadEvents(), loadFeaturedEvents()]);
}
index.jsを編集
...
// セレクターとアクションをインポート
- import makeSelectEventPage from './selectors';
+ import { makeEventsSelector, makeFeaturedEventsSelector } from './selectors';
+ import { loadEvents, loadFeaturedEvents } from './actions';
...
// データとディスパッチをプロップとして関数に入れる
export function EventPage({
+ events,
+ featuredEvents,
+ onLoadEvents,
+ onLoadFeaturedEvents,
}) {
// 最初のレンダリングと同時にデータをロードする
+ useEffect(() => {
+ onLoadEvents('1111', 10, 15, 'keywords');
+ onLoadFeaturedEvents('1111', 10, 15);
+ }, []);
return (
<div>
<Helmet>
<title>EventPage</title>
<meta name="description" content="Description of EventPage" />
</Helmet>
<FormattedMessage {...messages.header} />
{/* データを表示 */}
+ <h4>Featured Events</h4>
+ {featuredEvents.map(e => (
+ <p key={`event-${e.id}`}>{JSON.stringify(e)}</p>
+ ))}
+ <h4>Events</h4>
+ {events.map(e => (
+ <p key={`featuredEvent-${e.id}`}>{JSON.stringify(e)}</p>
+ ))}
</div>
);
}
...
// データとディスパッチのタイプを設定
EventPage.propTypes = {
+ events: PropTypes.array,
+ featuredEvents: PropTypes.array,
+ onLoadEvents: PropTypes.func,
+ onLoadFeaturedEvents: PropTypes.func,
};
// データをプロップに注入
const mapStateToProps = createStructuredSelector({
- eventPage: makeSelectEventPage(),
+ featuredEvents: makeFeaturedEventsSelector(),
+ events: makeEventsSelector(),
});
// ディスパッチをプロップに注入
function mapDispatchToProps(dispatch) {
return {
- dispatch,
+ onLoadEvents: (userId, skip, take, keywords) =>
+ dispatch(loadEvents(userId, skip, take, keywords)),
+ onLoadFeaturedEvents: (userId, skip, take) =>
+ dispatch(loadFeaturedEvents(userId, skip, take)),
};
}
...
テストしてみる
まずは新しいページが表示されるようにApp
内でルーティングを設定します
+ import EventPage from 'containers/EventPage/Loadable';
<Switch>
<Route exact path="/" component={HomePage} />
<Route path="/features" component={FeaturePage} />
+ <Route path="/events" component={EventPage} />
<Route path="" component={NotFoundPage} />
</Switch>
これで完了!npm run start
でhttp://localhost:3000/eventsを開いて確認しましょう!