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

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

numanomanu
November 15, 2017

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

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

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