名称: 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>
最佳实践
- 使用 Turbo Frames 进行隔离 - 将更新范围限制到特定部分
- 广播模型变化 - 保持所有客户端同步
- 渐进增强 - 确保在没有 JavaScript 时功能正常
- 懒加载帧 - 提高初始页面加载性能
- 使用 Stimulus 进行点缀 - 保持 JavaScript 最小化和专注
- 利用 Turbo Streams - 更新页面的多个部分
- 优雅处理错误 - 为网络问题提供回退
- 适当缓存 - 使用 HTTP 缓存与 Turbo
- 测试实时功能 - 验证广播工作正常
- 优化数据库查询 - 使用 includes/preload 防止 N+1
常见陷阱
- 过度使用 Turbo Frames - 并非所有内容都需要帧
- 缺少 CSRF 令牌 - 在 AJAX 请求中忘记令牌
- 竞态条件 - 未处理并发广播
- 内存泄漏 - 未断开 ActionCable 订阅
- 闪存消息问题 - 闪存消息在 Turbo 请求间持续存在
- 破坏浏览器历史 - 不当的 Turbo 导航
- SEO 关注 - 未考虑搜索引擎爬虫
- 表单状态丢失 - 在导航时丢失未保存数据
- 可访问性问题 - 未管理焦点和 ARIA 属性
- 过度工程化 - 当简单 HTML 足够时使用 Hotwire
使用时机
- 构建现代 Rails 应用程序
- 创建实时协作功能
- 实现无需轮询的实时更新
- 构建类似单页应用的体验
- 减少 JavaScript 复杂性
- 渐进增强场景
- 移动友好的响应式界面
- 具有实时数据的管理仪表板
- 聊天和消息应用
- 实时通知和订阅源