🛤️

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

2021/08/09に公開

はじめに

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

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

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

まず結論

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

CSS を可能な限り書かない

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

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

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

Tailwind CSS とは

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

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

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

Tailwind CSS を使わない場合の erb + CSS

<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>
.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;
}

Tailwind CSS を使った場合の 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>

Tailwind css のセットアップ

私はこちらの方法でセットアップして上手く動かすことができました。日本語で解説した記事もあるのでご参考にどうぞ。

CSS を書かざるを得ないとき

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

ファイルパススコープとは

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

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

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

これにより、Vue.js の scoped CSS のような、特定のファイルだけにスコープを限定した CSS を擬似的に再現できます。

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

views / components / FormHelper の3レイヤーで考える

Atomic Design

コンポーネント指向開発の文脈でよく登場する Atomic Design という概念があります。

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

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

Rails における対応

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

  • 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 は Rails 6.1 からデフォルトでサポートされている機能です。開発元は React にインスパイアされたと語っており、従来の partial をコンポーネント志向の文脈で再解釈・拡張したと言えるでしょう。

セットアップは非常に簡単です。公式を参考にどうぞ。

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

$ bin/rails generate component Example title
components/example_component.rb
class ExampleComponent < ViewComponent::Base
  def initialize(title:)
    @title = title
  end

  # このコンポーネント関連のメソッドを自由に定義できる
end
components/example_component.html.erb
<!-- example_component.rb のメソッドを自由に使える -->
<span title="<%= @title %>"><%= content %></span>

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

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

ViewComponent × Tailwind CSS 実装例

画像の水色のカード部分の実装です。

tweet_component.erb.html
<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>
tweet_component.rb
class TweetFormComponent < ViewComponent::Base
  def initialize(tweet:)
    @tweet = tweet
  end

end

partial との違い

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

個人的には、ViewComponent では、

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

に好感を持ちました。
partial では引数や命名がファイル名に制限されてしまう ので、ViewComponent の方がより柔軟なコンポーネント分割が可能になります。

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

FormHelper

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

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 を使おうと主張したばかりなのに、矛盾しているじゃないか!!と思った方もいると思いますが、ちゃんと下記のような理由があります。

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

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

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

Tailwind CSS を使った実装例

画像の Tweet 作成部分のフォームの実装です。

image.png

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

app/javascript/stylesheets/core-components.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;
}
tweet_form_component.erb.html
<%= 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 %>

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

もう一度結論

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

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

参考

Discussion