Ruby on RailsにReactとReduxの環境を標準のWebpackで構築する

# 概要
Ruby on Rails上でReactを利用する場合、通常はGemのWebpackerを利用します。

ただ、アセットパイプライン上でのビルドが遅かったり、
Webpackのカスタマイズ方法が特殊だったりで使いずらいので、
Gemを使わずに設定します。

今回は標準のWebpackを導入した上で、Reactとの繋ぎ込みを目的としています。

Rails側から呼び出すコンポーネントを管理したいため、
ストラクチャ設計が分かりやすいReduxのRe-ducksパターンを採用した方法で構築を行っていきます。

また、ReactとReduxについての設定方法がSPAを全体としたものが多いですが、
Railsに実装する場合は既存プロジェクトにページ毎にReactを使うことが多いため、
Railsでルーティングを管理する場合での使用方法を書いていきます。


# スペック
* CentOS7
* Ruby 2.7.1p83
* Ruby on Rails 5.2.4
* node js 13.14.0
* react 16.12.0

今回はWebpackでAssetを用意して、ファイルを読み込むだけの設計になるため、Ruby on Rails6でも同様に設定できるかと思います。

# 手順
## インストール
今回はRuby, Railsを使っている人がReactを実装することを想定しているため、インストール方法を省きます。

NodeJSとYarnをインストールします

```console
curl -sL https://rpm.nodesource.com/setup_8.x | sudo bash -
sudo yum install nodejs
npm install -g yarn
```

WebpackやReact、Redux等のパッケージをインストールします。
その他Reduxの運用に必要なパッケージやビルド用のLoaderをインストールしていきます。

```console
yarn add webpack webpack-cli webpack-manifest-plugin
yarn add react react-redux redux-logger redux-thunk reselect axios
yarn add babel-core babel-preset-react babel-preset-es2015 babel-loader
yarn add typescript ts-loader
yarn add node-sass sass-loader postcss-loader style-loader mini-css-extract-plugin
yarn add file-loader expose-loader url-loader
yarn add thread-loader hard-source-webpack-plugin
```

作成されたpackage.jsonにscriptsを書き込みます。

```js:package.json
{
  ...
  },
  "scripts": {
    "webpack-dev": "webpack --watch --progress --mode=development --config webpack.config.js",
    "webpack-build": "webpack --mode=production",
    "webpack-clear": "rm ./public/assets/*",
    "cache-clear": "rm ./node_modules/.cache/hard-source/*"
  }
}
```

これでWebpackのビルドができます。
`webpack-dev` を利用すれば開発中にwatchした状態で利用できます。

```console
yarn webpack-dev
```

## Webpackの設定
まずは必要なモジュールを読み込みます。

```js:webpack.config.js
const webpack = require('webpack');
const ManifestPlugin = require('webpack-manifest-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HardSourceWebpackPlugin = require('hard-source-webpack-plugin');
```

次にパッケージ化するファイルを指定します。
Webpack管理下のディレクトリは `app/frontend/`以下にします。
また、`app/frontend/containers/`以下にReactのコンテナー（ルートコンポーネント）を
`app/frontend/packs/`以下に通常利用できるJS等を配置します。

ビルドしたファイルは `public/assets/`以下に作成します。

```js:webpack.config.js
const path = require('path');
const glob = require('glob');
const glob_path = './app/frontend/{containers,packs}/**/*.{js,jsx,ts,tsx,css,scss,sass}';
const entries = Object.fromEntries(glob.sync(glob_path).map((f) => ([f.split('/').reverse()[0].split('.')[0], f])));
const outputs = {
  filename: "[name]-[hash].js",
  path: path.join(__dirname, 'public', 'assets'),
  publicPath: "/"
};
```

loaderとモジュールの設定を行います。
Babel（JS）、Typescript、Sass等のローダーの設定をファイル拡張子によって作成します。

ManifestPluginを利用することで、`manifest.json`でファイルを管理でき、
Rails等で利用しやすくなります。
MiniCssExtractPluginではCSSファイルの圧縮を行います。
HardSourceWebpackPluginはビルド時にパッケージ等をキャッシュしてくれるため、ファイルの変更時などにビルドが早くなります。

純正のWebpackの設定のため、Webpackerと比べて設定方法については検索しやすいと思います。

```js:webpack.config.js
module.exports = {
  entry: entries,
  output: outputs,
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        loader: 'babel-loader',
        query: {
          presets: ['react', 'es2015']
        }
      },
      {
        test: /\.(ts|tsx)$/,
        use: [
          {
            loader: 'thread-loader',
            options: {
              workers: require('os').cpus().length - 1
            }
          },
          {
            loader: 'ts-loader',
            options: {
              transpileOnly: true,
              happyPackMode: true
            }
          }
        ]
      },
      {
        test: /\.css$/,
        use:['style-loader', 'css-loader']
      },
      {
        test: /\.scss$/,
        use: [
          { loader: process.env.NODE_ENV !== 'production' ? 'style-loader' : MiniCssExtractPlugin.loader},
          { loader: 'css-loader',},
          { loader: 'postcss-loader',
            options: {
              plugins: function () {
                return [
                  require('precss'),
                  require('autoprefixer')
                ];
              }
            }
          },
          { loader: 'sass-loader'}
        ]
      },
      {
        test: /.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)$/,
        use: "url-loader?limit=100000"
      },
    ],
  },
  resolve: {
    modules:[path.join(__dirname, 'node_modules')],
    extensions: ['.js', '.jsx', '.ts', '.tsx']
  },
  plugins: [
    new ManifestPlugin({
      writeToFileEmit: true
    }),
    new MiniCssExtractPlugin({
      filename: '[name].css'
    }),
    new HardSourceWebpackPlugin(),
  ],
};
```

## Typescriptの設定
Typescriptを導入したため、設定ファイルも用意しておきます。
ReduxのAction等でTypescriptの構文など使いたいため、最低限の設定を施しておく。

いきなり、Anyタイプを禁止してしまうと、ReactとRedux部分のクラスとか調べるのが大変なため、`noImplicitAny: false`で一旦許可しておく。

```json:tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "noImplicitAny": false,
    "allowJs": true,
    "skipLibCheck": true,
    "module": "esnext",
    "jsx": "react"
  },
  "include": [
    "app/frontend"
  ]
}
```

## Rails Helperの設定
Rails側からのコンポーネントの呼び出しはWebpacker Gemがやっていてくれていたが、
今回は導入しないため、自分でHelperを用意する。

`webpack_asset_path`では、`app/frontend/packs/`以下からファイルのパスが取得できる
`react_component`では、`app/frontend/components/`以下からReactのコンテナーが取得できる

```ruby:app/helpers/react_helper.rb
module ReactHelper
  # /app/frontend/packs/
  def webpack_asset_path(file_name)
    @webpack_manifest ||= JSON.parse(File.read("public/assets/manifest.json")
    if @webpack_manifest.has_key?(file_name)
      "/assets#{manifest.fetch(file_name)}"
    else
      raise Exception.new("not found #{file_name} in manifest.")
    end
  end

  # /app/frontend/components/*
  def react_component(name, **props)
    id_name = "#{name}-container"
    js_file = "#{name.underscore}_container.js"
    props[:flash] = flash.presence&.to_h || {}
    valid_file?(js_file)

    content_tag :section do
      concat(content_tag(:div, "", id: id_name, data: {react_props: props}))
      concat(javascript_include_tag(asset_bundle_path(js_file)))
    end
  end
end
```

## React側の設定
これまでの設定から、ストラクチャ設計は以下の通りにする。

```
./app
└── frontend
    ├── rails_container.tsx
    ├── packs
    │   └── application_pack.js
    ├── containers
    │   └── sample
    │       └── sample_container.js
    ├── components
    │   └── sample
    │       └── sample_component.tsx
    ├── reducks
    │   └── samples
    │       ├── actions.js
    │       ├── index.js
    │       ├── operations.js
    │       ├── reducers.ts
    │       ├── selectors.js
    │       ├── store.js
    │       └── types.ts
    └── stylesheets
        └── application.scss
```

Railsのヘルパーで作ったIDにDOMでReactコンポーネントを作成しなければならないため、
関数を作ってコンポーネントを作成できるようにします。

SPAとは違い、ページ毎に使用するオブジェクトは異なるため、
ビルドのコスト等を考慮して、コンテナー毎にReducerが指定できるようにします。

```ts:app/frontend/rails_container.js
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { createStore, combineReducers } from 'redux';
import logger from 'redux-logger';
import thunk from 'redux-thunk';

type Component = React.FC<any> | React.ComponentClass<any>;
type Reducers = { [key: string]: any };

// ./app/helpers/react_helper.rb: ReactHelper.react_component(name, **props)
export default function railContainer(name: string, component: Component, reducers: Reducers) {
  const app: HTMLElement | null = document.getElementById(name + '-container');

  if (app) {
    const store = createStore(
      combineReducers(reducers),
      applyMiddleware(logger, thunk)
    );
    const reactProps: object = JSON.parse(app.dataset.reactProps || "{}");
    render(
      <Provider store={store}>
        {React.createElement(component, {railsProps: reactProps})}
      </Provider>,
      app
    );
  } else {
    console.log("not found "+name+" container");
  }
}
```

コンテナーで使用する、コンポーネントとReducerを読み込んで、先ほど作成した関数でコンテナーを作成します。

```js:app/frontend/containers/sample/sample_containers.js
import railsContainer from "../../lib/rails_container";
import { SamplesReducer } from "../../reducks/samples/reducers";
import { SampleComponent } from "../../components/sample_component";

railsContainer("SampleComponent", SampleComponent, {
  samples: SamplesReducer,
});
```

railsContainerで設定したコンポーネントにはPropsの中に `railsProps`が入っています。
Rails側からHashで渡したものがJS側のObjectで取得できます。

Re-ducksパターンにおいてOperationsはActionを呼び出す役割なので、`dispatch(mountSamples(props.railsProps))`でStoreにデータを設定できます。

`useEffect(()=>{}, [])`を使うことでコンポーネント起動時に実行されるため、
コンテナーを呼び出す際にRailsで渡した値を読み込むことができます。
(`componentDidMount`と同じです。)

```tsx:app/frontend/components/sample/sample_component.tsx
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from "react-redux";
import { mountSamples } from "../../reducks/samples/operations";
import { getSamplesState } from "../../reducks/samples/selectors";
import { Samples } from "../../reducks/samples/types";
import { ChildComponent } from "./child_component";

type Props = {
  railsProps: {
    samples: Samples,
  },
}

const SampleComponent: React.FC<Props> = (props) => {
  const dispatch = useDispatch();
  const selector = useSelector(state => state);

  const samples = getSamplesState(selector);

  useEffect(() => {
    dispatch(mountSamples(props.railsProps));
  }, []);

  return (
    <ChildComponent/>
  )
}

export default SampleComponent;
```

## 補足
### React Hooks, Re-ducksパターンについて
上記ではReact Hooksを使った実装をしていますが、
Re-ducksパターンやReact Hooksの説明はトラハックさんのYoutubeが分かりやすいと思います。
https://www.youtube.com/watch?v=FBMA34gUsgw&list=PLX8Rsrpnn3IWavNOj3n4Vypzwb3q1RXhr

### ビルドされたファイル
`yarn webpack-build`等でビルドすると、`public/assets/`以下に`app/frontend/packs/` `app/frontend/containers`以下に配置したファイルがビルドされていることが分かると思います。

この２つのディレクトリが必ずJSのプログラムの開始位置になると覚えておくと、コードの管理が楽になるかと思います。

```
public/assets/
├── manifest.json
├── application_pack-1732212f4623eefd2c49.js
└── sample_container-1732212f4623eefd2c49.js
```

### hard-source-webpack-pluginについて
今回RailsにWebpackerを使わないで、標準のWebpackで設定する方法を選んだのは、
コードが膨らんできてビルド時間が遅くなってしまう問題を抱えていたからでした。

Railsのアセットパイプラインから切り離して体感的にはビルドが早くなったのですが、あと一押し欲しい感じでした。
hard-source-webpack-pluginはキャッシュを作成することでビルド時間の短縮を図れるため、使用しました。
watchモードで開発する際などにキャッシュが効いていると、毎回のようにビルドされていたのが短縮されるのでとても便利です。

たまにキャッシュが悪さして上手くビルドができなくなる時があるらしいので、
その場合は`node_modules/.cache/hard-source/`以下のキャッシュデータを削除する方法が推奨されています。
(今回は`yarn cache-clear`でコマンドを用意しました。)

# 参考
* [Webpackerを導入してから外すまでをふりかえる](https://tech.misoca.jp/entry/2019/02/22/110000)
* [React×Reduxのディレクトリ構成にre-ducksパターンを採用すべき理由](https://tech.playground.style/javascript/re-ducks/)
* [CentOS 7 Node.js のインストール手順 (yum を利用)](https://qiita.com/daskepon/items/16a77868d38f8e585840)
* [yarnをインストールする](https://qiita.com/suisui654/items/1b89446e03991c7c2c3d)
* [webpack.config.jsがわからない](https://qiita.com/one-kelvin/items/b810aafb6b5ef90789a3)
* [tsconfig.jsonの全オプションを理解する（随時追加中）](https://qiita.com/ryokkkke/items/390647a7c26933940470)
* [日本一わかりやすいReact-Redux入門](https://www.youtube.com/watch?v=FBMA34gUsgw&list=PLX8Rsrpnn3IWavNOj3n4Vypzwb3q1RXhr)
* [webpackのビルドを10倍高速化するプラグインの紹介](https://qiita.com/kmdsbng/items/d23f9b5f5fbc8869baad)