关于 turbo
面向 Rails 开发者的 Turbo 技能,指导使用 Turbo Drive、Frames 和 Streams 构建少用自定义 JavaScript 的界面,并涵盖实时广播与 Turbo 8 页面形态更新。
主要功能
- 01Turbo Drive 导航与退出规则
- 02Turbo Frames 局部页面更新
- 03Turbo Streams 定向 DOM 更新
- 04ActionCable 实时广播
- 05Turbo 8 页面形态更新
- 06Turbo 表单与验证处理
适用场景
- 01为 Rails 页面添加延迟加载内容
- 02构建行内编辑、标签页和模态框
- 03通过 ActionCable 推送实时更新
- 04处理 Turbo 表单验证失败
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ thinkoodle/ rails-skills --skill turbo安装后在 Claude Code 中输入/turbo确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/broadcasting.mdedge-cases.mdframes.mdmorphing.mdstreams.mdtesting.md详细介绍
Rails Turbo 专家
使用 Turbo Drive、Frames 和 Streams 构建快速、现代的 Rails UI,无需(或仅需少量)自定义 JavaScript。
理念
核心原则:
- 通过网络传输 HTML——由服务器渲染 HTML,再由 Turbo 传递。UI 无需 JSON API。
- 渐进增强——Turbo Drive 会自动运行,Frames 和 Streams 则在此基础上扩展。
- 服务器是真实数据源——业务逻辑保留在 Ruby 中。Turbo 只负责传递 HTML。
- 尽量少用 JavaScript——如果你正在编写 JS 来更新 DOM,很可能做法不对。请使用 Turbo Streams。
- 可组合的基本构件——Drive、Frames 和 Streams 各自解决一个问题,可组合使用。
思维模型:
Turbo Drive → Speeds up ALL navigation (automatic, zero config)
Turbo Frames → Scopes updates to a REGION of the page (explicit, per-element)
Turbo Streams → Delivers TARGETED mutations to ANY element (server-pushed or response)
决策树:
Need faster page loads? → Turbo Drive (already on)
Need to update PART of a page on click? → Turbo Frame
Need to update MULTIPLE parts at once? → Turbo Stream
Need real-time updates from other users? → Turbo Stream + ActionCable broadcast
Need smooth page refresh without flicker? → Turbo Morph (Rails 8+)
何时使用此技能
- 使用 turbo_frame_tag 限定导航或更新范围
- 从控制器操作返回 turbo_stream 响应
- 使用 ActionCable 设置实时广播
- 实现延迟加载内容
- 构建行内编辑、标签页界面和模态框
- 配置 Turbo Drive 行为(针对特定链接或表单禁用)
- 使用 Turbo 8 的变形更新和页面刷新
说明
第 1 步:了解现有配置
在每个 Rails 7+ 应用中,Turbo Drive 默认启用。无需安装,也无需配置。每次链接点击和表单提交都会通过 Turbo Drive 处理。
检查应用是否包含 turbo-rails:
# Should already be in Gemfile
grep "turbo-rails" Gemfile
# JavaScript import should exist
rg "import.*turbo" app/javascript/
如果缺少(Rails 7+ 中不太可能):
bundle add turbo-rails
bin/rails turbo:install
第 2 步:Turbo Drive——了解何时禁用
Turbo Drive 会拦截每次 <a> 点击和 <form> 提交,通过 AJAX 获取内容并替换 <body>。这一切都是自动完成的。你只需在需要禁用时进行干预。
<%# Disable for a specific link or form %>
<%= link_to "External Site", "https://example.com", data: { turbo: false } %>
<%= form_with model: @upload, data: { turbo: false } do |f| %>
<%# Disable for a whole section %>
<div data-turbo="false">...</div>
**常见的禁用场景:**文件上传(未使用 Active Storage 直接上传)、外部链接、OAuth/SSO 重定向、文件下载、支付表单(Stripe)。
非 GET 链接方法——优先使用 button_to,而不是搭配 turbo_method 的 link_to:
<%# Acceptable %>
<%= link_to "Delete", post_path(@post), data: { turbo_method: :delete, turbo_confirm: "Sure?" } %>
<%# Better — more accessible and semantic %>
<%= button_to "Delete", post_path(@post), method: :delete,
form: { data: { turbo_confirm: "Are you sure?" } } %>
第 3 步:Turbo Frames——限定页面更新范围
Turbo Frames 是主要的工作工具,可将导航限定在页面的某个区域内。点击框架内的链接或提交表单时,只有该框架会更新。
⚠️ Agent 最常见的错误:忘记在两侧都使用匹配的 turbo_frame_tag 包裹内容。
当前页面和响应页面中的框架都必须包含 ID 相同的 <turbo-frame>。如果 ID 不匹配,就会出现 Content Missing 错误。
基本框架模式:
<%# index.html.erb — the page with the frame %>
<%= turbo_frame_tag "post_#{@post.id}" do %>
<h2><%= @post.title %></h2>
<%= link_to "Edit", edit_post_path(@post) %>
<% end %>
<%# edit.html.erb — the response MUST have matching frame %>
<%= turbo_frame_tag "post_#{@post.id}" do %>
<%= render "form", post: @post %>
<% end %>
使用 dom_id 保持 ID 一致:
<%= turbo_frame_tag dom_id(@post) do %>
<%# dom_id(@post) produces "post_123" %>
<% end %>
延迟加载的框架(在页面渲染后加载内容):
<%= turbo_frame_tag "comments", src: post_comments_path(@post), loading: :lazy do %>
<p>Loading comments...</p>
<% end %>
系统会自动获取 src URL。loading: :lazy 会延迟加载,直到框架可见(Intersection Observer)。如果不使用 :lazy,则会在页面加载后立即加载。
**⚠️ Agent 易忽略的问题:延迟加载端点必须返回包含匹配 turbo_frame_tag 的页面。**控制器无需知道请求来自框架;它照常渲染,Turbo 会提取匹配的框架。
跳出框架(将整个页面作为目标):
<%= turbo_frame_tag dom_id(@post) do %>
<%# This link stays in the frame %>
<%= link_to "Edit", edit_post_path(@post) %>
<%# This link breaks out and navigates the full page %>
<%= link_to "Show Full Page", post_path(@post), data: { turbo_frame: "_top" } %>
<% end %>
指定另一个框架为目标:
<%= turbo_frame_tag "sidebar" do %>
<%= link_to "Load Details", post_path(@post), data: { turbo_frame: "main_content" } %>
<% end %>
<%# This frame will be updated instead %>
<%= turbo_frame_tag "main_content" do %>
<p>Select a post from the sidebar</p>
<% end %>
使用自定义 target 属性的框架(其中的所有链接都以另一个框架为目标):
<%= turbo_frame_tag "nav", target: "main_content" do %>
<%# ALL links here update "main_content" frame instead %>
<%= link_to "Posts", posts_path %>
<%= link_to "Users", users_path %>
<% end %>
第 4 步:Turbo Streams — 有针对性的 DOM 修改
Turbo Streams 是精准操作。它们会说:“将这段 HTML 插入此 DOM 目标,或替换/移除该目标。”它们适用于两种场景:
- HTTP 响应 — 在表单提交(POST/PUT/PATCH/DELETE)后返回
- WebSocket 广播 — 通过 ActionCable 推送给所有已连接的用户
7 种流操作:
| 操作 | 功能 |
|---|---|
append | 将 HTML 添加到目标子元素的末尾 |
prepend | 将 HTML 添加到目标子元素的开头 |
replace | 替换整个目标元素 |
update | 替换目标元素的内部 HTML |
remove | 移除目标元素 |
before | 在目标元素之前插入 HTML |
after | 在目标元素之后插入 HTML |
⚠️ 关键区别:replace 与 update
replace— 移除目标元素本身,并在原位置放入新的 HTMLupdate— 保留目标元素,替换其子元素
<%# replace: <div id="post_1"> is GONE, replaced entirely %>
<%= turbo_stream.replace dom_id(@post), partial: "posts/post", locals: { post: @post } %>
<%# update: <div id="post_1"> stays, its contents are swapped %>
<%= turbo_stream.update dom_id(@post), partial: "posts/post", locals: { post: @post } %>
在控制器中使用 Turbo Streams 响应:
# app/controllers/posts_controller.rb
def create
@post = Post.new(post_params)
respond_to do |format|
if @post.save
format.turbo_stream # Renders create.turbo_stream.erb
format.html { redirect_to @post }
else
format.html { render :new, status: :unprocessable_entity }
end
end
end
<%# app/views/posts/create.turbo_stream.erb %>
<%= turbo_stream.prepend "posts", partial: "posts/post", locals: { post: @post } %>
<%= turbo_stream.update "post_count", html: "#{Post.count} posts" %>
<%= turbo_stream.update "new_post_form" do %>
<%= render "form", post: Post.new %>
<% end %>
⚠️ Agent 注意事项:Turbo Stream 响应仅适用于非 GET 请求。 GET 请求使用 Turbo Drive 或 Frames。如果尝试在 GET 请求中返回 turbo_stream 格式,将无法正常工作。请改用 frame。
内联渲染流(跳过模板):
def destroy
@post.destroy
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.remove(dom_id(@post)) }
format.html { redirect_to posts_path }
end
end
内联执行多个流操作:
format.turbo_stream do
render turbo_stream: [
turbo_stream.remove(dom_id(@post)),
turbo_stream.update("post_count", html: "#{Post.count} posts")
]
end
第 5 步:Turbo Stream 广播(实时)
广播会通过 ActionCable 向订阅某个频道的所有用户推送 Turbo Stream 操作。这是在不编写任何 JavaScript 的情况下实现“实时”功能的方式。
在视图中设置订阅:
<%# This creates an ActionCable subscription %>
<%= turbo_stream_from "posts" %>
<%# Scoped to a specific record %>
<%= turbo_stream_from @project %>
<%# Multiple stream names %>
<%= turbo_stream_from @project, "messages" %>
从模型广播:
class Post < ApplicationRecord
# Broadcast AFTER commit (not after save — important for transactions)
after_create_commit { broadcast_append_to "posts" }
after_update_commit { broadcast_replace_to "posts" }
after_destroy_commit { broadcast_remove_to "posts" }
# Shorthand for all three:
broadcasts_to ->(post) { "posts" }
# Or if broadcasting to a parent:
broadcasts_to :project
end
使用自定义 partial 广播:
after_create_commit do
broadcast_append_to "posts",
target: "posts_list",
partial: "posts/post_card",
locals: { post: self }
end
从任意位置广播(控制器、任务、服务):
Turbo::StreamsChannel.broadcast_append_to("posts", target: "posts_list",
partial: "posts/post", locals: { post: @post })
Turbo::StreamsChannel.broadcast_remove_to("posts", target: dom_id(@post))
⚠️ Agent 注意事项:广播渲染 partial 时没有请求上下文。 这意味着没有 current_user、没有 request,也没有会话。请设计能在没有这些信息的情况下运行的 partial,或者将所需数据作为 locals 传入。
第 6 步:Turbo 8 形变(Rails 8+)
形变是一种页面刷新策略,它通过比较差异而非替换来更新 DOM。它会保留表单状态、滚动位置和 CSS 过渡效果。
为页面启用形变:
<%# In the <head> of your layout or page %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
具体效果:
method: :morph— 将新的 HTML 与当前 DOM 进行比较,并应用最少的更改scroll: :preserve— 刷新后保留滚动位置
从服务器触发形变刷新:
# In a broadcast:
after_update_commit do
broadcast_refresh_to "posts"
end
# Shorthand:
broadcasts_refreshes
何时使用 morph,何时使用 streams:
- Morph — 需要重新渲染整个页面,但保留状态(表单、滚动位置)时
- Streams — 需要对特定元素进行精准、有针对性的更新时
标记在形变过程中需要保留的元素:
<div id="player" data-turbo-permanent>
<%# This element survives morph refreshes intact %>
</div>
第 7 步:Turbo 中的表单
表单是 Turbo 最容易让 Agent 出错的地方。关键规则如下:
规则 1:验证失败时必须返回 422 Unprocessable Entity。
def create
@post = Post.new(post_params)
if @post.save
redirect_to @post
else
render :new, status: :unprocessable_entity # ← CRITICAL
end
end
如果没有 :unprocessable_entity,Turbo 就不会渲染错误响应,而是会按照重定向状态进行处理。
规则 2:frame 内的表单会留在 frame 中。
<%= turbo_frame_tag "new_post" do %>
<%= form_with model: Post.new do |f| %>
<%= f.text_field :title %>
<%= f.submit "Create" %>
<% end %>
<% end %>
表单通过 Turbo 提交。响应必须包含匹配的 turbo_frame_tag "new_post"。或者,控制器可以使用 turbo_stream 格式响应,以跳过 frame 要求。
规则 3:成功提交表单后进行重定向。
# Turbo handles redirects correctly — it navigates the page
if @post.save
redirect_to @post, notice: "Created!" # Works fine with Turbo
end
规则 4:Rails 7+ 中的 form_with 默认使用 Turbo。 无需设置 local: false。如需禁用:
<%= form_with model: @post, data: { turbo: false } do |f| %>
步骤 8:常见模式
详细实现请参阅 references/ 目录:
references/frames.md— 内联编辑、标签页导航、延迟加载、模态框、无限滚动references/streams.md— 闪现消息、实时搜索、嵌套表单、计数器、Toast、模板约定references/broadcasting.md— 作用域广播、特定用户的流、后台任务模式references/morphing.md— Turbo 8 形态刷新设置、何时使用形态刷新而非流references/testing.md— 集成测试、系统测试、广播断言references/edge-cases.md— Stimulus 集成、注意事项(文件上传、DELETE 重定向、CSP、缓存)
快速参考
辅助方法
<%# Frames %>
<%= turbo_frame_tag "id" %>
<%= turbo_frame_tag dom_id(@record) %>
<%= turbo_frame_tag "id", src: path, loading: :lazy %>
<%= turbo_frame_tag "id", target: "_top" %>
<%# Stream subscription %>
<%= turbo_stream_from "channel_name" %>
<%= turbo_stream_from @record %>
<%# Stream actions (in .turbo_stream.erb templates) %>
<%= turbo_stream.append "target_id", partial: "partial" %>
<%= turbo_stream.prepend "target_id", partial: "partial" %>
<%= turbo_stream.replace dom_id(@record), partial: "partial" %>
<%= turbo_stream.update "target_id", html: "content" %>
<%= turbo_stream.remove dom_id(@record) %>
<%= turbo_stream.before dom_id(@record), partial: "partial" %>
<%= turbo_stream.after dom_id(@record), partial: "partial" %>
<%# Morphing (Rails 8+) %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
数据属性
data-turbo="false" <%# Disable Turbo for this element %>
data-turbo-method="delete" <%# HTTP method for link %>
data-turbo-confirm="Sure?" <%# Confirmation dialog %>
data-turbo-frame="_top" <%# Break out of frame %>
data-turbo-frame="frame_id" <%# Target specific frame %>
data-turbo-permanent <%# Preserve across morphs %>
data-turbo-temporary <%# Remove on morph refresh %>
data-turbo-action="advance" <%# Push to browser history %>
data-turbo-action="replace" <%# Replace browser history entry %>
模型广播方法
# Individual callbacks
after_create_commit { broadcast_append_to "stream" }
after_update_commit { broadcast_replace_to "stream" }
after_destroy_commit { broadcast_remove_to "stream" }
# All-in-one
broadcasts_to ->(record) { "stream_name" }
broadcasts_to :parent_association
# Morphing (Rails 8+)
broadcasts_refreshes
after_update_commit { broadcast_refresh_to "stream" }
# Manual broadcast from anywhere
Turbo::StreamsChannel.broadcast_append_to("stream", target: "id", partial: "path")
Turbo::StreamsChannel.broadcast_remove_to("stream", target: "id")
Turbo::StreamsChannel.broadcast_refresh_to("stream")
控制器响应模式
respond_to do |format|
if @record.save
format.turbo_stream # renders action.turbo_stream.erb
format.html { redirect_to @record }
else
format.html { render :new, status: :unprocessable_entity }
end
end
常见的 Agent 错误
- 缺少匹配的 frame ID — 两个页面都需要使用相同 ID 的
turbo_frame_tag - 验证失败时返回 200 — 必须使用
status: :unprocessable_entity(422) - 在 GET 请求中使用 Turbo Stream — Stream 仅适用于 POST/PUT/PATCH/DELETE
- 在广播 partial 中使用
current_user— 广播中没有请求上下文 - 在视图中忘记
turbo_stream_from— 广播需要订阅 - 本想使用
update,却用了replace—replace会完全移除目标元素 - 未使用
dom_id— 手动指定的 ID 容易不一致;dom_id(@post)始终一致 - 忘记
after_*_commit— 使用提交回调进行广播,而非after_save respond_to中没有 HTML 回退 — 始终包含format.html,以支持非 Turbo 客户端- 延迟加载的 frame 没有占位内容 — 在 frame 标签内显示加载状态
反模式
- 仅为更新 UI 而构建 JSON API — 改用 Turbo Streams
- 编写 JavaScript 来替换 DOM 内容 — 这正是 Turbo 的用途
- 深层嵌套 frame — 控制在 1–2 层;复杂度会急剧增加
- 广播每一次模型变更 — 有选择地广播;广播过多会导致性能问题
- 过于庞大的 turbo_stream.erb 模板 — 保持流响应聚焦;每个响应包含 1–3 个操作
- 对需要 Streams 的功能使用 Turbo Frames — 如果需要更新多个互不相关的区域,请使用 Streams
- 省略 HTML 回退 — 应用应能在没有 Turbo 的情况下运行(渐进增强)