Hotwire如何不使用JavaScript的反应式Rails?

454 阅读11分钟

现在是时候了,是时候投下期待已久的 新魔术并学习使用5分钟的教程以外的Hotwire。自从今年的大揭幕以来,这个用于构建现代网络界面的库背后的伞状名称似乎不费吹灰之力,也不使用JavaScript,就被大家挂在嘴边。HTML-over-the-wire方法在Rails世界中激起了涟漪:无数的博文、reddits、截屏,以及今年的五场 RailsConf演讲,包括你的演讲,都是专门针对它的。在这里,我想用更多的篇幅来彻底解释Hotwire--用代码实例和测试策略。正如我最喜欢的摇滚乐队所说的那样,让我们让Hotwire......自我毁灭学习新技巧吧

生命是短暂的

要想一目了然地看到Rails 6应用程序的Hotwire-ing,不用多说,请随时研究这个PR

下面的文章详细解释了上述代码。它是我在RailsConf 2021演讲中的改编和延伸。"Frontendless Rails frontend",所有RailsConf的参会者都可以在网上看到。如果你没有会议门票,也不用担心:你可以在这里找到幻灯片,一旦所有演讲公开,同一页面上的视频也会更新。

"这就是方法"

在过去的五年里,我主要是做纯后端开发。REST和GraphQL APIs、WebSocket、gRPC、数据库、缓存,所有这些都在屏幕后面。

整个前端的发展就像一个巨大的波浪一样从我身边过去了。我仍然不明白为什么我们需要用reactswebpacks塞满每一个web应用。经典的HTML优先的Rails方式我的方式(或高速公路😉)。还记得你的应用程序中的JavaScript不需要自己的MVC(或MVVM)来运作的日子吗?我怀念那些日子。而这些日子正在悄悄地卷土重来。

今天,我们正在见证*HTML-over-the-*wire的崛起(是的,它现在是一个真正的术语)。在Phoenix LiveView的推动下,基于通过WebSocket向所有连接的客户端推送后端渲染模板的方法在Rails社区得到了很大的发展,这要归功于StimulusReflex系列的宝石。今年年初,DHH向世界介绍Hotwire时,终于得到了他本人的祝福。

我们是否正站在网络开发的另一个全球范式转变的边缘?回到简单的服务器渲染模板的思维模式,这一次,所有的反应式界面的铃铛和口哨,几乎没有任何努力?尽管我很喜欢这样,但我意识到这是一厢情愿的想法:大科技公司在客户端渲染的应用程序上投入了太多的精力,永远不会再回去了。2020年的前端开发是一个独立的资格和一个独立的行业,有自己的入门要求;我们不可能再次成为 "全栈"。

然而,HOTWire(看到这个缩写了吗? Basecamp方面很聪明,嗯?)为复杂的,或者我们应该说 "复杂 "的,现代浏览器客户端编程的火箭科学提供了一个急需的替代方案。

对于一个厌倦了只做API应用而无法控制表现形式的Rails开发者来说,对于一个怀念创造用户体验以摆脱每周40小时的SQL和JSON的开发者来说,Hotwire是一股渴望已久的新鲜空气,使Web开发再次充满乐趣

在这篇文章中,我想演示如何通过Hotwire将HTML-over-the-wire理念应用于现有的Rails应用程序。与我最近的大多数文章一样,我将使用我的AnyCable演示应用程序作为小白鼠。

这个应用很适合这项任务:交互式和反应式,由Turbolinks和少量的自定义(Java)脚本驱动,它还拥有一个体面的系统测试覆盖率(意味着我们可以安全地重构)。我们的hotwire-ification将分步骤进行,很容易就能跟上。

从Turbolinks到Turbo Drive

Turbolinks在Rails世界中闻名已久;第一个主要版本早在2013年就已发布。然而,在我的早期,Rails开发者有一个经验法则:如果你的前端表现得很奇怪,可以尝试禁用Turbolinks。让第三方JS代码与Turbolink的(读作:pushState + AJAX)导航兼容并不是件容易的事。

StimulusJS出现时,我就不再回避Turbolinks了。它从根本上解决了依靠现代DOM突变API来连接和断开JavaScript洒落的问题。Turbolinks与Stimulus的代码组织和DOM操作相结合,产生了毫不费力的 "SPA "体验,其开发成本仅为React-Angular的一小部分。

同样好的老Turbolinks现在被重新命名为Turbo Drive,因为它实际上是在驱动 Turbo--Hotwire套餐的核心。

如果你的应用已经使用了Turbolinks(我的应用也是如此),切换到Turbo Drive就非常简单。这就是重命名的问题。

你只需要在你的package.json 中用@hotwired/turbo-rails 替换turbolinks ,在Gemfile-用turbo-rails 替换turbolinks

初始化代码看起来有点不同,现在更简洁了。

- import Turbolinks from 'turbolinks';

- Turbolinks.start();
+ import "@hotwired/turbo"

请注意,我们不需要手动启动Turbo Drive(也不能停止它)。

需要一些 "查找和替换 "来更新所有的HTML数据属性,从data-turbolinksdata-turbo

唯一花了我一些时间才搞清楚的变化是处理表单和重定向。以前,在Turbolinks中,我使用远程表单(remote: true)和重定向关注,用JavaScript模板进行响应。Turbo Drive对劫持表单有自己的内置支持,所以不再需要remote: true 。然而,事实证明,重定向的代码必须更新。或者,更确切地说,重定向状态代码

- redirect_to workspace
+ redirect_to workspace, status: :see_other

使用有点晦涩的See OtherHTTP响应代码(303)是一个聪明的选择:它允许Turbo依靠原生的Fetch APIredirect: "follow" 选项,所以你不必在表单提交后明确发起另一个请求来获取新内容。根据规范,*"如果状态是303,并且请求的方法不是GETHEAD",必须自动执行GET 请求。将此与"如果状态是301或302,并且请求的方法是POST"相比较--*看到区别了吗?

其他3xx状态只适用于POST请求,而在Rails中我们通常使用POST、PATCH、PUT和DELETE。

用Turbo Frames定格

.Turbo-frames-video video { border-right: 2px solid black; border-bottom:3px solid black; }

是时候转向真正的新东西了:Turbo Frames。

涡轮框架为页面的部分内容带来无缝更新(而不是像涡轮驱动那样为整个页面带来无缝更新)。我们可以说它与<iframe> 所做的非常相似,但没有创建独立的窗口、DOM树以及随之而来的安全噩梦。

让我们看一下使用的例子。

AnyCable演示应用程序(称为AnyWork)允许你创建带有多个ToDo列表和聊天的仪表板。用户可以与不同列表中的项目进行互动:添加、删除、标记为完成。

Turbo框架的作用:每个项目都是它自己的框架

最初,完成和删除项目是由AJAX请求和一个自定义的Stimulus控制器支持的。我决定使用Turbo Frames重写这个功能,以实现HTML的全功能。

我们怎样才能分解我们的ToDo列表来处理单个项目的更新呢?让我们把每个项目变成一个框架吧

<!-- _item.html.rb -->
<%= turbo_frame_tag dom_id(item) do %>
  <div class="any-list--item<%= item.completed? ? " checked" : ""%>">
    <%= form_for item do |f| %>
      <!-- ... -->
    <% end %>
    <%= button_to item_path(item), method: :delete %>
      <!-- ... -->
    <% end %>
  </div>
<% end %>

我们在这里做了三件重要的事情。

  • 通过帮助器将我们的项目容器包装成一个<turbo-frame> 标签,并传递一个唯一的标识符(查看来自ActionView 的方便的dom_id方法)。
  • 添加了一个HTML表单,使Turbo拦截提交并更新框架的内容;以及
  • button_to 助手与method: :delete 一起使用,它也在后台创建了一个 HTML 表单。

现在,只要有表单在框架内提交,Turbo就会拦截提交,执行AJAX请求,从响应的HTML中提取一个具有相同ID的框架,并替换这个框架的内容。

以上所有的工作,完全不需要手写的JavaScript!

让我们来看看我们更新的控制器代码。

class ItemsController < ApplicationController
  def update
    item.update!(item_params)

    render partial: "item", locals: { item }
  end

  def destroy
    item.destroy!

    render partial: "item", locals: { item }
  end
end

请注意,当我们删除一个项目时,我们用同样的部分进行响应。但是我们需要删除项目的HTML节点,而不是更新它。我们怎样才能做到这一点呢?我们可以用一个空的框架来响应!让我们更新我们的局部来做到这一点。

<!-- _item.html.rb -->
<%= turbo_frame_tag dom_id(item) do %>
  <% unless item.destroyed? %>
    <div class="any-list--item<%= item.completed? ? " checked" : ""%>">
      <!-- ... -->
    </div>
  <% end %>
<% end %>

你可能已经问过自己一个问题。"当把一个项目标记为完成时,我们如何触发表单提交?"换句话说,如何使复选框的状态变化触发表单的提交?我们可以通过定义一个内联事件监听器来做到这一点。

<%= f.check_box :completed, onchange: "this.form.requestSubmit();" %>

注意:使用requestSubmit() ,而不是submit() ,这一点很重要:前者会触发可能被Turbo拦截的 "提交 "事件,而后者则不会。

总而言之,我们可以通过改变我们的HTML模板和简化控制器的代码来摆脱这个特殊功能的所有自定义JS。我很兴奋,你呢?

我们可以更进一步,把我们的列表也转换成框架。这将允许我们在添加新项目时,从Turbo Drive页面更新切换到特定节点更新。你可以在家里试试这个!

想象一下,你还想在项目完成或删除时向用户显示一个Flash通知("项目已成功删除")。我们能用Turbo Frames做到这一点吗?听起来我们需要把我们的flash信息容器包装成一个框架,然后把更新的HTML和项目的标记一起推送。这是我最初的想法,但并不成功:框架的更新是以发起者框架为范围的。因此,我们不能更新它之外的任何东西。

经过一些研究,我发现Turbo Streams可以帮助解决这个问题。

使用Turbo Streams进行流式传输

.turbo-streams-video video { border-left: 2px solid black; border-bottom: 2px solid black; }

与Drive和Frames相比,Turbo Streams是一项全新的技术。与这两者不同,流是明确的。没有任何事情是自动发生的,负责页面上应该更新什么和什么时候更新。而要做到这一点,你需要使用特殊的<turbo-stream> 元素。

让我们看一下流元素的例子。

<turbo-stream action="replace" target="flash-alerts">
  <template>
    <div class="flash-alerts--container" id="flash-alerts">
      <!--  -->
    </div>
  </template>
</turbo-stream>

这个元素负责用<template> 标签内传递的新的HTML内容替换(action="replace")DOM ID下的节点flash-alerts 。每当你这样的<turbo-stream> 元素放到页面上,它就会立即执行动作并销毁自己。在引擎盖下,它使用了HTML自定义元素API--这是使用现代网络API的另一个例子,目的是为了让开发者高兴(也就是减少JavaScript 🙂)。

我想说的是,Turbo Streams是对过去的JavaScript模板的一种声明性替代。在2010年代,我们是这样写的。

// destroy.js.erb
$("#<%= dom_id(item) %>").remove();

而现在我们是这样做的。

<!--  destroy.html.erb -->
<%= turbo_stream.remove dom_id(item) %>

目前,只有五个动作可用:追加、预追加、替换、删除更新(只替换节点的文本内容)。我们将在下面讨论这个限制以及如何克服它。

让我们回到我们最初的问题:在响应ToDo项目的完成或删除时显示Flash通知。

我们想同时用<turbo-frame><turbo-stream> 更新来回应。我们怎样才能做到这一点呢?让我们为此添加一个新的部分模板。

<!-- _item_update.html.erb -->
<%= render item %>

<%= turbo_stream.replace "flash-alerts" do %>
  <%= render "shared/alerts" %>
<% end %>

ItemsController 中添加一点变化。

+    flash.now[:notice] = "Item has been updated"

-    render partial: "item", locals: { item }
+    render partial: "item_update", locals: { item }

不幸的是,上面的代码没有达到预期的效果:我们没有看到任何闪光警报。
经过挖掘文档,我发现Turbo期望HTTP响应具有text/vnd.turbo-stream.html 内容类型,以便激活流元素。好吧,让我们这么做吧。

-    render partial: "item_update", locals: { item }
+    render partial: "item_update", locals: { item }, content_type: "text/vnd.turbo-stream.html"

我们现在有了相反的情况:flash信息起作用了,但项目的内容却没有更新😞。我对Hotwire的要求是不是太高了?通过阅读Turbo的源代码,我发现像这样混合流和框架是不可能的

结果发现,有两种方法可以实现这个功能。

  • 所有东西都使用流。
  • <turbo-stream> 里面的<turbo-frame>

在我看来,第二种方案与重用HTML分词来实现常规页面加载和Turbo更新的想法相悖。所以,我选择了第一种。

<!-- _item_update.html.erb -->
<%= turbo_stream.replace dom_id(item) do %>
  <%= render item %>
<% end %>

<%= turbo_stream.replace "flash-alerts" do %>
  <%= render "shared/alerts" %>
<% end %>

任务完成。但代价是什么呢?我们不得不为这个用例添加一个新的模板。而且我担心在一个真实世界的应用中,随着应用的发展,这种临时性的参数的数量会越来越多。

更新(2021-04-13)。Alex Takitani提出了一个更优雅的解决方案:使用布局来更新Flash内容。我们可以为Turbo Stream响应定义应用程序布局,如下所示。

<!-- layouts/application.turbo_stream.erb -->
<%= turbo_stream.replace "flash-alerts" do %>
  <%= render "shared/alerts" %>
<% end %>

<%= yield %>

然后,我们需要从控制器中移除显式渲染(因为否则布局就不会被使用)。

   def update
     item.update!(item_params)

     flash.now[:notice] = "Item has been updated"
-
-    render partial: "item_update", locals: { item }, content_type: "text/vnd.turbo-stream.html"
   end

注意:不要忘记在你的控制器/请求规范中为相应的请求添加format: :turbo_stream ,以使隐式渲染发挥作用。

并将我们的_item_update 部分转换为update Turbo Stream模板。

<!-- update.turbo_stream.erb -->
<%= turbo_stream.replace dom_id(item) do %>
  <%= render item %>
<% end %>

很酷,对吗?这就是Rails的方式!

现在让我们来看看一些真正的(时间)流。

涡轮流经常在实时更新的背景下被提及(并且通常与StimulusReflex相比较)。

让我们看看我们如何在Turbo Streams之上建立列表同步。

运行中的Turbo Streams,为所有连接的客户端同步页面更新

在Turbo之前,我不得不添加一个自定义的Action Cable通道和一个Stimulus控制器来处理广播。我还需要照顾到消息的格式,因为我必须区分项目的删除和完成。换句话说,有很多代码需要维护。

Turbo Streams解决了大部分问题:turbo-rails gem带有一个通用的Turbo::StreamChannel 和一个帮助器(#turbo_stream_from ),可以直接从HTML中创建一个订阅。

<!-- worspaces/show.html.erb -->
<div>
  <%= turbo_stream_from workspace %>
  <!-- ... -->
</div>

在控制器中,我们已经有了#broadcast_new_item#broadcast_changes "行动后 "钩子,负责广播更新。现在我们所需要的是切换到Turbo::StreamChannel

 def broadcast_changes
   return if item.errors.any?
   if item.destroyed?
-    ListChannel.broadcast_to list, type: "deleted", id: item.id
+    Turbo::StreamsChannel.broadcast_remove_to workspace, target: item
   else
-    ListChannel.broadcast_to list, type: "updated", id: item.id, desc: item.desc, completed: item.completed
+    Turbo::StreamsChannel.broadcast_replace_to workspace, target: item, partial: "items/item", locals: { item }
   end
 end

这次迁移很顺利。差不多了。所有验证广播的控制器单元测试(#have_broadcasted_to )都失败了。

不幸的是,Turbo Rails没有提供任何测试工具(还没有?),所以我不得不自己写,这是一个再熟悉不过的故事了。

module Turbo::HaveBroadcastedToTurboMatcher
  include Turbo::Streams::StreamName

  def have_broadcasted_turbo_stream_to(*streamables, action:, target:) # rubocop:disable Naming/PredicateName
    target = target.respond_to?(:to_key) ? ActionView::RecordIdentifier.dom_id(target) : target
    have_broadcasted_to(stream_name_from(streamables))
      .with(a_string_matching(%(turbo-stream action="#{action}" target="#{target}")))
  end
end

RSpec.configure do |config|
  config.include Turbo::HaveBroadcastedToTurboMatcher
end

这就是我如何在测试中使用我的新匹配器。

 it "broadcasts a deleted message" do
-  expect { subject }.to have_broadcasted_to(ListChannel.broadcasting_for(list))
-    .with(type: "deleted", id: item.id)
+  expect { subject }.to have_broadcasted_turbo_stream_to(
+    workspace, action: :remove, target: item
+  )
 end

到目前为止,使用Turbo的实时性进行得很好!一批代码已经被删除。

而我们仍然没有写过一行JavaScript代码。这就是真实的生活吗?

这只是幻想吗?我什么时候才能醒过来?嗯,就是现在。

超越Turbo,或使用Stimulus和自定义元素

在Turbo迁移的过程中,我偶然发现了几个用例,使用现有的API是不够的,所以我最终不得不写了一些JavaScript代码。

用例一:实时添加新的列表到仪表板。这和前一章的项目列表例子有什么不同?标记。让我们看一下仪表盘的布局。

<div id="workspace_1">
  <div id="list_1">...</div>
  <div id="list_2">...</div>
  <div id="new_list">
    <form>...</form>
  </div>
</div>

最后一个元素总是新的列表形式的容器。每当我们添加一个新的列表时,它就被插入到#new_list 节点之前。你还记得Turbo Streams只支持五个动作吗?你看到问题出在哪里了吗?下面是我最初使用的代码。

handleUpdate(data) {
  this.formTarget.insertAdjacentHTML("beforebegin", data.html);
}

为了使用Turbo Streams实现类似的行为,我们需要添加一个黑客,在通过流添加列表后立即将其移动到正确的位置。所以,让我们加入我们自己的JavaScript sprinkle

首先让我们给我们的任务一个正式的定义。"当一个新的列表项被添加到工作区容器中时,它应该被定位在新的表单元素之前。"这里的*"当 "*字意味着我们需要观察DOM并对变化做出反应。这听起来是不是很熟悉?是的,我们已经提到了与Stimulus有关的MutationObserver API!让我们来使用它。

幸运的是,我们不需要写高级JavaScript来使用这个功能;我们可以使用刺激使用(原谅我这个不可避免的同义词)。刺激使用是刺激控制器的有用行为的集合,是解决复杂问题的简单片段。在我们的例子中,我们需要useMutation行为。

控制器的代码相当简洁,不言而喻。

import { Controller } from "stimulus";
import { useMutation } from "stimulus-use";

export default class extends Controller {
  static targets = ["lists", "newForm"];

  connect() {
    [this.observeLists, this.unobserveLists] = useMutation(this, {
      element: this.listsTarget,
      childList: true,
    });
  }

  mutate(entries) {
    // There should be only one entry in case of adding a new list via streams
    const entry = entries[0];

    if (!entry.addedNodes.length) return;

    // Disable observer while we modify the childList
    this.unobserveLists();
    // Move newForm to the end of the childList
    this.listsTarget.append(this.newFormTarget);
    this.observeLists();
  }
}

问题已经解决了。

让我们来谈谈第二个边缘案例:实现聊天功能。

我们在每个仪表盘上都有一个非常简单的聊天功能:用户可以发送短暂的(不存储在任何地方)信息,并实时接收它们。信息根据上下文有不同的外观:我的信息有绿色边框,位于左边;其他信息是灰色的,位于右边。但我们向所有连接到聊天的人播放相同的HTML。我们怎样才能让用户感受到其中的不同呢?对于类似聊天的应用程序来说,这是一个非常普遍的问题,而且,一般来说,它可以通过向每个用户通道发送个性化的HTML或者通过增强传入的HTML客户端来解决。我更喜欢第二种方案,所以我们来实现它。

为了将关于当前用户的信息传递给JavaScript,我使用了元标签。

<!-- layouts/application.html.erb -->
<head>
  <% if logged_in? %>
    <meta name="current-user-name" content="<%= current_user.name %>" data-turbo-track="reload">
    <meta name="current-user-id" content="<%= current_user.id %>" data-turbo-track="reload">
  <% end %>
  <!-- ... -->
</head>

和一个小的JS助手来访问这些值。

let user;

export const currentUser = () => {
  if (user) return user;

  const id = getMeta("id");
  const name = getMeta("name");

  user = { id, name };
  return user;
};

function getMeta(name) {
  const element = document.head.querySelector(
    `meta[name='current-user-${name}']`
  );
  if (element) {
    return element.getAttribute("content");
  }
}

为了广播聊天信息,我们将使用一个Turbo::StreamChannel

def create
  Turbo::StreamsChannel.broadcast_append_to(
    workspace,
    target: ActionView::RecordIdentifier.dom_id(workspace, :chat_messages),
    partial: "chats/message",
    locals: { message: params[:message], name: current_user.name, user_id: current_user.id }
  )
  # ...
end

这里是原始的chat/message 模板。

<div class="chat--msg">
  <%= message %>
  <span data-role="author" class="chat--msg--author"><%= name %></span>
</div>

以及预先存在的JS代码,根据当前用户应用不同的风格,我们很快就会拿出来。

// Don't get attached to it
appendMessage(html, mine) {
  this.messagesTarget.insertAdjacentHTML("beforeend", html);
  const el = this.messagesTarget.lastElementChild;
  el.classList.add(mine ? "mine" : "theirs");

  if (mine) {
    const authorElement = el.querySelector('[data-role="author"]');
    if (authorElement) authorElement.innerText = "You";
  }
}

现在,当Turbo负责更新HTML时,我们需要以不同的方式来做事情。当然,这里也可以使用useMutation 的技巧。而且这可能是我在真实项目中会做的。然而,我今天的目标是展示解决问题的不同方法。

还记得吗,我们一直在讨论自定义元素(是的,那是好几页以前的事了,抱歉,它变成了一个非常长的书)?它是为Turbo提供动力的网络API。我们为什么不使用它呢!?

让我首先分享一个更新的HTML模板。

<any-chat-message class="chat--msg" data-author-id="<%= user_id %>>
  <%= message %>
  <span data-role="author" class="chat--msg--author"><%= name %></span>
</any-chat-message>

我们只添加了data-author-id 属性,并用我们的自定义标签--<any-chat-message> 取代了<div>

现在让我们注册一下这个自定义元素。

import { currentUser } from "../utils/current_user";

// This is how you create custom HTML elements with a modern API
export class ChatMessageElement extends HTMLElement {
  connectedCallback() {
    const mine = currentUser().id == this.dataset.authorId;

    this.classList.add(mine ? "mine" : "theirs");

    const authorElement = this.querySelector('[data-role="author"]');

    if (authorElement && mine) authorElement.innerText = "You";
  }
}

customElements.define("any-chat-message", ChatMessageElement);

就是这样!现在,当一个新的<any-chat-message> 元素被添加到页面上时,如果消息来自于当前用户,它就会自动更新。而且,我们甚至不需要Stimulus来做这件事!

你可以在这个PR中找到这篇文章的全部源代码。

那么,零JavaScript的反应式Rails毕竟存在吗?并非如此。我们删除了大量的JS代码,但最终不得不用新的东西来代替它。这个新的代码与我们之前的不同:它更多,我说,是功利性。它也更先进,需要对JavaScript和最新的浏览器API有很好的了解,这绝对是一个需要考虑的权衡。

P.S. 我也有一个类似的CableReady和StimulusReflex的PR。你可以把它与Hotwire的那个进行比较,并在Twitter上与我们分享你的意见。


如果你想招募火星人后端(或前端)专家来增强你的数字产品,请给我们留言。