Tech Racho エンジニアの「?」を「!」に。
  • 開発

新しいRailsフロントエンド開発(2)コンポーネントベースでアプリを書く(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

新しいRailsフロントエンド開発(2)コンポーネントベースでアプリを書く(翻訳)

前書き

本記事は、フロントエンドのフレームワークに依存しないRailsプレゼンテーションロジックを現代的かつモジュール単位かつコンポーネントベースで扱う方法を独断に基いて解説するガイドです。3部構成のチュートリアルで、例を元に最新のフロントエンド技術の最小限に学習し、Railsフロントエンド周りをすべて理解しましょう。

Part 1のおさらい

こちらもお読みください: Part 1

Hacker NewsReddit数々の議論を呼び起こしたPart 1では、標準的なRailsアプリを現代的なフロントエンドプラクティスに合わせて組み替えました。Webpacker gemを用いてアセットをWebpackでビルドしつつ、CSSをPostCSScssnextで処理しています。BabelAutoprefixerBrowserslistのおかげでクロスブラウザの問題に悩まされずに済むようになりました。git commitのたびにPrettierAirBnB Base ConfigESLintstylelintでコードの文法エラーを自動チェックできるようになりました。

フォルダ構造をわかりやすく変えてコンポーネント指向で考えられるようにし、それでいてReactなどのいかなるフロントエンドフレームワークにも依存しません。昔ながらの.erbパーシャルもこれまでどおり扱えます。開発中はいつものrails sの代わりに弊社が推しているhivemindこちらからどうぞ)やforemanでサーバーを起動します。

チュートリアルPart 2のコードを含むGitHubリポジトリでコードをすぐにご覧いただけます。

ここまでのアプリは「Hello World」メッセージを表示する機能しかなく、まだ体をなしていません。今回は現実のアプリを作りましょう。チュートリアルに沿って私たちと一緒にアプリを作成するときはかなりコピペを繰り返すことになります(もちろんコード例を手入力しても好みに応じて変更しても構いません)。まずはPart 1を完了させておきましょう。

アプリを現実に近づける

前回表示に使った以下のコンポーネントを思い出しましょう。

<!-- app/views/pages/home.html.erb -->
<%= render "components/page/page" do %>
  <p>Hello from our first component!</p>
<% end %>

コンポーネントをレンダリングするヘルパーを導入して少々楽をしましょう。次のような感じです。

<%= c("page") do %>
  <%= c("auth-form") %>
<% end %>

これでフルパスを入力しなくてもコンポーネント名だけを指定するだけで済むようになります。このヘルパーは、同じフォルダ内に置かれた、機能がほんの少し異なる2つのパーシャルを扱うこともできます(_message-form.html.erb_message-form_admin.html.erbなど)。2つのパーシャルを区別しやすくするため、アンダースコア_を慣習として使っています。

application_helper.rbを開いてメソッドを1つ追加します。

module ApplicationHelper
  def component(component_name, locals = {}, &block)
    name = component_name.split("_").first
    render("components/#{name}/#{component_name}", locals, &block)
  end

  alias :c :component
end

次はコントローラです。現時点ではスモークテストで必要だったpages_controller.rbが1つあるだけです。これは削除しても問題ありません(その場合、対応するapp/views/pagesフォルダも削除します)。私たちのチャットアプリには、認証用のAuthControllerと、チャットウィンドウを受け持つChatControllerの2つのコントローラを置くことにします。次のコマンドで2つのコントローラを生成できます。

$ rails g controller auth
$ rails g controller chat

routes.rbも変更しておきます。

Rails.application.routes.draw do
  root to: "chat#show"

  get  "/login", to: "auth#new"
  post "/login", to: "auth#create"
end

認証ページの作成に取り掛かりましょう。

# app/controllers/auth_controller.rb
class AuthController < ApplicationController
  before_action :only_for_anonymous # 既知のユーザーかどうかをチェック

  def new; end

  # paramsからusernameを取得し、sessionに保存してチャットにリダイレクトする
  def create
    session[:username] = params[:username]
    redirect_to root_path
  end

  private

  # ユーザーが以前チャットしたことがある場合はそのままチャットウィンドウにリダイレクト
  def only_for_anonymous
    redirect_to root_path if session[:username]
  end
end

サンプルアプリなのでアクションはかなりシンプルです。初めてのユーザーにはusernameの入力を求め、それをsessionハッシュに保存します。リピーターの場合は認証ページをスキップします。newアクションで必要なビューは1つだけなので、作成してみましょう。設計上、ビューテンプレートにはコンポーネントのパーシャルを呼び出すrender呼び出しのみを含めるべきです。ここでは、Part 1の最後に作成したpageコンポーネントの内部にauthコンポーネントを埋め込みます。

$ touch app/views/auth/new.html.erb
<!-- app/views/auth/new.html.erb -->
<%= c("page") do %>
  <%= c("auth-form") %>
<% end %>

今度は認証フォーム用のコンポーネントを1つ作成しましょう。これには明示的にauth-formという名前を付けます。

$ mkdir -p frontend/components/auth-form
$ touch frontend/components/auth-form/{auth-form.css,auth-form.js,_auth-form.html.erb}

手作業が面倒になってきた方は、本記事の末尾にあるコンポーネント用ジェネレータの導入部分までスキップしてください。

新しいコンポーネントを1つ作成するたびに、これらの2つのコマンドを実行します。手始めに.erbパーシャルからやってみましょう。ここでは標準的なRailsヘルパーを使って標準的なフォームを作ります。

<!-- frontend/components/auth-form/_auth-form.html.erb -->
<div class="auth-form">
  <%= form_tag login_path, method: :post do %>
   <%= text_field_tag :username, "", class: "auth-form--input", placeholder: "Choose your username...", autofocus: true, required: true %>
   <%= submit_tag "Identify me", class: "auth-form--submit" %>
  <% end %>
</div>

最初の時点でCSSの命名ルールを定めておくのも合理的です。

明快な命名法を選ぶことで、共通の名前空間で名前の衝突を避けられますし、コードが自ら語るようになります。


本記事でご覧いただいているアプローチではCSS Modules使っていませんので、名前が衝突しないよう辛抱強くCSSに名前を付けることにします。

参考: CSS Modules所感

「ブロック/要素」アプローチを採用したいので、BEMのハンドブックから拝借することにします(ブロックは私たちのコンポーネント、要素はその論理的なパーツに相当します)。BEMの書式component-name--element-nameを選択します。こうすることで、テキストフィールドや送信ボタンは次のクラスに従う必要があります。auth-form--inputauth-formがコンポーネント、inputが要素になります。auth-form--submitauth-formがコンポーネント、submitが要素になります。BEMの「M」はmodifierの略ですが、このアプリでは簡単のためmodifierは使わないことにします。

もちろん、CSS命名ルールは、コンポーネント間で統一されていれば、各自のこだわりに合わせていただいて構いません。

とりあえずスタイルの下地はできあがりましたが、まだ何も追加されていません。現時点の認証ページ(localhost:5000/login)は次のようになっています。

スタイルなしの認証ページ

スタイルなしの認証ページ

ここで一手間かけて、CSSクラスをネストできるpostcss-nestedプラグインも有効にしておきましょう。ターミナルでyarn add postcss-nestedと入力し、pluginsセクション内の冒頭行に.postcssrc.yml: postcss-nested: {}を追記します。

それではいよいよスタイルをいくつか足してみましょう。スタイルはWebpackからJavaScript経由で取り込まれるので、常にコンポーネントのスタイルシートをimportでコンポーネントのJavaScriptファイルに取り込む必要があります。また、application.jsエントリポイントの内部でコンポーネントを「登録」する必要もあります。

// frontend/packs/application.js
import "init";
import "components/page/page";
import "components/auth-form/auth-form";
// frontend/components/auth-form/auth-form.js
import "./auth-form.css";
/* frontend/components/auth-form/auth-form.css */
.auth-form {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;

  &--input {
    width: 100%;
    padding: 12px 0;
    border: 1px solid rgba(0, 0, 0, 0.1);
    font-size: 18px;
    text-align: center;
    outline: none;
    transition: border-color 150ms;
    box-sizing: border-box;

    &:hover,
    &:focus {
      border: 1px solid #3f94f9;
    }
  }

  &--submit {
    width: 100%;
    margin-top: 6px;
    padding: 12px 0;
    background: #3f94f9;
    border: 1px solid #3f94f9;
    color: white;
    font-size: 18px;
    outline: none;
    transition: opacity 150ms;
    cursor: pointer;

    &:hover,
    &:focus {
      opacity: 0.7;
    }
  }
}

component-name--element-nameという命名ルールのおかげで、ネストした PostCSSをアンパサンド&で簡単に書けるようになったのがわかります。この&は、PostCSSが純粋なCSSに変換されるときに単に「親」クラス名に置き換えられるので、.auth-form { &--input }.auth-form.auth-form--inputの2つの別々のクラスになります。しかし私たちのコードでは、auth-formコンポーネントに関連するものはすべてauth-formクラスのスコープ内に含まれるので、クラス名の衝突を気にする必要はありません。ポイントは、「親」CSSクラス名をプロジェクト内のコンポーネントとそのフォルダに正確に一致させることです。こうしないと、たちまちコードがスパゲッティになってしまうでしょう。

これで、(サーバーが既に動いていれば)ブラウザウィンドウに戻るとログインページにスタイルが追加されていることがわかります。webpack-dev-serverはJavaScriptファイルの変更を検出してバックグラウンドでページを更新します。

スタイル付きの認証ページ

スタイル付きの認証ページ

CSSをこんなに簡単にいじれるようになったのがおわかりでしょうか?ボタンの色を変える必要があるなら、ブラウザとコードエディタをそれぞれ開いて横に並べて作業すれば、変更したファイルを保存するたびにブラウザに即座に反映されます。これでスタイル変更作業が非常にはかどります。

: このフォームを送信して認証ページが表示されなくなった場合(コントローラの現在のロジックでは、ユーザー名がsessionに保存されると戻れなくなります)、ブラウザのcookieを削除してください。

メッセンジャーを撃たないで

訳注: Don't shoot the messengerはYouTubeのコメディ番組のタイトルで、shooting the messenger(悪い知らせをもたらした人を責める言い回し)のもじりです。Pusciferのアルバムタイトル「Don't shoot the messenger」でもあります。

認証ページからどこか別のページにユーザーを導く必要がありますが、現時点ではルーティングが少々とからっぽのChatControllerしかありません。メッセージを扱えるようにしたいので、基本的なMessageモデルが必要です。さっそく作ってみましょう。

$ rails g model message author:string text:text
$ rails db:create
$ rails db:migrate

メッセージはActionCableを使って作成されるので、メッセージを表示する何らかの方法がコントローラに必要です。ページを最初に読み込んだときに最新の20件を表示することにします。

# app/controllers/chat_controller.rb
class ChatController < ApplicationController
  before_action :authenticate!

  # 最新メッセージを20件表示
  def show
    @messages = Message.order(created_at: :asc).last(20)
  end

  private

  # ユーザーがusernameを指定しなかった場合/loginにリダイレクト
  def authenticate!
    redirect_to login_path unless session[:username]
  end
end

繰り返しますが、ビューは1つあれば十分です。今回はshow.html.erbを作成します。

$ touch app/views/chat/show.html.erb
<!-- app/views/chat/show.html.erb -->
<%= c("page") do %>
  <%= c("chat", messages: @messages) %>
<% end %>

コンポーネントは単なる純粋なERBパーシャルであり、renderメソッドを使うヘルパーによってレンダリングされるので、いつもと同じようにローカルを渡します。コンポーネントの追加方法は既に学びましたね。

$ mkdir -p frontend/components/chat
$ touch frontend/components/chat/{chat.css,chat.js,_chat.html.erb}

ここからコンポーネントのネストが深くなります。私たちのchatコンポーネントは、ページのコンテンツ全体を参照する方法の1つです。ページには、動的に更新されるメッセージリストと、新しいメッセージを送信するフォームを1つずつ作成するので、messagesmessage-formの2つのコンポーネントに分割できます。また、メッセージが複数あるところにはメッセージが1件あるので、messageコンポーネントも必要です。ターミナルでもう少し作業しましょう。

$ mkdir -p frontend/components/message
$ touch frontend/components/message/{message.css,message.js,_message.html.erb}

$ mkdir -p frontend/components/messages
$ touch frontend/components/messages/{messages.css,messages.js,_messages.html.erb}

$ mkdir -p frontend/components/message-form
$ touch frontend/components/message-form/{message-form.css,message-form.js,_message-form.html.erb}

ファイルとフォルダの作成がすべて終わると、次のような構造になるはずです。

frontend/components
   ├── auth-form
   │   ├── _auth-form.html.erb
   │   ├── auth-form.css
   │   └── auth-form.js
   ├── chat
   │   ├── _chat.html.erb
   │   ├── chat.css
   │   └── chat.js
   ├── message
   │   ├── _message.html.erb
   │   ├── message.css
   │   └── message.js
   ├── message-form
   │   ├── _message-form.html.erb
   │   ├── message-form.css
   │   └── message-form.js
   ├── messages
   │   ├── _messages.html.erb
   │   ├── messages.css
   │   └── messages.js
   └── page
       ├── _page.html.erb
       ├── page.css
       └── page.js

親コンポーネントchatでコードの空白を埋めていきます。

<!-- frontend/components/chat/_chat.html.erb -->
<div class="chat">
 <div class="chat--messages">
   <%= c("messages", messages: messages) %>
 </div>
 <div class="chat--form">
   <%= c("message-form") %>
 </div>
</div>

上のコードから、このコンポーネントはサブコンポーネントもレンダリングすることがわかりますが、サブコンポーネントを個別のエントリポイントにすべて入れたくないので、このままではすぐ手に負えなくなってしまう可能性があります。そこで次の経験則を導入することにします。「あるコンポーネントに子が1つ以上ある場合は、子をcomponent's .jsファイルでimportすること」。こうすることで、application.jsには階層のトップに位置するコンポーネントだけを登録すれば済むようになります。ここで正しい方法でやっておけば、後々忘れずに済みます。

// 更新後のfrontend/packs/application.js
import "init";
import "components/page/page";
import "components/auth-form/auth-form";
import "components/chat/chat";

続いて、chat内部のネストしたコンポーネントのJSファイルをchat.jsでインポートします。

// frontend/components/chat/chat.js
import "components/messages/messages";
import "components/message-form/message-form";
import "./chat.css";

最後はCSSです。

/* frontend/components/chat/chat.css */
.chat {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  width: 100%;
  height: 100%;
  overflow: hidden;

  &--messages {
    width: 100%;
    flex: 1 0 0;
  }

  &--form {
    width: 100%;
    background: white;
    flex: 0 0 50px;
  }
}

1つ目のコンポーネントが終わりました。あと3つです!

message-formのERBは次のとおりです。

<!-- frontend/components/message-form/_message-form.html.erb -->
<div class="message-form js-message-form">
  <textarea class="message-form--input js-message-form--input" autofocus></textarea>
  <button class="message-form--submit js-message-form--submit">Send</button>
</div>

ここでは<form>タグを使っていないことにご注意ください。ActionCableを使うために、<textarea>の内容をJavaScriptで送信するからです。

おそらく、ここでクラス名がmessage-formjs-message-formと2回使われている点が気になる方がいらっしゃると思います。この慣習に従っておくことで、設計が変更されてクラス名が変更されたときに、JavaScriptのセレクタが影響を受けずに済みます。つまり、CSSの名前とJavaScriptの名前の2とおりの命名が共存することになります。皆さんのコードでこの通りにする必要はありませんので、単一のセレクタを使ってもかまいません。しかしその場合、CSSクラス名が変更されるたびに、再設計でロジックが壊れないようにするためにDOMを操作するJavaScriptコードも手動で変更しなければならなくなります。

// frontend/components/message-form/message-form.js
import "./message-form.css";
/* frontend/components/message-form/message-form.css */
.message-form {
  display: flex;
  width: 100%;
  height: 100%;

  &--input {
    flex: 1 1 auto;
    padding: 12px;
    border: 1px solid rgba(0, 0, 0, 0.1);
    font-size: 18px;
    outline: none;
    transition: border-color 150ms;
    box-sizing: border-box;
    resize: none;

    &:hover,
    &:focus {
      border: 1px solid #3f94f9;
    }
  }

  &--submit {
    flex: 0 1 auto;
    height: 100%;
    padding: 12px 48px;
    background: #3f94f9;
    border: 1px solid #3f94f9;
    color: white;
    font-size: 18px;
    outline: none;
    transition: opacity 150ms;
    cursor: pointer;

    &:hover,
    &:focus {
      opacity: 0.7;
    }

    &:active {
      transform: translateY(2px);
    }
  }
}

作業中はいつでもlocalhost:5000でチャットウィンドウを表示できます。準備ができていないコンポーネントについてはcレンダリング呼び出しをコメントアウトして止めておくことだけお忘れなく。

先に進みましょう。ここまでで、親コンポーネントとフォームが1つずつできました。次は、メッセージを表示する場所と、各メッセージのテンプレートが必要です。これまでのパターンどおり、ERB、JS、CSSの順に作成します。

<!-- frontend/components/messages/_messages.html.erb -->
<div class="messages js-messages">
  <div class="messages--content js-messages--content">
    <% messages.each do |message| %>
      <%= c("message", message: message) %>
    <% end %>
  </div>
</div>
// frontend/components/messages/messages.js
import "components/message/message"; // メッセージはネストされるので、ここでimportする
import "./messages.css";
/* frontend/components/messages/messages.css */
.messages {
  position: relative;
  width: 100%;
  height: 100%;
  background: white;
  border: 1px solid rgba(0, 0, 0, 0.1);
  border-bottom: 0;
  box-sizing: border-box;

  &--content {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    overflow-x: hidden;
    overflow-y: auto;
  }
}

最後は個別のメッセージのコードです。

<!-- frontend/components/message/_message.html.erb -->
<div class="message">
  <div class="message--header">
    <span class="message--author">
      <%= message.author %>
    </span>
    <span class="message--time">
      <% if message.created_at > Time.now - 24.hours %>
        <%= l(message.created_at, format: :short) %>
      <% else %>
        <%= l(message.created_at, format: :long) %>
      <% end %>
    </span>
  </div>
  <div class="message--text">
    <% message.text.lines.each do |line| %>
      <p><%= line %></p>
    <% end %>
  </div>
</div>
// frontend/components/message/message.js
import "./message.css";
/* frontend/components/message/message.css */
.message {
  margin: 12px 6px;

  &:first-child {
    margin-top: 0;
  }

  &:last-child {
    margin-bottom: 0;
  }

  &--author {
    font-weight: bold;
  }

  &--time {
    color: rgba(0, 0, 0, 0.5);
    font-size: 12px;
  }

  &--text p {
    margin: 0;
  }
}

ここまでの作業がすべてうまくいっているかどうかテストしましょう。まだフォームでメッセージを作成できないので、rails consoleMessageインスタンスをいくつか作成し、正しく表示されるかどうかを実際にチェックします。

# rails consoleで以下を入力する
> Message.create(author: "Evil Martian", text: "Surrender!")

サーバーが実行されていることを確認し、ブラウザを更新します。上のとおりに進めていれば、以下のように表示されるはずです。

チャットウィンドウ

チャットウィンドウ

おまけ

コンポーネントのフォルダやファイルの手動作成ばかり続いて疲れたら、ここでご紹介するRailsジェネレータを使って必要に応じて調整するとよいでしょう。libフォルダの中にgeneratorというフォルダを作成し、そこにcomponent_generator.rbというファイルを置いて以下を記述します。

$ mkdir lib/generators
$ touch lib/generators/component_generator.rb
# lib/generators/component_generator.rb
class ComponentGenerator < Rails::Generators::Base
  argument :component_name, required: true, desc: "Component name, e.g: button"

  def create_view_file
    create_file "#{component_path}/_#{component_name}.html.erb"
  end

  def create_css_file
    create_file "#{component_path}/#{component_name}.css"
  end

  def create_js_file
    create_file "#{component_path}/#{component_name}.js" do
      # コンポーネントのCSSをJS内で自動requireする
      "import \"./#{component_name}.css\";\n"
    end
  end

  protected

  def component_path
    "frontend/components/#{component_name}"
  end
end

これで以下のコマンドラインでコンポーネントを生成できます。

$ rails g component コンポーネント名

チュートリアルPart 2の完了おめでとうございます!もしうまく動かない場合はGitHubリポジトリのコードでチェックしましょう。ここまでお読みいただきありがとうございます。次回Part 3では、いよいよActionCableでアプリをインタラクティブにし、いくつか仕上げ作業を行ってからHerokuにデプロイします。「sprockets抜き」のRailsアプリで生じる問題についても取り上げます。どうぞお楽しみに!


Part 1 | Part 2 | Part 3

スタートアップをワープ速度で成長させられる地球外エンジニアよ!Evil Martiansのフォームにて待つ。

関連記事

新しいRailsフロントエンド開発(1)Asset PipelineからWebpackへ(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。