4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Rails】eachの中でテンプレートをrenderをするのは危険である

Last updated at Posted at 2022-03-20

早速ですが、みなさんはviewで部分テンプレートを利用されていますでしょうか?
こういうやつです。よく見ますね。

<%= render 'path', user: user %>

私はコードの見た目がきれいになるだけでなく、管理もしやすくなるといった理由から多用しています。

そんな便利な部分テンプレートですが、実は使いすぎるとパフォーマンスが悪くなってしまうことを知っていますか?
今回はその理由と改善点を説明できればと思います。

eachの中でrenderを使ってはいけない

例えば以下のようなコードがあるとします。(適当な例です。こんなコード書かないよ、というのは一旦置いといてください。)

<% @users.each do |user| %>
   <%= render 'user', user: user %>
<% end %>

で、これの何が良くないかですが、単純にパフォーマンスが悪いです。

雑な例ですが、10,000件のユーザを一覧に表示してみます。データはscaffoldとdb:seedで適当に作成しました。

スクリーンショット 2022-03-20 16.35.15.png

①renderを使わない(1441ms)

index.html.erb
<tbody>
  <% @users.each do |user| %>
    <tr>
      <td><%= user.name %></td>
      <td><%= user.email %></td>
      <td><%= link_to 'Show', user %></td>
      <td><%= link_to 'Edit', edit_user_path(user) %></td>
      <td><%= link_to 'Destroy', user, method: :delete, data: { confirm: 'Are you sure?' } %></td>
    </tr>
  <% end %>
</tbody>
Started GET "/users" for 172.23.0.1 at 2022-03-20 15:34:35 +0000
Cannot render console from 172.23.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
Processing by UsersController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering users/index.html.erb within layouts/application
  User Load (10.4ms)  SELECT "users".* FROM "users"
  ↳ app/views/users/index.html.erb:15
  Rendered users/index.html.erb within layouts/application (Duration: 1336.3ms | Allocations: 1028471)
[Webpacker] Everything's up-to-date. Nothing to do
  Rendered layout layouts/application.html.erb (Duration: 1386.1ms | Allocations: 1032213)
Completed 200 OK in 1441ms (Views: 1408.8ms | ActiveRecord: 48.9ms | Allocations: 1033540)

②renderをeachの中で使う(47554ms)

index.html.erb
<tbody>
  <% @users.each do |user| %>
    <%= render 'user', user: user %>
  <% end %>
</tbody>
_user.html.erb
<tr>
  <td><%= user.name %></td>
  <td><%= user.email %></td>
  <td><%= link_to 'Show', user %></td>
  <td><%= link_to 'Edit', edit_user_path(user) %></td>
  <td><%= link_to 'Destroy', user, method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>
Started GET "/users" for 172.23.0.1 at 2022-03-20 15:34:35 +0000
Cannot render console from 172.23.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
Processing by UsersController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering users/index.html.erb within layouts/application
  User Load (10.4ms)  SELECT "users".* FROM "users"
  ↳ app/views/users/index.html.erb:15
  Rendered users/_user.html.erb (Duration: 6.7ms | Allocations: 125)
  Rendered users/_user.html.erb (Duration: 7.4ms | Allocations: 125)
    : × 10,000
  Rendered users/_user.html.erb (Duration: 6.3ms | Allocations: 125)
  Rendered users/_user.html.erb (Duration: 7.4ms | Allocations: 125)
  Rendered users/index.html.erb within layouts/application (Duration: 47458.4ms | Allocations: 1891572)
[Webpacker] Everything's up-to-date. Nothing to do
  Rendered layout layouts/application.html.erb (Duration: 47537.5ms | Allocations: 1895479)
Completed 200 OK in 47554ms (Views: 47530.6ms | ActiveRecord: 11.6ms | Allocations: 1896610)

原因

結果の通り、eachの中で部分テンプレートをrenderする方法はめちゃめちゃパフォーマンスが悪いことがわかりました。
ログを見てもらえればわかると思いますが、②では1つ1つテンプレートを読み込む処理が走ってしまうことが原因のようです。

今回は単純な例なので、こんな書き方することはないと思いますが、どうしても処理をまとめようと部分テンプレートに切り分けて、、という作業をすると知らず知らずこのような書き方になってしまって、気づかないうちにパフォーマンスを低下させてしまっていることが私も過去に何回かありました。

解決策

では、解決策です。
①を使えば解決するのですが、それではeachがあるところではrenderを使うなって言ってるようなものなので良くないですね。

というわけで、③つ目の方法です。

③renderの対象をコレクションにする(939ms)

index.html.erb
<tbody>
  <%= render @users %>
</tbody>
_user.html.erb
<tr>
  <td><%= user.name %></td>
  <td><%= user.email %></td>
  <td><%= link_to 'Show', user %></td>
  <td><%= link_to 'Edit', edit_user_path(user) %></td>
  <td><%= link_to 'Destroy', user, method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>
Started GET "/users" for 172.23.0.1 at 2022-03-20 16:08:01 +0000
Cannot render console from 172.23.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
Processing by UsersController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering users/index.html.erb within layouts/application
  User Load (6.4ms)  SELECT "users".* FROM "users"
  ↳ app/views/users/index.html.erb:15
  Rendered collection of users/_user.html.erb [10000 times] (Duration: 602.4ms | Allocations: 960182)
  Rendered users/index.html.erb within layouts/application (Duration: 893.5ms | Allocations: 1075930)
[Webpacker] Everything's up-to-date. Nothing to do
  Rendered layout layouts/application.html.erb (Duration: 927.3ms | Allocations: 1079708)
Completed 200 OK in 939ms (Views: 932.6ms | ActiveRecord: 15.7ms | Allocations: 1080830)

すでに気づいている方もいらっしゃったと思いますが、Railsにはコレクションを渡すとそれらを1つ1つよしなに処理してくれる機能が備わっています。
書き方は②に似ていますが、テンプレートが一度しか呼ばれないので①と同じくらいパフォーマンスが良いです。

ただしこれ、わたしは使いづらいなって思っていました。
<%= render @users %>と書くためには、 @usersがUserのコレクションであり、かつusers/_user.html.erbというファイル配置でないと対応しません。

プロジェクトが大きくなってくると、フォルダを分けて管理しやすくするために、テンプレートの配置が複雑になることは多々あります。
例えば、users/partials/_user.html.erbみたいな配置にすると<%= render @users %>のような書き方はできなくなってしまうのです。

そこでどんなフォルダ構成になっていても使える方法を記載します。

万能なコレクションの書き方

index.html.erb
<tbody>
  <%= render partial: "partial/user", collection: @users, as: :user, locals: { title: "Users Page" } %>
</tbody>

partial:に部分テンプレートのパス、collection:にコレクションを渡します。ちなみにActiveRecordでなくても、配列やハッシュもコレクションなのでこの書き方で使えます。
as:は部分テンプレート内でコレクションの1つ1つ処理される対象の変数です。partials/_user.html.erbは②や③と同じですのでそちらを参照してください。
locals:も今までと同じように使えます。

ちなみに、部分テンプレートの中で何回目の繰り返しかを取得するには、下記のように asの引数 + _counter で取得できます(カウントは0からです)。

_user.html.erb
<%= user_counter %>

どうしようもない場合

上記の方法でも解決できないコードを書かなければいけないパターンもコードをたくさん書いていると遭遇します。
そんなときはどうすればいいのかも簡単に紹介します。

renderを使うのを諦める

行数がそこまでないのなら部分テンプレートで切り分けずに、そのまま書いてしまってもいいかもしれません。

renderをeachの中で使うことを許容する

eachの繰り返し回数が少ないと決まっているなら、パフォーマンスに著しい低下が起こらないという前提で使ってしまって良いと思います。
一度それぞれのパターンで記述してみて、どれくらい速度に差があるか検証してみましょう。

helperに書く

実はhelperに書いた場合は、同じように切り分けているのにも関わらず、速度はそんなに変わりません。
下記は例が悪いので適当な書き方になっていますが、部分テンプレートのようにhtmlで記述することはできないので、あまり複雑な場合は逆に見づらくなってしまうかもしれません。

index.html.erb
<tbody>
  <% @users.each do |user| %>
    <%= user_tr(user) %>
  <% end %>
</tbody>
user_helper.rb
module UsersHelper
  def user_tr(user)
    user_tr = ""
    user_tr += "<tr>"
    user_tr += "<td>#{ user.name }</td>"
    user_tr += "<td>#{ user.email }</td>"
    user_tr += "<td>#{ link_to 'Show', user }</td>"
    user_tr += "<td>#{ link_to 'Edit', edit_user_path(user) }</td>"
    user_tr += "<td>#{ link_to 'Destroy', user, method: :delete, data: { confirm: 'Are you sure?' } }</td>"
    user_tr += "</tr>"
    user_tr.html_safe
  end
end

Started GET "/users" for 172.23.0.1 at 2022-04-01 14:11:22 +0000
Cannot render console from 172.23.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
Processing by UsersController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering users/index.html.erb within layouts/application
  User Load (5.5ms)  SELECT "users".* FROM "users"
  ↳ app/views/users/index.html.erb:15
  Rendered users/index.html.erb within layouts/application (Duration: 603.1ms | Allocations: 1170888)
[Webpacker] Everything's up-to-date. Nothing to do
  Rendered layout layouts/application.html.erb (Duration: 647.2ms | Allocations: 1174478)
Completed 200 OK in 650ms (Views: 642.6ms | ActiveRecord: 5.5ms | Allocations: 1174883)

参考

偉そうにいろいろ書いていましたが、実は公式リファレンスに載っています。
この方法を知らずにしばらくパフォーマンスの悪い書き方を続けてしまっていたことが恥ずかしい。。:sweat:

4
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?