rails-hotwire

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Rails 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
undefined
bash
undefined

Add 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
undefined
rails generate channel turbo_stream
undefined

Configuration

配置

ruby
undefined
ruby
undefined

config/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
undefined
Rails.application.routes.draw do mount ActionCable.server => '/cable' end
undefined

Core Patterns

核心模式

1. Turbo Drive (Page Acceleration)

1. Turbo Drive(页面加速)

ruby
undefined
ruby
undefined

Turbo 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>
undefined

2. Turbo Frames (Lazy Loading & Decomposition)

2. Turbo Frames(懒加载与页面拆分)

ruby
undefined
ruby
undefined

app/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" } %>
undefined

3. Turbo Streams (Real-time Updates)

3. Turbo Streams(实时更新)

ruby
undefined
ruby
undefined

app/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
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
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

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 } %>
undefined

4. Broadcasting Updates

4. 广播更新

ruby
undefined
ruby
undefined

app/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 %>
undefined

5. 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

最佳实践

  1. Use Turbo Frames for isolation - Scope updates to specific parts
  2. Broadcast model changes - Keep all clients synchronized
  3. Progressive enhancement - Ensure functionality without JavaScript
  4. Lazy load frames - Improve initial page load performance
  5. Use Stimulus for sprinkles - Keep JavaScript minimal and focused
  6. Leverage Turbo Streams - Update multiple parts of the page
  7. Handle errors gracefully - Provide fallbacks for network issues
  8. Cache appropriately - Use HTTP caching with Turbo
  9. Test real-time features - Verify broadcasts work correctly
  10. Optimize database queries - Prevent N+1 with includes/preload
  1. 使用Turbo Frames实现隔离 - 将更新范围限定到特定页面部分
  2. 广播模型变更 - 保持所有客户端数据同步
  3. 渐进式增强 - 确保在禁用JavaScript时仍能正常使用
  4. 懒加载Frames - 提升初始页面加载性能
  5. 使用Stimulus实现轻量增强 - 保持JavaScript代码精简且聚焦
  6. 充分利用Turbo Streams - 同时更新页面的多个部分
  7. 优雅处理错误 - 为网络问题提供回退方案
  8. 合理使用缓存 - 结合Turbo使用HTTP缓存
  9. 测试实时功能 - 验证广播功能正常工作
  10. 优化数据库查询 - 使用includes/preload避免N+1查询问题

Common Pitfalls

常见陷阱

  1. Over-using Turbo Frames - Not everything needs to be a frame
  2. Missing CSRF tokens - Forgetting tokens in AJAX requests
  3. Race conditions - Not handling concurrent broadcasts
  4. Memory leaks - Not disconnecting ActionCable subscriptions
  5. Flash message issues - Flash persisting across Turbo requests
  6. Breaking browser history - Improper Turbo navigation
  7. SEO concerns - Not considering search engine crawlers
  8. Form state loss - Losing unsaved data on navigation
  9. Accessibility issues - Not managing focus and ARIA attributes
  10. Over-engineering - Using Hotwire when simple HTML suffices
  1. 过度使用Turbo Frames - 并非所有内容都需要封装为Frame
  2. 缺少CSRF令牌 - 在AJAX请求中忘记携带CSRF令牌
  3. 竞态条件 - 未处理并发广播问题
  4. 内存泄漏 - 未断开ActionCable订阅
  5. Flash消息问题 - Flash消息在Turbo请求中持续存在
  6. 破坏浏览器历史 - Turbo导航配置不当
  7. SEO问题 - 未考虑搜索引擎爬虫的需求
  8. 表单状态丢失 - 导航时丢失未保存的表单数据
  9. 可访问性问题 - 未正确管理焦点和ARIA属性
  10. 过度设计 - 在简单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复杂度
  • 渐进式增强场景
  • 移动端友好的响应式界面
  • 包含实时数据的管理后台
  • 聊天和消息应用
  • 实时通知和信息流

Resources

参考资源