mcpField
L
layouts-and-rendering

layouts-and-rendering

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

关于 layouts-and-rendering

面向 Rails 开发的技能,指导布局、模板、局部模板及响应方式的选择与实现。重点包括为局部模板显式传递 locals,以及设置适用于 Turbo 的 HTTP 状态码。

主要功能

  1. 01选择 render、redirect_to 或 head
  2. 02配置控制器与嵌套布局
  3. 03为局部模板显式传递 locals
  4. 04渲染集合与共享区域
  5. 05设置适用于 Turbo 的状态码
  6. 06避免重复渲染及常见问题

适用场景

  1. 01为 CRUD 操作设置正确的成功与错误响应
  2. 02构建可复用的局部模板和共享布局
  3. 03修复 Turbo 表单提交行为

安装

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

$npx skills add https://github.com/thinkoodle/rails-skills --skill layouts-and-rendering

安装后在 Claude Code 中输入/layouts-and-rendering确认已加载。

附带文件reference.md

详细介绍

Rails 布局与渲染专家

以正确的方式,渲染正确的内容,并返回正确的状态码。

规则 #1:局部模板使用局部变量,而非实例变量

# ❌ WRONG — implicit coupling, untestable, will break
<%= render "product" %>
# _product.html.erb uses @product

# ✅ RIGHT — explicit, testable, reusable
<%= render partial: "product", locals: { product: @product } %>
# or shorthand:
<%= render "product", product: @product %>

每个局部模板都通过局部变量获取数据。 无一例外。在局部模板中使用实例变量,会在控制器与视图之间造成隐性耦合,导致局部模板复用时出现问题。

规则 #2:render 与 redirect_to

这两者的作用有本质区别。弄错它们是 Agent 最常见的错误。

renderredirect_to
作用在当前请求中渲染模板发送 HTTP 302,浏览器发起新请求
实例变量可用(同一请求)不可用(新请求)
浏览器中的 URL保持不变更改为新 URL
适用场景显示错误、展示内容成功执行更改后
HTTP 往返次数0(同一请求)1(浏览器再次向服务器请求)
def create
  @post = Post.new(post_params)
  if @post.save
    redirect_to @post              # ← Success: redirect (browser gets new URL)
  else
    render :new, status: :unprocessable_entity  # ← Failure: render (keep form data)
  end
end

重要: render :action_name 不会运行该操作的代码,只会使用该操作的模板。如果 index.html.erb 需要 @posts,从 show 中渲染 :index 并不会设置 @posts——你必须自行设置它,或改为重定向。

何时使用此技能

  • 在 render、redirect_to 和 head 之间进行选择
  • 设置布局(按控制器、按操作、条件式、嵌套)
  • 创建带有恰当局部变量的局部模板
  • 使用 content_for / yield 构建多区域布局
  • 高效渲染集合
  • 正确处理 Turbo/Hotwire 状态码
  • 流式传输响应
  • 在控制器中渲染 JSON/XML/纯文本/HTML

说明

步骤 1:选择正确的响应类型
# Full HTML response (most common)
render :show                          # Convention: renders show.html.erb
render "products/show"                # Cross-controller template
render partial: "form", locals: { post: @post }

# Data responses
render json: @product                 # Auto-calls .to_json
render xml: @product                  # Auto-calls .to_xml
render plain: "OK"                    # text/plain, no layout
render html: helpers.tag.strong("Hi") # HTML fragment

# Redirect (after successful mutation)
redirect_to @product                  # 302 by default
redirect_to products_path, status: :see_other  # 303 for Turbo
redirect_back fallback_location: root_path

# Headers only
head :no_content                      # 204, for API delete
head :created, location: photo_url(@photo)
步骤 2:正确设置状态码(对 Turbo 至关重要)

使用 Turbo Drive(Rails 7+)时,状态码决定了行为:

# After failed validation — MUST be 422 for Turbo to replace the page
render :new, status: :unprocessable_entity    # 422

# After successful redirect — MUST be 303 for Turbo
redirect_to @post, status: :see_other         # 303

# Turbo Stream responses
render turbo_stream: turbo_stream.remove(@post)  # 200 OK
场景状态码原因
验证失败,重新渲染表单422 :unprocessable_entityTurbo 会替换页面内容
成功创建/更新303 :see_other(重定向)Turbo 会通过 GET 跟随重定向
成功销毁303 :see_other(重定向)原因相同
API 请求成功200 :ok 或 201 :created标准 API 约定
未找到404 :not_found标准

在使用 Turbo 的应用中,如果使用 redirect_to 时没有指定 status: :see_other,Turbo 在表单提交后可能无法正确跟随重定向。

步骤 3:布局
Rails 如何查找布局
  1. 按操作:在操作中使用 render layout: "special"
  2. 按控制器:声明 layout "admin"
  3. 约定:PhotosController 对应 app/views/layouts/photos.html.erb
  4. 回退:app/views/layouts/application.html.erb
# Per-controller layout
class AdminController < ApplicationController
  layout "admin"
end

# Conditional layout
class ProductsController < ApplicationController
  layout "product", except: [:index, :rss]
end

# Runtime layout selection
class ProductsController < ApplicationController
  layout :choose_layout

  private

  def choose_layout
    current_user&.admin? ? "admin" : "application"
  end
end

# Per-action override
def special
  render layout: "minimal"
end

# No layout at all
def api_endpoint
  render json: @data, layout: false
end
布局继承

布局会沿控制器层级向下级联:

class ApplicationController < ActionController::Base
  layout "main"       # All controllers use "main"
end

class ArticlesController < ApplicationController
  # Inherits "main" layout
end

class SpecialArticlesController < ArticlesController
  layout "special"    # Overrides to "special"
end

class ApiController < ApplicationController
  layout false        # No layout at all
end
嵌套布局(子模板)

使用 content_for + render template: 扩展父布局:

<%# app/views/layouts/admin.html.erb — extends application layout %>
<% content_for :head do %>
  <%= stylesheet_link_tag "admin" %>
<% end %>

<% content_for :content do %>
  <div class="admin-sidebar"><%= yield :sidebar %></div>
  <div class="admin-main"><%= yield %></div>
<% end %>

<%= render template: "layouts/application" %>

应用布局需要支持此功能:

<%# app/views/layouts/application.html.erb %>
<html>
<head><%= yield :head %></head>
<body>
  <%= content_for?(:content) ? yield(:content) : yield %>
</body>
</html>
步骤 4:局部模板——始终使用局部变量
基本的局部模板渲染
# Explicit (preferred when passing locals)
<%= render partial: "form", locals: { post: @post } %>

# Shorthand (works for simple cases)
<%= render "form", post: @post %>

# Model shorthand — renders _post.html.erb with local `post`
<%= render @post %>

# Cross-directory partial
<%= render "shared/navbar", current_user: @user %>
局部模板约定

每个局部模板都应在顶部注明其所需的局部变量:

<%# app/views/posts/_post.html.erb %>
<%# locals: (post:, show_actions: true) %>
<article>
  <h2><%= post.title %></h2>
  <p><%= post.body %></p>
  <% if show_actions %>
    <%= link_to "Edit", edit_post_path(post) %>
  <% end %>
</article>

魔法注释 <%# locals: (post:, show_actions: true) %>(Rails 7.1+)有两个作用:

  1. 注明所需的局部变量
  2. 缺少必需局部变量时引发错误
可选局部变量(Rails 7.1 之前)
<%# Check with local_assigns for optional params %>
<% if local_assigns[:full] %>
  <%= simple_format article.body %>
<% else %>
  <%= truncate article.body %>
<% end %>
步骤 5:集合渲染

列表始终使用集合渲染。 这样更快(单次渲染调用),也更简洁。

# ❌ SLOW — N render calls
<% @products.each do |product| %>
  <%= render partial: "product", locals: { product: product } %>
<% end %>

# ✅ FAST — single render call, Rails optimizes internally
<%= render partial: "product", collection: @products %>

# ✅ FASTEST shorthand — Rails infers partial name from model
<%= render @products %>

# Empty collection handling
<%= render(@products) || "No products yet." %>
集合功能
# Custom local variable name
<%= render partial: "product", collection: @products, as: :item %>

# Counter variable (0-indexed) — available as product_counter
# Inside _product.html.erb: product_counter gives 0, 1, 2...

# Spacer template — rendered between items
<%= render partial: @products, spacer_template: "product_divider" %>

# Extra locals passed to every item
<%= render partial: "product", collection: @products,
           locals: { show_price: true } %>

# Layout for each item
<%= render partial: "product", collection: @products, layout: "card" %>
异构集合
# Rails picks the right partial based on model class
<%= render [customer1, employee1, customer2] %>
# Renders customers/_customer.html.erb and employees/_employee.html.erb
步骤 6:content_for 和 yield

使用 content_for 将内容注入布局中的命名区域。

<%# Layout: app/views/layouts/application.html.erb %>
<html>
<head>
  <title><%= yield :title %></title>
  <%= yield :head %>
</head>
<body>
  <%= yield :breadcrumbs %>
  <%= yield %>  <%# Main content (unnamed yield) %>
</body>
</html>

<%# View: app/views/posts/show.html.erb %>
<% content_for :title, @post.title %>

<% content_for :head do %>
  <%= tag.meta name: "description", content: @post.excerpt %>
<% end %>

<% content_for :breadcrumbs do %>
  <nav>Posts > <%= @post.title %></nav>
<% end %>

<article>
  <h1><%= @post.title %></h1>
  <%= simple_format @post.body %>
</article>
content_for?——条件区域
<%# Only render sidebar wrapper if content exists %>
<% if content_for?(:sidebar) %>
  <aside><%= yield :sidebar %></aside>
<% end %>
provide 与 content_for
provide :title, "My Page"       # Sets once, stops looking (streaming-friendly)
content_for :title, "My Page"   # Appends, can be called multiple times

单个值(如页面标题)使用 provide。累积内容(如多个脚本标签)使用 content_for。

第 7 步:避免重复渲染错误

如果渲染或重定向两次,Rails 会引发 AbstractController::DoubleRenderError。

# ❌ BUG — both render calls execute
def show
  @book = Book.find(params[:id])
  if @book.special?
    render :special_show
  end
  render :regular_show  # Always runs!
end

# ✅ FIX — return after render
def show
  @book = Book.find(params[:id])
  if @book.special?
    return render :special_show
  end
  render :regular_show
end

# ✅ ALSO FINE — implicit render for else case
def show
  @book = Book.find(params[:id])
  render :special_show if @book.special?
  # Implicit render of :show if special? is false
end

对于条件渲染,return render 模式最简洁。

第 8 步:模板继承

控制器会从父控制器继承模板查找路径:

# Lookup order for Admin::ProductsController#index:
# 1. app/views/admin/products/index.html.erb
# 2. app/views/admin/index.html.erb
# 3. app/views/application/index.html.erb

因此,app/views/application/ 非常适合存放共享局部模板:

<%# app/views/application/_empty_list.html.erb %>
<p>No items yet.</p>

<%# Usable from any controller's view: %>
<%= render(@products) || render("empty_list") %>

快速参考

render 速查表
# Templates
render :edit                          # Same controller template
render "edit"                         # Same (string)
render "products/show"                # Other controller template
render template: "products/show"      # Explicit

# Data
render json: @product                 # JSON
render xml: @product                  # XML
render plain: "OK"                    # Plain text
render html: "<b>Hi</b>".html_safe    # HTML fragment
render body: "raw"                    # Raw body, no content type
render js: "alert('hi')"             # JavaScript

# Files
render file: Rails.root.join("public/404.html"), layout: false
render inline: "<%= 1 + 1 %>"        # Don't use this

# Options (combinable)
render :edit, status: :unprocessable_entity
render :show, layout: "minimal"
render :show, layout: false
render :show, content_type: "application/rss"
render :show, formats: [:json]
render :show, variants: [:mobile]
redirect_to 速查表
redirect_to @post                            # record → show path
redirect_to posts_path                       # named route
redirect_to "https://example.com"            # URL
redirect_to action: :index                   # hash
redirect_back fallback_location: root_path   # back button

# With status (important for Turbo!)
redirect_to @post, status: :see_other        # 303
redirect_to posts_path, status: 301          # permanent

# With flash
redirect_to @post, notice: "Created!"
redirect_to @post, alert: "Problem!"
标准 CRUD 控制器模式(支持 Turbo)
class PostsController < ApplicationController
  def create
    @post = Post.new(post_params)
    if @post.save
      redirect_to @post, notice: "Created!", status: :see_other
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    @post = Post.find(params[:id])
    if @post.update(post_params)
      redirect_to @post, notice: "Updated!", status: :see_other
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @post = Post.find(params[:id])
    @post.destroy!
    redirect_to posts_path, notice: "Deleted!", status: :see_other
  end
end

反模式

  1. 在局部模板中使用实例变量 — 始终显式传递局部变量
  2. 以为 render 就是 redirect_to — render 不会更改 URL,也不会重新运行操作
  3. 遗漏 status: :unprocessable_entity — 没有 422 状态码,Turbo 不会替换页面
  4. 遗漏 status: :see_other — POST/PUT/DELETE 后,Turbo 可能不会跟随重定向
  5. 使用 render inline: — 违背 MVC 原则。请使用模板
  6. 重复渲染但不使用 return — 使用 return render :action 模式
  7. 使用 redirect_to 处理表单错误 — 重定向会丢失 @record.errors;应改用 render
  8. 使用 redirect_back 时忘记设置 fallback_location — 如果没有 referer,就会引发错误
  9. 将用户输入用于 render file: — 存在路径遍历漏洞
  10. provide 已足够时仍使用 content_for — provide 更适合流式传输

有关详细的渲染模式、布局示例和边缘情况,请参阅此技能目录中的 reference.md。

评论

支持 Markdown