読者です 読者をやめる 読者になる 読者になる

Nukindex 開発ブログ

アダルトサイトNukindexの開発ブログです

初心者によるReduxのふわっとしたイメージ解説

Redux + React に挑戦している。 なんとなく感覚はつかめてきたが小さい謎が多い。 感覚をつかむまでの苦しみがすごいのでメモを残しておきたい。

実際の Redux のライフサイクル

f:id:katz_ura:20170427000805p:plain

イメージ解説:「りんごがひとつ売れたので在庫情報を更新したい」

f:id:katz_ura:20170426234316p:plain

ユーザーさん(View):「りんごがひとつ売れたので在庫情報を更新したい」

f:id:katz_ura:20170426234601p:plain

受付さん(Action Creator):「わかりました。少し待っててくださいね。φ(・_・”)」

受付さん(Action Creator):「お待たせしました。これを事務さんに持っていってください。」

f:id:katz_ura:20170426234640p:plain

依頼書(Action):「【業務】りんごが売れたため在庫更新【個数】1」

f:id:katz_ura:20170426235023p:plain

事務さん(Reducer):「りんごが売れたため在庫更新、個数は1、ですね。わかりました。」

f:id:katz_ura:20170426235213p:plain

事務さん(Reducer):「書類棚から在庫管理表を取ってきて、」

f:id:katz_ura:20170426235504p:plain

  • 書類棚(Store)
  • 在庫管理表(State)

f:id:katz_ura:20170427000420p:plain

事務さん(Reducer):「新しい在庫管理表と依頼書を照らし合わせて新しい在庫管理表を作る。」

f:id:katz_ura:20170427000631p:plain

事務さん(Reducer):「それでは在庫更新しておきましたのでもう大丈夫ですよ。」

f:id:katz_ura:20170426234316p:plain

ユーザーさん(View):「ありがとうございました。」

テスト用プロジェクト

ROOT_DIRECTORY/
  + node_modules/
  + build/
  + source/
  |   + renderer/
  |   |   + components/
  |   |       + app/
  |   |           + container.js
  |   |           + view.js
  |   |           + reducer.js
  |   |           + actions.js
  |   |
  |   + index.html
  |
  + gulpfile.babel.js
  + .babelrc
  + package.json

source/index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Redux Sample</title>
</head>
<body>
  <div id="app"></div>

  <script src="./renderer/bundle.js"></script>
</body>
</html>

source/renderer/components/app/container.js

connect は Redux と React をつなげる。 mapStateToProps は Redux で管理する state を React コンポーネントの props にひもづける。 mapDispatchToProps は Action Creator への呼び出しを React コンポーネントの props にひもづける。

React コンポーネントでは state ではなく props を使っていくことになる。

自分でも書いててよく分からんけど何回か書いてたら慣れる。

import React from "react";
import { connect } from "react-redux";
import Component from "./view";
import Actions from "./actions";

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Component);

function mapStateToProps(state) {
  console.log("container.js: mapStateToProps\n" +
      "state: " + JSON.stringify(state, null, "  "));

  return state.app;
}

function mapDispatchToProps(dispatch) {
  console.log("container.js: mapDispatchToProps");

  return {
    "sellHandler": () => {
      dispatch(Actions.sell());
    },
    "recieveHandler": () => {
      dispatch(Actions.recieve());
    }
  };
}

source/renderer/components/app/view.js

React のコンポーネント

import React from "react";

export default class Component extends React.Component {
  render() {
    console.log("view.js: render\n" +
        "state: " + JSON.stringify(this.state, null, "  ") + "\n" +
        "props: " + JSON.stringify(this.props, null, "  "));

    return (
      <div>
        <table>
          <thead>
            <tr>
              <th>品名</th>
              <th>単価</th>
              <th>在庫</th>
            </tr>
          </thead>
          <tbody>
            <tr>
              <td>{this.props.product.name}</td>
              <td>{this.props.product.price}</td>
              <td>{this.props.product.stock}</td>
            </tr>
          </tbody>
        </table>

        <button onClick={this.props.sellHandler}>売る</button>

        <button onClick={this.props.recieveHandler}>入荷する</button>
      </div>
    );
  }
}

source/renderer/components/app/reducer.js

Action による state の変更を管理する。state の初期値もここで設定する。事務さん。

const initialState = {
  "product": {
    "name": "りんご",
    "price": 120,
    "stock": 100
  }
};

export default function reducer(state = initialState, action) {
  console.log("reducer.js: reducer\n" +
      "state: " + JSON.stringify(state, null, "  ") + "\n" +
      "action: " + JSON.stringify(action, null, "  "));

  switch(action.type) {
    case "PRODUCT_SOLD":
      return Object.assign({}, state, {
        "product": {
          "name": state.product.name,
          "price": state.product.price - action.amount,
          "stock": state.product.stock
        }
      });

    case "PRODUCT_RECIEVED":
      return Object.assign({}, state, {
        "product": {
          "name": state.product.name,
          "price": state.product.price + action.amount,
          "stock": state.product.stock
        }
      });

    default:
      return state;
  }
}

source/renderer/components/app/actions.js

Action を返し、state の変更パターンを定義する。受付さん。

export default {
  "sell": () => {
    console.log("actions.js: sell");

    return {
      "type": "PRODUCT_SOLD",
      "amount": 1
    };
  },
  "recieve": () => {
    console.log("actions.js: recieve");

    return {
      "type": "PRODUCT_RECIEVED",
      "amount": 1
    }
  }
}

ログつきテストページ

デバッガーのコンソールタブにログがでる。

テストページを開く

ページ読み込み時のログ

初期描画時に謎に Reducer が 3回も動いている。謎でしかない。

reducer.js: reducer
state: {
  "product": {
    "name": "りんご",
    "price": 120,
    "stock": 100
  }
}
action: {
  "type": "@@redux/INIT"
}


reducer.js: reducer
state: {
  "product": {
    "name": "りんご",
    "price": 120,
    "stock": 100
  }
}
action: {
  "type": "@@redux/PROBE_UNKNOWN_ACTION_v.1.7.7.f.r"
}


reducer.js: reducer
state: {
  "product": {
    "name": "りんご",
    "price": 120,
    "stock": 100
  }
}
action: {
  "type": "@@redux/INIT"
}


container.js: mapStateToProps
state: {
  "app": {
    "product": {
      "name": "りんご",
      "price": 120,
      "stock": 100
    }
  }
}


container.js: mapDispatchToProps


view.js: render
state: null
props: {
  "product": {
    "name": "りんご",
    "price": 120,
    "stock": 100
  }
}

「売る」ボタンを押したときのログ

Action Creator => Reducer の流れで state の値が変化していること、 state 変化後に render が呼ばれていることがわかる。

  • stock を変えたつもりが price が変わっている。凡ミス。
  • mapStateToProps って毎回呼ばれるのね。 props にひもづけたらお役御免かと思っていた。
actions.js: sell


reducer.js: reducer
state: {
  "product": {
    "name": "りんご",
    "price": 120,
    "stock": 100
  }
}
action: {
  "type": "PRODUCT_SOLD",
  "amount": 1
}


container.js: mapStateToProps
state: {
  "app": {
    "product": {
      "name": "りんご",
      "price": 119,
      "stock": 100
    }
  }
}


view.js: render
state: null
props: {
  "product": {
    "name": "りんご",
    "price": 119,
    "stock": 100
  }
}

「入荷する」ボタンを押したときのログ

Action Creator => Reducer の流れで state の値が変化していること、 state 変化後に render が呼ばれていることがわかる。

actions.js: recieve


reducer.js: reducer
state: {
  "product": {
    "name": "りんご",
    "price": 120,
    "stock": 100
  }
}
action: {
  "type": "PRODUCT_RECIEVED",
  "amount": 1
}


container.js: mapStateToProps
state: {
  "app": {
    "product": {
      "name": "りんご",
      "price": 121,
      "stock": 100
    }
  }
}


view.js: render
state: null
props: {
  "product": {
    "name": "りんご",
    "price": 121,
    "stock": 100
  }
}

nukindex.com

全体的な View の作成が終わった

全体的な View の作成が終わった。

作成したもの

source/renderer/components/ 以下の view ファイル、 style ファイル

期待する動き

現行ページのレイアウトを再現する。

結果

成功

手順など

コンポーネントディレクトリの下には view.js と style.css が配置されている。

ROOT_DIRECTORY/
  + node_modules/
  + build/
  + source/
  |   + renderer/
  |   |   + components/
  |   |   |   + app/
  |   |   |   + header/
  |   |   |   + main/
  |   |   |   + footer/
  |   |   |   + advertisement/
  |   |   |   + ranking-actress/
  |   |   |   + search-entry/
  |   |   |   + entries/
  |   |   |       + view.js
  |   |   |       + style.css
  |   |   |
  |   |   + renderer.js
  |   |
  |   + styles/
  |   |   + reset.css
  |   |
  |   + index.html
  |
  + gulpfile.babel.js
  + .babelrc
  + package.json

source/renderer/components/app/view.js

ページ全体の骨組みを作る。

import React from "react";
import styles from "./style";

import HeaderComponent from "../header/view";
import MainComponent from "../main/view";
import FooterComponent from "../footer/view";

export default class Component extends React.Component {
  render() {
    return (
      <div className={styles.container}>
        <HeaderComponent />

        <MainComponent />

        <FooterComponent />
      </div>
    );
  }
}

source/renderer/components/header/view.js

ヘッダー部分のコンポーネント

import React from "react";
import styles from "./style";

export default class Component extends React.Component {
  render() {
    return (
      <header className={styles.container}>
        <h1 className={styles.title}>
          <a href="/">Nukindex</a>
        </h1>

        <p className={styles.description}>
          毎日のおかずを提供するサイトです
        </p>
      </header>
    );
  }
}

source/renderer/components/footer/view.js

フッター部分のコンポーネント

import React from "react";
import styles from "./style";

export default class Component extends React.Component {
  render() {
    return (
      <footer className={styles.container}>
        <p className={styles.row}>
          Copyright © 2017 Nukindex. All rights reserved.
        </p>

        <p className={styles.row}>
          Contact:
          <a href="mailto:ura.katz.1988@gmail.com">ura.katz.1988@gmail.com</a>
        </p>
      </footer>
    );
  }
}

source/renderer/components/main/view.js

メインコンテンツを入れるコンポーネント

import React from "react";
import styles from "./style";

import RankingActressComponent from "../ranking-actress/view";
import AdvertisementComponent from "../advertisement/view";
import SearchEntryComponent from "../search-entry/view";
import EntriesComponent from "../entries/view";

export default class Component extends React.Component {
  render() {
    return (
      <main className={styles.container}>
        <RankingActressComponent />

        <AdvertisementComponent>
          <a href="http://track.bannerbridge.net/click.php?APID=138222&affID=88332&siteID=172583" target="_blank">
            <img src="http://track.bannerbridge.net/adgserv.php?APID=138222&affID=88332&siteID=172583" />
          </a>
        </AdvertisementComponent>

        <SearchEntryComponent />

        <EntriesComponent />

        <AdvertisementComponent>
          <a href="http://track.bannerbridge.net/clickprod.php?adID=1661429&affID=88332&siteID=172583" target="_blank">
            <img src="http://track.bannerbridge.net/adgprod.php?adID=1661429&affID=88332&siteID=172583" />
          </a>
        </AdvertisementComponent>
      </main>
    );
  }
}

source/renderer/components/advertisement/view.js

広告部分のコンポーネント

import React from "react";
import styles from "./style";

export default class Component extends React.Component {
  render() {
    return (
      <div className={styles.container}>
        <h2 className={styles.title}>
          広告
        </h2>

        <div className={styles.ads}>
          {this.props.children}
        </div>
      </div>
    );
  }
}

source/renderer/components/ranking-actress/view.js

女優ランキング部分のコンポーネント

import React from "react";
import styles from "./style";

export default class Component extends React.Component {
  render() {
    const actressRankingData = getActressRankingData();

    const actressRankingItems = actressRankingData.map(actress => {
      return (
        <li className={styles.item}>
          <a href="#">{actress}</a>
        </li>
      );
    });

    return (
      <div className={styles.container}>
        <h2 className={styles.title}>
          私的女優ランキング
        </h2>

        <p className={styles.description}>
          個人的な女優ランキングです。
        </p>

        <ol className={styles.list}>
          {actressRankingItems}
        </ol>
      </div>
    );
  }
}

function getActressRankingData() {
  return [
    "跡美しゅり",
    "橋本ありな",
    "市川まさみ",
    "月本れいな"
  ];
}

source/renderer/components/search-entry/view.js

検索部分のコンポーネント

import React from "react";
import styles from "./style";

export default class Component extends React.Component {
  render() {
    return (
      <div className={styles.container}>
        <h2 className={styles.title}>
          動画検索
        </h2>

        <p className={styles.row}>
          <input className={styles.field}
              placeholder="動画を検索" />
        </p>
      </div>
    );
  }
}

source/renderer/components/entries/view.js

動画リスト部分のコンポーネント

import React from "react";
import styles from "./style";

export default class Component extends React.Component {
  render() {
    const entriesData = getEntriesData();

    const entryItems = entriesData.map(entryData => {
      const tagItems = entryData.tags.map(tag => {
        return (
          <li className={styles.item_tag}>
            {tag}
          </li>
        );
      });

      return (
        <li className={styles.item_entry}>
          <h2 className={styles.title_entry}>
            {entryData.title}
          </h2>

          <ul className={styles.list_tags}>
            {tagItems}
          </ul>
        </li>
      );
    });

    return (
      <div className={styles.container}>
        <ul className={styles.list_entries}>
          {entryItems}
        </ul>
      </div>
    );
  }
}

function getEntriesData() {
  return [
    {
      "videoId": 12345678,
      "imageId": 0,
      "title": "動画タイトル",
      "tags": ["タグ1", "タグ2"]
    }
  ];
}

いろいろ調べながら書いた。 React の基本的な描画機能は使えていると思う。

次はデータの外部化と Redux の導入を頑張りたい。

nukindex.com

reset.cssを自作してみた

リセットCSSを導入した。

作成したもの

reset.css(自作)

期待する動き

デフォルトのマージンやパディングなどの無駄なスタイリングを削除し、ブラウザ間の差異を吸収する。

結果

不明

手順など

styles ディレクトリに reset.css を作成する。

ROOT_DIRECTORY/
  + node_modules/
  + build/
  + source/
  |   + renderer/
  |   |   + components/
  |   |   |   + app/
  |   |   |   |   + view.js
  |   |   |   |   + style.css
  |   |   |   |
  |   |   |   + header/
  |   |   |       + view.js
  |   |   |       + style.css
  |   |   |
  |   |   + renderer.js
  |   |
  |   + styles/
  |   |   + reset.css
  |   |
  |   + index.html
  |
  + gulpfile.babel.js
  + .babelrc
  + package.json

source/index.html

reset.css を読み込めるようにする。

<!DOCTYPE html>
<html>
<head>
  <title>Nukindex</title>
  <link rel="stylesheet" href="./styles/reset.css">
  <link rel="stylesheet" href="./styles/bundle.css">
</head>
<body>
  <div id="app"></div>

  <script src="./renderer/bundle.js"></script>
</body>
</html>

source/styles/reset.css

とりあえずよく気になる部分をリセットしてみた。

html, body {
  margin: 0;
  padding: 0;
  font-weight: 300;
  font-family:
      -apple-system, BlinkMacSystemFont,
      "Helvetica Neue",
      "Segoe UI",
      "Noto Sans Japanese",
      "ヒラギノ角ゴ ProN W3",
      Meiryo,
      sans-serif;
}

p {
  line-height: 1.5;
  font-size: 16px;
}

h1 {
  margin: 0;
  padding: 0;
  line-height: 1.2;
  font-size: 32px;
}

h2,
h3,
h4,
h5,
h6 {
  margin: 0;
  padding: 0;
  line-height: 1.2;
  font-size: 16px;
}

ol,
ul {
  margin: 0;
  padding: 0;
  list-style: none;
}

input,
textarea {
  box-sizing: border-box;
}

input:focus,
textarea:focus {
  outline: none;
}

[hidden="true"] {
  display: none;
}

gulpfile.babel.js

CSSをビルドする関数を追加する。 React 用のCSSはビルドしたくないので gulp.src から renderer 以下を除外する。

import gulp from "gulp";
import browserify from "browserify";
import babelify from "babelify";
import source from "vinyl-source-stream";
import transform from "vinyl-transform";
import cssModulesify from "css-modulesify";

gulp.task("build", () => {
  buildHTML();
  buildCSS();
  buildRenderer();
});

function buildHTML() {
  gulp.src(["source/**/*.html", "!source/renderer/**/*"])
      .pipe(gulp.dest("build"));
}

function buildCSS() {
  gulp.src(["source/**/*.css", "!source/renderer/**/*"])
      .pipe(gulp.dest("build"));
}

function buildRenderer() {
  browserify({
    "entries": ["source/renderer/renderer.js"],
    "extensions": ["", ".js", ".jsx", ".css"]
  }).plugin(cssModulesify, {
    "output": "./build/styles/bundle.css"
  }).transform(babelify).bundle()
      .pipe(source("bundle.js"))
      .pipe(gulp.dest("build/renderer"));
}

nukindex.com

CSS Moduleを導入した

View に関してはこれで一段落。

作成したもの

CSS Moduleの導入

期待する動き

cssファイルを使ってコンポーネント毎にスタイルを定義する。ビルド時にひとつのcssファイルにまとめて出力する。

結果

成功

事例が探せなかったので苦労した。

手順など

コンポーネントディレクトリに style.css を作成する。

ROOT_DIRECTORY/
  + node_modules/
  + build/
  + source/
  |   + renderer/
  |   |   + components/
  |   |   |   + app/
  |   |   |   |   + view.js
  |   |   |   |   + style.css
  |   |   |   |
  |   |   |   + header/
  |   |   |       + view.js
  |   |   |       + style.css
  |   |   |
  |   |   + renderer.js
  |   |
  |   + index.html
  |
  + gulpfile.babel.js
  + .babelrc
  + package.json

gulpfile 内にCSSモジュールの使用準備をする。

$ npm install css-modulesify --save-dev

gulpfile.babel.js

browserify のプラグインとして導入する。

import gulp from "gulp";
import browserify from "browserify";
import babelify from "babelify";
import source from "vinyl-source-stream";
import transform from "vinyl-transform";
import cssModulesify from "css-modulesify";

gulp.task("build", () => {
  buildHTML();
  buildRenderer();
});

function buildHTML() {
  gulp.src("source/**/*.html")
      .pipe(gulp.dest("build"));
}

function buildRenderer() {
  browserify({
    "entries": ["source/renderer/renderer.js"],
    "extensions": ["", ".js", ".jsx", ".css"]
  }).plugin(cssModulesify, {
    "output": "./build/styles/bundle.css" // CSSファイルの出力先を指定
  }).transform(babelify).bundle()
      .pipe(source("bundle.js"))
      .pipe(gulp.dest("build/renderer"));
}

source/renderer/components/header/style.css

普通のCSSファイル。

.container {
  background-color: #fff;
}

.title {
  margin: 16px 0 12px;
  padding: 0 24px;
}

.description {
  margin: 12px 0 16px;
  padding: 0 24px;
}

source/renderer/components/header/view.js

stylesCSSファイルをインポートする。 styles にはCSSファイルに定義したクラス名がプロパティとして入る。 className にそのプロパティを入れ込む。

import React from "react";
import CSSModules from "react-css-modules";
import styles from "./style";

export default class Component extends React.Component {
  render() {
    return (
      <header className={styles.container}>
        <h1 className={styles.title}>
          <a href="/">Nukindex</a>
        </h1>

        <p className={styles.description}>
          毎日のおかずを提供するサイトです
        </p>
      </header>
    );
  }
}

source/index.html

ビルドしたCSSファイルを読み込む。

<!DOCTYPE html>
<html>
<head>
  <title>Nukindex</title>
  <link rel="stylesheet" href="./styles/bundle.css">
</head>
<body>
  <div id="app"></div>

  <script src="./renderer/bundle.js"></script>
</body>
</html>

React のスタイリングとしては CSS in JS がメジャーなのか、 CSS modules の使用例はあまり見つけられなかった。

先例がないと初心者にはつらい。

nukindex.com

React + Redux 開発用のディレクトリ構造を作った

先を見据えて丁寧に作っていきたいが先がどうなるか分からない。

作成したもの

React + Redux 開発用のディレクトリ構造

期待する動き

コンポーネント毎に独立したディレクトリ構造を作る

結果

成功

手順など

ROOT_DIRECTORY/
  + node_modules/
  + build/
  + source/
  |   + renderer/
  |   |   + components/
  |   |   |   + app/
  |   |   |   |   + view.js
  |   |   |   |
  |   |   |   + header/
  |   |   |       + view.js
  |   |   |
  |   |   + renderer.js
  |   |
  |   + index.html
  |
  + gulpfile.babel.js
  + .babelrc
  + package.json

source/index.html

React 描画エリアと React 描画ファイルを配置する。

<!DOCTYPE html>
<html>
<head>
  <title>Nukindex</title>
</head>
<body>
  <div id="app"></div>

  <script src="./renderer/bundle.js"></script>
</body>
</html>

source/renderer/renderer.js

React コンポーネントと DOM を結びつける。

React クラスは処理部分で使用していないが、これを削除するとブラウザ実行時にエラーになる。

import React from "react";
import ReactDOM from "react-dom";

import App from "./components/app/view";

ReactDOM.render(
  <App />,
  document.getElementById("app")
);

source/renderer/components/header/view.js

コンポーネントは components 以下に配置していく。 コンポーネント毎に MVC を用意していく予定なので js ファイルではなくディレクトリにまとめる。

import React from "react";

export default class HeaderComponent extends React.Component {
  render() {
    return (
      <header>
        <h1>
          <a href="/">Nukindex</a>
        </h1>

        <p>毎日のおかずを提供するサイトです</p>
      </header>
    );
  }
}

source/renderer/components/app/view.js

コンポーネントは別コンポーネントを呼び出し、使用することができる。

import React from "react";

import HeaderComponent from "../header/view";

export default class App extends React.Component {
  render() {
    return (
      <div>
        <HeaderComponent />
      </div>
    );
  }
}

詰まったのは ReactDOM クラスが React クラスに依存しているところくらいだった。

React クラスは処理部分で使用していないが、これを削除するとブラウザ実行時にエラーになる。

nukindex.com