Upgrade to Pro — share decks privately, control downloads, hide ads and more …

React Redux を用いた SPA 新規サービスを運用して得た知見と実装例

Avatar for numanomanu numanomanu
November 15, 2017

React Redux を用いた SPA 新規サービスを運用して得た知見と実装例

Avatar for numanomanu

numanomanu

November 15, 2017
Tweet

More Decks by numanomanu

Other Decks in Technology

Transcript

  1. 対象者 - ある程度 react や redux を触ったことがある方。 - SPA の開発に興味がある方

    - 以前の勉強会に参加された方 目的 - 具体的な実装例をもとに知見を共有し、何かの役に立ててほしい 話す内容 - 利用しているライブラリや開発環境、開発フロー - コードベースでの実装例の紹介 - その他 SPA サービスを運用する上での構成や知見 今日お話しする内容
  2. React - Facebook が開発した UI ライブラリ - 旧来の DOM 操作による状態管理を

    props や state で抽象化 - パーツをコンポーネントごとに管理するのが得意
  3. コンポーネントのテストには Enzyme React のコンポーネントをテストするためのツール。 - airbnb が開発。 - react のコンポーネントのレンダリングをアサーションしてくれる

    - 受け取ったprops によって、何がレンダリングされるべきかなどのテストが書ける コンポーネントのテスト以外にも、Redux の action や reducer のテストを書いている。
  4. /src ├── actions ├── components ├── containers ├── routes.js ├──

    index.js ├── index.html ├── middleware ├── reducers ├── sagas ├── store ├── types /test flux standard なフォルダ構成 アクション UI群 state を受け取る層 ルーティング アプリの起点 ホスティングされるファイル ミドルウェアの処理 リデューサー redux-saga による非同期処理 Store 生成処理 FlowType による型定義群 ↑  大体の色の内訳を合わせた
  5. // @flow import * as ActionTypes from './action_types'; import type

    { Meta, ErrorMessage } from './../types/models'; import type { GetPayload, GetOkPayload } from './../types/payload/service_list'; import type { Action } from '../types/actions'; export function get(payload: GetPayload = {}, meta: any = {}): Action{ return { type: ActionTypes.GET_SERVICE_LIST_START, payload, meta, }; } export function getOk(payload: GetOkPayload, meta: Meta = {}): Action { return { type: ActionTypes.GET_SERVICE_LIST_OK, payload, meta, }; } export function getNg(payload: ErrorMessage, meta: Meta = {}): Action { return { type: ActionTypes.GET_SERVICE_LIST_NG, payload, meta, }; } action
  6. action の引数は payload, meta で統一  export function get(payload: Object =

    {}, meta: Object = {}): Action { return { type: ActionTypes.GET_SERVICE_LIST_START, payload, meta, }; } どんなアクションも引数のインターフェイスを payload, meta と命名したObjectで統一する。flux-standard-action payload には例えば { usreId: 1 } など Object でラップして渡す metaには副作用的に利用する情報を渡す(statusCode や error 情報など) middleware などで共通の処理を書きやすくなるメリットがある(後述) Actionの型イメージ type Action = { type: string, payload: Object, meta?: Object, }
  7. // @flow import { handleActions } from 'redux-actions'; import *

    as ActionTypes from '../actions/action_types'; import type { Action } from '../types/actions'; import type { Service } from '../types/models'; type StateType = { data: Array<Service>; loadingFlag: boolean; } export const initialState: StateType = { data: [], loadingFlag: false, }; const serviceList = handleActions({ [ActionTypes.GET_SERVICE_LIST_START]: (state: StateType) => { return { ...state, loadingFlag: true }; }, [ActionTypes.GET_SERVICE_LIST_OK]: (state: StateType, action: Action) => { return { ...state, data: action.payload, loadingFlag: false, }; }, [ActionTypes.GET_SERVICE_LIST_NG]: (state: StateType, action: Action) => { return { ...state, loadingFlag: false }; }, }, initialState); export default serviceList; reducer
  8. reducer redux-actionsを使って、case 文を省略 ...state, など スプレッドシンタックス で Object を上書き reducer

    では action.type ごとに loadingFlag を持たせている 複雑な処理は middleware に寄せて、reducer をシンプルにしている。 import { handleActions } from 'redux-actions'; const serviceList = handleActions({ [ActionTypes.GET_SERVICE_LIST_START]: (state: StateType) => { return { ...state, loadingFlag: true }; }, [ActionTypes.GET_SERVICE_LIST_OK]: (state: StateType, action: Action) => { return { ...state, data: action.payload, loadingFlag: false, }; }, 〜後略
  9. container // @flow import React, { Component } from 'react';

    import { bindActionCreators, compose } from 'redux'; import { connect } from 'react-redux'; import CircularProgress from 'material-ui/CircularProgress'; import * as actions from '../../actions/service_list'; function mapStateToProps(state: Object): Object { return { serviceList: state.currentUsersServiceList }; } function mapDispatchToProps(dispatch: Function): Object { return { actions: bindActionCreators(actions, dispatch) }; } class ServiceListContainer extends Component { componentWillUnmount() { this.props.actions.get(); // マウント時に API をコール } props: { actions: { get: Function; }; serviceList: { data: Array<Object>; loadingFlag: boolean; }; } render() { const { data, loadingFlag } = this.props.serviceList; if (loadingFlag) { return <CircularProgress/> } return ( <div> {data && data.map((service, i) => <div key={i}>{service.title}</div>})} </div> ); } } export default connect(mapStateToProps, mapDispatchToProps)(ServiceListContainer);
  10. // @flow import React, { Component } from 'react'; import

    CircularProgress from 'material-ui/CircularProgress'; // 省略... render() { const { loadingFlag } = this.props.serviceList; if (loadingFlag) { return <CircularProgress/>; } return ( <div>ロードが終わった後に表示されるコンテンツ </div> ) } // 省略... [ActionTypes.GET_SERVICE_LIST_START]: (state: StateType) => { return { ...state, loadingFlag: true }; }, [ActionTypes.GET_SERVICE_LIST_OK]: (state: StateType, action: Action) => { return { ...state, data: action.payload, loadingFlag: false, }; },
  11. middleware redux-sagaで非同期処理と戦う https://qiita.com/kuy/items/716affc808ebb3e1e8ac redux-saga で API コールを監視 非同期的な処理だが、ネストに ならないので読みやすい!? //

    @flow import 'babel-polyfill' // 古いブラウザで動かない場合があるため import { call, fork, put, take } from 'redux-saga/effects'; import { getOk, getNg } from '../actions/service_list'; import * as ActionTypes from '../actions/action_types'; import Api from '../services/api'; import type { Action } from '../types/actions'; function* getServiceList(action: Action): Generator<any, any, any> { try { const response: any = yield call(Api.getServiceList, action.payload); const payload = response.body; const meta = { statusCode: response.statusCode }; yield put(getOk(payload, meta)); } catch (e) { const payload = e.response.error.message; const meta = { statusCode: e.response.statusCode }; yield put(getNg(payload, meta)); } } export function* watchGetServiceList(): Generator<any, any, any> { while (true) { const action = yield take(ActionTypes.GET_SERVICE_LIST_START); yield fork(getServiceList, action); } }
  12. middleware redux-saga で API コールを監視 import { fork } from

    'redux-saga/effects'; import { watchGetServiceList } from './service_list_saga'; export default function* rootSaga(): Generator<any, any, any> { yield [ fork(watchGetServiceList), ]; } 上記のコードを store で呼び出す
  13. store // @flow import { createStore, applyMiddleware, compose } from

    'redux'; import createSagaMiddleware from 'redux-saga'; import { routerMiddleware } from 'react-router-redux'; import createHistory from 'history/createHashHistory' import rootSaga from '../sagas/index'; import rootReducer from '../reducers'; export const history = createHistory(); const routing = routerMiddleware(history); const sagaMiddleware = createSagaMiddleware(); const enhancer = compose( applyMiddleware( routing, sagaMiddleware, ), ); function configureStore(initialState: any) { const store = createStore(rootReducer, initialState, enhancer); sagaMiddleware.run(rootSaga); return store; } export default configureStore;
  14. import React, { Component } from 'react'; import { ConnectedRouter

    } from 'react-router-redux'; import { Route, Redirect, Switch } from 'react-router-dom'; import MainContainer from './containers/main_container'; import ServiceListContainer from './containers/service_list_container'; import { history } from '../store/configure_store'; class Routes extends Component { render() { return ( <ConnectedRouter history={history}> <Switch> <MainContainer> <Route path="/services" component={SeriviceListContainer} /> </MainContainer> </Switch> </ConnectedRouter> ); } } export default Routes; routing
  15. import React, { Component } from 'react'; import RaisedButton from

    'material-ui/RaisedButton'; function createPrimaryButton(WrappedComponent) { return class designedButtonComponent extends Component { render() { return (<WrappedComponent {...this.props} primary={true} />); } } } export const PrimaryButton = createPrimaryButton(RaisedButton); HOC を利用してコンポーネントの種類を管理 HOC(Higher-order-components) とは - コンポーネントに関数を適応し、機能が合成されたコンポーネントを返す - propsを新しく加えたり、ライフサイクルメソッドを追加することも可能 Higher-order Components の利用方法 https://qiita.com/numanomanu/items/2b66d8b2887d44f857dc
  16. import { PrimaryButtonFullWidth, AccentButtonHalfWidth, NSecondaryButtonRounded } from './buttons'; export const

    renderSomePageWithButton = buttonAction => <div> <NPrimaryButtonFullWidth label={'プライマリボタン幅MAX'} /> <NSecondaryButtonRounded label={'角丸セカンダリボタン '} /> <NAccentButtonHalfWidth label={'アクセントボタン幅半分 '} /> </div> import React, { Component }from 'react'; import RaisedButton from 'material-ui/RaisedButton'; import { buttonColor, buttonSize, buttonShape } from './button_styles'; function createButton(WrappedComponent, color, size, shape) { return class designedButtonComponent extends Component { render() { return (<WrappedComponent {...this.props} {...buttonColor[color]} {...buttonSize[size]} {...buttonShape[shape]} />); } } } export const PrimaryButtonFullWidth = createButton(RaisedButton, 'primary', 'fullWidth', 'square'); export const SecondaryButtonRounded = createButton(RaisedButton, 'secondary', 'original', 'round'); export const AccentButtonHalfWidth = createButton(RaisedButton, 'accent', 'halfWidth', 'original');
  17. ちょっとだけスタイルを当てたい場合 material-ui の Avatar で縦長画像が歪む import React from 'react'; import

    Avatar from 'material-ui/Avatar'; ... <Avator src="..."> ... photo from https://unsplash.com/
  18. import NAvatar from './componsnts/n_avatar.js'; … <NAvatar src="..." /> … //

    @flow import React from 'react'; import Avatar from 'material-ui/Avatar'; const overrideStyle = { style: { // 画像を歪ませないためのスタイル objectFit: 'cover', }, }; const NAvatar = (props: Object) => { return ( <Avatar {...props} style={{ ...overrideStyle.style, ...props.style, }} /> ); }; export default NAvatar; - 特定の props をスプレッドシンタックスでオーバーラ イドする - プレフィックスつけて管理
  19. - 素早くそれなりの UI が実装できる - アニメーションをお任せできる - 細かいスタイル当てる方法が微妙 - material-ui

    に記されているプロパティを利用 - style プロパティを上書き - theme ファイルを自作してプロパティを全体的に上書き - css を当てる - 上記を行っても上書きできないプロパティもあり、ライブラリのコード読みに行くこと がよくある - マテリアル UI 推しで使わない限り、利用は控えた方が良いかもしれない - ver 1.0 から色々変わるみたいです material UI を使ってみて
  20. ダイアログのComponent material-ui の Dialog を拡張 トップレベルのコンテナ内に設置 (or HOC にする) props

    でアクションを受け取る OK を押した時、アクションを発行 import Dialog from 'material-ui/Dialog'; import RaisedButton from 'material-ui/RaisedButton'; class GlobalDialog extends Component { props: { title: string; openFlag: boolean; okClickHandler: Function; }; renderDialogButtons(): React.Element<*> { return ( <div> <RaisedButton            label="キャンセル" onClick={/*ダイアログを閉じる処理*/}/> <RaisedButton            label="はい" onClick={this.props.okClickHandler} /> </div> ); } render() { return ( <Dialog open={this.props.openFlag} title={this.props.title} actions={this.renderDialogButtons()} /> ); } } export default GlobalDialog;
  21. Action の呼び出し const payload = {} const meta = {

      dialogInfo: {     title: 'ログインが必要な操作です ',   }, } // dialog が出現する action になる this.props.actions.update(payload, meta); meta に Object を渡す middleware で 「dialogInfo」 をキャッチさせる
  22. middleware export default (store: any) => (next: any) => (action:

    any) => { if (action.meta && action.meta.dialogInfo) { const payload = { ...action.meta.dialogInfo, okClickHandler: () => { // meta 情報を削除. 次のアクションで dialog を出さない delete action.meta.dialogInfo; next(action); }, }; return next(dialogOpen(payload)); } // dialogInfo があれば上で return するからここに来ない next(action); }; meta.dialogInfo があれば dialogOpen でダイアログを開くアクションを呼ぶ next(action) は呼ばない okClickHandler に 本来の action を渡す アクションを middleware で せき止めるイメージ React × Redux で action 発行時に確認ダイアログを挟む middleware の実装例 https://engineer.blog.lancers.jp/2017/07/react_redux_update_state/
  23. middleware を活用する際のポイント - どんなアクション呼び出しにも、共通した処理ができる - action の引数を payload, meta に統一するとフックの処理が書きやすい

    - ロジックを middleware に集めることでテストしやすい 注意)middleware は定義した順に next(action) で次の処理が呼び出される。 dialog アクションの次に api コールをしたい場合などは sagaMiddleware の前に置く。 と言うような実装が必要 const enhancer = compose( applyMiddleware( routing, // 1番目に実行 dialogChecker, // 2番目に実行 sagaMiddleware, // 3番目に実行 ), );
  24. ログインしている人だけ見れるページを作りたい import loggedInRequired from '../logged_in_required.js' // 省略... class ProjectContainer extends

    Component { // 省略... } export default loggedInRequired( connect(mapStateToProps, mapDispatchToProps) (ProjectContainer) ); HOCを利用して、複数ページで利用したい。 呼び出し例)
  25. HOC export default function loggedInRequired(WrappedComponent) { class loggedInRequiredComponent extends WrappedComponent

    { componentWillMount() { // react-redux のステートには this.store.getState() でアクセスできます if (!this.store.getState().session.userId) { // ログインしていないユーザが見た時のアクションを書く } } render() { if (!this.store.getState().session.userId) { // ログインしていないユーザが閲覧したらレンダリングしない return null; } return super.render(); } } return loggedInRequiredComponent; } ラップしたコンポーネント自身を extends している。(Inheritance Inversion) HOCのライフサイクルメソッドを、ラップしたコンポーネントに適応 HOCから、ラップしたコンポーネントの state や props に thisでアクセス コンポーネントのレンダリングをハイジャックできる
  26. ローカルストレージに state を保存したい - ユーザー情報など、扱っているステートをそのまま localStorage に入れたい=> redux-persist が便利 -

    whiteList 形式で特定の state をローカルストレージに保存できる - アプリ起動時に autoRehydrate で state を復元してくれる
  27. redux-persist の設定(configureStore) import { persistStore, autoRehydrate } from 'redux-persist'; const

    enhancer = compose( autoRehydrate(), applyMiddleware( routing, sagaMiddleware, ), ); export default function configureStore(initialState) { const store = createStore(rootReducer, initialState, enhancer); persistStore(store, { whiteList: ["currentUser", "permanence"] }, () => { // autoRehydrate が終わった後に呼ばれる }); return store; }
  28. action import assert from 'power-assert'; import * as actions from

    '../../src/actions/service'; import * as ActionTypes from '../../src/actions/action_types'; describe('service Action', () => { it('get', () => { const payload = {}; const meta = {}; const expectedAction = { type: ActionTypes.GET_SERVICE_START, payload, meta, }; assert.deepStrictEqual(actions.get(payload), expectedAction); }); }
  29. reducer import assert from 'power-assert'; import * as ActionTypes from

    '../../src/actions/action_types'; import reducer, { initialState } from '../../src/reducers/service'; describe('Service reducer', () => { it('GET_SERVICE_START で loadingFlag が true になること', () => { const executed = reducer(initialState, { type: ActionTypes.GET_SERVICE_START }); const expected = { ...initialState, loadingFlag: true, }; assert.deepStrictEqual(executed, expected); }); }
  30. component import React from 'react'; import assert from 'power-assert'; import

    { shallow } from 'enzyme'; import Dialog from 'material-ui/Dialog'; import NDialog from '../../../../src/browser/components/common/n_dialog'; describe('NDialog component', () => { it('this.props.message がある場合は Dialog が描画されること', () => { const props = { closeNDialog: () => {}, isOpen: true, message: 'foobar', }; const component = shallow(<NDialog {...props} />); assert.ok(component.containsMatchingElement(Dialog)); }); }
  31. middleware import assert from 'power-assert'; import sinon from 'sinon'; import

    confirmationDialogChecker from '../../../src/middleware/common/dialog_checker'; import * as ActionTypes from '../../../src/actions/action_types'; import * as action from '../../../src/actions/confirmation_dialog'; const createFakeStore = () => {}; let spy; describe('dialog_checker' ミドルウェア', () => { beforeEach(() => { spy = sinon.spy(action, 'open'); }); afterEach(() => { action.open.restore(); }); it('action の meta に DialogInfo があれば確認ダイアログがでること', () => { const meta = { dialogInfo: { title: 'message', }, }; const action = { type: ActionTypes.GET_SERVICE_LIST_START, payload: {}, meta, }; const dispatch = confirmationDialogChecker(createFakeStore())(() => {}); dispatch(action); assert.ok(spy.calledOnce); assert.equal(spy.args[0][0].title, meta.dialogInfo.title); }); }
  32. テストのポイント - ビジネスロジックを middleware に集めて、middleware のテストを厚めにやる。 - 新規サービスのため UI がすぐ変わるので

    components のテストは全てカバーし ていない。 - middleware にロジックを持っていくと action, reducer のテストが簡単になる - 書いてて無駄に感じることもあるが、何か機能を消した時のレグレッションに気付け るので、書く意味はあると思う。
  33. 導入例 import Raven from 'raven-js'; Raven.config('https://セントリーのurl', { release: RELEASE, environment:

    process.env.NODE_ENV, // production, stating 環境以外のエラーを送信しない設定 shouldSendCallback: () => { return ['production', 'staging'].indexOf(process.env.NODE_ENV) !== -1; }, }).install();
  34. import * as ActionTypes from '../../actions/action_types'; export default (store: any)

    => (next: any) => (action: any) => { next(action); // react-router-redux の発行するアクションを監視 if (action.type === ActionTypes.REACT_ROUTER_LOCATION_CHANGE) { const routing = store.getState().routing; const nextPath = routing.location.pathname; const query = routing.location.search; ga('set', 'page', nextPath + query); ga('send', 'pageview'); } }; PV計測などは、独自にイベントを発行する必要がある middleware で react-router-redux の発行するアクションタイプを監視して、 動的に pageview のイベントを発火させる React.js/redux アプリでの Google Analytics のイベントトラッキングの設定 https://engineer.blog.lancers.jp/2017/09/google-analytics-redux/
  35. // @flow import React, { Component } from 'react'; import

    Helmet from 'react-helmet'; // see https://github.com/nfl/react-helmet class DocumentMeta extends Component { static defaultProps = { image: 'https://pook.life/img/logo/ogp_image.jpg', // 画像に相対パスは使えない title: 'pook(プック)| スキルシェアリングサービス', keywords: 'シェアリングサービス、スキルシェア、CtoC', description: 'pook(プック)は、得意や趣味などのできることを売り買いするサー... } props: { image?: string; title?: string; keywords?: string; description?: string; } render() { return ( <Helmet> <title>{this.props.title} | pook(プック)</title> <meta name="keywords" content={this.props.keywords} /> <meta name="description" content={this.props.description} /> </Helmet> ); } } export default DocumentMeta; react-helmet meta 情報を動的に書き換え可能
  36. manifest.json を書くと Android で「ホームに追加」できる https://developers.google.com/web/fundamentals/web-app-ma nifest/?hl=ja { "short_name": "pook", "name":

    "pook(プック)自分らしい暮らしをもっと身近に。", "icons": [{ "src": "img/logo/icon-128x128.png", "type": "image/png", "sizes": "128x128" }, { その他サイズの画像 }], "background_color": "#FFFFFF", "display": "standalone", "theme_color": "#0086D1", "start_url": "/home?utm_source=homescreen" }
  37. アドレスバーが出ない ホームに icon 追加 Splash が付く "display": "standalone", "name": "pook(プック)自分らしい暮らし

    をもっと身近に。", "short_name": "pook", "icons": [{ "src": "img/logo/icon-128x128.png", "type": "image/png", "sizes": "128x128" },
  38. APIドキュメントを共通仕様として作成&運用 - API Blueprint という API仕様書を mark down で書ける構文を利用 -

    Github で管理し、PRで新規作成・更新し、機能の詳細は issue で管理 - API はフロントエンドエンジニアが設計 フロントエンド GET: /users したら、ユーザー 一覧のJson返して欲しい。 バックエンド name name name API サーバー API仕様書 仕様書に基 づいたjson を返す
  39. APIができるまではモックサーバーを使う - API仕様書をそのままモックサーバーにできる仕組みが存在 (api-mock) - API が完成していなくても、フロントエンドの実装に着手できる モック サーバー localhost:3002

    GET: http://localhost:3002/users フロントエンド API 仕様書 [ { id: 1, name: hoge}, { id: 2, name: huga}, { id: 3, name: bar} ] api-mock で、仕様書を モックサーバー化 hoge huga bar
  40. デプロイ 開発のフロー全体(レビューとCI、デプロイ) コードを書く テストを書く 構文チェック、型チェック github から pull トランスパイル、ビルド 実機確認

    CIでgithubにあげた ファイルをチェック コードレビュー オープンソースのフローベースの開発 - commit は anguralr のルールをベース - ドキュメントは厚めに書く hoge huga bar
  41. - デザイナーがjsxを書くのに敷居が高く、HTML直書きより協業しにくい - Reducer がどんどん肥えていく => normalize したい - コミュニティライブラリに依存しすぎると思わぬところでハマる

    - ビジネスロジックの置き場に悩む => middleware においた - FlowType入れたけど、型を使いこなせているか不安 - Google は js 動くが、 Facebook twitter は js 動かない。 OGP 画像でない - ユーザーがアプリと勘違いするので、アプリクオリティが求められる - トレンドを追いかけつつ、プロダクトの製作は結構忙しい React Redux で SPA を開発、運用してみた課題感
  42. React Redux で SPA を開発、運用して - Redux は結構薄いフレームワークなので随所に工夫が必要 - middleware

    を活用するとコードの見通しは良くなる - ユーザーが求める 「普通」 は確実に難易度が上がってきている - 継続的に改善していける健全なコードを書く環境が必要そう まとめ。と言うか感想です