スケーラブルなフロントエンド開発 on Rails

# はじめに
Rails オンリーでのフロントエンド開発は、Vue.js や React と比べると、**型安全が得られないこと**、**コンポーネント指向ではない**等の理由からスケールしづらく、大規模開発には向いていない、と考えていました。

一方で、SPA を使う理由が特に無い場合、Rails オンリーでの開発にはそれなりにメリットがあるとも考えていました。
**技術要素が少なくて済む**こと、**ActionView の恩恵を受けて爆速で開発できる**こと等です。

本記事では、**Rail には乗りつつ、スケーラブルにフロントエンド開発をするにはどうすればいいか**ということに焦点を当てて、いくつかの提案をします。

# まず結論
- CSS を可能な限り書かない → **Tailwind on Rails**
- どうしても CSS を使いたいときは Scoped に書く
- views / components / FormHelper の３レイヤーで考える
  - グローバルな CSS は 基本的な HTML 要素に限り使用可とする
  - ViewComponent を使用する

# CSS を可能な限り書かない
CSS はグローバルに定義される定数のようなものです。どのファイルからでもアクセスできますが、それはつまり**リファクタリングすると、その影響が画面全体に及ぶ**ということです。

そのため、クラス名やファイルの管理の仕方を事前に十分に設計しておく必要があります。
開発が始まってからも、そのルールを開発者に周知し、守っていくという運用が発生してしまいます。

CSS を使わなければ上記を考えなくて済みます。**Tailwind CSS** を使いましょう。

### Tailwind CSS とは
Tailwind CSS は最近流行っている [CSS ライブラリ](https://tailwindcss.com/)です。
CSS をクラスで指定できます。私の経験上、実際に開発で使用する CSS の **99% は置き換え可能**です。
Tailwind CSS を使用すれば、前述したような CSS の管理から開放されます。

それだけではなく、**スタイルを html 上で表現できる**というメリットも得られます。
最初は冗長に見えるかも知れないですが、CSS ファイルをわざわざ見に行かずとも**どの要素にどのスタイルが当たっているかが一目瞭然である**というメリットは、一度体験すると手放せなくなります。

下記の２つの例は等価です。

<details><summary>Tailwind CSS を使わない場合の erb + CSS</summary><div>

```erb
<div class="tweet-index-page">
  <div class="tweet-index-title">Tweets</div>
  <div class="tweet-form-container">
    <%= render(TweetFormComponent.new(tweet: @tweet)) do %>
      <div class="text-blue-400"><%= notice %></div>
    <% end %>
  </div>
  <div class="tweet-list-container">
    <% @tweets.each do |tweet| %>
      <%= render(TweetComponent.new(tweet: tweet))%>
      <% end %>
  </div>
</div>
```

```.scss
.tweet-index-page {
  width: 20rem;
  height: 100vh;
  padding-top: 1.25rem;
  padding-bottom: 1.25rem;
  display: flex;
  flex-direction: column;
}

.tweet-index-title {
  padding-top: 1.25rem;
  padding-bottom: 1.25rem;
  font-size: 1.5rem;
  line-height: 2rem;
  font-weight: 700;
}

.tweet-form-container {
  position: sticky;
  top: 0px;
  --tw-bg-opacity: 1;
  background-color: rgba(255, 255, 255, var(--tw-bg-opacity));
  padding-bottom: 0.75rem;
}

.tweet-list-container {
  display: flex;
  flex-direction: column;
  --tw-space-y-reverse: 0;
  margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse)));
  margin-bottom: calc(0.75rem * var(--tw-space-y-reverse));
  overflow-y: scroll;
}
```
</div></details>

<details><summary>Tailwind CSS を使った場合の erb</summary><div>

```erb
<div class="w-80 h-screen py-5 flex flex-col">
  <div class="py-5 text-2xl font-bold">Tweets</div>
  <div class="sticky top-0 bg-white pb-3">
    <%= render(TweetFormComponent.new(tweet: @tweet)) do %>
      <div class="text-blue-400"><%= notice %></div>
    <% end %>
  </div>
  <div class="flex  flex-col space-y-3 overflow-y-scroll">
    <% @tweets.each do |tweet| %>
      <%= render(TweetComponent.new(tweet: tweet))%>
      <% end %>
  </div>
</div>
```
</div></details>

### Tailwind css のセットアップ
私は[こちらの方法](https://davidteren.medium.com/tailwindcss-2-0-with-rails-6-1-postcss-8-0-9645e235892d)でセットアップして上手く動かすことができました。[日本語で解説した記事](https://qiita.com/kazuya_minei109/items/e76ff2b0af49e69eb441)もあるのでご参考にどうぞ。

# CSS を書かざるを得ないとき
一部の CSS は Tailwind で表現できないので、生の CSS を書かざるを得ない状況もあるでしょう。
そのような場合は、ファイルパススコープの使用を推奨します。

### ファイルパススコープとは
- [Rails の CSS にスコープを持たせる](https://tech.basicinc.jp/articles/147)

ファイルパススコープは上記の記事で提唱されている概念です。公式にそのような名称が存在するわけではないようですが、非常に有用なアイデアだと思います。

下記のように、一番外側の要素に `data-scope-path="[ファイルパス]"` という属性を付与することで、CSS のスコープを限定するという方法です。

```erb:app/views/users/show.html.erb
<div data-scope-path="users/show">
  <div class="user-name">
    <%= @user.name %>
  </div>
</div>
```

```scss:app/assets/stylesheets/scopes/users/show.scss
[data-scope-path="users/show"] {
  .user-name {
    // ここに書いた CSS は users/show.html.erb のみに適用される
  }
}
```

これにより、[Vue.js の scoped CSS](https://vue-loader-v14.vuejs.org/ja/features/scoped-css.html) のような、特定のファイルだけにスコープを限定した CSS を擬似的に再現できます。

この CSS ファイルを変更しても、**影響範囲は特定のファイル内だけに限定されるので、安全なリファクタリングが可能になります。**

# views / components / FormHelper の３レイヤーで考える
### Atomic Design
コンポーネント指向開発の文脈でよく登場する [Atomic Design](https://bradfrost.com/blog/post/atomic-web-design/) という概念があります。

ここでは詳細は割愛しますが、要するに**画面を構成する要素を粒度や役割ごとにいくつかのレイヤーに分割しよう**という考え方です。

下記の図は５つの階層での分割を表しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/474068/5f6f2b4a-5732-426b-ad29-4925813d1d32.png)


### Rails における対応
Rails においても、Atomic Design の考え方を適用できます。
Rails が持つ機能は下記のように適用できると考えられます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/474068/49065a44-b900-ac0e-23b3-0963e2d1e9fc.png)

- **Atoms** はコンポーネント分割における最小単位で、Rails では `ActionView` が提供する`text_field` `options_for_select` などの `FormHelper` のメソッド群がこれに対応すると考えることができます。
- **Molecules / Organisms / Templates** は Atoms を組み合わせてできるより大きな単位のコンポーネントです。Rails では、`ViewComponent` で表現できます。
- **Pages** は最も上位に位置するコンポーネントです。views/users/show.html.erb` のような従来の views ディレクトリ内のページファイルがこれに相当します。

ViewComponent → FormHelper の順で解説します。

## ViewComponent
[View Components](https://viewcomponent.org/) は Rails 6.1 からデフォルトでサポートされている機能です。開発元は React にインスパイアされたと語っており、従来の `partial` をコンポーネント志向の文脈で再解釈・拡張したと言えるでしょう。

セットアップは非常に簡単です。[公式](https://viewcomponent.org/guide/getting-started.html)を参考にどうぞ。

`rails generate` コマンドに対応しているので、下記のようにファイルを生成できます。

```shell
$ bin/rails generate component Example title
```

```ruby:components/example_component.rb
class ExampleComponent < ViewComponent::Base
  def initialize(title:)
    @title = title
  end

  # このコンポーネント関連のメソッドを自由に定義できる
end
```

```erb:components/example_component.html.erb
<!-- example_component.rb のメソッドを自由に使える -->
<span title="<%= @title %>"><%= content %></span>
```

コンポーネントを呼び出す側では、下記のように記述します。

```erb:views/home/index.html.erb
<%= render(ExampleComponent.new(title: "my title")) do %>
  Hello, World!
<% end %>
```

### **ViewComponent** × **Tailwind CSS** 実装例
画像の水色のカード部分の実装です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/474068/9ad09659-6194-54ca-344c-a7f1f913ed2e.png)


<details><summary>tweet_component.erb.html</summary><div>

```erb
<div class="bg-blue-100 rounded p-3 flex flex-col justify-between space-y-2 ">
  <%= link_to(@tweet) do %>
    <pre class="text-lg"><%= @tweet.content %></pre>
  <% end %>
  <div class="flex flex-row justify-end space-x-3">
    <div><%= button_to 'Edit', edit_tweet_path(@tweet), method: :get, class: "btn btn-white" %></div>
    <div><%= button_to 'Delete', @tweet, method: :delete, data: { confirm: 'Are you sure?' }, class: "btn-white" %></div>
  </div>
</div>
```
</div></details>

<details><summary>tweet_component.rb</summary><div>

```ruby
class TweetFormComponent < ViewComponent::Base
  def initialize(tweet:)
    @tweet = tweet
  end

end
```
</div></details>


### partial との違い
partial との違いとして、**テストが書きやすいこと**、**パフォーマンスが優れていること**がよく挙げられているようです。
下記の記事でそれぞれ分かりやすく書かれているので参考にどうぞ。

- [ViewComponent を試してみた](https://zenn.dev/cobachie/articles/tried-view-component)
- [【Rails】ViewComponentとPartialのパフォーマンスを比較](https://zenn.dev/d0ne1s/articles/94d4dfaf0b5187)

個人的には、ViewComponent では、

- **各コンポーネント専用のメソッドを記述するファイルが用意されていること**
- **prop (引数)として渡すオブジェクトを自由に設定できるという点**

に好感を持ちました。
partial では[引数や命名がファイル名に制限されてしまう](https://railsguides.jp/layouts_and_rendering.html#%E3%83%AD%E3%83%BC%E3%82%AB%E3%83%AB%E5%A4%89%E6%95%B0%E3%82%92%E6%B8%A1%E3%81%99) ので、ViewComponent の方がより柔軟なコンポーネント分割が可能になります。

Vue.js や React などのコンポーネント指向フレームワークっぽいインターフェースに似ており、非常に使いやすいと感じました。


## FormHelper
FormHelper は皆さんご存じの通り、基本的な要素を簡単に表示するための便利なメソッド群です。

```ruby
label(:article, :title)
# => <label for="article_title">Title</label>

text_field(:article, :title)
# => <input type="text" id="article_title" name="article[title]" value="#{@article.title}" />
```

AtomicDesign における Atoms はアプリケーションの中で繰り返し頻繁に使われる要素であるため、

**1. シンプルで使いやすいインターフェースであること**
**2. アプリケーションのテーマに沿ったスタイルであること**

が求められます。
このうち、1 に関しては FormHelper が提供しているインターフェースが該当します。
一方で、2 で要求されているスタイルを FormHelper ごとに保持するような昨日は提供されていません。

言い換えると、**FormHelper に統一されたスタイルを簡単に当てることができれば、十分に Atoms コンポーネントである**ということが言えそうです。

### どうするか
**グローバル CSS で要素ごとにスタイル定義をする**ことで解決できます。
[scoped CSS を使おうと主張したばかり](#CSS-を可能な限り書かない)なのに、矛盾しているじゃないか！！と思った方もいると思いますが、ちゃんと下記のような理由があります。

- 基本的な HTML 要素のスタイルはプロジェクト全体で共有する必要がある。
- 基本的な HTML 要素は種類が限られているため、管理の手間が爆発的に増えることはない。
- ViewComponent 内にスタイルを定義することも可能であるが Atoms コンポーネントの実装には向いていない。(インターフェースの利便性は FormHelper が圧倒的に優れているため)


これらの理由から、**FormHelper メソッドには例外的にグローバル CSS の使用を認めて良いと私は考えます。**

ただし、ちょっとした注意点もあります。**原則クラス指定のみにしてください。**
要素指定だと影響範囲が広く、意図しない要素にまでスタイルがついてしまう可能性が高いためです。

### Tailwind CSS を使った実装例
画像の Tweet 作成部分のフォームの実装です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/474068/9ad09659-6194-54ca-344c-a7f1f913ed2e.png)

tailwind の `@apply` メソッドを使っています。SCSS ファイル内でも Tailwin のクラス名を用いてスタイルを記述できる方法です。


<details><summary>app/javascript/stylesheets/core-components.scss</summary><div>

```scss
.btn {
  @apply rounded p-1 cursor-pointer;
}

.btn-white {
  @apply btn bg-white;
}

.btn-outline-blue {
  @apply bg-white border border-blue-500 border-solid;
}

.textarea {
  @apply border border-blue-500 border-solid rounded;
}
```

</div></details>

<details><summary>tweet_form_component.erb.html</summary><div>

```ruby
<%= form_with(model: @tweet) do |form| %>
  <% if @tweet.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@tweet.errors.count, "error") %> prohibited this @tweet from being saved:</h2>

      <ul>
        <% @tweet.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <%= form.label :content,  hidden: true %>
  <%= form.text_area :content, class: "base-textarea w-full" %>

  <div class="flex flex-col space-y-1">
    <div><%= content %></div>
    <%= form.submit class: "btn btn-outline-blue" %>
  </div>
<% end %>
```
</div></details>


※ `@apply` メソッドは node_modules に依存しているので、`core-components.scss` を `app/javascript/stylesheets/` ディレクトリ配下に置く必要があることに注意してください。`app/assets/stylesheets/` 配下では動きません。

# もう一度結論
- CSS を可能な限り書かない → **Tailwind on Rails**
- どうしても CSS を使いたいときは Scoped に書く
- views / components / FormHelper の３レイヤーで考える
  - グローバルな CSS は 基本的な HTML 要素に限り使用可とする
  - ViewComponent を使用する

Rails でも、CSS を使わず、コンポーネント分割を意識することで、スケールするフロントエンド開発ができそうです。
部分的にでも皆さんの開発の参考になればと思います。

# 参考
- [この記事の発表用に作った資料](https://qiita.com/punkshiraishi/items/4d4d38967677e761fcf5)
- [この記事の実装例として作ったサンプルアプリケーションのソースコード](https://github.com/punkshiraishi/rails-view-sample)