Rails 表单实战:用 re-former 项目打通从纯 HTML 表单到 form_with 辅助方法的完整链路
【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum
本指南以 The Odin Project Ruby on Rails 课程中的 Forms Project(re-former 应用)为实战主线,带领你完整经历一条"从零手写 HTML 表单 → 理解 CSRF 防护与 Turbo 提交机制 → 嵌套参数与强参数 → 使用form_with辅助方法 → 支持编辑与验证错误回显"的全过程。读完本文,你将不仅会"用" Rails 生成表单,更能看懂服务器日志中每一行参数的含义,理解 Rails 表单在背后替你做了什么,从而在出问题时具备独立的排错能力。
本文对应仓库中的 project_forms.md,其前置理论可参考 form_basics.md、controllers.md 与 routing.md。
项目目标:先"裸奔"再"Rails 化"
这个项目的核心练习方式是对照式学习:先构建一个"几乎纯 HTML"的表单,让它完全等同于 Rails 辅助方法生成的表单,借此看清 Rails 在背后替你做了什么;然后逐步把它改造成使用form_with的"Rails 风格"表单,并在过程中亲手处理 CSRF 防护、Turbo 提交、强参数、编辑(PATCH)与验证错误显示这些真实开发中必然遇到的问题。
你将构建的应用叫re-former,它只包含一个User模型(字段为username、email、password)以及对应的新建、创建、编辑、更新流程——麻雀虽小,五脏俱全。
第一阶段:快速搭建后端骨架
原文档强调,你会通过一系列"几乎相同"的步骤来快速搭建 Rails 应用,之后每节课的引导会越来越少。完整步骤如下:
- 创建新 Rails 应用:
rails new re-former - 在 GitHub 上新建仓库,将远端关联到本地 git 仓库,提交初始代码。
- 修改 README,写入便于日后回忆的内容,例如说明这是 The Odin Project Ruby on Rails 课程中的 Forms Project。
- 创建并迁移
User模型,字段为:username、:email、:password。 - 在模型中为每个字段添加
presence验证。 - 在路由文件中创建
:users资源,并使用only:选项只暴露:new和:create两个动作:# config/routes.rb resources :users, only: [:new, :create]为什么用
only:?如 routing.md 所述,resources :users一行会展开成 RESTful 的七个动作(index/show/new/create/edit/update/destroy),而本项目当前只需要新建与创建,显式收窄路由可以让应用边界更清晰。 - 新建
UsersController(手工创建,或用生成器rails generate controller Users)。 - 在控制器中写下空的
#new和#create方法。 - 创建
#new视图文件app/views/users/new.html.erb。 - 另开一个终端标签页启动服务器
rails server。 - 浏览器访问
http://localhost:3000/users/new,确认一切正常。
关于模型与迁移的背景,可参阅 active_record_basics.md(User.new+save两步创建记录、ORM 概念)与 migrations.md(rails generate model生成迁移、rails db:migrate应用迁移)。验证部分可参考 basic_validations.md,其中validates :name, presence: true即服务端验证的典型写法——永远不要信任来自客户端的输入。
第二阶段:纯 HTML 表单(Bare Metal Forms)
现在开始手写表单,目标是做出一个与 Rails 辅助方法生成结果几乎一致的表单,从而看清幕后的实现。
表单标签的method与action
在app/views/users/new.html.erb中书写<form>标签时,必须指定method和action两个属性。如何知道该用哪个 HTTP 方法与路径?运行:
rails routes根据你为:users资源定义的 RESTful 路由,创建用户对应的期望是POST /users(关于 RESTful 路由与 HTTP 动词的对应关系,可回顾 routing.md)。同时,还要加上accept-charset="UTF-8"属性,这是 Rails 为表单自然添加的,用于声明 Unicode 字符编码。
输入控件与name属性
为用户的三个字段(email、username、password)创建对应的输入控件,其中密码必须使用type="password"的密码输入框。务必为每个输入指定name属性——正如 form_basics.md 所强调的,name属性决定了该输入框的值在params哈希中以什么键出现,例如name="description"会生成params[:description];name同时也是 radio 按钮分组的依据。每个字段还要配上对应的<label>标签。
关闭 Turbo:观察"真正的"HTTP 提交
在 Rails 7 中,Turbo 默认开启。Turbo 会拦截表单提交,发起一次部分 XHR 请求,而不是带整页刷新的标准 HTTP 请求。为了直观理解 Rails 对 CSRF(跨站请求伪造)的防护,文档要求我们做一个小插曲:为表单标签设置data-turbo=false,禁用 Turbo,然后在开发者工具的 Network 面板中对比有无该属性时的请求类型,确认它按预期工作。
关闭 Turbo 后提交表单,观察服务器输出——请求会在到达你的控制器之前被拦截,服务器抛出 CSRF 错误:
ActionController::InvalidAuthenticityToken (Can't verify CSRF token authenticity.)原因在于:Rails 默认自动防护跨站请求伪造,它要求验证表单确实是从你生成的页面提交的。为此,Rails 会生成一个"authenticity token"(看起来像一串乱码),用它把表单与当前会话和应用匹配起来。没有它,恶意站点就可能通过他们控制的链接或按钮,诱导你的用户在不知情的情况下替你执行操作。
因此,要让自定义表单被 Rails 正确处理,你必须自行提供这个 token。Rails 提供了form_authenticity_token方法:
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>" >加上之后,服务器日志中就能看到 token:
... Parameters: {"utf8"=>"✓", "authenticity_token"=>"jJa87aK1OpXfjojryBk2Db6thv0K3bSZeYTuW8hF4Ns=", "email"=>"foo@bar.com", "commit"=>"Submit Form"}注意日志中同时出现的"utf8"=>"✓"(Unicode 编码检查)与"commit"(提交按钮的值),这些都是 Rails 表单默认附带的内容。
服务器日志解读与 204 响应
提交后如果再看服务器输出,会发现参数被接收后"没有更多事情发生",响应状态为 204(No Content)。这正是因为我们控制器的#create动作是空的——没有指定接下来该做什么。在 Network 面板中可以看到请求已发出,但返回了204 No Content。
服务器日志大致如下:
Started POST "/users" for 127.0.0.1 at 2013-12-12 13:04:19 -0800 Processing by UsersController#create as TURBO_STREAM Parameters: {"authenticity_token"=>"WUaJBOpLhFo3Mt2vlEmPQ93zMv53sDk6WFzZ2YJJQ0M=", "username"=>"foobar", "email"=>"foo@bar.com", "password"=>"[FILTERED]"}注意两点:第一行说明了 HTTP 方法与路由,第二行说明了请求由哪个控制器的哪个动作处理(TURBO_STREAM是默认的请求格式),第三行则是塞进params哈希的全部内容;密码字段在日志中会被自动过滤为[FILTERED],这正体现了 Rails 默认的安全行为。这段输出与 Rails 辅助方法生成的表单提交结果几乎一模一样——你已经成功完成了"幕前"的部分。
第三阶段:控制器#create动作与保存逻辑
接下来让#create动作真正干活:接收参数、创建 User,成功则重定向回新建表单页(空白),失败则重新渲染:new表单(保留已填写的信息):
# app/controllers/users_controller.rb def create @user = User.new(username: params[:username], email: params[:email], password: params[:password]) if @user.save redirect_to new_user_path else render :new, status: :unprocessable_entity end end这里体现了控制器中redirect_to与render的核心区别(详见 controllers.md):重定向是一次全新的 HTTP 请求,实例变量会被清空;而render只是用当前动作的实例变量渲染另一个视图——这正是失败时表单能保留用户已填内容的原因。
测试一下:现在能否通过表单创建用户?如果成功,服务器日志中会出现一条 INSERT SQL 命令。
关于status: :unprocessable_entity的重要细节(来自 form_basics.md):Turbo 只会处理重定向、4XX 状态或 5XX 状态。成功时redirect_to返回 303;失败时如果不指定状态码,render :new默认返回 200 OK——"请求按预期完成"意味着 Turbo 不会做任何处理,表单提交后看起来"什么都没发生"。因此失败渲染必须显式指定 422(Unprocessable Entity),Turbo 才会用返回的 HTML 更新页面。这是一个非常容易踩坑的点:提交表单后没有任何反应时,先检查你是否忘了返回正确的状态码。
第四阶段:嵌套参数与强参数
目前用三次params[:xxx]逐个取值,既啰嗦又容易出错。更优雅的做法是让表单提交一个用户属性的哈希,这样控制器里只需User.new(user_params)。
用name属性嵌套参数
如 form_basics.md 所讲,只需稍微改动name属性,用方括号把三个字段嵌套到user变量名下:
<input type="text" name="user[email]"> <input type="text" name="user[username]"> <input type="password" name="user[password]">重新提交后,服务器日志中的参数就变成了嵌套哈希:
Parameters: {"authenticity_token" => "WUaJBOpLhFo3Mt2vlEmPQ93zMv53sDk6WFzZ2YJJQ0M=", "user" =>{ "username" => "foobar", "email" => "foo@bar.com", "password" => "[FILTERED]" } }嵌套访问方式为params[:user][:email]。
强参数(Strong Parameters)
参数变成嵌套哈希后,控制器需要相应修改。关键点在于:我们不再被允许直接调用params[:user]——直接调用会返回哈希,而 Rails 的安全特性要求你显式验证(白名单化)后才可使用。这正是 Strong Parameters 的用途:防止用户提交有害数据(例如注册时把自己伪装成 admin)。
在控制器中注释掉原来实例化 User 的那一行(稍后复用),然后在控制器底部添加一个私有方法user_params,用expect声明允许的字段(强参数相关知识可回顾 controllers.md):
# app/controllers/users_controller.rb private def user_params params.expect(user: [:username, :email, :password]) end再添加一行使用该方法的User.new:
def create @user = User.new(user_params) if @user.save redirect_to new_user_path else render :new, status: :unprocessable_entity end end版本注意:controllers.md 明确指出,Rails 8 之前强参数的处理方式不同——需要用params.require(:user).permit(:username, :email, :password)(先require顶层键、再permit属性列表)。这种写法仍然有效,但因存在安全缺陷,促使 Rails 8 引入了expect;你在老项目、旧博客和 Stack Overflow 答案中仍会大量见到require/permit写法,二者功能相同,只是版本不同。
重新提交表单,调试完拼写错误后,一切应该正常工作了!
第五阶段:Rails 化——form_with辅助方法
现在把表单改造成完整的 Rails 表单,使用form_with,让它直接基于模型对象构建。
控制器提供@user
先修改#new动作,实例化一个空白的User对象并存入实例变量@user:
# app/controllers/users_controller.rb def new @user = User.new end这印证了 form_basics.md 中"#new动作通常创建一个未保存的新实例并传给视图"的说法——现在你终于看到它被form_with使用的意义了。
用form_with替换<form>标签
注释掉整个 HTML 表单(建议保留,卡住时可以对照)。然后:
<%# app/views/users/new.html.erb %> <%= form_with model: @user do |form| %> <%= form.text_field :username %> <%= form.email_field :email %> <%= form.password_field :password %> <%= form.submit "Create User" %> <% end %>form_with会替你自动插入 authenticity token 隐藏输入,所以你不再需要手写form_authenticity_token标签。输入字段通过表单对象(Form Builder)调用对应方法生成,如text_field、email_field、password_field、submit等。
form_with的"魔法":自动判断 create 与 update
form_with最强大的地方在于:传入模型对象后,Rails 会自动检查该对象是否已保存(详见 form_basics.md)——新对象自动提交到#create动作,已存在的对象自动提交到#update动作。它是通过自动生成正确的 URL 来实现的。
玩弄 Form Builder 的选项
尝试form.text_field等输入方法的选项:
- 为 email 字段添加默认占位符(placeholder),如
"<example@example.com>"; - 生成与默认不同的标签文案,如把 username 的标签改为 "Your user name here";
- 让某个字段初始就带有值(value 已预先填充)。
这些选项的完整列表可查阅 Form Builder 相关 API 文档;本项目的理论部分 form_basics.md 也给出了form_with(url: "/search", method: "get")这种"无模型"用法及其生成的 HTML 对照,例如:
<%= form_with(url: "/search", method: "get") do %> <%= label_tag(:query, "Search for:") %> <%= text_field_tag(:query) %> <%= submit_tag("Search") %> <% end %>会生成<form accept-charset="UTF-8" action="/search" method="get">以及name与id一致(均为query)的输入框——注意id与name匹配这一规律。
第六阶段:重新开启 Turbo
前面为练习需要关闭了 Turbo,现在把它打开:
- 移除表单标签上的
data-turbo=false属性,同时删掉手写的 CSRF token 隐藏输入,然后提交。你会发现不再报 CSRF 错误了。 - 表单现在由 Turbo 提交,但 Rails 仍然通过验证 CSRF token 保护你。那么这个 token 从哪来?检查开发者工具和你的
application.html.erb布局模板(位于app/views/layouts/application.html.erb),能否找到一个"始终可用"的 CSRF token?答案在csrf_meta_tags辅助方法——它把 token 写入页面的<meta>标签,Turbo 提交时会自动读取并附带。 - 把这个 meta 标签也从
application.html.erb中移除,再次提交,验证服务器重新抛出 CSRF 错误。 - 确认理解后,把两处的 CSRF token 标签都恢复原状,继续往下走。
这个练习的价值在于:你亲眼确认了"Rails 始终验证 CSRF token",以及 Turbo 与csrf_meta_tags之间"token 从布局页 meta 标签传递"的配合关系。关于会话与认证的更多背景,可参考 sessions_cookies_authentication.md。
第七阶段:编辑(Edit/Update)流程
最后为应用加上编辑既有用户的能力。
更新路由与控制器以处理编辑。需要让控制器根据提交的
params中的 ID 找到用户:# config/routes.rb resources :users, only: [:new, :create, :edit, :update]控制器中对应的动作:
def edit @user = User.find(params[:id]) end def update @user = User.find(params[:id]) if @user.update(user_params) redirect_to new_user_path else render :edit, status: :unprocessable_entity end end创建编辑视图
app/views/users/edit.html.erb,把新建视图中的表单复制过来。注意:你之前注释掉的 HTML 表单和form_tag表单在这里会失效——它们会把表单提交为 POST 请求,而编辑需要的是 PATCH(PUT)请求(再回顾一下rails routes的输出吧)。这是个很容易修复的问题:如果你用form_with表单尝试编辑用户(它足够聪明,能判断是编辑还是新建),就能看出来端倪。对编辑视图里
form_with生成的表单执行"查看源代码",特别留意<form>顶部嵌套的隐藏字段——你会看到<input name="_method" type="hidden" value="patch">。这正是 Rails 绕过"浏览器原生只支持 GET 和 POST"限制的手段:浏览器提交 POST,Rails 通过_method隐藏字段识别出你真正想要的是 PATCH(或 DELETE)请求,如 form_basics.md 所述。修改表单视图顶部,显示验证失败时附着在模型对象上的错误列表。回顾
errors与full_messages方法:<% if @user.errors.any? %> <div id="error_explanation"> <h2><%= pluralize(@user.errors.count, "error") %> prohibited this user from being saved:</h2> <ul> <% @user.errors.full_messages.each do |msg| %> <li><%= msg %></li> <% end %> </ul> </div> <% end %>关于验证失败时对象如何获得
errors集合、#count与#full_messages的用法,可回顾 form_basics.md 的"Forms and validations"一节。还有一个小彩蛋:当表单针对特定模型对象渲染(如form_with model: @user)时,Rails 会自动为出错字段包裹带field_with_errors类的<div>,方便你写 CSS 高亮错误字段。将项目提交到 Git 并推送到 GitHub,完成整个练习。
总结:这张"表单全景图"教会了你什么
通过 re-former 这个项目,你实际上完成了一次对 Rails 表单机制的"逆向工程":
- HTML 层:
method、action、accept-charset、name属性如何决定请求去向与params结构; - 安全层:authenticity token 为什么存在、
form_authenticity_token如何手动注入、csrf_meta_tags与 Turbo 的配合、_method隐藏字段如何实现 PATCH/DELETE; - 参数层:
user[email]方括号嵌套如何形成params[:user]哈希,强参数(Rails 8 的expect/ 旧版require+permit)为何不可或缺; - 控制器层:
#new提供空白模型、#create/#update的保存分支、redirect_to与render :xxx, status: :unprocessable_entity的取舍; - 视图层:
form_with model: @user如何自动选择动作、自动注入 token、自动处理错误高亮。
正如 form_basics.md 的结论所说:理解表单底层的原理,比记住辅助方法的语法更重要——因为当辅助方法出问题时,只有真正懂得幕后发生了什么的人才能快速定位。掌握了这套机制,你就可以顺畅地进入下一个实战项目 project_members_only.md(Members Only 俱乐部项目),把表单能力与 Devise 认证、before_action权限控制组合起来,构建完整的登录与授权系统。
【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考