表单
这一课你将能做到
- 用
form_with 生成绑定模型的表单,新增/编辑复用同一模板
- 在视图中渲染字段级错误信息,配合 422 状态码完成「失败回填」体验
- 处理文件上传(Active Storage)和一对多嵌套表单
为什么要学
表单是用户向系统输入数据的唯一入口,也是安全问题最密集的地方:CSRF、批量赋值、XSS、文件上传漏洞全在这一课交汇。Rails 的 form_with 把「生成正确字段名、带 CSRF 令牌、回填旧值、显示错误」这些琐事全包了——你要做的是理解它包了什么,才能在出错时知道去哪查。
对比本站:Next.js 里你要手写受控组件、useState 管理每个字段、fetch 提交、处理后端返回的错误映射;Rails 全栈模式下这些由 form_with + Strong Parameters + errors 对象一条流水线完成,前端几乎零代码。
先建立图像
表单提交的完整回路:
GET /articles/new POST /articles
│ │
▼ ▼
@article = Article.new @article.save ?
│ ╱ ╲
▼ 成功 失败
渲染 form_with redirect render :new (422)
字段名 article[title] 到详情页 errors 回填表单
│ │
└──────── 用户提交 ──────────────┘
form_with 自动判断:模型是新记录(new_record?)→ 表单 POST 到 /articles;是已存在记录 → PATCH 到 /articles/:id。新增和编辑因此能共用同一个 _form.html.erb。
跟着做
步骤 1:基础表单
app/views/articles/_form.html.erb:
<%= form_with model: article do |form| %>
<div class="field">
<%= form.label :title, "标题" %>
<%= form.text_field :title %>
</div>
<div class="field">
<%= form.label :body, "正文" %>
<%= form.text_area :body, rows: 10 %>
</div>
<div class="field">
<%= form.label :published, "发布" %>
<%= form.check_box :published %>
</div>
<%= form.submit "保存" %>
<% end %>
new 和 edit 视图都只有一行:
<%# app/views/articles/new.html.erb %>
<h1>新文章</h1>
<%= render "form", article: @article %>
<%# app/views/articles/edit.html.erb %>
<h1>编辑文章</h1>
<%= render "form", article: @article %>
生成的 HTML 关键部分:
<form action="/articles" method="post">
<input type="hidden" name="authenticity_token" value="...">
<input type="text" name="article[title]" id="article_title">
<textarea name="article[body]" id="article_body"></textarea>
</form>
注意 article[title] 这种命名——这就是控制器里 params.require(:article).permit(:title, :body) 能工作的前提。编辑模式下 Rails 还会注入 <input type="hidden" name="_method" value="patch"> 模拟 PATCH。
步骤 2:渲染错误信息
<%= form_with model: article do |form| %>
<% if article.errors.any? %>
<div class="error-summary">
<h3><%= pluralize(article.errors.count, "个错误") %>阻止了保存:</h3>
<ul>
<% article.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= form.label :title, "标题" %>
<%= form.text_field :title %>
<% article.errors[:title].each do |msg| %>
<span class="field-error"><%= msg %></span>
<% end %>
</div>
...
<% end %>
控制器失败分支必须是 render :new, status: :unprocessable_entity——这样 @article.errors 和用户已填的值都在,form_with 会自动回填。Turbo 依赖 422 状态码识别「该替换页面内容」。
步骤 3:下拉、单选、日期等常用控件
<%= form.select :category, ["技术", "生活", "随笔"], { prompt: "请选择分类" } %>
<%= form.collection_select :author_id, Author.all, :id, :name %>
<%= form.radio_button :visibility, "public" %>
<%= form.label :visibility_public, "公开" %>
<%= form.radio_button :visibility, "private" %>
<%= form.label :visibility_private, "私密" %>
<%= form.date_field :published_on %>
<%= form.number_field :reading_minutes, min: 1 %>
<%= form.password_field :access_code %>
collection_select 从模型集合生成 option:参数依次是(值字段, 显示字段)的 id/name——别搞反。
步骤 4:文件上传(Active Storage)
Rails 内置文件上传方案,无需第三方 gem:
bin/rails active_storage:install
bin/rails db:migrate
# app/models/article.rb
class Article < ApplicationRecord
has_one_attached :cover # 一张封面图
end
<%= form_with model: article do |form| %>
<%= form.label :cover, "封面图" %>
<%= form.file_field :cover, accept: "image/*" %>
<%= form.submit "保存" %>
<% end %>
# 控制器白名单放行 :cover
def article_params
params.require(:article).permit(:title, :body, :published, :cover)
end
<% if article.cover.attached? %>
<%= image_tag article.cover.variant(resize_to_limit: [800, 400]) %>
<% end %>
开发环境文件存本地 storage/,生产切 S3/七牛只需改 config/storage.yml 一行 service 配置——代码零改动。
步骤 5:嵌套表单(一对多)
class Article < ApplicationRecord
has_many :tags, dependent: :destroy
accepts_nested_attributes_for :tags, allow_destroy: true, reject_if: :all_blank
end
<%= form.fields_for :tags do |tag_form| %>
<div class="nested-field">
<%= tag_form.text_field :name, placeholder: "标签名" %>
<%= tag_form.check_box :_destroy %> 删除
</div>
<% end %>
params.require(:article).permit(:title, :body, tags_attributes: [:id, :name, :_destroy])
注意 :id(已有记录需要)和 :_destroy(勾选删除)必须放行。
讲清楚要点
- form_with 的智能源于模型状态:
new_record? 为 true 走 POST + create,否则走 PATCH + update,URL 和 method 自动推断。这就是约定优于配置在表单层的体现。
- 字段名
article[title] 是参数结构的契约:表单命名、Strong Parameters、模型赋值三者按同一份约定协作,一环脱节就报 ParameterMissing 或静默丢数据。
- CSRF 令牌自动注入:
authenticity_token 隐藏字段由 form_with 生成,服务端自动校验。AJAX 请求从 csrf_meta_tags 读令牌带上。
- 失败回填 = render + 422 + errors 三件套:redirect 会丢失模型状态,render 保留;422 让 Turbo 正确处理;errors 对象驱动错误展示。
- Active Storage 抽象了存储后端:本地磁盘、S3、GCS 用同一套 API,切换只改配置。多文件用
has_many_attached。
你可能会懵的地方
-
form_with 和老式的 form_for 区别?
form_for 是 Rails 5 之前的写法,必须显式给模型;form_with 是现在的统一入口,默认 data-remote(Turbo 接管)。新代码一律 form_with。
-
checkbox 提交时 Rails 为什么生成两个 input?
一个 hidden(value=0)一个 checkbox(value=1)。checkbox 不勾选时浏览器不提交该字段,hidden 兜底保证服务端收到 0——否则「取消勾选」无法保存。
-
file_field 上传后 @article.cover 是什么?
是 ActiveStorage::Attached::One 代理对象,不是文件本身。.attached? 判断有无、.variant 生成缩略图、.download 拿二进制、.url(配了 CDN)拿地址。
-
嵌套表单太深(三层以上)怎么办?
accepts_nested_attributes_for 只建议一层。深层嵌套用 cocoon gem(动态增删子表单)或拆成独立页面分步操作。
-
表单对象(Form Object)是什么?
当一个表单涉及多个模型(如注册同时建 User + Profile),把表单逻辑抽成纯 Ruby 类(ActiveModel::Model),控制器只调 form.save。复杂表单的解耦手法,了解了概念,需要时再深入。
错法对照
| 错法 | 问题 | 正解 |
|---|
手写 <form> + <input name="title"> | 字段名结构错、无 CSRF 令牌 | form_with model: 生成 |
| 校验失败后 redirect_to new | 错误和已填内容丢失 | render :new, status: :unprocessable_entity |
permit 漏了 :cover | 文件静默丢失不报错 | 白名单加上附件字段 |
嵌套表单没 permit :id | 更新已有子记录失败 | tags_attributes: [:id, :name, :_destroy] |
| 上传不校验文件类型/大小 | 任意文件上传漏洞 | accept 属性 + 模型 validate 内容类型和大小 |
卡点急救
- 提交报
ActionController::InvalidAuthenticityToken:CSRF 令牌缺失。检查是否手写了 form 没用 form_with;布局里 csrf_meta_tags 是否被删;清理浏览器 Cookie 重试。
- 文件上传后
attached? 是 false:八成是 permit 漏了附件字段名,或表单没写 file_field(form_with 检测到有附件字段会自动加 enctype)。
- 编辑表单提交后创建了新记录:form_with 收到的模型是新建对象而非数据库查出的。检查 edit 动作是否用
set_article 回调正确加载了记录。
面试常问
-
CSRF 是什么?Rails 怎么防?
答:跨站请求伪造——恶意网站诱导用户浏览器向目标站发请求(自动带 Cookie)。Rails 防御:每个表单/请求带 authenticity_token(与会话绑定),服务端校验不通过即拒绝。API 模式用 Token 认证则无需 CSRF 保护(不带 Cookie 就不受此威胁)。
-
form_with 如何区分新增和编辑?
答:看模型的 new_record?。新记录 → action 指向 collection 路径(POST /articles);持久化记录 → member 路径(PATCH /articles/:id),并注入 _method=patch 隐藏字段,因为 HTML 表单原生只支持 GET/POST,由 Rack 中间件还原成 PATCH。
-
文件上传的安全要点?
答:校验内容类型(不轻信扩展名,校验 MIME/魔数)、限制大小、文件名重命名防路径穿越、存到 Web 根目录之外或用对象存储、图片用 variant 处理而不是直接暴露原图 URL。Active Storage 默认存 storage/ 目录(非 public),通过控制器代理访问,天然满足大半要求。
自测三问
- 为什么 new 和 edit 能共用同一个
_form.html.erb?
- 校验失败的正确响应三要素是什么?缺一不可的理由?
- 嵌套表单白名单里为什么必须放行
:id 和 :_destroy?
小结一句话
form_with 把字段命名、CSRF、回填、错误展示打包成约定,你只需守住白名单、记住失败 render 422,表单就不再有玄学。
延伸