mcpField
T
turbo

turbo

github.com/thinkoodle开发7190 次安装8 周前更新
代码仓库

关于 turbo

面向 Rails 开发者的 Turbo 技能,指导使用 Turbo Drive、Frames 和 Streams 构建少用自定义 JavaScript 的界面,并涵盖实时广播与 Turbo 8 页面形态更新。

主要功能

  1. 01Turbo Drive 导航与退出规则
  2. 02Turbo Frames 局部页面更新
  3. 03Turbo Streams 定向 DOM 更新
  4. 04ActionCable 实时广播
  5. 05Turbo 8 页面形态更新
  6. 06Turbo 表单与验证处理

适用场景

  1. 01为 Rails 页面添加延迟加载内容
  2. 02构建行内编辑、标签页和模态框
  3. 03通过 ActionCable 推送实时更新
  4. 04处理 Turbo 表单验证失败

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/thinkoodle/rails-skills --skill turbo

安装后在 Claude Code 中输入/turbo确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
broadcasting.md
edge-cases.md
frames.md
morphing.md
streams.md
testing.md

详细介绍

Rails Turbo 专家

使用 Turbo Drive、Frames 和 Streams 构建快速、现代的 Rails UI,无需(或仅需少量)自定义 JavaScript。

理念

核心原则:

  1. 通过网络传输 HTML——由服务器渲染 HTML,再由 Turbo 传递。UI 无需 JSON API。
  2. 渐进增强——Turbo Drive 会自动运行,Frames 和 Streams 则在此基础上扩展。
  3. 服务器是真实数据源——业务逻辑保留在 Ruby 中。Turbo 只负责传递 HTML。
  4. 尽量少用 JavaScript——如果你正在编写 JS 来更新 DOM,很可能做法不对。请使用 Turbo Streams。
  5. 可组合的基本构件——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 目标,或替换/移除该目标。”它们适用于两种场景:

  1. HTTP 响应 — 在表单提交(POST/PUT/PATCH/DELETE)后返回
  2. WebSocket 广播 — 通过 ActionCable 推送给所有已连接的用户

7 种流操作:

操作功能
append将 HTML 添加到目标子元素的末尾
prepend将 HTML 添加到目标子元素的开头
replace替换整个目标元素
update替换目标元素的内部 HTML
remove移除目标元素
before在目标元素之前插入 HTML
after在目标元素之后插入 HTML

⚠️ 关键区别:replace 与 update

  • replace — 移除目标元素本身,并在原位置放入新的 HTML
  • update — 保留目标元素,替换其子元素
<%# 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 错误

  1. 缺少匹配的 frame ID — 两个页面都需要使用相同 ID 的 turbo_frame_tag
  2. 验证失败时返回 200 — 必须使用 status: :unprocessable_entity(422)
  3. 在 GET 请求中使用 Turbo Stream — Stream 仅适用于 POST/PUT/PATCH/DELETE
  4. 在广播 partial 中使用 current_user — 广播中没有请求上下文
  5. 在视图中忘记 turbo_stream_from — 广播需要订阅
  6. 本想使用 update,却用了 replace — replace 会完全移除目标元素
  7. 未使用 dom_id — 手动指定的 ID 容易不一致;dom_id(@post) 始终一致
  8. 忘记 after_*_commit — 使用提交回调进行广播,而非 after_save
  9. respond_to 中没有 HTML 回退 — 始终包含 format.html,以支持非 Turbo 客户端
  10. 延迟加载的 frame 没有占位内容 — 在 frame 标签内显示加载状态

反模式

  1. 仅为更新 UI 而构建 JSON API — 改用 Turbo Streams
  2. 编写 JavaScript 来替换 DOM 内容 — 这正是 Turbo 的用途
  3. 深层嵌套 frame — 控制在 1–2 层;复杂度会急剧增加
  4. 广播每一次模型变更 — 有选择地广播;广播过多会导致性能问题
  5. 过于庞大的 turbo_stream.erb 模板 — 保持流响应聚焦;每个响应包含 1–3 个操作
  6. 对需要 Streams 的功能使用 Turbo Frames — 如果需要更新多个互不相关的区域,请使用 Streams
  7. 省略 HTML 回退 — 应用应能在没有 Turbo 的情况下运行(渐进增强)

评论

支持 Markdown