18
16

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.

フロントエンドの全部揃っている環境が欲しくてreact-ssr-starterを作った

Last updated at Posted at 2018-05-15
1 / 38

react-ssr-starterとは

フロントエンド開発でよく使うものを導入しまくって動くようにセットアップしたもの。


経緯

僕は開発中はhot reloading(react-hot-loader)して欲しいし、lint(eslint stylelint prettier)はもちろん欲しいし、code splitting(loadable-components)もやりたいし、テスト(jest, enzyme)も書きたいし、コンポーネントも確認(storybook)したいし...etc

といった感じでとにかく全部揃っている環境が欲しかった。


Next.js使えばいいんじゃない?

Next.jsを使うのもありですが、使ったとしても結局ある程度は自分で導入しないといけないので、とにかく作業するときに考える手間と導入する手間をなくしたかったのが発端。(でもNext.jsにかなり影響受けてます)


リポジトリ


画面


スタック

  "dependencies": {
    "axios": "^0.18.0",
    "body-parser": "^1.18.3",
    "compression": "^1.7.2",
    "cookie-parser": "^1.4.3",
    "express": "^4.16.3",
    "helmet": "^3.12.0",
    "history": "^4.7.2",
    "hpp": "^0.2.2",
    "html-minifier": "^3.5.15",
    "jsonwebtoken": "^8.2.1",
    "loadable-components": "^2.1.0",
    "morgan": "^1.9.0",
    "react": "^16.3.2",
    "react-cookie": "^1.0.5",
    "react-dom": "^16.3.2",
    "react-helmet": "^5.2.0",
    "react-hot-loader": "^4.1.3",
    "react-redux": "^5.0.7",
    "react-router-config": "^1.0.0-beta.4",
    "react-router-dom": "^4.2.2",
    "react-router-redux": "^5.0.0-alpha.9",
    "redux": "^4.0.0",
    "redux-logger": "^3.0.6",
    "redux-thunk": "^2.2.0",
    "serialize-javascript": "^1.5.0",
    "serve-favicon": "^2.5.0",
    "styled-components": "^3.2.6"
  },
  "devDependencies": {
    "@babel/core": "^7.0.0-beta.47",
    "@babel/preset-env": "^7.0.0-beta.47",
    "@babel/preset-flow": "^7.0.0-beta.47",
    "@babel/preset-react": "^7.0.0-beta.47",
    "@babel/preset-stage-0": "^7.0.0-beta.47",
    "@babel/register": "^7.0.0-beta.47",
    "@storybook/addon-links": "^3.4.4",
    "@storybook/react": "^3.4.4",
    "babel-core": "^7.0.0-bridge.0",
    "babel-eslint": "^8.2.3",
    "babel-jest": "^22.4.3",
    "babel-loader": "^8.0.0-beta.2",
    "babel-plugin-add-module-exports": "^0.2.1",
    "babel-plugin-dynamic-import-node": "^1.2.0",
    "babel-plugin-module-resolver": "^3.1.1",
    "babel-plugin-styled-components": "^1.5.1",
    "clean-webpack-plugin": "^0.1.19",
    "copy-webpack-plugin": "^4.5.1",
    "core-js": "^2.5.6",
    "enzyme": "^3.3.0",
    "enzyme-adapter-react-16": "^1.1.1",
    "enzyme-to-json": "^3.3.3",
    "eslint": "^4.19.1",
    "eslint-config-airbnb": "^16.1.0",
    "eslint-config-prettier": "^2.9.0",
    "eslint-import-resolver-babel-module": "^5.0.0-beta.0",
    "eslint-plugin-flowtype": "^2.46.3",
    "eslint-plugin-import": "^2.11.0",
    "eslint-plugin-jsx-a11y": "^6.0.3",
    "eslint-plugin-prettier": "^2.6.0",
    "eslint-plugin-react": "^7.8.2",
    "flow-bin": "^0.72.0",
    "flow-typed": "^2.4.0",
    "friendly-errors-webpack-plugin": "^1.7.0",
    "husky": "^0.14.3",
    "jest": "^23.0.0-beta.2",
    "json-loader": "^0.5.7",
    "lint-staged": "^7.1.0",
    "nodemon": "^1.17.4",
    "prettier": "^1.12.1",
    "raf": "^3.4.0",
    "stylelint": "^8.4.0",
    "stylelint-config-prettier": "^3.2.0",
    "stylelint-config-standard": "^18.2.0",
    "stylelint-config-styled-components": "^0.1.1",
    "stylelint-processor-styled-components": "^1.3.1",
    "uglify-js": "^3.3.25",
    "uglifyjs-webpack-plugin": "^1.2.5",
    "webpack": "^3.11.0",
    "webpack-bundle-analyzer": "^2.11.3",
    "webpack-dev-middleware": "^2.0.6",
    "webpack-hot-middleware": "^2.22.2",
    "webpack-node-externals": "^1.7.2",
    "workbox-sw": "^3.2.0",
    "workbox-webpack-plugin": "^3.2.0"
  },

ディレクトリ構成

├── public                # 静的ファイルディレクトリ
│   ├── manifest.json     # Web App Manifest
│   ├── favicon.ico       # ファビコン 
│   └── static            # 静的ファイル
│        └── images       # 画像
├── src                   # ソース
│   ├── config            # config
│   ├── components        # コンポーネント
│   ├── pages             # ページ
│   ├── actions           # reduxのaction
│   ├── reducers          # reduxのreducer
│   ├── utils             # ユーティリティー
│   ├── styles            # styled-componentsでinjectGlobalするやつとか
│   ├── types             # flowのためのreducer, action, state, storeの型
│   ├── client.js         # クライアントのエントリー
│   ├── routes.js         # ルーティングの設定ファイル
│   ├── server.js         # サーバーのエントリー
│   └── sw.js             # Service Workerのファイル
├── tools                 # ツール
│   ├── flow              # flowの設定
│   ├── jest              # jestの設定
│   ├── storybook         # storybookの設定
│   └── webpack           # webpackの設定
└── index.js              # アプリケーションのエントリー

使い方


①導入

$ git clone https://github.com/osamu38/react-ssr-starter.git
$ cd react-ssr-starter
$ npm i

②開発

$ npm start (http://localhost:2525)

③ビルド

$ npm run build
$ npm run build:client (クライアントのみ)
$ npm run build:server (サーバーのみ)

④本番

$ npm run start:prod (http://localhost:2525)

⑤flow

$ npm run flow

⑥lint(eslint & prettier)

$ npm run lint

⑦stylelint

$ npm run lint:css

⑧test(jest, enzyme)

normal

$ npm test

coverage

$ npm run test:coverage

update snapshot

$ npm run test:update

⑨storybook

$ npm run storybook (http://localhost:2626)

コンポーネントの作り方


components以下にButtonのようなディレクトリを作り、index.js,index.stories.js, index.spec.jsを作る。

  • index.jsにはコンポーネントを書く。
  • index.stories.jsstoriesOf().add()でstorybookにコンポーネントを表示させる。
  • index.spec.jsではこのコンポーネントのテストを書く。(テスト内でtoMatchSnapshotした場合はテスト実行時に__snapshops__/index.spec.js.snapが生成される。)

例(index.js)


/* @flow */

import * as React from 'react';
import styled, { css } from 'styled-components';
import { colors } from 'styles/variables';

const ButtonUI = styled.button`
  display: block;
  width: 100%;
  max-width: 240px;
  line-height: 40px;
  font-size: 16px;
  font-weight: bold;
  color: ${colors.accent};
  text-align: center;
  cursor: pointer;
  border: 2px ${colors.accent} solid;
  ${({ isCenter }) =>
    isCenter &&
    css`
      margin: 0 auto;
    `};
`;

type Props = {
  children?: React.Node,
  onClick?: Function,
  isCenter?: boolean,
};

export default function Button(props: Props) {
  const { children, onClick, isCenter } = props;

  return (
    <ButtonUI onClick={onClick} isCenter={isCenter}>
      {children}
    </ButtonUI>
  );
}

例(index.stories.js)



import React from 'react';
import { storiesOf } from '@storybook/react';
import Button from 'components/Button';

storiesOf('Button', module).add('normal', () => <Button>Button</Button>);

例(index.spec.js)


import React from 'react';
import Button from 'components/Button';

describe('<Button />', () => {
  it('render children', () => {
    const children = 'Button';
    const wrapper = shallow(<Button>{children}</Button>);

    expect(wrapper.prop('children')).toEqual(children);
  });
  it('render snapshots', () => {
    const wrapper = shallow(<Button>Button</Button>);

    expect(toJson(wrapper)).toMatchSnapshot();
  });
  it('call onclick', () => {
    const mock = jest.fn();
    const wrapper = shallow(<Button />);

    wrapper.setProps({ onClick: mock });
    wrapper.simulate('click');
    expect(mock).toHaveBeenCalled();
  });
});

code splitting

src/components/index.jsで下記のように記述するとcode splittingを制御できる。


  // code splittingする場合
  Button: loadable(() => import('components/Button')),
  // code splittingしない場合
  Button: require('components/Button'),

ページの作り方


pages以下にAboutのようなディレクトリを作り、index.js, index.spec.jsを作る。あとはコンポーネントの作り方と一緒。


pagesの設計

アンチパターンかもしれませんが、pagesの中のコンポーネントにはあらかじめbindActionCreatorsdispatchされた全てのactionと全てのstore情報が渡って来ているので、connectする必要はありません。
さらにwithRouterhot(module)(component)もする必要はないので、とてもシンプルにコンポーネントを書くことが出来ます。


例(index.js)



/* @flow */

import * as React from 'react';
import Helmet from 'react-helmet';
import { LoginForm, Title } from 'components';

type Props = {
  userActions: {
    login: Function,
  },
};

export default function Landing(props: Props) {
  const {
    userActions: { login },
  } = props;

  return (
    <>
      <Helmet title="Landing" />
      <Title>Landing Page</Title>
      <LoginForm login={login} />
    </>
  );
}

サーバーサイドリクエスト

src/routes.js 40行目付近

    ...
    component: loadable(() => import('pages/UserDetail')),
    loadData: (dispatch, state, params) => dispatch(fetchUser(params.id)),
  },
  ...

loadDataでserver sideでのリクエストが送れます。(Next.jsにおけるgetInitialProps


変わった特徴


①画像はimportやrequireせずに絶対パスで指定



<img src="/static/images/svg/oct-icon.svg" alt="oct-icon" />

require出来るようにしても良かったんですが、あまりメリットを感じなかったのでやらず。


②エイリアスを設定している


import user from 'reducers/user';
import ui from 'reducers/ui';

rootとsrc以下はエイリアスが設定されているので、参照が楽。


③開発中はSPA、本番はSSR

開発中はSPAの方がやりやすい部分があったのでSPAで動いてます。


④ちょっとした認証機能を入れている

src/routes.js 20行目付近


...
export default [
  {
    path: '/',
    component: Auth,
    routes: addExact([
      {
        path: url.endpoint.landing,
        isLoggedIn: false,
        component: loadable(() => import('pages/Landing')),
      },
      ...

このAuthというcomponentsで認証をしているため、store内のuser.status.isLoggedInとroutesの中にあるisLoggedInの値を見てサーバーサイドとクライアントでリダイレクトがかかります。
LandingページはisLoggedIn: falseと設定してあるので、ログインしている状態では遷移することができません。


⑤本番でservice workerが動いてる

workboxを導入しているので、そのままの設定だとhtml以外の全ての静的ファイルがPre cacheされ、apiへのリクエストもRuntime cacheされるようになっています。htmlをキャッシュしてるわけではないのでofflineで動いたりはしません。


⑥ちなみに

どうしてもloginのapiが必要でserver.jsにしれっと書いてありますが、使わない場合は消してください。


悔しさ

  • webpack4に出来なかった。(loadable-componentsと相性が悪い?)
  • stylelintが9系にするとstyled-componentsに対して機能しなくなる。(謎)
  • READMEをちゃんと書けていない。。。(申し訳ない)

まとめ

フロントエンドのセットアップめんどくせーと思っている人にはぴったりのスターターになったかなと思います。
もし使ってくれたら感想欲しいです!
そして「ここがイケてない」ってのもあれば、コメントお願いします。

18
16
3

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
18
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?