- 新しいRailsフロントエンド開発(1)Asset PipelineからWebpackへ(翻訳)
- 新しいRailsフロントエンド開発(2)コンポーネントベースでアプリを書く(翻訳)
- 新しいRailsフロントエンド開発(3)Webpackの詳細、ActionCableの実装とHerokuへのデプロイ(翻訳)
概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Evil Front Part 2: Modern Front-end in Rails
- 原文公開日: 2017/12/12
- 著者: Andy Barnov、Alexey Plutalov
- サイト: Evil Martians
新しいRailsフロントエンド開発(2)コンポーネントベースでアプリを書く(翻訳)
前書き
本記事は、フロントエンドのフレームワークに依存しないRailsプレゼンテーションロジックを現代的かつモジュール単位かつコンポーネントベースで扱う方法を独断に基いて解説するガイドです。3部構成のチュートリアルで、例を元に最新のフロントエンド技術の最小限に学習し、Railsフロントエンド周りをすべて理解しましょう。
Part 1のおさらい
こちらもお読みください: Part 1
Hacker NewsやReddit数々の議論を呼び起こしたPart 1では、標準的なRailsアプリを現代的なフロントエンドプラクティスに合わせて組み替えました。Webpacker gemを用いてアセットをWebpackでビルドしつつ、CSSをPostCSSとcssnextで処理しています。Babel、Autoprefixer、Browserslistのおかげでクロスブラウザの問題に悩まされずに済むようになりました。git commit
のたびにPrettier、AirBnB Base Config、ESLint、stylelintでコードの文法エラーを自動チェックできるようになりました。
フォルダ構造をわかりやすく変えてコンポーネント指向で考えられるようにし、それでいて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--input
はauth-form
がコンポーネント、input
が要素になります。auth-form--submit
はauth-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つずつ作成するので、messages
とmessage-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-form
とjs-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 console
でMessage
インスタンスをいくつか作成し、正しく表示されるかどうかを実際にチェックします。
# 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アプリで生じる問題についても取り上げます。どうぞお楽しみに!
スタートアップをワープ速度で成長させられる地球外エンジニアよ!Evil Martiansのフォームにて待つ。