Hotdry.
application-security

使用自定义元素在 Rails 中构建乐观 UI

在 Rails 中利用 Web Components 的自定义元素实现乐观 UI,用户操作即时反馈,通过 Turbo Stream 服务器协调,无需完整重载。

在现代 Web 应用中,用户体验的核心在于响应速度。乐观 UI(Optimistic UI)是一种强大技术:用户提交表单后,界面立即显示预期结果,而非等待服务器响应。这种 “先乐观,后确认” 的策略,能显著减少感知延迟,尤其适合聊天、评论或待办事项等场景。

在 Rails + Hotwire 环境中,自定义元素(Custom Elements)是实现这一点的理想工具。它是 Web Components 标准的一部分,允许定义带行为的自定义 HTML 标签,如 <turbo-frame><turbo-stream>。相比 Stimulus,自定义元素更接近原生 JS,更易复用,且无需额外框架约定。

自定义元素基础

自定义元素通过继承 HTMLElement 创建,生命周期钩子如 connectedCallback()(元素插入 DOM 时执行,类似 Stimulus 的 connect())和 disconnectedCallback() 用于初始化和清理。

简单示例:计数器。

// app/javascript/components/click_counter.js
class ClickCounter extends HTMLElement {
  connectedCallback() {
    this.count = 0;
    this.addEventListener("click", () => this.#increment());
  }

  #increment() {
    this.count++;
    this.querySelector("span").textContent = this.count;
  }
}

customElements.define("click-counter", ClickCounter);

application.js 导入:

import "components/click_counter";

配置 importmap.rb

pin_all_from "app/javascript/components", under: "components"

视图中使用:

<click-counter>
  <button>Clicked <span>0</span> times</button>
</click-counter>

点击按钮,计数即时递增。无需 Stimulus targets,使用标准 DOM 查询。

属性变化监听通过 static observedAttributes = ["name"]attributeChangedCallback() 实现,类似于 values。

乐观表单实现

核心:<optimistic-form> 包装表单和 <template response>(包含消息 partial)。

HTML 结构:

<optimistic-form>
  <%= form_with url: messages_path, method: :post, local: true do |f| %>
    <%= f.text_area :content, placeholder: "Write a message…", required: true %>
    <%= f.submit "Send" %>
  <% end %>

  <template response>
    <%= render Message.new(content: "", created_at: Time.current) %>
  </template>
</optimistic-form>

<ul id="messages">
  <!-- 现有消息 -->
</ul>

注意 partial 中的 data-field 属性匹配表单名:

<!-- app/views/messages/_message.html.erb -->
<li id="<%= dom_id(message) %>" data-field="message[content]">
  <%= message.content %>
  <small><%= message.created_at %></small>
</li>

JS 实现(app/javascript/components/optimistic_form.js):

class OptimisticForm extends HTMLElement {
  connectedCallback() {
    this.form = this.querySelector("form");
    this.template = this.querySelector("template[response]");
    this.target = document.querySelector("#messages");

    this.form.addEventListener("submit", () => this.#submit());
    this.form.addEventListener("turbo:submit-end", () => this.#reset());
  }

  #submit() {
    if (!this.form.checkValidity()) return;

    const formData = new FormData(this.form);
    const optimisticElement = this.#render(formData);
    this.target.append(optimisticElement);  // 即时追加
  }

  #render(formData) {
    const element = this.template.content.cloneNode(true).firstElementChild;
    element.id = "optimistic-message";  // 关键 ID,用于替换

    for (const [name, value] of formData.entries()) {
      const field = element.querySelector(`[data-field="${name}"]`);
      if (field) field.textContent = value;
    }

    return element;
  }

  #reset() {
    this.form.reset();
  }
}

customElements.define("optimistic-form", OptimisticForm);

流程:

  1. 用户提交:浏览器验证(checkValidity()),通过后克隆 template,填充 formData 到 data-field,追加 #optimistic-message#messages

  2. 表单正常 POST 到 Rails(Turbo 处理)。

  3. Rails 控制器:

# app/controllers/messages_controller.rb
def create
  @message = Message.create!(message_params)
  respond_to do |format|
    format.turbo_stream { render turbo_stream: turbo_stream.replace("optimistic-message", @message) }
  end
end

create.turbo_stream.erb

<%= turbo_stream.replace "optimistic-message", @message %>

服务器渲染真实消息(带 ID、时间戳),替换乐观元素。无缝过渡,无闪烁。

失败时,Rails 正常渲染错误,Turbo 显示,用户感知乐观元素未持久化。

工程化参数与监控

关键阈值

  • 乐观超时:Turbo 默认 100ms 进度条;自定义 data-turbo-stream-delay 调整。
  • 表单验证:依赖浏览器 required,后端强参数验证。
  • 目标选择器#messages 固定;动态用 data-target 属性。
  • ID 约定:始终 "optimistic-message";多表单用时间戳 optimistic-${Date.now()}

回滚策略

  • 服务器 4xx/5xx:移除乐观元素,显示 flash 错误。
  • 扩展 #submit:添加 try/catch 监听 turbo:submit-error
this.form.addEventListener("turbo:submit-error", () => {
  document.getElementById("optimistic-message")?.remove();
});

监控要点

  • 性能:Chrome DevTools > Network 观察 Turbo 替换延迟 <500ms。
  • 兼容:Safari 支持 is 属性有限,优先自主元素(带 -)。
  • 复用:组件自带,无状态依赖;测试用 Playwright 模拟提交。

实施清单

  1. 配置 importmap pin components。
  2. 导入 optimistic_form.js。
  3. Partial 加 data-field="message[content]" 等。
  4. 控制器 turbo_stream.replace "optimistic-message"。
  5. 测试:慢网络(DevTools Throttling),确认即时 + 替换。
  6. 错误:模拟失败,确认回滚。

此方案零依赖(纯浏览器 API + Hotwire),适用于 SaaS 聊天 / 评论。扩展到列表编辑:多 optimistic ID。

资料来源:

  • Rails Designer: Building optimistic UI in Rails with custom elements
  • GitHub 示例仓库
查看归档