编写更好的StimulusJS控制器(详细指南)

491 阅读7分钟

我们在Basecamp编写了大量的JavaScript,但我们并不使用它来创建当代意义上的 "JavaScript应用程序"。我们所有的应用程序都以服务器端渲染的HTML为核心,然后再添加一些JavaScript,使其熠熠生辉。- DHH

2018年初,Basecamp向世界发布了StimulusJSStimulus结束了构建Rails应用程序的 "Basecamp风格 "的循环。

很难为这个堆栈命名,但基本方法是一个带有服务器渲染视图的vanilla Rails应用程序,Turbolinks("HTML-over-the-wire",pjax)用于快速的页面加载,最后,Stimulus将互动行为 "洒 "在你无聊的旧HTML页面之上。

Basecamp的许多原则和DHH构建软件的方法在这个堆栈中交织在一起:

  • 程序员的幸福:避免 "现代 "JavaScript的不断变化的流沙。
  • 雄伟的巨石:为大中型Rails应用放弃SPA和微服务
  • 小团队做大事情:概念压缩和工具化,这样你就可以用5个人,而不是50个人来构建应用。
  • Omakase:单独使用的工具很好,但在一起使用时却很出色。

坦率地说,最吸引我的是:从真实世界的产品中提取代码的传统(而不是试图教训鸟类如何飞行)。

随着Basecamp准备推出HEY,我很高兴看到这个堆栈的进一步完善。

在未来的几个月里,我们应该看到Stimulus 2.0的发布,以完善API,重新启动服务器生成的JavaScript响应(SJR),并使用web-sockets将所有东西连接起来。

这些技术是非常强大的,但需要看到整个画面。希望深入了解这种堆栈(和开发风格)的人,会比平时更容易感受到"Rails是一把尖刀 "的隐喻

但我已经在厨房里呆了一段时间,我将帮助你切出漂亮的细丝(而不是切掉你的拇指)。

Rails中的服务器渲染视图是一条已知的路径。Turbolinks,除了一些注意事项外,现在几乎是一个可以直接使用的工具。

所以今天,我将重点讨论如何编写更好的Stimulus控制器

这篇文章显然不是对Stimulus的介绍。官方文档手册是很好的资源,我不会在这里重复。

如果你从来没有写过任何Stimulus控制器,我想在这里分享的经验可能不会马上沉淀下来。我知道,因为对我来说,它们并没有沉淀下来。

我花了18个月的时间全职生活在一个使用这个堆栈的代码库中,才开始有所收获。希望我可以帮助你缩短这个时间。让我们开始吧!

可能出错的地方

在开始使用Stimulus的时候,我看到的常见的失败路径。

把控制器做得太具体(无论是通过命名还是功能)

在开始的时候,为每一个你需要JavaScript的页面或部分编写一对一的Stimulus控制器是很诱人的。特别是如果你已经用React或Vue做了整个应用程序的视图层。这通常不是使用Stimulus的最佳方式。

刚开始的时候,你很难写出漂亮的可组合控制器。这没关系。

试图在Stimulus中编写React

Stimulus不是React。React不是Stimulus。当我们让服务器做渲染时,Stimulus工作得最好。没有虚拟DOM或反应式更新,也没有 "数据向下,动作向上 "的传递。

这些模式并没有错,只是有所不同,试图把它们塞进Turbolinks/Stimulus的设置中是行不通的。

摆脱jQuery后的成长之痛

编写成语的ES6对于来自jQuery旧时代的人来说可能是一个绊脚石。

原生语言已经有了飞跃性的发展,但你仍然会不时地挠头,怀疑人们是否真的认为:

new Array(...this.element.querySelectorAll(".item"));

是对$('.item') 。(我同意你的观点,但我想说的是......)

如何写出更好的Stimulus控制器

在试驾了Stimulus并把它搞得一团糟之后,我重新审视了《手册》,突然间我对这些例子有了全新的认识。

例如,手册中显示了一个懒惰加载HTML的例子:

<div data-controller="content-loader" data-content-loader-url="/messages.html">
  Loading...
</div>

请注意使用data-content-loader-url 来传递URL,以便懒惰地加载。

这里的关键思想是,你不是在做一个MessageList 的组件。你是在做一个通用的异步加载组件,可以渲染任何提供的URL。

与提取页面组件的思维模式不同,你更上一层楼,建立了 "基元",你可以在多种用途中粘合在一起。

你可以使用同一个控制器来懒惰地加载页面的一个部分,或标签组中的每个标签,或在悬停在一个链接上时在服务器上获取的模式。

你可以在GitHub等网站上看到这种技术的实际例子。

(请注意,GitHub没有直接使用Stimulus,但其概念是相同的)

GitHub Lazy load

GitHub的活动feed首先加载页面的外壳,然后进行AJAX调用,获取更多的HTML注入到页面:

<!-- Snippet from github.com -->
<div class="js-dashboard-deferred" data-src="/dashboard-feed" data-priority="0">
  ...
</div>

GitHub对整个网站的 "悬停卡 "使用了同样的延迟加载技术:

Github Hover Card

<!-- Snippet from github.com -->
<a
  data-hovercard-type="user"
  data-hovercard-url="/users/swanson/hovercard"
  href="/swanson"
>
  swanson
</a>

通过制作通用的控制器,你开始看到Stimulus的真正力量。

第一层是一个有主见的,更现代的jQueryon("click") 函数。

第二层是一套 "行为",你可以用它来快速建立整个应用程序的交互式洒落。

例子:切换类

你要写的第一个Stimulus控制器之一是一个 "切换 "或 "显示/隐藏 "控制器。你渴望着更简单的时代,即用点击事件来调用$(el).hide()

你的实现将看起来像这样:

// toggle_controller.js
import { Controller } from "stimulus";

export default class extends Controller {
  static targets = ["content"];

  toggle() {
    this.contentTarget.classList.toggle("hidden");
  }
}

你会像这样使用它:

<div data-controller="toggle">
  <button data-action="toggle#toggle">Toggle</button>
  <div data-target="toggle.content">Some special content</div>
</div>

为了应用《手册》中推荐的关于构建更多可配置组件的经验,重新设计控制器,不要硬编码CSS类来切换。

在即将发布的Stimulus 2.0版本中,当 "类 "有一个专门的API时,这一点将变得更加明显:

// toggle_controller.js
import { Controller } from "stimulus";

export default class extends Controller {
  static targets = ["content"];

  toggle() {
    this.contentTargets.forEach((t) => t.classList.toggle(data.get("class")));
  }
}

现在控制器支持多个目标和一个可配置的CSS类来切换。

你需要将用法更新为:

<div data-controller="toggle" data-toggle-class="hidden">
  <button data-action="toggle#toggle">Toggle</button>
  <div data-target="toggle.content">Some special content</div>
</div>

这在第一眼看来可能是不必要的,但是当你发现有更多的地方需要使用这个行为时,你可能会想要一个不同的类来进行切换。

考虑一下当你还需要一些基本的标签来切换内容的情况:

<div data-controller="toggle" data-toggle-class="active">
  <div
    class="tab active"
    data-action="click->toggle#toggle"
    data-target="toggle.content"
  >
    Tab One
  </div>
  <div
    class="tab"
    data-action="click->toggle#toggle"
    data-target="toggle.content"
  >
    Tab Two
  </div>
</div>

你可以使用同样的代码。新功能,但没有新的JavaScript!梦想!

例子:过滤一个结果列表

让我们通过另一个常见的例子:通过特定的字段过滤一个结果列表。

在这个例子中,用户想通过品牌、价格或颜色来过滤一个鞋子列表。

Filter Sneakers screenshot

我们将编写一个控制器来获取输入值,并将它们作为查询参数附加到当前的URL上:

Base URL: /app/shoes
Filtered URL: /app/shoes?brand=nike&price=100&color=6

这种URL方案使得用Rails在后端过滤结果变得非常容易:

// filters_controller.js
import { Controller } from "stimulus";

export default class extends Controller {
  static targets = ["brand", "price", "color"];

  filter() {
    const url = `${window.location.pathname}?${this.params}`;

    Turbolinks.clearCache();
    Turbolinks.visit(url);
  }

  get params() {
    return [this.brand, this.price, this.color].join("&");
  }

  get brand() {
    return `brand=${this.brandTarget.value}`;
  }

  get price() {
    return `price=${this.priceTarget.value}`;
  }

  get color() {
    return `color=${this.colorTarget.value}`;
  }
}

这将会起作用,但它在这个页面之外是不能重用的。如果我们想对订单或用户表进行相同类型的过滤,我们就必须制作单独的控制器。

相反,改变控制器来处理任意的输入,它可以在这两个地方重复使用--特别是输入标签已经有了构建查询参数所需的name 属性:

// filters_controller.js
import { Controller } from "stimulus";

export default class extends Controller {
  static targets = ["filter"];

  filter() {
    const url = `${window.location.pathname}?${this.params}`;

    Turbolinks.clearCache();
    Turbolinks.visit(url);
  }

  get params() {
    return this.filterTargets.map((t) => `${t.name}=${t.value}`).join("&");
  }
}

例子:复选框的列表

我们已经看到了如何通过传入值和使用通用目标来使控制器更加可重用。还有一种方法是在控制器中使用可选目标。

想象一下,你需要建立一个checkbox_list_controller ,让用户选中所有(或没有)的复选框列表。此外,它还需要一个可选的count 目标来显示所选项目的数量。

你可以使用has[Name]Target 属性来检查目标是否存在,然后有条件地采取一些行动:

// checkbox_list_controller.js
import { Controller } from "stimulus";

export default class extends Controller {
  static targets = ["count"];

  connect() {
    this.setCount();
  }

  checkAll() {
    this.setAllCheckboxes(true);
    this.setCount();
  }

  checkNone() {
    this.setAllCheckboxes(false);
    this.setCount();
  }

  onChecked() {
    this.setCount();
  }

  setAllCheckboxes(checked) {
    this.checkboxes.forEach((el) => {
      const checkbox = el;

      if (!checkbox.disabled) {
        checkbox.checked = checked;
      }
    });
  }

  setCount() {
    if (this.hasCountTarget) {
      const count = this.selectedCheckboxes.length;
      this.countTarget.innerHTML = `${count} selected`;
    }
  }

  get selectedCheckboxes() {
    return this.checkboxes.filter((c) => c.checked);
  }

  get checkboxes() {
    return new Array(...this.element.querySelectorAll("input[type=checkbox]"));
  }
}

在这里,我们可以使用控制器为一个基本的表单添加 "Check All "和 "Check None "功能。

Email Preferences screenshot

我们可以使用同样的代码建立一个复选框过滤器,显示选择的数量和一个 "清除过滤器 "按钮("检查无")。

Color Checkbox Filter screenshot

和其他例子一样,你可以看到创建可以在多种情况下使用的Stimulus控制器的力量。

把它放在一起:合成多个控制器

我们可以把这三个控制器结合起来,建立一个高度互动的多选复选框过滤器。

下面是一个关于它是如何一起工作的简介:

  • 使用toggle_controller ,在点击输入时显示或隐藏颜色过滤器选项。

Toggle controller example

  • 使用checkbox_list_controller ,以保持所选颜色的数量,并添加一个 "清除过滤器 "选项。

Checkbox list example

  • 使用filters_controller ,在过滤器输入改变时更新URL,包括基本的HTML输入和我们的多选过滤器。

Filters example

每个单独的控制器都很简单,很容易实现,但它们可以结合起来,以创建更复杂的行为。

下面是这个例子的完整标记:

<div class="filter-section">
  <div class="filters" data-controller="filters">
    <div>
      <div class="filter-label">Brand</div>
      <%= select_tag :brand,
            options_from_collection_for_select(
              Shoe.brands, :to_s, :to_s, params[:brand]
            ),
            include_blank: "All Brands",
            class: "form-select",
            data: { action: "filters#filter", target: "filters.filter" } %>
    </div>
    <div>
      <div class="filter-label">Price Range</div>
      <%= select_tag :price,
            options_for_select(
              [ ["Under $100", 100], ["Under $200", 200] ], params[:price]
            ),
            include_blank: "Any Price",
            class: "form-select",
            data: { action: "filters#filter", target: "filters.filter" } %>
    </div>

    <div>
      <div class="filter-label">Colorway</div>
      <div class="relative"
        data-controller="toggle checkbox-list"
      >
        <button class="form-select text-left"
          data-action="toggle#toggle"
          data-target="checkbox-list.count"
        >
          All
        </button>

        <div class="hidden select-popup" data-target="toggle.content">
          <div class="flex flex-col">
            <div class="select-popup-header">
              <div class="select-label">Select colorways...</div>

              <button class="clear-filters"
                data-action="checkbox-list#checkNone filters#filter"
              >
                Clear filter
              </button>
            </div>

            <div class="select-popup-list space-y-2">
              <% Shoe.colors.each do |c| %>
                <%= label_tag nil, class: "leading-none flex items-center" do %>
                  <%= check_box_tag 'colors[]', c, params.fetch(:colors, []).include?(c),
                    class: "form-checkbox text-indigo-500 mr-2",
                    data: { target: "filters.filter"} %>
                  <%= c %>
                <% end %>
              <% end %>
            </div>

            <div class="select-popup-action-footer">
              <button class="p-2 w-full select-none"
                data-action="filters#filter"
              >
                Apply
              </button>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

包裹它

当Stimulus被用来为你现有的HTML添加一些行为时,它的效果最好。由于Rails和Turbolinks在处理服务器渲染的HTML方面非常有效,所以这些工具是一个自然的选择。

使用Stimulus需要改变jQuery片段和React/Vue的思维方式。考虑添加行为,而不是制作成熟的组件。

如果你能使你的控制器小而简洁,并可重复使用,你就能避免Stimulus的常见绊脚石。

你可以将多个Stimulus控制器组合在一起,以混合和匹配功能,创建更复杂的交互。

这些技术可能会让你难以理解,但你最终可以建立高度互动的应用程序,而根本不需要写太多特定于应用程序的JavaScript!

DHH tweet on not writing app-specific javascript

这是一个激动人心的时刻,因为这个堆栈在不断发展,越来越多的人在快速运送软件方面获得了成功,而且它成为 "全靠JavaScript的SPA "方法的一个更知名的替代方案。

其他资源