Вопреки слухам на пространствах девелоперских комьюнити, 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
Новый сайт со ставками на спорт. Лучшие коэффициенты xD

Итак, вы сделали 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

<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>

И вот как это будет выглядеть:

Людям нравится

Теперь мы можем добавить немного интерактивности, используя 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 приложении. Впрочем, это уже совсем другая история.