Im Prinzip ist der View Layer einer Ruby on Rails-Anwendung nicht sehr kompliziert: Für jede Ressource gibt es eine Reihe von Templates, die vielleicht ein paar Partials verwenden. Aber wenn die App wächst und komplexer wird, kann es unübersichtlich werden: Shared partials werden an verschiedenen Stellen eingesetzt, vielleicht in jeweils leicht unterschiedlicher Form. Und wenn ein View mehr als ein bisschen eigene Logik braucht, ist die schnell über viele Stellen verstreut. Sauber gekapselt ist anders, und von Testbarkeit haben wir noch gar nicht geredet.
Im Frontend, vor allem in JavaScript, gibt es doch diese Komponenten, oder? Das sind nett zusammengeschnürte Pakete aus Markup und Logik, die man hinpflanzen kann wo man sie gerade braucht. Gibt es sowas nicht auch für Rails?
Gibt es tatsächlich, und es heißt ViewComponent.
Eine ViewComponent ist eine Klasse, die ein Template rendert. Das Ergebnis davon kann in einen View eingesetzt werden. Die Komponente kann getestet werden, man kann Parameter übergeben, CSS, JavaScript und Übersetzungen einfügen, bestimmte Bereiche der Komponente als Slots gezielt befüllen und einiges mehr. Aber von Anfang an.
Den Anfang macht wie üblich ein Gem, das man dem Bundle hinzufügt, nämlich view_component. Um die Komponenten mit RSpec zu testen, bindet man noch die mitgelieferten Test Helper ein:
require 'view_component/test_helpers'
RSpec.configure do |config|
config.include ViewComponent::TestHelpers, type: :component
config.include ViewComponent::SystemSpecHelpers, type: :feature
config.include ViewComponent::SystemSpecHelpers, type: :system
end
Meine erste Komponente
Nehmen wir als Beispiel eine UserCardComponent, die einen kurzen Steckbrief eines Benutzers darstellt, beispielsweise des Autors eines Artikels oder des Ansprechpartners für einen Bereich. Erzeugt wird die Komponente mit
bundle exec bin/rails generate view_component:component UserCard user
UserCard ist der Name der Komponente, die einen user als Parameter übergeben bekommt. Das Ergebnis sind die Dateien
app/components/user_card_component.rb(die eigentliche Komponente)app/components/user_card_component.html.erb(das Template)spec/components/user_card_component_spec.rb(die Specs).
Mit der Option --template engine kann man eine andere template engine auswählen, unterstützt wird neben erb noch haml und slim. Ähnlich kann man mit --test-framework zwischen rspec und test-unit wählen.
Die Komponente sieht in ihrer einfachsten Form so aus:
class UserCardComponent < ViewComponent::Base
def initialize(user:)
@user = user
end
end
Die als Argument übergebene User-Instanz wird im Konstruktor in eine Instanzvariable geschrieben, die man im Template verwenden kann:
<div>
<p><strong><%= @user.name %></strong></p>
</div>
Zusätzliche Logik findet seinen Platz in der Komponente. Beispielsweise können wir den vollen Namen aus Vor- und Nachname zusammensetzen und das Ergebnis direkt im Template verwenden:
class UserCardComponent < ViewComponent::Base
def initialize(user:)
@user = user
end
private
def full_name
"#{user.first_name} #{user.last_name}"
end
end
<div>
<p><strong><%= full_name %></strong></p>
</div>
Verwendet wird die Komponente mit einem einfachen render in einem View:
<%= render(UserCardComponent.new(@author)) %>
Beim Testen prüft man, ob die Komponente das rendert, was man erwartet:
RSpec.describe UserCardComponent, type: :component do
let(:user) { create(:user) }
let(:rendered) { render_inline(described_class.new(user)) }
it 'renders the full user name' do
full_name = "#{user.first_name} #{user.last_name}"
expect(rendered.css("strong").to_html).to_include full_name
end
end
Content, Content, Content
Zusätzlich zu Parametern kann man einer Komponente auch direkt Inhalte übergeben. Beispielsweise kann man content als Block an den render-Aufruf übergeben. Damit kann unsere UserCardComponent an verschiedenen Stellen verschiedene Informationen enthalten:
<%= render(UserCardComponent.new(@internal_contact)) do %>
Kontakt: <%= user.messenger_id %>
<% end %>
<%= render(UserCardComponent.new(@author)) do %>
Für Rückfragen zum Artikel: <%= user.email %>
<% end %>
Im Template stehen diese Inhalte als content zur Verfügung:
<p class="contact"><%= content %></p>
Zielgenauer geht das mit sogenannten Slots. Dabei definiert man Bereiche in der Komponente, die höchstens einmal (renders_one)
bzw. mehrfach (renders_many) befüllt werden können:
class UserCardComponent < ViewComponent::Base
renders_one :contact
renders_many :articles
(...)
end
Im Template wird bei renders_one der Name des Slots verwendet, bei renders_many iteriert man darüber:
<p class="contact"><%= contact %></p>
<div>
<% articles.each do |article| %>
<p><%= article %></p>
<% end %>
</div>
Die Einbindung sieht dann so aus:
<%= render(UserCardComponent.new(user: @user)) do |component| %>
<% component.with_contact do %>
Kontakt: <%= user.messenger %>
<% end %>
<% user.articles.each do |article| %>
<% component.with_article do %>
<%= link_to article.title, article.url %>
<% end %>
<% end %>
<% end %>
Mit dieser kurzen Einführung haben wir die Features, die ViewComponents bieten, noch lange nicht ausgeschöpft. Man kann einer Komponente einen Stimulus-Controller (in JavaScript oder TypeScript) mitgeben, und in dem auch komponentenspezifisches CSS laden. Eine Komponente kann Sprachdaten für die Lokalisierung mitbringen. Man kann Previews einbauen, ähnlich wie ActionMailer::Preview. Man kann Bedingungen festlegen, nach denen entschieden wird, ob die Komponente gerendert wird oder nicht. Man kann seine Komponenten in Namensräume gruppieren. Ähnlich wie ein Partial eine Collection rendern kann, geht das auch mit einer ViewComponent. Und je nach den genauen Umständen können ViewComponents sogar einen Performance-Vorteil bringen, indem sie beispielsweise cached templates nutzen. Das ist insbesondere bei Rails 8 interessant. ViewComponents sind also ein Konzept, dass einen Platz im Werkzeugkasten eines Rails-Entwicklers verdient hat.