rails-hotwire
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseRails Hotwire
Rails Hotwire
Master Hotwire for building modern, reactive Rails applications using Turbo
and Stimulus without requiring heavy JavaScript frameworks.
掌握Hotwire,使用Turbo和Stimulus构建现代化响应式Rails应用,无需依赖重型JavaScript框架。
Overview
概述
Hotwire (HTML Over The Wire) is a modern approach to building web applications
that sends HTML instead of JSON over the wire. It consists of Turbo (for
delivering server-rendered HTML) and Stimulus (for JavaScript sprinkles).
Hotwire(HTML Over The Wire)是一种现代化的Web应用构建方式,它通过网络发送HTML而非JSON。它由Turbo(用于交付服务器渲染的HTML)和Stimulus(用于轻量JavaScript增强)两部分组成。
Installation and Setup
安装与配置
Installing Hotwire
安装Hotwire
bash
undefinedbash
undefinedAdd to Gemfile
添加到Gemfile
bundle add turbo-rails stimulus-rails
bundle add turbo-rails stimulus-rails
Install Turbo
安装Turbo
rails turbo:install
rails turbo:install
Install Stimulus
安装Stimulus
rails stimulus:install
rails stimulus:install
Install Redis for ActionCable (Turbo Streams)
为ActionCable(Turbo Streams)安装Redis
bundle add redis
bundle add redis
Configure ActionCable
配置ActionCable
rails generate channel turbo_stream
undefinedrails generate channel turbo_stream
undefinedConfiguration
配置
ruby
undefinedruby
undefinedconfig/cable.yml
config/cable.yml
development:
adapter: redis
url: redis://localhost:6379/1
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: myapp_production
development:
adapter: redis
url: redis://localhost:6379/1
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: myapp_production
config/routes.rb
config/routes.rb
Rails.application.routes.draw do
mount ActionCable.server => '/cable'
end
undefinedRails.application.routes.draw do
mount ActionCable.server => '/cable'
end
undefinedCore Patterns
核心模式
1. Turbo Drive (Page Acceleration)
1. Turbo Drive(页面加速)
ruby
undefinedruby
undefinedTurbo Drive is automatic, but you can customize behavior
Turbo Drive 默认自动启用,但你可以自定义其行为
app/views/layouts/application.html.erb
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>
<!DOCTYPE html>
<html>
<head>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
</head>
<body>
<%= yield %>
</body>
</html>
Disable Turbo for specific links
为特定链接禁用Turbo
<%= link_to "Legacy Page", legacy_path, data: { turbo: false } %>
<%= link_to "传统页面", legacy_path, data: { turbo: false } %>
Disable Turbo for forms
为表单禁用Turbo
<%= form_with url: upload_path, data: { turbo: false } do |f| %>
<%= f.file_field :document %>
<% end %>
<%= form_with url: upload_path, data: { turbo: false } do |f| %>
<%= f.file_field :document %>
<% end %>
Custom progress bar
自定义进度条
<style>
.turbo-progress-bar {
background: linear-gradient(to right, #4ade80, #3b82f6);
}
</style>
undefined<style>
.turbo-progress-bar {
background: linear-gradient(to right, #4ade80, #3b82f6);
}
</style>
undefined2. Turbo Frames (Lazy Loading & Decomposition)
2. Turbo Frames(懒加载与页面拆分)
ruby
undefinedruby
undefinedapp/views/posts/index.html.erb
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>
<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
app/views/posts/_post.html.erb
<article>
<h2><%= post.title %></h2>
<p><%= post.body %></p>
<%= link_to "Edit", edit_post_path(post) %>
<%= link_to "Delete", post_path(post),
data: { turbo_method: :delete,
turbo_confirm: "Are you sure?" } %>
</article><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
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 dom_id(@post) do %>
<%= form_with model: @post do |f| %>
<%= f.text_field :title %>
<%= f.text_area :body %>
<%= f.submit %>
<% end %>
<% end %>
Lazy loading frames
懒加载Frames
<%= turbo_frame_tag "analytics", src: analytics_path, loading: :lazy do %>
<p>Loading analytics...</p>
<% end %><%= turbo_frame_tag "analytics", src: analytics_path, loading: :lazy do %>
<p>正在加载分析数据...</p>
<% end %>Target different frames
目标不同的Frames
<%= link_to "Show Post", post_path(post),
data: { turbo_frame: "modal" } %>
<%= link_to "查看文章", post_path(post),
data: { turbo_frame: "modal" } %>
Break out of frame
跳出当前Frame
<%= link_to "New Page", new_post_path,
data: { turbo_frame: "_top" } %>
undefined<%= link_to "新页面", new_post_path,
data: { turbo_frame: "_top" } %>
undefined3. Turbo Streams (Real-time Updates)
3. Turbo Streams(实时更新)
ruby
undefinedruby
undefinedapp/controllers/posts_controller.rb
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
endend
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 }
endend
end
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
endend
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 }
endend
end
app/views/posts/create.turbo_stream.erb
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: "Post created!" } %>
<%= turbo_stream.prepend "posts", partial: "posts/post",
locals: { post: @post } %>
<%= turbo_stream.update "new_post", "" %>
<%= turbo_stream.replace "flash",
partial: "shared/flash",
locals: { message: "文章创建成功!" } %>
Multiple Turbo Stream actions
多个Turbo Stream操作
<%= turbo_stream.append "notifications" do %>
<div class="notification">New post created!</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 } %>
undefined<%= 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 } %>
undefined4. Broadcasting Updates
4. 广播更新
ruby
undefinedruby
undefinedapp/models/post.rb
app/models/post.rb
class Post < ApplicationRecord
broadcasts_to ->(post) { [post.user, "posts"] }, inserts_by: :prepend
Or more explicit
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
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
app/views/posts/index.html.erb
<%= turbo_stream_from "posts" %>
<div id="posts">
<%= render @posts %>
</div><%= turbo_stream_from "posts" %>
<div id="posts">
<%= render @posts %>
</div>Broadcast to specific users
向特定用户广播
class Comment < ApplicationRecord
belongs_to :post
after_create_commit -> {
broadcast_prepend_to [post.user, :comments],
partial: "comments/comment",
locals: { comment: self },
target: "comments"
}
end
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
app/views/posts/show.html.erb
<%= turbo_stream_from current_user, :comments %>
undefined<%= turbo_stream_from current_user, :comments %>
undefined5. Stimulus Controllers
5. Stimulus 控制器
javascript
// 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 || "Copied!"
setTimeout(() => {
this.buttonTarget.textContent = "Copy"
}, 2000)
}
showError() {
this.buttonTarget.textContent = this.errorMessageValue || "Failed!"
}
}erb
<!-- app/views/posts/show.html.erb -->
<div data-controller="clipboard"
data-clipboard-success-message-value="Copied to clipboard!">
<input type="text"
value="<%= @post.share_url %>"
data-clipboard-target="source"
readonly>
<button data-clipboard-target="button"
data-action="click->clipboard#copy">
Copy
</button>
</div>javascript
// 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 || "复制失败!"
}
}erb
<!-- 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. Form Validation with Stimulus
6. 结合Stimulus的表单验证
javascript
// 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)
}
}
}erb
<%= 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 "Sign Up",
data: { form_target: "submit",
action: "click->form#submit" } %>
<% end %>javascript
// 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)
}
}
}erb
<%= 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. Infinite Scroll
7. 无限滚动
javascript
// 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)
}
}
}erb
<!-- 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 %>javascript
// 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)
}
}
}erb
<!-- 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. Modal Dialogs
8. 模态对话框
javascript
// 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()
}
}
}erb
<!-- 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">
Close
</button>
</div>
</div>
</div>
<!-- Trigger modal -->
<%= link_to "Edit Post",
edit_post_path(@post),
data: { turbo_frame: "modal" } %>javascript
// 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()
}
}
}erb
<!-- 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. Autosave with Stimulus
9. 结合Stimulus的自动保存
javascript
// 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("Saving...")
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("Saved", "success")
} else {
this.showStatus("Error saving", "error")
}
} catch (error) {
this.showStatus("Error saving", "error")
} finally {
this.saving = false
}
}
showStatus(message, type = "info") {
this.statusTarget.textContent = message
this.statusTarget.className = `status-${type}`
setTimeout(() => {
this.statusTarget.textContent = ""
}, 2000)
}
}erb
<%= 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 %>javascript
// 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)
}
}erb
<%= 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. Search with Debouncing
10. 防抖搜索
javascript
// 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 = ""
}
}erb
<div data-controller="search"
data-search-url-value="<%= search_posts_path %>">
<input type="text"
data-search-target="input"
data-action="input->search#search"
placeholder="Search posts...">
<button data-action="click->search#clear">Clear</button>
<div id="search-results" data-search-target="results"></div>
</div>javascript
// 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 = ""
}
}erb
<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>Best Practices
最佳实践
- Use Turbo Frames for isolation - Scope updates to specific parts
- Broadcast model changes - Keep all clients synchronized
- Progressive enhancement - Ensure functionality without JavaScript
- Lazy load frames - Improve initial page load performance
- Use Stimulus for sprinkles - Keep JavaScript minimal and focused
- Leverage Turbo Streams - Update multiple parts of the page
- Handle errors gracefully - Provide fallbacks for network issues
- Cache appropriately - Use HTTP caching with Turbo
- Test real-time features - Verify broadcasts work correctly
- Optimize database queries - Prevent N+1 with includes/preload
- 使用Turbo Frames实现隔离 - 将更新范围限定到特定页面部分
- 广播模型变更 - 保持所有客户端数据同步
- 渐进式增强 - 确保在禁用JavaScript时仍能正常使用
- 懒加载Frames - 提升初始页面加载性能
- 使用Stimulus实现轻量增强 - 保持JavaScript代码精简且聚焦
- 充分利用Turbo Streams - 同时更新页面的多个部分
- 优雅处理错误 - 为网络问题提供回退方案
- 合理使用缓存 - 结合Turbo使用HTTP缓存
- 测试实时功能 - 验证广播功能正常工作
- 优化数据库查询 - 使用includes/preload避免N+1查询问题
Common Pitfalls
常见陷阱
- Over-using Turbo Frames - Not everything needs to be a frame
- Missing CSRF tokens - Forgetting tokens in AJAX requests
- Race conditions - Not handling concurrent broadcasts
- Memory leaks - Not disconnecting ActionCable subscriptions
- Flash message issues - Flash persisting across Turbo requests
- Breaking browser history - Improper Turbo navigation
- SEO concerns - Not considering search engine crawlers
- Form state loss - Losing unsaved data on navigation
- Accessibility issues - Not managing focus and ARIA attributes
- Over-engineering - Using Hotwire when simple HTML suffices
- 过度使用Turbo Frames - 并非所有内容都需要封装为Frame
- 缺少CSRF令牌 - 在AJAX请求中忘记携带CSRF令牌
- 竞态条件 - 未处理并发广播问题
- 内存泄漏 - 未断开ActionCable订阅
- Flash消息问题 - Flash消息在Turbo请求中持续存在
- 破坏浏览器历史 - Turbo导航配置不当
- SEO问题 - 未考虑搜索引擎爬虫的需求
- 表单状态丢失 - 导航时丢失未保存的表单数据
- 可访问性问题 - 未正确管理焦点和ARIA属性
- 过度设计 - 在简单HTML就能满足需求时使用Hotwire
When to Use
适用场景
- Building modern Rails applications
- Creating real-time collaborative features
- Implementing live updates without polling
- Building single-page-like experiences
- Reducing JavaScript complexity
- Progressive enhancement scenarios
- Mobile-friendly responsive interfaces
- Admin dashboards with live data
- Chat and messaging applications
- Live notifications and feeds
- 构建现代化Rails应用
- 创建实时协作功能
- 实现无需轮询的实时更新
- 构建类单页应用体验
- 降低JavaScript复杂度
- 渐进式增强场景
- 移动端友好的响应式界面
- 包含实时数据的管理后台
- 聊天和消息应用
- 实时通知和信息流