概要
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を書き込みます。
{
...
},
"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の設定
まずは必要なモジュールを読み込みます。
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/
以下に作成します。
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と比べて設定方法については検索しやすいと思います。
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
で一旦許可しておく。
{
"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のコンテナーが取得できる
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が指定できるようにします。
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を読み込んで、先ほど作成した関数でコンテナーを作成します。
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
と同じです。)
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
でコマンドを用意しました。)