Вопреки слухам на пространствах девелоперских комьюнити, Rails не становится устаревшей технологией, он не собирается умирать, и остается отличным инструментом для разработки вашего нового проекта. И одна из причин заключается в том, что у Rails имеется достаточно инструментов, чтобы покрыть базовый функционал типичного веб-приложения. Вам не нужно думать о том, как обрабатывать НТТР запросы, что использовать для ввода и получения данных из базы, как отрисовать HTML, который пользователи увидят в своих браузерах, и даже как "вдохнуть жизнь" в пользовательский интерфейс.
Я работаю в продуктовой студии и к нам часто обращаются за разработкой MVP для различных продуктов. Инструменты и подходы для построения пользовательского интерфейса от команды Rails прекрасно подходят для этой задачи. В этой статье я немного расскажу о них и покажу, как все работает.
То, что есть из коробки: rails-ujs, turbolinks
Rails UJS
Давным давно, когда я только пытался сверстать свою первую HTML страничку, у Rails уже был крутой инструмент jquery-ujs (unobtrusive javascript), который теперь называется rails-ujs. Он отлично работает с рельсовым бэкендом, когда вам нужно добавить парочку AJAX запросов малой ценой.
Можете попробовать сделать что-то вроде этого:
app/controllers/money_controller.rb
class MoneyController < ApplicationController
def show
@money = GetAllMoney.call
end
def destroy
SpendAllMoney.call
end
end
views/money/show.html.erb
<div class="money">
<h3>Your money</h3>
<span id="money-amount"><%= @money %></span>
<span>$</span>
<%= link_to 'Spend all money',
money_path,
method: 'delete',
remote: true,
data: { confirm: 'Do you want to spend all money?' },
class: 'spend-money-button' %>
</div>
views/money/destroy.js
document.querySelector('#money-amount').innerHTML = 0
Итак, вы сделали AJAX запрос, используя всего несколько HTML атрибутов и один JS файл с одной строчкой кода. Круто, правда?
Turbolinks
Еще один старожил в мире Rails - Turbolinks. Эта библиотека не находится в стадии активной разработки, но о ее преемнике мы поговорим немного позже. В двух словах, Turbolinks приносит вам SPA опыт почти без клиентского кода. Если подробно, то эта библиотека:
загружает содержимое новых страниц с помощью JS и заменяет его на странице без перезагрузки браузера;
она кэширует страницы, чтобы повторные посещения казались мгновенными;
позволяет сохранять элементы на странице неизменными во время навигации.
Первые две фичи просто работают из коробки, а последняя должна быть явно определена в вашем коде. Я покажу небольшой и надуманный пример того, что мы можем достичь с ее помощью. Предположим, что где-то на странице есть счетчик уведомлений.
app/helpers/application_helper.rb
module ApplicationHelper
def notifications_count
sleep 3 # emulate some calculations
10
end
def articles
Article.last(5)
end
end
app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
<head>
<title>Turbolinks</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>
<body>
<div class="container">
<nav class="navigation">
<ul>
<%- articles.each do |article| %>
<li>
<%= link_to article.title, article_path(article.id) %>
</li>
<% end %>
</ul>
<div class="notifications">
<div class="notifications-badge">
<%= notifications_count %>
</div>
</div>
</nav>
<section class="content">
<%= yield %>
</section>
</div>
</body>
</html>
Подсчет количества уведомлений может занять некоторое время, но это цена, которую вы платите за поддержку актуальности данных.
Позже, возможно, вы также захотите обновлять количество уведомлений, подписавшись на такие обновления в режиме реального времени. У Rails даже есть встроенный Action Cable для этого.
Поскольку эта работа проделана на фронтенде, вам не нужно подсчитывать общее количество страниц между переходами, обработанными Turbolinks. Конечно, вся проблема может быть решена с помощью простого кэширования, но знаете… есть только две сложные вещи в CS… инвалидация кэша… и мы все равно говорим о Turbolinks.
Таким образом, мы можем просто не выполнять код, если страница запрашивалась Turbolinks и и запретить Turbolinks обновлять часть страницы. Вот как это выглядит:
app/helpers/application_helper.rb
module ApplicationHelper
def notifications_count
+ return nil if request.headers['Turbolinks-Referrer'].present?
+
sleep 3 # emulate some calculations
10
end
def articles
Article.last(5)
end
end
app/views/layouts/application.html.erb
<div class="notifications">
- <div class="notifications-badge" id="notifications-badge">
+ <div class="notifications-badge" id="notifications-badge" data-turbolinks-permanent>
<%= notifications_count %>
</div>
</div>
Чего для нас не достаточно
Старенькие фичи Rails делают свою работу хорошо и многие приложения успешно строили на них сложные юзерские интерфейсы без использования сложного JS фреймворка. Несмотря на это, нам все еще не хватает фич, которые сделают наши приложения более удобными для обслуживания и упростят разработку интерфейса.
Новые инструменты от команды Rails
В начале 2021 года DHH объявил о появлении альтернативного подхода Hotwire, нового способа Rails для построения пользовательских интерфейсов. Несмотря на то, что Hotwire является собирательным названием для семейства библиотек, эта семья довольно мала. По состоянию на октябрь 2021 года было всего две библиотеки: Turbo и Stimulus.
Они обе разработаны командой Rails и могут без проблем интегрироваться в ваш величественный монолит. Я расскажу больше о Turbo, так как эта библиотека относительно новая и заменит уже существующую Turbolinks.
Turbo
Если вы думали, что Turbolinks потеряли свою часть "links", потому что это теперь больше чем навигация, вы на 100% правы. Библиотека Turbo разделена на несколько частей, где каждая служит единой цели - доставить в ваше приложение HTML, отрисованный на сервере, с разницей в том, когда и как это делается:
Turbo Drive - тот старый добрый Turbolinks, с которым мы знакомы.
Turbo Frames - “отдельные” фреймы, которые могут быть загружены асинхронно и обновлены, когда сервер возвращает фрейм с тем же id.
Turbo Streams - другой тип фреймов, который обновляется в результате HTTP запроса или с помощью сервера через Websocket.
Turbo Native - обёртка вашего “турбированного” веб-приложения, которая интегрирует его в мобильное приложение.
Итак, теперь обо всем по порядку.
Turbo Drive
Как упоминалось ранее, Turbo Drive просто заменяет Turbolinks и берет на себя навигацию между страницами. Поскольку почти ничего не изменилось, миграция довольно проста.
Вам нужно просто добавить пакет npm
yarn add @hotwired/turbo
Заменить Turbolinks на Turbo в вашем javascript коде
app/javascript/packs/application.js
import Rails from "@rails/ujs"
- import Turbolinks from "turbolinks"
+ import * as Turbo from "@hotwired/turbo"
Rails.start()
- Turbolinks.start()
Зменить data-turbolinks...
атрибуты с data-turbo…
app/views/layouts/application.html.erb
- <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
- <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
+ <%= stylesheet_link_tag 'application', media: 'all', 'data-turbo-track': 'reload' %>
+ <%= javascript_pack_tag 'application', 'data-turbo-track': 'reload' %>
Важный момент, на который нужно обратить внимание - заполнение формы. Turbo drive берет на себя и это. Прежде всего, он ожидает, что redirect после отправки формы будет со статусом 303, чтобы позволить Fetch API автоматически следовать за редиректом. Это правильный статус НТТР для неиндемпотентных (умное слово для описания HTTP методов помимо GET и HEAD ????) запросов, если вы хотите, чтобы переадресация осуществлялась с помощью метода GET. В противном случае правильно перенаправлены будут лишь POST запросы, поскольку они также предусматривают статусы 301 и 302. Так что вам следует явно указать код статуса для редиректа. И вот как это сделать:
app/controllers/any_controller.rb
- redirect_to money_path
+ redirect_to money_path, status: :see_other
Так или иначе в рельсовых формах все равно используется метод POST и добавляется <input type="hidden" name="_method" value="patch">
, чтобы определить какое действие контроллера использовать. Это означает, что ваши формы все еще будут работать, а о необходимости правильного кода статуса уже велись бурные дискуссии.
Следующее, на что стоит обратить внимание это то, что Turbo не поддерживает параметр local: true
, который вы могли использовать для отключения JS-контроля над формой. Если это ваш случай, необходимо внести еще одно небольшое изменение:
app/views/_any_form.html.erb
- <%= form_with(url: money_path, local: true) do |f| %>
+ <%= form_with(url: money_path, data: { turbo: false }) do |f| %>
Turbo Frames
Наконец мы подобрались к чему-то новенькому в рельсе. Turbo Frame - это простой инструмент для создания контейнера с контентом, который может загружаться и обновляться отдельно. Так же, как и в геме render_async или .ejs
от Rails, но с меньшим количеством кода.
Давайте посмотрим пример того, как мы можем разложить нашу страницу на несколько частей, которые асинхронно подгружаются только когда пользователь их видит. Представьте страницу продукта с общей информацией, свойствами продукта и отзывами клиентов. Нет гарантии, что пользователь посетит каждый из этих разделов, поэтому мы можем загрузить их только если он действительно добрался до этой части страницы.
Я пропущу часть примера, относящуюся к настройке моделей, маршрутов, установке Bootstrap и добавлению CSS. Полагаю, вас не интересуют такие базовые вещи.
Вот так выглядит наш app/views/products/show.html.erb
<div class="product">
<ul class="nav nav-tabs" id="product-tab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#general" type="button" role="tab" aria-controls="general" aria-selected="true">General</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="properties-tab" data-bs-toggle="tab" data-bs-target="#properties" type="button" role="tab" aria-controls="properties" aria-selected="false">Properties</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="reviews-tab" data-bs-toggle="tab" data-bs-target="#reviews" type="button" role="tab" aria-controls="reviews" aria-selected="false">Reviews</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane active p-3" id="general" role="tabpanel" aria-labelledby="general-tab">
<turbo-frame id="<%= dom_id(@product, 'general') %>" loading="lazy" src="<%= general_product_path %>">
<%= render('common/spinner') %>
</turbo-frame>
</div>
<div class="tab-pane p-3" id="properties" role="tabpanel" aria-labelledby="properties-tab">
<turbo-frame id="<%= dom_id(@product, 'properties') %>" loading="lazy" src="<%= properties_product_path %>">
<%= render('common/spinner') %>
</turbo-frame>
</div>
<div class="tab-pane p-3" id="reviews" role="tabpanel" aria-labelledby="reviews-tab">
<turbo-frame id="<%= dom_id(@product, 'reviews') %>" loading="lazy" src="<%= reviews_product_path %>">
<%= render('common/spinner') %>
</turbo-frame>
</div>
</div>
</div>
Это обычные табы из Bootstrap. Но самое интересное в элементах .tab-page
. Мы добавили тег turbo-frame
, который является нашим контейнером для загрузки и обновления. У каждого фрейма должен быть свой собственный атрибут идентификатора (id
), а хелпер dom_id
будет хорошим инструментом, чтобы освободить нас от необходимости думать над именами. Для асинхронной загрузки фрейма мы должны добавить атрибут src
, и ответ из этого пути должен вернуть фрейм с таким же идентификатором (id
).
Поскольку мы хотим загружать только видимую часть, мы добавляем loading="lazy"
и фрейм будет загружаться только тогда, когда этот элемент появится на странице. Обратите внимание, что не важно, как этот элемент стал видимым. Пользователь может просто проскролить страницу к этому тегу и его содержимое загрузится, стили родительского элемента могут измениться с display: none
на display: block
, приложение может вставить этот тег на страницу с помощью Javascript или вы даже можете рекурсивно рендерить один фрейм из другого (но не забудьте как-нибудь выйти из рекурсии).
Спиннер в примере - это просто div с CSS анимацией. Вам ничего не нужно с этим делать. Он просто будет вращаться, пока содержимое фрейма не загрузится и появится на странице.
app/views/common/_spinner.html.erb
<div class="text-center mt-5">
<div class="spinner-grow text-secondary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
В качестве альтернативы можно использовать атрибут busy
, который добавляется во фрейм при загрузке, и добавить свой CSS, чтобы показать состояния загрузки.
Наш контроллер довольно простой:
app/controllers/products_controller.rb
class ProductsController < ApplicationController
def show
@product = Product.find(params[:id])
end
def general
@product = Product.find(params[:id])
render partial: 'products/general'
end
def properties
@product = Product.find(params[:id])
render partial: 'products/properties'
end
def reviews
@product = Product.find(params[:id])
render partial: 'products/reviews'
end
end
app/views/products/_general.html.erb
<turbo-frame id="<%= dom_id(@product, 'general') %>" loading="lazy" src="<%= general_product_path(product_id: @product) %>">
<div class="product--general">
<h1>
<%= @product.title %>
</h1>
<div class="row mt-4">
<div class="col">
<div class="product--image">
<%= image_tag @product.image %>
</div>
</div>
<div class="col">
<h3>
<%= @product.price %>
</h3>
<%= @product.content %>
</div>
</div>
</div>
</turbo-frame>
app/views/products/_properties.html.erb
<turbo-frame id="<%= dom_id(@product, 'properties') %>">
<h1>
<%= @product.title %> properties
</h1>
<dl class="row mt-4">
<%- @product.properties.each do |name, value| %>
<dt class="col-sm-3"><%= name.to_s.titleize %></dt>
<dd class="col-sm-9"><%= value %></dd>
<% end %>
</dl>
</turbo-frame>
app/views/products/_review.html.erb
<turbo-frame id="<%= dom_id(@product, 'reviews') %>">
<%- @product.reviews.each do |review| %>
<div class="card mb-3">
<div class="card-body">
<div class="card-title">
<%= review.author %>
</div>
<div class="card-text">
<%= review.content %>
</div>
</div>
</div>
<% end %>
</turbo-frame>
Тут мы рендерим отдельные фрагменты, но это может быть и вся страница с макетом. Главное - отрендерить тег turbo-frame
с тем же идентификатором, что и у тега куда контент будет вставлен.
В целом, это все, что вам нужно, чтобы получить “лениво загруженную страницу”. К сожалению, я не смог найти удобный способ обработки ошибок при работе с turbo frames, но набросал решение, которое может быть вам в помощь:
app/controllers/any_controller.rb
def general
@product = Product.find(params[:id])
raise StandardError, 'Some error'
render partial: 'products/general', locals: { in_cart: product_in_cart?(@product) }
rescue StandardError
render partial: 'common/turbo_error',
locals: { id: dom_id(@product, 'general'), error_message: 'Oops. Something went wrong' }
end
app/views/common/_turbo_error.html.erb
<turbo-frame id="<%= id %>">
<%= error_message %>
</turbo-frame>
Ещё одна замечательная вещь, которую мы можем сделать с turbo frames, это заменять части страницы в ответ на заполнение формы. Идея очень похожая. Действие контроллера должно вернуть тег turbo-frame
и Turbo заменит его на странице. Давайте расширим предыдущий пример, чтобы получить возможность добавлять и удалять товары в корзине.
app/controllers/products_controller.rb
class ProductsController < ApplicationController
include ActionView::RecordIdentifier
def show
@product = Product.find(params[:id])
end
def general
@product = Product.find(params[:id])
- render partial: 'products/general'
+ render partial: 'products/general', locals: { in_cart: product_in_cart?(@product) }
end
def properties
@product = Product.find(params[:id])
render partial: 'products/properties'
end
def reviews
@product = Product.find(params[:id])
render partial: 'products/reviews'
end
+ def add_to_cart
+ @product = Product.find(params[:id])
+
+ session[:cart] = (session[:cart] || []) << @product.id
+
+ render partial: 'products/general', locals: { in_cart: product_in_cart?(@product) }
+ end
+
+ def remove_from_cart
+ @product = Product.find(params[:id])
+
+ session[:cart] = (session[:cart] || []).reject { |id| @product.id == id }
+
+ render partial: 'products/general', locals: { in_cart: product_in_cart?(@product) }
+ end
+
+ private
+
+ def product_in_cart?(product)
+ return false unless product && session[:cart]
+
+ session[:cart].include?(product.id)
+ end
end
app/views/products/_general.html.erb
<turbo-frame id="<%= dom_id(@product, 'general') %>" loading="lazy" src="<%= general_product_path(product_id: @product) %>">
<div class="product--general">
<h1>
<%= @product.title %>
</h1>
<div class="row mt-4">
<div class="col">
<div class="product--image">
<%= image_tag @product.image %>
</div>
</div>
<div class="col">
<h3>
<%= @product.price %>
</h3>
+ <%- if in_cart %>
+ <%= form_with(url: remove_from_cart_product_path) do |f| %>
+ <%= f.submit 'Remove from cart', class: 'my-3 btn btn-danger' %>
+ <%- end %>
+ <%- else %>
+ <%= form_with(url: add_to_cart_product_path) do |f| %>
+ <%= f.submit 'Add to cart', class: 'my-3 btn btn-success' %>
+ <%- end %>
+ <% end %>
<%= @product.content %>
</div>
</div>
</div>
</turbo-frame>
Вы видите, что теперь у контроллера есть экшены для добавления и удаления товаров. Оба этих метода просто рендерят фрагмент general
и он волшебным образом обновляется на странице. Это очень похоже на то, что обычно делается в шаблонах .js.erb
. Тем не менее, turbo - более предпочтительный вариант, чтобы избежать дополнительного JS кода, который, к тому же, лежит в папке views
.
Turbo Streams
Turbo дал нам еще один интересный инструмент для изменения HTML на странице - Turbo Streams. Он дает больше возможностей для обновления интерфейса DOM и вы не ограничены заменой только одного фрейма, как это происходит с Turbo frames. Этa манипуляция с DOM называются action
и она должна выполняться на элементах targets
, полученных из какого-либо селектора. Turbo streams дает вам 7 действий для выполнения:
append - добавить html в начало цели.
prepend - добавить html в конец цели.
replace - заменить всю цель на html.
update - обновить html внутри цели.
remove - удалить всю цель.
before - добавить html после цели.
after - добавить html перед целью
Вы обычно можете услышать/прочитать о Turbo Streams, когда речь заходит про обновления в режиме реального времени и создание еще одного приложения для чата. Но мы можем начать с примера попроще и посмотреть, как Turbo Streams помогает отобразить заполнение формы в пользовательском интерфейсе. Давайте продолжим с предыдущим примером: добавим возможность разместить новый отзыв и показать общее количество отзывов. В начале я просто добавлю количество отзывов и форму для добавления нового отзыва:
app/views/products/show.html.erb
<ul class="nav nav-tabs" id="product-tab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#general" type="button" role="tab" aria-controls="general" aria-selected="true">General</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="properties-tab" data-bs-toggle="tab" data-bs-target="#properties" type="button" role="tab" aria-controls="properties" aria-selected="false">Properties</button>
</li>
<li class="nav-item" role="presentation">
- <button class="nav-link" id="reviews-tab" data-bs-toggle="tab" data-bs-target="#reviews" type="button" role="tab" aria-controls="reviews" aria-selected="false">Reviews</button>
+ <button
+ class="nav-link"
+ id="reviews-tab"
+ data-bs-toggle="tab"
+ data-bs-target="#reviews"
+ type="button"
+ role="tab"
+ aria-controls="reviews"
+ aria-selected="false"
+ >
+ Reviews
+ <span id=<%= dom_id(@product, 'reviews_count') %>>
+ <%= render(partial: 'products/reviews/count_badge', locals: { count: @product.reviews.count }) %>
+ </span>
+ </button>
</li>
</ul>
app/views/products/_reviews.html.erb
<turbo-frame id="<%= dom_id(@product, 'reviews') %>">
<%= render(partial: 'products/reviews/form', locals: { product: @product }) %>
<div id="<%= dom_id(@product, 'reviews_list') %>">
<%- @product.reviews.each do |review| %>
<%= render(partial: 'products/reviews/card', locals: { review: review }) %>
<% end %>
</div>
</turbo-frame>
app/views/products/reviews/_count_badge.html.erb
<span class="badge bg-primary">
<%= count %>
</span>
app/views/products/reviews/_form.html.erb
<%= form_with(url: add_review_product_path(id: product.id), class: 'mb-4', id: dom_id(product, 'reviews_form')) do |f| %>
<%= f.text_area :review, class: "form-control mb-1" %>
<%= f.submit 'Add a review', class: 'btn btn-primary' %>
<% end %>
app/views/products/reviews/_card.html.erb
<%= form_with(url: add_review_product_path(id: product.id), class: 'mb-4', id: dom_id(product, 'reviews_form')) do |f| %>
<%= f.text_area :review, class: "form-control mb-1" %>
<%= f.submit 'Add a review', class: 'btn btn-primary' %>
<% end %>
И вот как это будет выглядеть:
Теперь мы можем добавить немного интерактивности, используя Turbo Streams:
app/controllers/products_controller.rb
+ def add_review
+ @product = Product.find(params[:id])
+ @review = @product.add_review(author: 'You', content: params[:review])
+ end
app/views/products/add_review.turbo_stream.erb
<%= turbo_stream.update(dom_id(@product, 'reviews_count')) do %>
<%= render(partial: 'products/reviews/count_badge', locals: { count: @product.reviews.count }) %>
<% end %>
<%= turbo_stream.replace(dom_id(@product, 'reviews_form')) do %>
<%= render(partial: 'products/reviews/form', locals: { product: @product }) %>
<% end %>
<%= turbo_stream.prepend(dom_id(@product, 'reviews_list')) do %>
<%= render(partial: 'products/reviews/card', locals: { review: @review }) %>
<% end %>
Самый интересный файл вот здесь _add_review.turbo_stream.erb
. Формат turbo_stream
может быть новым для вас, если вы впервые сталкиваетесь с Turbo Streams. Turbo требует, чтобы HTTP ответ имел контент-тип text/vnd.turbo-stream.html
, поэтому вы должны либо передать content_type: "text/vnd.turbo-stream.html"
в метод render в действии контроллера, либо добавить расширения .turbo_stream.erb
для вашего шаблона. Второй вариант мне кажется более практичным. Главный субъект в _add_review.turbo_stream.erb
это хэлпер turbo_stream
. Мы используем его для вызова ранее упомянутых действий. А если точнее, он генерирует XML теги, которые описывают, какие манипуляции DOM должны быть сделаны. Этот файл делает три вещи:
Обновляет счетчик отзывов - обновляет содержимое тега с идентификатором
dom_id(@product, 'reviews_count')
Сбрасывает форму обзора - заменяет весь тег на id
dom_id(@product, 'reviews_count').
Показывает новый обзор на странице - добавляет контент в начало тега с id dom_id(@product, 'reviews_list')
Это все, что вам нужно для создания действительно интерактивного веб-приложения. Без единой строки JS кода! И этого будет достаточно для большинства приложений.
С Turbo Streams вы также можете изменять содержимое страницы с помощью WebSocket. Это не потребует много действий с нашей стороны. Предлагаю вернуться к нашему примеру и обновить отзывы во всех открытых браузерах, когда будет добавлен новый отзыв.
Перед тем, как мы начнем, вы должны добавить в свой Gemfile гем turbo-rails
и запустить эту команду bundle exec rails turbo:install
Он установит @hotwired/turbo-rails
и заменит адаптер Action Cable с async
(по умолчанию) на redis. Теперь мы готовы к работе в режиме реального времени.
Первое, что нам нужно сделать, это подписаться на обновления продукта. Это очень просто благодаря хэлперу turbo_stream_from
. Вот как это выглядит:
app/views/products/show.html.erb
<div class="product">
+ <%= turbo_stream_from @product %>
<ul class="nav nav-tabs" id="product-tab" role="tablist">
И теперь, вместо того, чтобы возвращать теги turbo-frame
, которые показывают, какие действия должны быть выполнены на пользовательском интерфейсе, мы отправим эти действия всем слушателям (всем открытым страницам продукта)
app/controllers/products_controller.rb
def add_review
@product = Product.find(params[:id])
@review = @product.add_review(author: 'You', content: params[:review])
+ Turbo::StreamsChannel.broadcast_update_to(
+ @product,
+ target: ActionView::RecordIdentifier.dom_id(@product, 'reviews_count'),
+ partial: 'products/reviews/count_badge',
+ locals: { count: @product.reviews.count }
+ )
+
+ Turbo::StreamsChannel.broadcast_prepend_to(
+ @product,
+ target: ActionView::RecordIdentifier.dom_id(@product, 'reviews_list'),
+ partial: 'products/reviews/card',
+ locals: { review: @review }
+ )
end
И чтобы не выполнять некоторые действия дважды, удалим их из НТТР ответа
app/views/products/add_review.turbo_stream.erb
- <%= turbo_stream.update(dom_id(@product, 'reviews_count')) do %>
- <%= render(partial: 'products/reviews/count_badge', locals: { count: @product.reviews.count }) %>
- <% end %>
<%= turbo_stream.replace(dom_id(@product, 'reviews_form')) do %>
<%= render(partial: 'products/reviews/form', locals: { product: @product }) %>
<% end %>
- <%= turbo_stream.prepend(dom_id(@product, 'reviews_list')) do %>
- <%= render(partial: 'products/reviews/card', locals: { review: @review }) %>
- <% end %>
Обновление формы все еще останется в HTTP ответе, так как форма должна быть очищена после отправки, и мы не хотим очищать ее для всех пользователей.
Вот и всё, что вам нужно, если вы хотите добавить в Rails приложение немного коммуникации в режиме реального времени. Магия рельсов во всей её красе!
Команда Rails проделала большую работу, чтобы свести к минимуму взаимодействие со всем этим большим и страшным миром JS, оставив фреймворк отличным инструментом для создания современных веб-приложений. Конечно, реальный мир может (и, скорее всего, будет) требовать больше, чем может дать Turbo. И команда Rails разработала Stimulus и request.js, чтобы сделать вашу жизнь легче, когда вам все-таки придется писать JS код в Rails приложении. Впрочем, это уже совсем другая история.
Комментарии (9)
leotada
20.11.2021 12:25Спасибо за статью. Для новичка вроде меня, количество пакетов, их почти одинаковые названия добавляют дополнительную сложность. Понять разницу между stimulus и stimulus-reflex не сразу получилось (да и до сих пор ещё не до конца понял).
На днях автор stimulus-reflex делился статистикой по небольшому проекту.
https://twitter.com/hopsoft/status/1461345714508730371 — в рельсовых гемах значительно меньше зависимостей
https://twitter.com/hopsoft/status/1460999812526981122 — набор технологий и минимальное количество js позволяет ему создавать современные, интерактивные проекты
woto
25.11.2021 23:42Stimulus-reflex это от другого разработчика. Могу ошибаться, но это "конкурент" Turbo. И вроде даже появились раньше
hazg
22.11.2021 09:57Что-то подсказывает, что апи на рельсах и js фреймворк на фронте в наше время - актуальней.
woto
25.11.2021 23:48Популярней - да. Но если грамотно подходить к этим вещам, то имеют право на жизнь и могут дать выигрыш (имею ввиду комплексный подход и с фронтенд фреймворком и базовыми инструментами типа Stimulus + Turbo). Например такой подход используют Basecamp, Evil Martians.
Bigata
Как-то все не очень, раздуто и лишнего целый мешок, просто ajax в js добавляется одним fetch().
leotada
Что именно сложно? Всё это позволяет работать практически работать только с одним языком, только с одним фрейморком и не добавлять лишнюю сложность в проект.
Это ведь не только про ajax. Быстрая загрузка, возможность добавлять интерактив, сокеты — как только сложность фичей увеличивается, количество js разрастается.
Akuma
Я думаю тут хотели сказать, что нет смысла лезть в браузер без JS (или сахаренных подвидов). Все равно все приводится к JS, будь по Руби или что-то еще.
leotada
Но ведь эти библиотеки и подходы как раз созданы для того, чтобы разработчик как можно меньше сталкивался с чистым js и вместо того, чтобы делать фронт на js фреймворке мог работать с чистым (или почти чистым) Rails.
Т.е. мне без разницы какой в проекте будет js если вся сложность от меня скрыта и я могу работать с кодом почти так же как и раньше.
Те кто плотно работает с рельсами очень часто пишут именно об этом как об основном преимуществе. Можно создать интерактивный сайт, без перезагрузок работая привычным образом и не добавляя в проект реакт, вью или что-то ещё, чтобы получить то же самое.
dark_gf
Это все хорошо пока не наткнулся на как либо затык и надо лезть и дебагать, вот там будет весело.
PS: вспомните про gwt