Open29

Rails 7 + React + tailwind のプロジェクト新規作成(esbuild)

s10akirs10akir

Rail7 +esbuildでReactが書けるアプリを新規で構築してみる

環境:

OS: M2 Macbook Air
ruby: v3.2.2
node: 20.9.0

s10akirs10akir

rails newのオプション
https://railsdoc.com/page/rails_new

結構知らないのが色々ある
今回は最小構成にして、必要なものをあとから使いたいので --minimal 相当で構築することにする

minimalはこの辺がskipされるらしい

Based on the specified options, the following options will also be activated:

  --skip-active-job [due to --minimal]
  --skip-action-mailer [due to --skip-active-job, --minimal]
  --skip-active-storage [due to --skip-active-job, --minimal]
  --skip-action-mailbox [due to --skip-active-storage, --minimal]
  --skip-action-text [due to --skip-active-storage, --minimal]
  --skip-javascript [due to --minimal]
  --skip-hotwire [due to --skip-javascript, --minimal]
  --skip-action-cable [due to --minimal]
  --skip-bootsnap [due to --minimal]
  --skip-dev-gems [due to --minimal]
  --skip-jbuilder [due to --minimal]
  --skip-system-test [due to --minimal]

ただし、今回は --skip--javascript をしてほしくないので(後述)minimal相当のオプションを明示していく方式にする(--minimal--javascript オプションを同列に並べても --javascript が負けてしまった)

s10akirs10akir

Reactを書くに当たって、今回はesbuildをビルド環境に使いたいので、 --javascript esbuild を明示する

shakapacker(webpackerの後継)と比べると低機能だが軽いらしい
importmapというオプションもあってこれはCDNからjsのモジュールを読み込むのでnodeの実行環境すら必要ないが、reactはjsxのビルドが必要なので単体では機能せず追加でビルド環境を足す必要がある

shakapackerは前に試したので今回はesbuild

https://techracho.bpsinc.jp/hachi8833/2022_05_26/118202

s10akirs10akir

ついでにtailwindも使いたいので --css tailwind も明示する

s10akirs10akir

つまりこう

rails new --name my-new-app \
 --skip-active-job \
 --skip-action-mailer \
 --skip-active-storage \
 --skip-action-mailbox \
 --skip-action-text \
 --skip-hotwire \
 --skip-action-cable \
 --skip-bootsnap \
 --skip-dev-gems \
 --skip-jbuilder \
 --skip-system-test \
 --javascript esbuild \
 --css tailwind \
 .

(ディレクトリは先に切ってあり、カレントがそこ)

s10akirs10akir

いい感じ

tree . -I node_modules/
.
├── Dockerfile
├── Gemfile
├── Gemfile.lock
├── Procfile.dev
├── README.md
├── Rakefile
├── app
│   ├── assets
│   │   ├── builds
│   │   │   ├── application.css
│   │   │   ├── application.js
│   │   │   └── application.js.map
│   │   ├── config
│   │   │   └── manifest.js
│   │   ├── images
│   │   └── stylesheets
│   │       └── application.tailwind.css
│   ├── controllers
│   │   ├── application_controller.rb
│   │   └── concerns
│   ├── helpers
│   │   └── application_helper.rb
│   ├── javascript
│   │   └── application.js
│   ├── models
│   │   ├── application_record.rb
│   │   └── concerns
│   └── views
│       └── layouts
│           └── application.html.erb
├── bin
│   ├── bundle
│   ├── dev
│   ├── docker-entrypoint
│   ├── rails
│   ├── rake
│   └── setup
├── config
│   ├── application.rb
│   ├── boot.rb
│   ├── credentials.yml.enc
│   ├── database.yml
│   ├── environment.rb
│   ├── environments
│   │   ├── development.rb
│   │   ├── production.rb
│   │   └── test.rb
│   ├── initializers
│   │   ├── assets.rb
│   │   ├── content_security_policy.rb
│   │   ├── filter_parameter_logging.rb
│   │   ├── inflections.rb
│   │   └── permissions_policy.rb
│   ├── locales
│   │   └── en.yml
│   ├── master.key
│   ├── puma.rb
│   └── routes.rb
├── config.ru
├── db
│   └── seeds.rb
├── lib
│   ├── assets
│   └── tasks
├── log
│   └── development.log
├── package.json
├── public
│   ├── 404.html
│   ├── 422.html
│   ├── 500.html
│   ├── apple-touch-icon-precomposed.png
│   ├── apple-touch-icon.png
│   ├── favicon.ico
│   └── robots.txt
├── storage
├── tailwind.config.js
├── test
│   ├── controllers
│   ├── fixtures
│   │   └── files
│   ├── helpers
│   ├── integration
│   ├── models
│   └── test_helper.rb
├── tmp
│   ├── cache
│   │   └── assets
│   ├── pids
│   └── storage
├── vendor
└── yarn.lock

40 directories, 53 files
s10akirs10akir

Rails7ではプロジェクト初期構築時にDockerfileも作ってくれて、これが地味に便利

Dockerfile
# syntax = docker/dockerfile:1

# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.2.2
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base

# Rails app lives here
WORKDIR /rails

# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"


# Throw-away build stage to reduce size of final image
FROM base as build

# Install packages needed to build gems and node modules
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential curl git node-gyp pkg-config python-is-python3

# Install JavaScript dependencies
ARG NODE_VERSION=20.9.0
ARG YARN_VERSION=1.22.19
ENV PATH=/usr/local/node/bin:$PATH
RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
    /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
    npm install -g yarn@$YARN_VERSION && \
    rm -rf /tmp/node-build-master

# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git

# Install node modules
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

# Copy application code
COPY . .

# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile


# Final stage for app image
FROM base

# Install packages needed for deployment
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y curl libsqlite3-0 && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Copy built artifacts: gems, application
COPY --from=build /usr/local/bundle /usr/local/bundle
COPY --from=build /rails /rails

# Run and own only the runtime files as a non-root user for security
RUN useradd rails --create-home --shell /bin/bash && \
    chown -R rails:rails db log storage tmp
USER rails:rails

# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD ["./bin/rails", "server"]
s10akirs10akir

Procfile.dev というエントリポイントができている
これでrails serverとフロントビルド環境を同時に起動するらしい

Procfile.dev
web: env RUBY_DEBUG_OPEN=true bin/rails server
js: yarn build --watch
css: yarn build:css --watch
s10akirs10akir

なので、開発環境は rails s ではなく bin/dev というエントリポイントを利用する
プロセスマネージャはforemanらしい

bin/dev
#!/usr/bin/env sh

if ! gem list foreman -i --silent; then
  echo "Installing foreman..."
  gem install foreman
fi

# Default to port 3000 if not specified
export PORT="${PORT:-3000}"

exec foreman start -f Procfile.dev "$@"
s10akirs10akir

とりあえず起動 ほぼまっさらとはいえ開発環境上がるの早いな

terminal
> ./bin/dev
01:47:45 web.1  | started with pid 72333
01:47:45 js.1   | started with pid 72334
01:47:45 css.1  | started with pid 72335
01:47:45 js.1   | yarn run v1.22.19
01:47:45 css.1  | yarn run v1.22.19
01:47:45 js.1   | $ esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets --watch
01:47:45 css.1  | $ tailwindcss -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css --minify --watch
01:47:45 js.1   | [watch] build finished, watching for changes...
01:47:45 css.1  |
01:47:45 css.1  | Rebuilding...
01:47:45 css.1  |
01:47:45 css.1  | warn - No utility classes were detected in your source files. If this is unexpected, double-check the `content` option in your Tailwind CSS configuration.
01:47:45 css.1  | warn - https://tailwindcss.com/docs/content-configuration
01:47:45 css.1  |
01:47:45 css.1  | Done in 100ms.
01:47:45 web.1  | DEBUGGER: Debugger can attach via UNIX domain socket (/var/folders/gq/2k4rl7w53y10zd0g9xb_94j00000gn/T/ruby-debug-sock-501/ruby-debug-s10akir-72333)
01:47:45 web.1  | => Booting Puma
01:47:45 web.1  | => Rails 7.1.2 application starting in development
01:47:45 web.1  | => Run `bin/rails server --h01:47:45 web.1  | Puma starting in single mode...
01:47:45 web.1  | * Puma version: 6.4.0 (ruby 3.2.2-p53) ("The Eagle of Durango")
01:47:45 web.1  | *  Min threads: 5
01:47:45 web.1  | *  Max threads: 5
01:47:45 web.1  | *  Environment: development
01:47:45 web.1  | *          PID: 72333
01:47:45 web.1  | * Listening on http://127.0.0.1:3000
01:47:45 web.1  | * Listening on http://[::1]:3000
01:47:45 web.1  | Use Ctrl-C to stop
s10akirs10akir

Yay! You're on Rails! って言わないんだ...(Rails老人会)

s10akirs10akir

tailwindが効いているか確認したいのでとりあえず雑にエンドポイントを追加

terminal
> bundle exec rails g controller Top index
      create  app/controllers/top_controller.rb
       route  get 'top/index'
      invoke  erb
      create    app/views/top
      create    app/views/top/index.html.erb
      invoke  test_unit
      create    test/controllers/top_controller_test.rb
      invoke  helper
      create    app/helpers/top_helper.rb
      invoke    test_unit

routingに追加

config/routes.rb
 Rails.application.routes.draw do
+  root 'top#index'
+
   # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

   # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
   # Can be used by load balancers and uptime monitors to verify that the app is live.
   get "up" => "rails/health#show", as: :rails_health_check

   # Defines the root path route ("/")
   # root "posts#index"
 end

templateを修正

app/views/top/index.html.erb
-<h1>Top#index</h1>
+<h1 class="text-4xl font-bold">Top#index</h1>
-<p>Find me in app/views/top/index.html.erb</p>
+<p class="italic">Find me in app/views/top/index.html.erb</p>

全然関係ないけどtailwindのクラス覚えられないのでいつもこれ見てる
https://tailwindcomponents.com/cheatsheet/

s10akirs10akir

紛らわしくなるのでtailwindの確認のために行った変更は一回全部破棄

s10akirs10akir

react関連パッケージを追加
yarn add react react-dom

TSで書きたいので型定義も
yarn add -D @types/react @types/react-dom

package.json
 {
  "name": "app",
  "private": "true",
  "dependencies": {
    "autoprefixer": "^10.4.16",
    "esbuild": "^0.19.5",
    "postcss": "^8.4.31",
+   "react": "^18.2.0",
+   "react-dom": "^18.2.0",
    "tailwindcss": "^3.3.5"
  },
  "scripts": {
    "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets",
    "build:css": "tailwindcss -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css --minify"
+  },
+  "devDependencies": {
+    "@types/react": "^18.2.37",
+    "@types/react-dom": "^18.2.15"
  }
}
s10akirs10akir

ちなみにesbuildはpackage.jsonもwatchしてるらしくて勝手に再ビルドが走ってた えらいじゃん

01:49:00 js.1   | [watch] build started (change: "package.json")
01:49:00 js.1   | [watch] build finished
s10akirs10akir

Reactがちゃんと動くか確認したいので雑にエンドポイントを追加(2回目)

terminal
> bundle exec rails g controller Top index
      create  app/controllers/top_controller.rb
       route  get 'top/index'
      invoke  erb
      create    app/views/top
      create    app/views/top/index.html.erb
      invoke  test_unit
      create    test/controllers/top_controller_test.rb
      invoke  helper
      create    app/helpers/top_helper.rb
      invoke    test_unit

routingに追加

config/routes.rb
 Rails.application.routes.draw do
+  root 'top#index'
+
   # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

   # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
   # Can be used by load balancers and uptime monitors to verify that the app is live.
   get "up" => "rails/health#show", as: :rails_health_check

   # Defines the root path route ("/")
   # root "posts#index"
 end

Reactコンポーネントを作成

app/javascript/Top.tsx
import React from "react";
import { createRoot } from "react-dom/client";

const container = document.getElementById("root");

if (!container) {
  throw new Error("Couldn't find a root element");
}

const root = createRoot(container);

document.addEventListener("DOMContentLoaded", () => {
  root.render(
    <>
      <h1 className="text-4xl font-bold">Yay! You're on Rails!</h1>
    </>
  );
});

templateを修正

app/views/top/index.html.erb
<%= javascript_include_tag "Top", defer: true %>

<div id="root"></div>
s10akirs10akir

React Componentは読まれた、が クラスはちゃんと当たってるのにtailwindのスタイルが当たっていないな?

s10akirs10akir

tailwindは最適化のために実際に使われているclassのみを含んだスタイルを吐き出すが、この動的ビルドを行うときにviewファイルだけを見ていてtsx側のクラス利用状況が読まれてないっぽい

s10akirs10akir

Railsアプリ生成時に初期生成されている tailwind.config.js*.js のファイルしか見ないようになっていた
これを修正する

tailwind.config.js
 module.exports = {
   content: [
     './app/views/**/*.html.erb',
     './app/helpers/**/*.rb',
     './app/assets/stylesheets/**/*.css',
-     './app/javascript/**/*.js'
+     './app/javascript/**/*.*'
   ]
 }
s10akirs10akir

特に何も考えず拡張子をts/tsxにするだけでTypeScriptが書けるのえらいな
ただしesbuildは型チェックまではしないらしいので、静的解析は別でCIとかに入れたほうがいいかも

少なくともコードを書いてる最中はvimに入れてるlanguage serverが指摘してくれるので問題なし

s10akirs10akir

eslintの初期設定をする
前は eslint --init とかしてた記憶があるけど今は @eslint/config というツールが用意されてる模様

オプションはこんな感じにした
enforce code styleは切ったほうがいいかもと思ったけど、このあと eslint-config-prettierで打ち消すので一旦一番強いconfigで設定

yarn create @eslint/config
yarn create v1.22.19
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...

success Installed "@eslint/create-config@0.4.6" with binaries:
      - create-config
✔ How would you like to use ESLint? · style
✔ What type of modules does your project use? · esm
✔ Which framework does your project use? · react
✔ Does your project use TypeScript? · No / Yes
✔ Where does your code run? · browser
✔ How would you like to define a style for your project? · guide
✔ Which style guide do you want to follow? · standard-with-typescript
✔ What format do you want your config file to be in? · JavaScript
Checking peerDependencies of eslint-config-standard-with-typescript@latest
The config that you've selected requires the following dependencies:

eslint-plugin-react@latest eslint-config-standard-with-typescript@latest @typescript-eslint/eslint-plugin@^6.4.0 eslint@^8.0.1 eslint-plugin-import@^2.25.2 eslint-plugin-n@^15.0.0 || ^16.0.0  eslint-plugin-promise@^6.0.0 typescript@*
✔ Would you like to install them now? · No / Yes
✔ Which package manager do you want to use? · yarn
s10akirs10akir

eslintのconfigを書き換えてprettierと競合するルールを落とす

.eslintrc.js
 module.exports = {
     "env": {
         "browser": true,
         "es2021": true
     },
     "extends": [
         "standard-with-typescript",
-        "plugin:react/recommended"
+        "plugin:react/recommended",
+        "prettier"
     ],
     "overrides": [
         {
             "env": {
                 "node": true
             },
             "files": [
                 ".eslintrc.{js,cjs}"
             ],
             "parserOptions": {
                 "sourceType": "script"
             }
         }
     ],
     "parserOptions": {
         "ecmaVersion": "latest",
         "sourceType": "module"
     },
     "plugins": [
         "react"
     ],
     "rules": {
     }
 }
s10akirs10akir

tscを使わなくてもesbuildがトランスパイルをしてくれるので一旦tsconfigを作っていなかったが、作っておかないとeslintが文句を言うようになるのでとりあえず生成
細かい内容は後で調整することにするが、esbuildはtsconfigの一部項目しか参照しないらしいのであんまりこだわる必要はなさそう

yarn tsc --init
s10akirs10akir

eslintが色々文句言ってくれるようになったのでいい感じ
そしてprettierが管轄するフォーマットの文脈には口を出していないことを確認

s10akirs10akir

開発環境をdocker composeであげられるようにした
foremanでプロセス管理されてる部分を個別のコンテナにしている

compose.yaml
services:
  web:
    image: ruby:3.2.2
    command: 'env RUBY_DEBUG_OPEN=true bin/rails server --binding 0.0.0.0'
    ports:
      - 3000:3000
    volumes:
      - ./:/usr/src/app
      - bundler:/usr/local/bundle
    working_dir: /usr/src/app

  js-build:
    image: node:20.9.0
    command: 'yarn build --watch'
    stdin_open: true
    working_dir: /usr/src/app
    volumes:
      - ./:/usr/src/app
      - node_modules:/usr/src/app/node_modules
      - yarn_cache:/usr/local/share/.cache/yarn/v6

  css-build:
    image: node:20.9.0
    command: 'yarn build:css --watch'
    stdin_open: true
    working_dir: /usr/src/app
    volumes:
      - ./:/usr/src/app
      - node_modules:/usr/src/app/node_modules
      - yarn_cache:/usr/local/share/.cache/yarn/v6

volumes:
  bundler:
  node_modules:
  yarn_cache:

gemの解決とnode_modulesの解決は

docker compose run --rm web bundle install
docker compose run --rm js-build yarn install

これでdocker volumeに永続化される
node系の依存はjs-buildとcss-buildで共通化しているのでどっちかでやればOK