🐇
React on Rails (with Webpacker) - gemあり/なしを比較する
動機
- jQueryは嫌だからReact使いたい
- モノリシックなRailsアプリを、フロントとバックエンドに分離するほどリソースに余裕がない
- Rails7からフロントエンド周りが結構変わったから、アップグレードが大変
あたりの理由から、「Webpackerを使って、モノリシックなRailsアプリの上でReactを動かす」という状態になっているアプリはまだそこそこあるんじゃないかと思います。
ちょうどそんなアプリのメンテをする機会があり、どういう仕組みでReactが動いているか調べることになったので、わかったことをまとめます。
この記事では扱わないこと
- importmaps (Rails7でデフォルトで採用されている、フロントエンド周りを処理する仕組み)
- Shakapacker (Webpackerの後継)
- Hotwire
- ReactアプリとRailsアプリを分離し、APIで通信するパターンの実装
環境
$ ruby -v
ruby 3.1.2p20 (2022-04-12 revision 4491bb740a) [arm64-darwin21]
$ rails -v
Rails 6.1.7
$ yarn -v
1.22.19
react用のgemを使わない場合の実装方法
npmパッケージのインストール & 設定ファイルの書き換え
$ rails webpacker:install:react
変更の差分
package.json
{
"name": "react-r",
"private": true,
"dependencies": {
+ "@babel/preset-react": "^7.18.6",
"@rails/actioncable": "^6.0.0",
"@rails/activestorage": "^6.0.0",
"@rails/ujs": "^6.0.0",
"@rails/webpacker": "5.4.3",
+ "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
+ "prop-types": "^15.8.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
"turbolinks": "^5.2.0",
"webpack": "^4.46.0",
"webpack-cli": "^3.3.12"
},
"version": "0.1.0",
"devDependencies": {
"webpack-dev-server": "^3"
}
}
babel.config.js
module.exports = function(api) {
//中略
return {
presets: [
isTestEnv && [
'@babel/preset-env',
{
targets: {
node: 'current'
+ },
+ modules: 'commonjs'
+ },
+ '@babel/preset-react'
],
(isProductionEnv || isDevelopmentEnv) && [
'@babel/preset-env',
{
forceAllTransforms: true,
useBuiltIns: 'entry',
corejs: 3,
modules: false,
exclude: ['transform-typeof-symbol']
}
+ ],
+ [
+ '@babel/preset-react',
+ {
+ development: isDevelopmentEnv || isTestEnv,
+ useBuiltIns: true
+ }
//中略
[
'@babel/plugin-transform-runtime',
{
+ helpers: false,
+ regenerator: true,
+ corejs: false
}
],
[
'@babel/plugin-transform-regenerator',
{
async: false
}
+ ],
+ isProductionEnv && [
+ 'babel-plugin-transform-react-remove-prop-types',
+ {
+ removeImport: true
+ }
]
].filter(Boolean)
}
}
config/webpacker.yml
extensions:
+ - .jsx
- .mjs
- .js
viewファイルに、コンポーネントをマウントするDOMを作成
app/views/sample/index.html.erb
<div id="hogeHoge"></div>
コンポーネントを作成
$ mkdir app/javascript/packs/components
$ touch app/javascript/packs/components/hoge_hoge.jsx
app/javascript/packs/components/hoge_hoge.jsx
import React from 'react';
const HogeHoge = props => {
return (
<h3>Hello, {props.message}!</h3>
);
};
export default HogeHoge
コンポーネントをマウント
$ touch app/javascript/packs/mount.js
app/javascript/packs/mount.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import HogeHoge from './components/hoge_hoge'
document.addEventListener('turbolinks:load', () => {
const mountNode = document.getElementById('hogeHoge');
const root = createRoot(mountNode);
root.render(<HogeHoge message="Hello World" />);
})
app/views/layouts/application.html.erb
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
+<%= javascript_pack_tag 'mount', 'data-turbolinks-track': 'reload' %>
ビルド
$ bin/webpack-dev-server
Railsの変数をReactコンポーネントに渡す
app/views/sample/index.html.erb
<div id="hogeHoge" data-message="<%= @message %>"></div>
app/javascript/packs/mount.js
document.addEventListener('turbolinks:load', () => {
const mountNode = document.getElementById('hogeHoge');
+ const message = mountNode.dataset.message;
const root = createRoot(mountNode);
- root.render(<HogeHoge message="Hello World" />);
+ root.render(<HogeHoge message="{message} />);
})
コンポーネントの受け渡しを楽にするメソッドを作る
app/helpers/application_helper.rb
def react_component(name, props)
content_tag(:div, { id: name, data: { react_props: props } }) do
end
end
app/views/sample/index.html.erb
- <div id="hogeHoge" data-message="<%= @message %>"></div>
+ <%= react_component('hogeHoge', {message: @message, foo: @bar}) %>
app/javascript/packs/mount.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import HogeHoge from './hoge_hoge'
const mount = (Component, mountNodeId) => {
document.addEventListener('turbolinks:load', () => {
const mountNode = document.getElementById(mountNodeId);
const propsJSON = mountNode.getAttribute('data-react-props');
const props = JSON.parse(propsJSON);
const root = createRoot(mountNode);
root.render(<Component {...props} />);
})
}
mount(HogeHoge, 'hogeHoge');
react-rails
というgemを使う場合の実装方法
react-railsをインストール
Gemfile
gem 'react-rails'
$ bundle install
npmパッケージのインストール & 設定ファイルの書き換え
$ rails webpacker:install:react
変更の差分
package.json
{
"name": "react-r",
"private": true,
"dependencies": {
+ "@babel/preset-react": "^7.18.6",
"@rails/actioncable": "^6.0.0",
"@rails/activestorage": "^6.0.0",
"@rails/ujs": "^6.0.0",
"@rails/webpacker": "5.4.3",
+ "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
+ "prop-types": "^15.8.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
"turbolinks": "^5.2.0",
"webpack": "^4.46.0",
"webpack-cli": "^3.3.12"
},
"version": "0.1.0",
"devDependencies": {
"webpack-dev-server": "^3"
}
}
babel.config.js
module.exports = function(api) {
//中略
return {
presets: [
isTestEnv && [
'@babel/preset-env',
{
targets: {
node: 'current'
+ },
+ modules: 'commonjs'
+ },
+ '@babel/preset-react'
],
(isProductionEnv || isDevelopmentEnv) && [
'@babel/preset-env',
{
forceAllTransforms: true,
useBuiltIns: 'entry',
corejs: 3,
modules: false,
exclude: ['transform-typeof-symbol']
}
+ ],
+ [
+ '@babel/preset-react',
+ {
+ development: isDevelopmentEnv || isTestEnv,
+ useBuiltIns: true
+ }
//中略
[
'@babel/plugin-transform-runtime',
{
+ helpers: false,
+ regenerator: true,
+ corejs: false
}
],
[
'@babel/plugin-transform-regenerator',
{
async: false
}
+ ],
+ isProductionEnv && [
+ 'babel-plugin-transform-react-remove-prop-types',
+ {
+ removeImport: true
+ }
]
].filter(Boolean)
}
}
config/webpacker.yml
extensions:
+ - .jsx
- .mjs
- .js
ujs周りの調整
$ rails g react:install
変更の差分
package.json
"react-dom": "^18.2.0",
+"react_ujs": "^2.6.2",
"turbolinks": "^5.2.0",
app/javascript/packs/application.js
Rails.start()
Turbolinks.start()
ActiveStorage.start()
+var componentRequireContext = require.context("components", true);
+var ReactRailsUJS = require("react_ujs");
+ReactRailsUJS.useContext(componentRequireContext);
app/javascript/packs/server_rendering.js
+var componentRequireContext = require.context("components", true);
+var ReactRailsUJS = require("react_ujs");
+ReactRailsUJS.useContext(componentRequireContext);
コンポーネントを作成
$ rails g react:component samples/HelloWorld greeting:string
以下のファイルが生成される
app/javascript/components/samples/HelloWorld.js
import React from "react"
import PropTypes from "prop-types"
class HelloWorld extends React.Component {
render () {
return (
<React.Fragment>
Greeting: {this.props.greeting}
</React.Fragment>
);
}
}
HelloWorld.propTypes = {
greeting: PropTypes.string
};
export default HelloWorld
コンポーネントをviewから呼び出す
app/views/sample/index.html.erb
<%= react_component("samples/HelloWorld", { greeting: "Hello from react-rails." }) %>
gemを使うことのメリット/デメリット
メリット
- viewの中でpartialを呼び出すようなノリで、Reactコンポーネントを呼び出せるようになる
- DOMにReactコンポーネントをマウントする処理(空のdiv要素を作って、getElementById→renderするやつ)が不要になる
- 特に自分で関数を作らなくても、Rails側からReactに変数を渡せる
- 新規コンポーネントを生成するとき、
rails g
コマンドが使えるようになる- ファイルのパスとかに秩序が生まれる
- 生成されるファイルの中身は、classコンポーネントで書かれてたりして微妙
デメリット
- gemがreactのアップデートに対応しきれなくなる可能性がこわい
- 動かなくなるまではいかなくても、謎のwarningがたくさん出ることになりそう
- 内部の処理がわかりにくくなるので、デバッグがめんどそう
参考
Discussion