1
2

More than 3 years have passed since last update.

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

Posted at

概要

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をインストールします

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

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

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を書き込みます。

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した状態で利用できます。

yarn webpack-dev

Webpackの設定

まずは必要なモジュールを読み込みます。

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/以下に作成します。

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と比べて設定方法については検索しやすいと思います。

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で一旦許可しておく。

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のコンテナーが取得できる

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が指定できるようにします。

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を読み込んで、先ほど作成した関数でコンテナーを作成します。

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と同じです。)

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以下に配置したファイルがビルドされていることが分かると思います。

この2つのディレクトリが必ず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でコマンドを用意しました。)

参考

1
2
0

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
1
2