🐇

React on Rails (with Webpacker) - gemあり/なしを比較する

2022/12/28に公開

動機

  • 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を使う場合の実装方法

https://github.com/reactjs/react-rails

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がたくさん出ることになりそう
  • 内部の処理がわかりにくくなるので、デバッグがめんどそう

参考

https://jtway.co/rails-typescript-react-js-c52a591e8276

Discussion