Как написать Front-end на Рельсах почти без JavaScript

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Всем привет! Меня зовут Дмитрий, я Full Stack инженер, и вот уже 5 лет успешно использую Ruby. В продуктовой студии Railsware, где я работаю, к нам часто обращаются за разработкой MVP для различных продуктов. Инструменты и подходы для создания пользовательского интерфейса от команды Rails прекрасно подходят для этой задачи. Сегодня я немного расскажу о них и покажу, как все работает.

Вопреки слухам в девелоперских комьюнити, Rails не становится устаревшей технологией, не собирается умирать, и остается отличным инструментом для разработки вашего нового проекта. Одна из причин заключается в том, что в Rails есть достаточно инструментов, чтобы покрыть базовый функционал типичного веб-приложения. Вам не нужно думать о том, как обрабатывать НТТР-запросы, что использовать для сохранения и получения данных из базы, как отрисовать HTML, который пользователи увидят в своих браузерах, и даже как «вдохнуть жизнь» в пользовательский интерфейс.

Инструменты из библиотеки

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 автоматически следовать за редиректом. Это правильный статус НТТР для неиндемпотентных (умное слово для описания методов не 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. Полагаю, вас мало интересуют такие базовые вещи.

Вот так выглядит наш products/show.html.erb:

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 вы сможете без дополнительных усилий сделать ваше приложение максимально похожим на SPA при этом оставив бОльшую часть логики и разметки на бэкенде. Конечно, реальный мир может (и, скорее всего, будет) требовать больше, чем может дать Turbo. Но и тут команда Rails разработала Stimulus и request.js, чтобы сделать вашу жизнь легче, когда вам все-таки придется писать JS код в Rails приложении. Но это уже совсем другая история ;)

👍НравитсяПонравилось8
В избранноеВ избранном6
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Раскройте слегка тему как в таком приложении в форме из 15 полей делать сложные валидации (аяксовые в том числе), показывать скрывать контролы в зависимости от других контролов, накладывать маски на поля. Ну и святая святых — гриды с кучей колонок, с фильтрациями, пагинациями, сортировками. То есть понятно, что это будет все в яваскрипте, но как именно это будет организовано на уровне файлов, чтобы оживлять все эти вьюхи.

С turbo/hotwired стеком работать в целом понравилось но были такие сложности:

1) Если код падает где-то в неожидаемом месте, то логика как это показать в интерфейсе может быть *очень разная* и по умолчанию это будет просто какой-то HTML response(без turbo-stream тегов). То есть глобальным rescue_from & render partial: ’common/turbo_error’ навряд ли можно обойтись.

Многие мануалы и курсы по турбо стеку концентрируются именно на happy-path сценариях и почти полностью игнорируют failure handling complexity.

2) Пока все еще слишком быстро все меняется, тратится время на изучение релиз ченджлогов.

я уявляю який там код на фронтенді генерується

чтобы свести к минимуму взаимодействие со всем этим большим и страшным миром JS

нахіба 🤦‍♂️

Потому что джаваскрипт это слооооооооооожно.

Щоб тримати логіку в одному місці та мати одну команду а не дві

www.meme-arsenal.com/...​1b9304e096103f3a9f13b.jpg
Ну якщо ненавидиш юзерів, то вкладатись у фронтенд не треба :)

Я бачу що ви або не читали матеріал, або прочитали його дуже поверхнево, або прочитали та не зрозуміли тому що не маєте відповідного досвіду з Rails, або просто сперечаєтесь або сперечатися, тому що те що ви пишете ніяк не корелює з темою матеріалу. Можливо вам смішно, але ви поводите себе як невіглас. Перечитайте матеріал або перечитайте оригінал від DHH щоб зрозуміти суть а потім повертайтеся з конструктивною критикою а не мемами та нерелевантними гаслами.

Ось матеріал де можна краще зрозуміти мотивацію переходити на Hotwire якщо вам цікаво: Bearer switched from React, Rails, and GraphQL to Hotwire.

Якщо нецікаво—проходьте повз.

Також: twitter.com/...​tatus/1439891231128043523

Gmail loads 4MB of compressed JavaScript to render the inbox (13MB uncompressed, out of 29MB in total assets).

@heyhey loads 63KB to render the imbox (240KB uncompressed, out of 903KB in total assets).

HEY is built using hotwired.dev.

Мені дійсно не цікаво після того як ви мене явно не зрозуміли і навіть не намагались, а просто обізвали невігласом. На форумі в інтернеті прийнято вести себе інакше з колегами, але ок.

Заради поінту, процитую

Notably, Turbo allows us to send HTML from the server (whether in response to user interaction or a server-side event) that will dynamically change parts of the page without any custom JavaScript

Це намагання уникнути вкладання часу і ресурсів в ту частину аплікейшену, яка є найближче до користувача. Фраза із статті каже:

чтобы свести к минимуму взаимодействие со всем этим большим и страшным миром JS

Якщо ваш хотвайр заміняє повноцінну інвестицію у фронтенд, то в це мені складно повірити. Хіба що у вас деви всі фулстаки і не проти писати html в темплейтах. Мені дійсно цікаво як це виглядатиме на великому проєкті. Бо як тільки ви захочете підключити щось складніше чим формочки, то це є велике питання як воно працюватиме. Відповіді я не побачив після швидкого перегляду посилань. Буду радий якщо вкажете де :)

Якщо ваш хотвайр заміняє повноцінну інвестицію у фронтенд, то в це мені складно повірити

для деяких задач заміняє, для деяких не заміняє 🤷‍♂️

Бо як тільки ви захочете підключити щось складніше чим формочки

для деяких продуктів «щось складніше чим формочки» не є пріоритетною фічою 🤷‍♂️

Gmail резко стал страшно тупым после переезда на новый дизайн. Сейчас уже мало кто вспомнит каким он был шустрым до.

там вроде есть режим отображения просто html, но выходит так, что письма неправильно отображаются, потому что те кто делали эти письма, решили напичкать их свистоперделками настолько, что ни html версия gmail, ни thunderbird их нормально не отображает.

щоб зрозуміти суть

А суть тут часом не гонять голый отрендереный html с сервера, как в застойные времена, и вставлять его в iframe html компонент через вебсокеты/ajax?

А якщо юзери ненавидять сайти, які без JavaScript не працюють?

На фронтенді ніякого коду не генерується.

на фронтенд*
авжеж генерується, інакше як таби працюють, на цсс і чекбоксах?

Нічого не генерується. Почитайте уважно як воно працює.

Найближчий аналог це twinspark-js від наших хлопців з Касти, там використовується той самий принцип що і в Hotwire.

Також вже давно є Phoenix LiveView та htmx.

whatever, ви мене не зрозуміли

Как сделать троллейбус из буханки хлеба?

Пришел исключительно за этим комментарием ;)

Ну ладу ниву как-то уже полвека делают из хлеба, и тролейбусы начнём.

Отметим, что всеми нами любимый стэковерфлоу написан с использованием stimulus.

Там у файлі app/views/products/reviews/_card.html.erb невірний контент.

Добридень. Що саме в цьому прикладі невірно?

Подписаться на комментарии