Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

ได้แรงบันดาลใจจาก React ViewComponents = Ruby Objects สร้าง Markup สำห Render Views ViewComponent = Framework สร้าง Reusable, Testable, Encapsulated View Components ใน Rails ปกติ Reusable Views ใช้ Partials Render ใน Views ต่างๆ แต่ ViewComponent Gem ทำให้ Swap Partials ด้วย View Components ได้ เพราะมี Advantages มากกว่า

When และ Why Use ViewComponents?

View Components Reusable และ Testable ใช้ได้เมื่อ View ถูก Reuse หรือได้ประโยชน์จาก Direct Testing Benefits ตาม Documentation:

  • ~10x Faster Than Partials
  • Ruby Objects -> Initialize Method ชัดเจน Define Requirements Render View -> เข้าใจง่าย Reuse ได้หลาย Views Enforce Ruby Code Quality Standards -> Reduce Bug Risk
  • Unit Test ได้ ตรงข้าม Traditional Views ต้อง Integration Tests (Exercise Routing + Controller + View)

ViewComponent Implementation

ViewComponents = Subclasses ของ ViewComponent::Base อยู่ใน app/components Names End with Component Named สำหรับ What They Render ไม่ใช่ What They Accept Generate ด้วย Generator (ต้อง Add Gem + bundle install ก่อน):

gem "view_component", require: "view_component/engine"

Generator Command:

rails generate component <Component name> <arguments needed for initialization>

Example: Component แสดง Course List บน Learning Platform:

rails g component Course course

Pass course Argument เพราะ Initialize ด้วย Course ที่จะ Display ชื่อ Course เพราะ Render Course

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

Component + View + Test File สร้างใน app/components

Template Engines: ERB, Haml, Slim Default จาก config.generators.template_engine ระบุ Engine ได้:

rails generate component Course course --template-engine <your template engine>

สร้าง Course Model + Courses:

rails g model Course title:string price:decimal location:string
rails db:migrate
Course.create(title: 'The Art of Learning', price: 125.00, location: 'Denmark')
Course.create(title: 'Organizing your Time', price: 55.00, location: 'London')

course_component.rb มี Initialize Method แล้ว:

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

สร้าง Courses Controller:

rails g controller Courses index

Routes Root: root 'courses#index'

View Creation ใน course_component.html.erb:

<div>
  <h2><%= @course.title %></h2>
  <h4><%= number_to_currency(@course.price, unit: "€") %></h4>
  <h4><%= @course.location %></h4>
</div>

Display Title, Price, Location ด้วย @course จาก Initialize Method คล้าย Controller Variable Available ใน View

Render ใน index.html.erb:

# app/views/courses/index.html.erb
<%= render(CourseComponent.new(course: Course.find(1))) %>

Render Instance ใหม่ Initialize ด้วย Course ที่ต้องการ -> กลายเป็น @course ใน View

Render จาก Controller โดยตรงได้ (Bypass Index View):

class CoursesController < ApplicationController
  def index
    render(CourseComponent.new(course: Course.find(1)))
  end
end

ผลลัพธ์:

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

Additional Content Pass ให้ Component ได้ 2 วิธี:

<%= render(CourseComponent.new(course: Course.find(1))) do %> container <% end %>
<%= render(CourseComponent.new(course: Course.find(1)).with_content("container")) %>

ใน View Component Include Content ที่ต้องการ Edit Div:

<div class=<%= content %>>

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

Rendering a Collection (Render Collection)

อยาก Render All Courses? ViewComponent มี with_collection แทน .new Pass Collection เป็น Variable:

CourseComponent.with_collection(Course.all)

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

with_collection_parameter ระบุชื่อ Parameter ต่างกัน:

class CourseComponent < ViewComponent::Base
  with_collection_parameter :item
  def initialize(item:)
    @item = item
  end
end

View ใช้ @item แทน @course

Additional Parameters ต่อ Item ใน Collection ใส่ Buy Me Text:

# app/views/courses/index.html.erb
<%= render(CourseComponent.with_collection(Course.all, notice: "Buy Me")) %>
# app/components/course_component.rb
class CourseComponent < ViewComponent::Base
  with_collection_parameter :item
  def initialize(item:, notice:)
    @item = item
    @notice = notice
  end
end

Add Paragraph ใน View:

<p><a href='#'> <%= @notice %> </a></p>

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

Counter Variable Number Items Enable: _counter Suffix ใน Collection Parameter Available ใน Initialize:

def initialize(item:, notice:, item_counter:)
  @item = item
  @notice = notice
  @counter = item_counter
end

View ใส่ Counter ข้าง Title:

<h2><%= @counter %>. <%= @item.title %></h2>

สร้าง Course ที่ 3:

Course.create(title: 'Understanding Databases', price: '100', location: 'Amsterdam')

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

Conditional Rendering (การ Render แบบมีเงื่อนไข)

render? Hook กำหนดว่า View ควร Render หรือไม่ Implement: 10% Discount สำหรับ Courses Price >= 100€ สร้าง Component:

rails generate component Discount item

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

Auto Initialize ด้วย Item:

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

View discount_component.html.erb:

<p class="green"> A 10% discount is available on this course </p>

CSS Class green ใส่สีเขียว Component RB Add render?:

def render?
  @item.price >= 100
end

Render ใน Course Component View:

# app/components/course_component.html.erb
<%= render(DiscountComponent.new(item: @item)) %>

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

Helpers

Traditional Views เรียก Helpers โดยตรงใน Views แต่ ViewComponents เรียกใน Views ไม่ได้ ต้อง Include ใน Component courses_helper.rb (Generated ตอน CoursesController) สร้าง Helper Method นับ Enrollees (Price/4):

module CoursesHelper
  def count_enrollees(course)
    count = (course.price / 4).round
    tag.p "#{count} enrollees so far"
  end
end

สร้าง Component เรียก Helper Include Helper แล้ว Call Methods ได้:

# app/components/enrollee_component.rb
class EnrolleeComponent < ViewComponent::Base
  include CoursesHelper
  def total_enrollees(course)
    count_enrollees(course)
  end
end

Add ใน Course Component View:

# app/components/course_component.html.erb
<%= EnrolleeComponent.new.total_enrollees(@item) %>

ไม่ใช้ render เพราะไม่มี View Output = Helper Method Result

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

Helpers ใช้ได้ Icons, Gravatars, Whatever ViewComponent ไม่เปลี่ยน Use ของ Helpers แค่เปลี่ยนวิธีเรียกใน Components

The before_render Method

before_render เรียกก่อน Render Component Add Star Icon ใกล้ Discount Notice Helper Method ดึง Star (star.png ใน app/assets/images):

# app/helpers/courses_helper.rb
def star_icon
  image_tag("/assets/star.png", width: "1%")
end

Add before_render ใน Discount Component:

# app/components/discount_component.rb
def before_render
  @star_icon = helpers.star_icon
end

Helpers เรียกได้ด้วย helpers.method ใน Component RB View ใส่ Star Icon:

# app/components/discount_component.html.erb
<p class="green">
  <%= @star_icon %> A 10% discount is available on this course
</p>

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

ไม่จำเป็นต้อง Helpers สำหรับ before_render ใช้ Helpers เพื่อ Demo Another Approach

Previews (พรีวิว)

เหมือน Action Mailer ViewComponent Preview Components ได้ Enable ใน config/application.rb:

config.view_component.preview_paths << "#{Rails.root}/lib/component_previews"

Preview Components ใน test/components/previews Preview Component ด้วย Inputs ต่างๆ Preview DiscountComponent:

# test/components/previews/discount_component_preview.rb
class DiscountComponentPreview < ViewComponent::Preview
  def with_first_course
    render(DiscountComponent.new(item: Course.find(1)))
  end
  def with_second_course
    render(DiscountComponent.new(item: Course.find(2)))
  end
end

Visit https://localhost:3000/rails/view_components เห็น Previews Click ดูได้:

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

Course 1 Render Discount (Price > 100€) Course 2 ไม่ Render (Price < 100€) render? Check Worked Method Names ไว้ให้ Guess เอง :)

Previews Enable/Disable Per Environment show_previews Default Enabled Dev/Test:

# config/environments/test.rb
config.view_component.show_previews = false

JS AND CSS Inclusion

Include JS/CSS พร้อม Components เรียกว่า "Sidecar" Assets/Files Experimental Feature ไม่ Deep Dive ในบทความนี้ Detail: ViewComponent Docs

Templates (เทมเพลต)

Template ของ View Component Define ได้หลายวิธี Simple: View + Component Same Folder (app/components)

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

Alternative: Subdirectory ชื่อ Component เหมือนกัน app/components/course_component/ มี View + Assets Generator Flag --sidecar:

rails g component Example name --sidecar

Add CSS/JS ใน Component Folder ViewComponents Render โดยไม่ต้อง Template File ด้วย call Method (ดู Slots Section)

Slots (สล็อต)

Multiple Content Blocks Pass ให้ Single ViewComponent = Slots คล้าย has_one/has_many ใน Models Slots Defined By renders_one/renders_many:

  • renders_one: Slot Render Max Once Per Component: renders_one :header
  • renders_many: Slot Render Multiple Times: renders_many :titles

Imagine Page Render Header (Once) + Course Titles (Many) สร้าง ListComponent (Header Once) + TitleComponent (Many Titles):

# app/components/list_component.rb
class ListComponent < ViewComponent::Base
  renders_one :header, "HeaderComponent"
  renders_many :titles, TitleComponent

  class HeaderComponent < ViewComponent::Base
    def call
      content_tag :h1, content
    end
  end
end

Header Render Once Titles Many HeaderComponent Defined Inside ListComponent (Possible!) ใช้ call Method Render H1 Tag ไม่ต้อง View File HTML File:

# app/components/list_component.html.erb
<div>
  <%= header %> <!-- renders header component -->
  <% titles.each do |title| %>
    <div>
      <%= title %> <!-- renders individual course title -->
    </div>
    <% end %>
  </div>

View ไม่ต้อง Specify Component Name Slots จัดการให้ เรียก header, title

TitleComponent + HTML:

# app/components/title_component.rb
class TitleComponent < ViewComponent::Base
  def initialize(title:)
    @title = title
  end
end
# app/components/title_component.html.erb
<div>
  <h3><%= @title %></h3>
</div>

.erb Replace ด้วย ListComponent Render:

# app/views/courses/index.html.erb
<%= render ListComponent.new do |c| %>
  <% c.header do %>
    <%= link_to "List of Available Courses", root_path %>
  <% end %>
  <%= c.title(title: "First title") %>
  <%= c.title(title: "Second title!") %>
<% end %>

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

Pass Courses As Collection ใน Slot Pass Array of Objects ที่มี Init Variable ต้องการ Title Argument Extract Titles -> Array of Hashes Render:

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

Replace Multiple Titles ด้วย c.titles(course_titles):

# app/views/courses/index.html.erb
<% course_titles = Course.all.pluck(:title).map { |title| {title: title} } %>
<% c.titles(course_titles) %>

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

Slots ทำงานแบบนี้ Render Multiple Components ใน Single ViewComponent รูปแบบอื่นๆ ดู Docs

Testing (การทดสอบ)

Test ViewComponents: Require view_component/test_case ใช้ render_inline Test Helper Assert Against Rendered Output Test DiscountComponent:

require "test_helper"
require "view_component/test_case"

class DiscountComponentTest < ViewComponent::TestCase
  def test_render_component
    render_inline(DiscountComponent.new(item: "my item"))
  end
end

Run rails test test/components/discount_component_test.rb Error:

เชี่ยวชาญ ViewComponent: คู่มือปฏิบัติสร้าง Reusable Rails View Components

Test ถูก Component แต่ Prop ไม่ตรง (Item ต้องเป็น Course มี Price Property ไม่ใช่ String) มี render? Check ก่อน Render Fix Props:

def test_render_component
  course = Course.create(title: 'Organizing your Time', price: 55.00, location: 'London')
  render_inline(DiscountComponent.new(item: course))
end

Pass Add Assertions:

def test_render_component
  course = Course.create(title: 'Organizing your Time', price: 155.00, location: 'London')
  render_inline(DiscountComponent.new(item: course))
  assert_selector 'p[class="green"]'
  assert_text "10% discount"
end

Pass Test Component Not Rendered (refute_component_rendered) Course Price < 100€:

def test_component_not_rendered
  course = Course.create(title: 'Organizing your Time', price: 55.00, location: 'London')
  render_inline(DiscountComponent.new(item: course))
  refute_component_rendered
end

Pass Test CourseComponent Render All Nested Components:

require "test_helper"
require "view_component/test_case"

class CourseComponentTest < ViewComponent::TestCase
  def test_component_renders_all_children
    course = Course.create(title: 'Organizing your Time', price: 155.00, location: 'London')
    render_inline(CourseComponent.new(item: course, notice: 'A new test', item_counter: 1))
    assert_selector("h2", text: "Organizing your Time")
    assert_selector("h4", text: "€155.00")
    assert_selector("h4", text: "London")
    assert_text("enrollees")
    assert_text("discount")
  end
end

Pass Test Enrollee + Discount Components Rendering

Slot Component Test (Render Header + Many Titles):

require "test_helper"
require "view_component/test_case"

class ListComponentTest < ViewComponent::TestCase
  def test_renders_slots_with_content
    render_inline(ListComponent.new) do |component|
      component.header { "A Test List" }
      component.titles [{title: 'Test title 1'}, {title: 'Test title 2'}]
    end
    assert_selector("h1", text: "A Test List")
    assert_text("Test title 1")
    assert_text("Test title 2")
  end
end

Pass :)

RSpec Testing

RSpec Preferred Additional Config ใน spec/rails_helper.rb:

# spec/rails_helper.rb
require "view_component/test_helpers"
require "capybara/rspec"

RSpec.configure do |config|
  config.include ViewComponent::TestHelpers, type: :component
  config.include Capybara::RSpecMatchers, type: :component
end

DiscountComponent Test RSpec:

require "rails_helper"
RSpec.describe DiscountComponent, type: :component do
  it "renders the component correctly" do
    course = Course.create(title: 'Organizing your Time', price: 155.00, location: 'London')
    render_inline(DiscountComponent.new(item: course))
    expect(rendered_component).to have_css "p[class='green']", text: "10% discount"
    expect(rendered_component).to have_css "img[src*='/assets/star.png']"
  end
end

Pass ขึ้น Star Icon เห็นชัด

Conclusion (สรุป)

เขียน ViewComponents หลายตัว ทำ Code Readable มากขึ้น Less Error-Prone จาก Complications ทำให้ Test Views In Isolation ได้ ไม่ได้แค่ During HTTP Requests ViewComponents ใส่ใน Existing Rails App ง่าย เริ่มจาก Views ที่ Reuse มากที่สุด ด้วย Knowledge จนตอนนี้ Should Be Easy หากอยาก Info เพิ่ม Docs หรือ RubyDoc Info