RailsHotwire开发Skill rails-hotwire

Rails Hotwire 是一种用于在 Ruby on Rails 框架中构建现代反应式 Web 应用程序的技术,通过 Turbo 实现页面加速和实时更新,Stimulus 处理 JavaScript 交互,无需复杂的前端框架。它允许发送 HTML 而不是 JSON,提供高效的前端开发体验,适用于实时协作、管理仪表板等场景。关键词:Rails, Hotwire, Turbo, Stimulus, 前端开发, 实时更新, Web 应用, Ruby on Rails, 反应式界面, 无 JavaScript 框架。

后端开发 0 次安装 0 次浏览 更新于 3/25/2026

名称: rails-hotwire 用户可调用: false 描述: 当使用 hotwire(Turbo 和 Stimulus)构建现代反应式 Rails 应用程序而无需复杂 JavaScript 框架时使用。 允许工具:

  • 读取
  • 写入
  • 编辑
  • 查找
  • 全局
  • Bash

Rails Hotwire

掌握 Hotwire,使用 Turbo 和 Stimulus 构建现代、反应式的 Rails 应用程序,无需重量级 JavaScript 框架。

概述

Hotwire(HTML Over The Wire)是一种构建 Web 应用程序的现代方法,通过发送 HTML 而不是 JSON 来实现。它由 Turbo(用于提供服务器端渲染的 HTML)和 Stimulus(用于 JavaScript 点缀)组成。

安装与设置

安装 Hotwire

# 添加到 Gemfile
bundle add turbo-rails stimulus-rails

# 安装 Turbo
rails turbo:install

# 安装 Stimulus
rails stimulus:install

# 为 ActionCable(Turbo Streams)安装 Redis
bundle add redis

# 配置 ActionCable
rails generate channel turbo_stream

配置

# config/cable.yml
开发环境:
  适配器: redis
  url: redis://localhost:6379/1

生产环境:
  适配器: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  频道前缀: myapp_production

# config/routes.rb
Rails.application.routes.draw do
  mount ActionCable.server => '/cable'
end

核心模式

1. Turbo Drive(页面加速)

# Turbo Drive 是自动的,但您可以自定义行为

# app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  <head>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= turbo_refreshes_with method: :morph, scroll: :preserve %>
  </head>
  <body>
    <%= yield %>
  </body>
</html>

# 为特定链接禁用 Turbo
<%= link_to "旧版页面", legacy_path, data: { turbo: false } %>

# 为表单禁用 Turbo
<%= form_with url: upload_path, data: { turbo: false } do |f| %>
  <%= f.file_field :document %>
<% end %>

# 自定义进度条
<style>
  .turbo-progress-bar {
    background: linear-gradient(to right, #4ade80, #3b82f6);
  }
</style>

2. Turbo Frames(懒加载与分解)

# app/views/posts/index.html.erb
<div id="posts">
  <% @posts.each do |post| %>
    <%= turbo_frame_tag dom_id(post) do %>
      <%= render post %>
    <% end %>
  <% end %>
</div>

# app/views/posts/_post.html.erb
<article>
  <h2><%= post.title %></h2>
  <p><%= post.body %></p>

  <%= link_to "编辑", edit_post_path(post) %>
  <%= link_to "删除", post_path(post),
              data: { turbo_method: :delete,
                      turbo_confirm: "确定吗?" } %>
</article>

# app/views/posts/edit.html.erb
<%= turbo_frame_tag dom_id(@post) do %>
  <%= form_with model: @post do |f| %>
    <%= f.text_field :title %>
    <%= f.text_area :body %>
    <%= f.submit %>
  <% end %>
<% end %>

# 懒加载帧
<%= turbo_frame_tag "analytics", src: analytics_path, loading: :lazy do %>
  <p>加载分析中...</p>
<% end %>

# 目标不同帧
<%= link_to "显示文章", post_path(post),
            data: { turbo_frame: "modal" } %>

# 跳出帧
<%= link_to "新页面", new_post_path,
            data: { turbo_frame: "_top" } %>

3. Turbo Streams(实时更新)

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def create
    @post = Post.new(post_params)

    respond_to do |format|
      if @post.save
        format.turbo_stream
        format.html { redirect_to @post }
      else
        format.html { render :new, status: :unprocessable_entity }
      end
    end
  end

  def destroy
    @post = Post.find(params[:id])
    @post.destroy

    respond_to do |format|
      format.turbo_stream { render turbo_stream: turbo_stream.remove(@post) }
      format.html { redirect_to posts_path }
    end
  end
end

# app/views/posts/create.turbo_stream.erb
<%= turbo_stream.prepend "posts", partial: "posts/post",
                         locals: { post: @post } %>
<%= turbo_stream.update "new_post", "" %>
<%= turbo_stream.replace "flash",
    partial: "shared/flash",
    locals: { message: "文章已创建!" } %>

# 多个 Turbo Stream 操作
<%= turbo_stream.append "notifications" do %>
  <div class="notification">新文章已创建!</div>
<% end %>

<%= turbo_stream.update "post_count",
    Post.count %>

<%= turbo_stream.remove "loading_spinner" %>

<%= turbo_stream.replace dom_id(@post),
    partial: "posts/post",
    locals: { post: @post } %>

4. 广播更新

# app/models/post.rb
class Post < ApplicationRecord
  broadcasts_to ->(post) { [post.user, "posts"] }, inserts_by: :prepend

  # 或更显式
  after_create_commit -> {
    broadcast_prepend_to "posts",
      partial: "posts/post",
      locals: { post: self },
      target: "posts"
  }

  after_update_commit -> {
    broadcast_replace_to "posts",
      partial: "posts/post",
      locals: { post: self },
      target: dom_id(self)
  }

  after_destroy_commit -> {
    broadcast_remove_to "posts", target: dom_id(self)
  }
end

# app/views/posts/index.html.erb
<%= turbo_stream_from "posts" %>

<div id="posts">
  <%= render @posts %>
</div>

# 广播给特定用户
class Comment < ApplicationRecord
  belongs_to :post

  after_create_commit -> {
    broadcast_prepend_to [post.user, :comments],
      partial: "comments/comment",
      locals: { comment: self },
      target: "comments"
  }
end

# app/views/posts/show.html.erb
<%= turbo_stream_from current_user, :comments %>

5. Stimulus 控制器

// app/javascript/controllers/clipboard_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["source", "button"]
  static values = {
    successMessage: String,
    errorMessage: String
  }

  copy(event) {
    event.preventDefault()

    navigator.clipboard.writeText(this.sourceTarget.value).then(
      () => this.showSuccess(),
      () => this.showError()
    )
  }

  showSuccess() {
    this.buttonTarget.textContent = this.successMessageValue || "已复制!"
    setTimeout(() => {
      this.buttonTarget.textContent = "复制"
    }, 2000)
  }

  showError() {
    this.buttonTarget.textContent = this.errorMessageValue || "失败!"
  }
}
<!-- app/views/posts/show.html.erb -->
<div data-controller="clipboard"
     data-clipboard-success-message-value="已复制到剪贴板!">
  <input type="text"
         value="<%= @post.share_url %>"
         data-clipboard-target="source"
         readonly>
  <button data-clipboard-target="button"
          data-action="click->clipboard#copy">
    复制
  </button>
</div>

6. 使用 Stimulus 进行表单验证

// app/javascript/controllers/form_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["email", "password", "submit"]
  static classes = ["error"]

  connect() {
    this.validateForm()
  }

  validateField(event) {
    const field = event.target
    const isValid = field.checkValidity()

    if (isValid) {
      field.classList.remove(this.errorClass)
    } else {
      field.classList.add(this.errorClass)
    }

    this.validateForm()
  }

  validateForm() {
    const isValid = this.element.checkValidity()
    this.submitTarget.disabled = !isValid
  }

  async submit(event) {
    event.preventDefault()

    if (!this.element.checkValidity()) {
      return
    }

    const formData = new FormData(this.element)
    const response = await fetch(this.element.action, {
      method: this.element.method,
      body: formData,
      headers: {
        "Accept": "text/vnd.turbo-stream.html"
      }
    })

    if (response.ok) {
      const html = await response.text()
      Turbo.renderStreamMessage(html)
    }
  }
}
<%= form_with model: @user,
    data: { controller: "form",
            form_error_class: "border-red-500" } do |f| %>

  <%= f.email_field :email,
      required: true,
      data: { form_target: "email",
              action: "blur->form#validateField" } %>

  <%= f.password_field :password,
      required: true,
      minlength: 8,
      data: { form_target: "password",
              action: "blur->form#validateField" } %>

  <%= f.submit "注册",
      data: { form_target: "submit",
              action: "click->form#submit" } %>
<% end %>

7. 无限滚动

// app/javascript/controllers/infinite_scroll_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["entries", "pagination"]
  static values = {
    url: String,
    page: Number
  }

  initialize() {
    this.scroll = this.scroll.bind(this)
  }

  connect() {
    this.createObserver()
  }

  disconnect() {
    this.observer.disconnect()
  }

  createObserver() {
    this.observer = new IntersectionObserver(
      entries => this.handleIntersect(entries),
      { threshold: 1.0 }
    )
    this.observer.observe(this.paginationTarget)
  }

  handleIntersect(entries) {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        this.loadMore()
      }
    })
  }

  async loadMore() {
    const url = this.paginationTarget.querySelector("a[rel='next']")?.href

    if (!url) return

    this.pageValue++

    const response = await fetch(url, {
      headers: {
        Accept: "text/vnd.turbo-stream.html"
      }
    })

    if (response.ok) {
      const html = await response.text()
      Turbo.renderStreamMessage(html)
    }
  }
}
<!-- app/views/posts/index.html.erb -->
<div data-controller="infinite-scroll">
  <div id="posts" data-infinite-scroll-target="entries">
    <%= render @posts %>
  </div>

  <div data-infinite-scroll-target="pagination">
    <%= paginate @posts %>
  </div>
</div>

<!-- app/views/posts/index.turbo_stream.erb -->
<%= turbo_stream.append "posts" do %>
  <%= render @posts %>
<% end %>

<%= turbo_stream.replace "pagination" do %>
  <%= paginate @posts %>
<% end %>

8. 模态对话框

// app/javascript/controllers/modal_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["container", "backdrop"]

  connect() {
    document.body.classList.add("overflow-hidden")
  }

  disconnect() {
    document.body.classList.remove("overflow-hidden")
  }

  close(event) {
    if (event.target === this.backdropTarget ||
        event.currentTarget.dataset.closeModal === "true") {
      this.element.remove()
    }
  }

  closeWithKeyboard(event) {
    if (event.key === "Escape") {
      this.element.remove()
    }
  }
}
<!-- app/views/posts/_modal.html.erb -->
<div data-controller="modal"
     data-action="keyup@window->modal#closeWithKeyboard"
     class="fixed inset-0 z-50">

  <div data-modal-target="backdrop"
       data-action="click->modal#close"
       class="fixed inset-0 bg-black bg-opacity-50"></div>

  <div data-modal-target="container"
       class="fixed inset-0 flex items-center justify-center">
    <div class="bg-white rounded-lg p-6 max-w-lg">
      <%= turbo_frame_tag "modal_content" do %>
        <%= yield %>
      <% end %>

      <button data-close-modal="true"
              data-action="click->modal#close">
        关闭
      </button>
    </div>
  </div>
</div>

<!-- 触发模态 -->
<%= link_to "编辑文章",
    edit_post_path(@post),
    data: { turbo_frame: "modal" } %>

9. 使用 Stimulus 自动保存

// app/javascript/controllers/autosave_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["status"]
  static values = {
    delay: { type: Number, default: 1000 },
    url: String
  }

  connect() {
    this.timeout = null
    this.saving = false
  }

  save() {
    clearTimeout(this.timeout)

    this.timeout = setTimeout(() => {
      this.persist()
    }, this.delayValue)
  }

  async persist() {
    if (this.saving) return

    this.saving = true
    this.showStatus("保存中...")

    const formData = new FormData(this.element)

    try {
      const response = await fetch(this.urlValue, {
        method: "PATCH",
        body: formData,
        headers: {
          "X-CSRF-Token": document.querySelector("[name='csrf-token']").content,
          "Accept": "application/json"
        }
      })

      if (response.ok) {
        this.showStatus("已保存", "success")
      } else {
        this.showStatus("保存错误", "error")
      }
    } catch (error) {
      this.showStatus("保存错误", "error")
    } finally {
      this.saving = false
    }
  }

  showStatus(message, type = "info") {
    this.statusTarget.textContent = message
    this.statusTarget.className = `status-${type}`

    setTimeout(() => {
      this.statusTarget.textContent = ""
    }, 2000)
  }
}
<%= form_with model: @post,
    data: { controller: "autosave",
            autosave_url_value: post_path(@post),
            action: "input->autosave#save" } do |f| %>

  <div data-autosave-target="status"></div>

  <%= f.text_field :title %>
  <%= f.text_area :body %>
<% end %>

10. 带防抖的搜索

// app/javascript/controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["input", "results"]
  static values = {
    url: String,
    delay: { type: Number, default: 300 }
  }

  connect() {
    this.timeout = null
  }

  search() {
    clearTimeout(this.timeout)

    this.timeout = setTimeout(() => {
      this.performSearch()
    }, this.delayValue)
  }

  async performSearch() {
    const query = this.inputTarget.value

    if (query.length < 2) {
      this.resultsTarget.innerHTML = ""
      return
    }

    const url = new URL(this.urlValue)
    url.searchParams.set("q", query)

    const response = await fetch(url, {
      headers: {
        Accept: "text/vnd.turbo-stream.html"
      }
    })

    if (response.ok) {
      const html = await response.text()
      Turbo.renderStreamMessage(html)
    }
  }

  clear() {
    this.inputTarget.value = ""
    this.resultsTarget.innerHTML = ""
  }
}
<div data-controller="search"
     data-search-url-value="<%= search_posts_path %>">

  <input type="text"
         data-search-target="input"
         data-action="input->search#search"
         placeholder="搜索文章...">

  <button data-action="click->search#clear">清除</button>

  <div id="search-results" data-search-target="results"></div>
</div>

最佳实践

  1. 使用 Turbo Frames 进行隔离 - 将更新范围限制到特定部分
  2. 广播模型变化 - 保持所有客户端同步
  3. 渐进增强 - 确保在没有 JavaScript 时功能正常
  4. 懒加载帧 - 提高初始页面加载性能
  5. 使用 Stimulus 进行点缀 - 保持 JavaScript 最小化和专注
  6. 利用 Turbo Streams - 更新页面的多个部分
  7. 优雅处理错误 - 为网络问题提供回退
  8. 适当缓存 - 使用 HTTP 缓存与 Turbo
  9. 测试实时功能 - 验证广播工作正常
  10. 优化数据库查询 - 使用 includes/preload 防止 N+1

常见陷阱

  1. 过度使用 Turbo Frames - 并非所有内容都需要帧
  2. 缺少 CSRF 令牌 - 在 AJAX 请求中忘记令牌
  3. 竞态条件 - 未处理并发广播
  4. 内存泄漏 - 未断开 ActionCable 订阅
  5. 闪存消息问题 - 闪存消息在 Turbo 请求间持续存在
  6. 破坏浏览器历史 - 不当的 Turbo 导航
  7. SEO 关注 - 未考虑搜索引擎爬虫
  8. 表单状态丢失 - 在导航时丢失未保存数据
  9. 可访问性问题 - 未管理焦点和 ARIA 属性
  10. 过度工程化 - 当简单 HTML 足够时使用 Hotwire

使用时机

  • 构建现代 Rails 应用程序
  • 创建实时协作功能
  • 实现无需轮询的实时更新
  • 构建类似单页应用的体验
  • 减少 JavaScript 复杂性
  • 渐进增强场景
  • 移动友好的响应式界面
  • 具有实时数据的管理仪表板
  • 聊天和消息应用
  • 实时通知和订阅源

资源