Ruby 使用 Rails 自带的 Form Builder 来重构你的 Form

hfpp2012 · December 13, 2016 · Last by kevinyu replied at October 16, 2018 · 9865 hits
Topic has been selected as the excellent topic by the admin.

原文链接: 使用 rails 自带的 Form Builder 来重构你的 Form

1. 介绍

开发一个项目,你可能难以避免使用到表单,特别是后台项目的增删改查,你就可能使用得更多。

这些表单,可能内容和格式都差不多,或许你就用 bootstrap 这样组件来格式化你的表单。

有时候,你要写好多代码,且是重复的,来看下下面的代码:

<%= form_for [:admin, @executive], html: { class: 'form-horizontal form-label-left', "data-parsley-validate" => true } do |f| %>
  <% if @executive.errors.any? %>
    <div id="error_explanation"></div>
    <h3><%= "#{@executive.errors.count}个错误:" %></h3>
    <ul>
      <% @executive.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
    </ul>
  <% end %>

  <div class="form-group">
    <label class="col-sm-2 control-label">名称</label>
    <div class="col-sm-10"><%= f.text_field :name, required: 'required', class: 'form-control' %></div>
  </div>

  <% if params[:level] || @executive.parent_id.present? %>
    <div class="form-group">
      <label class="col-sm-2 control-label">所属产品系列</label>
      <div class="col-sm-10"><%= f.collection_select :parent_id, @executives, :id, :name, { include_blank: "选择产品系列" }, { class: 'form-control', required: 'required'} %></div>
    </div>
  <% end %>

  <div class="hr-line-dashed"></div>

  <div class="form-group">
    <div class="col-sm-4 col-sm-offset-2">
      <%= f.submit value: '提交', class: 'btn btn-primary' %>
    </div>
  </div>
<% end %>

这样大约有 31 行代码,可以把它精简成 10 行 (有空行) 那样。

除去form_for那行,先不看,来看第一部分,就是显示错误的,如果表单提交不成功,把错误信息显示出来。

<% if @executive.errors.any? %>
  <div id="error_explanation"></div>
  <h3><%= "#{@executive.errors.count}个错误:" %></h3>
  <ul>
    <% @executive.errors.full_messages.each do |message| %>
      <li><%= message %></li>
    <% end %>
  </ul>
<% end %>

类似这样的代码,有时候换了另一个表对象,你又要重新写一次:

<% if @category.errors.any? %>
  <div id="error_explanation"></div>
  <h3><%= "#{@category.errors.count}个错误:" %></h3>
  <ul>
    <% @category.errors.full_messages.each do |message| %>
      <li><%= message %></li>
    <% end %>
  </ul>
<% end %>

只是把@executive换成了@category而已,却要写这么多行代码,就算是复制,看着也不爽,能不能把它们都简化呢?

能的,像上面刚才那样的代码,我只需要一行。

包括下面的代码:

<div class="form-group">
  <label class="col-sm-2 control-label">名称</label>
  <div class="col-sm-10"><%= f.text_field :name, required: 'required', class: 'form-control' %></div>
</div>

这样的代码结构都差不多,只是不同的字段在换而已。

统统简化一下。

2. 使用 Form Builder

我们不需要使用第三方的插件,rails 默认就有这样的功能,让你重新修改系统的 tag 或重新定义自己的 tag。

先在app下新建一个目录叫form_builders,再在里面新建一个文件叫bootstrap_form_builder.rb

内容如下:

# app/form_builders/bootstrap_form_builder.rb
class BootstrapFormBuilder < ActionView::Helpers::FormBuilder
  delegate :content_tag, to: :@template

  def error_messages
    if object && object.errors.any?
      content_tag(:div, id: 'error_explanation') do
        content_tag(:h3, "#{object.errors.count}个错误") +
          content_tag(:ul) do
            object.errors.full_messages.map do |msg|
              content_tag(:li, msg)
            end.join.html_safe
          end
      end
    end
  end
end

config/application.rb文件中加入这行配置:

config.autoload_paths << Rails.root.join('app', 'form_builders')

按照#10 楼的建议,在 rails 5 中这里需要把autoload_paths改成eager_load_paths

再到app/helpers/application_helper.rb文件中添加一个方法。

module ApplicationHelper
  def bootstrap_form_for(object, options = {}, &block)
    options[:builder] = BootstrapFormBuilder
    form_for(object, options, &block)
  end
end

接下来就可以使用bootstrap_form_for这个方法来代替默认的form_for方法了。

<%= bootstrap_form_for [:admin, @executive], html: { class: 'form-horizontal form-label-left', "data-parsley-validate" => true } do |f| %>
  <%= f.error_messages %>

 ...
<% end %>

看到没有,之前显示错误信息那里是好几行代码的,现在被简化成了一行代码。

我们接下来把显示名称所属产品系列这两个地方也简化一下。

app/form_builders/bootstrap_form_builder.rb文件中,再增加一些内容。

class BootstrapFormBuilder < ActionView::Helpers::FormBuilder
  delegate :content_tag, to: :@template

  %w( text_field text_area url_field file_field collection_select select ).each do |method_name|
    define_method(method_name) do |method, *tag_value|
      content_tag(:div, class: 'form-group') do
        label(method, class: 'col-sm-2 control-label') +
          content_tag(:div, class: 'col-sm-10') do
            super(method, *tag_value)
          end
      end
    end
  end

  def error_messages
  ...
end

现在就可以只用<%= f.text_field :name, required: 'required', class: 'form-control' %>来代替之前的好多行代码。

把那些多余的divclass删除掉即可。

这个可以看下面最后的结果。

同样的提交按钮,也能处理,在BootstrapFormBuilder中增加下面这个方法:

def submit(*tag_value)
  content_tag(:div, class: 'form-group') do
    content_tag(:div, class: 'col-sm-4 col-sm-offset-2') do
      super
    end
  end
end

回到前面。

<%= form_for [:admin, @executive], html: { class: 'form-horizontal form-label-left', "data-parsley-validate" => true } do |f| %>

这里有好多 class,都是一样的,我也不想重复,想改成这样写。

<%= bootstrap_form_for [:admin, @executive] do |f| %>

到 helper 方法中,修改一下。

def bootstrap_form_for(object, options = {}, &block)
  options[:html] ||= {}
  options[:html][:class] = 'form-horizontal form-label-left'
  options[:html]["data-parsley-validate"] = true
  options[:builder] = BootstrapFormBuilder
  form_for(object, options, &block)
end

最后精简后的代码可能是这样的子:

<%= bootstrap_form_for [:admin, @executive] do |f| %>
  <%= f.error_messages %>

  <%= f.text_field :name, required: 'required', class: 'form-control' %>

  <% if params[:level] || @executive.parent_id.present? %>
    <%= f.collection_select :parent_id, @executives, :id, :name, { include_blank: "选择产品系列" }, { class: 'form-control', required: 'required'} %>
  <% end %>

  <div class="hr-line-dashed"></div>

  <%= f.submit value: '提交', class: 'btn btn-primary' %>
<% end %>

比之前精简多了。

这样的代码又不影响正常的 form_for,你还可以继续使用默认的 form_for 下的各种 tag,没有影响,当你要使用自己定义的 form_for,只要改成自己定义的就可以了。

3. 加一个额外的功能

你的应用可能有好多图片编辑的功能,比如下面这样:

编辑的时候需要显示图片。

你可能会这样写:

<% if @product.image_url.present? %>
  <div class="form-group">
    <label class="col-sm-2 control-label"></label>
    <div class="col-sm-10">
      <%= image_tag @product.image_url(:product), width: '100', height: '100' %>
    </div>
  </div>
<% end %>

<div class="form-group">
  <label class="col-sm-2 control-label">图片<span class="required">*</span></label>
  <div class="col-sm-10">
    <%= f.file_field :image, required: (@product.new_record? ? true : false), class: 'form-control' %>
  </div>
</div>

我把它精简成了一行

<%= f.image_file_field :image, required: (@product.new_record? ? true : false), class: 'form-control' %>

按照#6 楼的建议 @product.new_record? ? true : false改成@product.new_record?会好点。

参考一下我的写法。

还是到bootstrap_form_builder.rb文件中增加下面几行:

def image_file_field(method, *tag_value)
  image_method = "#{method}_url".to_sym
  image_cache = "#{method}_cache".to_sym
  if object.send(image_method)
    image_version = tag_value.first[:image_version].to_sym if tag_value.first[:image_version].present?
    content_tag(:div, class: 'form-group') do
      label(method, "&nbsp;".html_safe, class: 'col-sm-2 control-label') +
        content_tag(:div, class: 'col-sm-10') do
          image_tag object.send(image_method, image_version), width: '100', height: '100'
        end
    end +
    file_field(method, *tag_value) + hidden_field(image_cache)
  else
    file_field(method, *tag_value)
  end
end

可能你运行的时候会提示报错,提示没有image_tag这个方法,没关系。

改一下最前面的delegate方法。

class BootstrapFormBuilder < ActionView::Helpers::FormBuilder
  delegate :content_tag, :image_tag, to: :@template
  ...
end

缺少啥 helper 方法或 tag 方法,就在delegate后面添加就好了。

对了,最后你可能会发现结果表单中的字段名怎么变成英文输出了,这没关系,你加上 i18n 国际化就好了,会全中文的。

完结。

阁下总是能发掘出这样有意思的玩意

huacnlee mark as excellent topic. 13 Dec 21:59

赞,其实这是 form builder 原本的设计用法。

之前看开源代码的时候,看到过这种用法,今天再看一下,有种其义自现的感觉。👍
另外,ruby china 里为什么 直接输入表情代码,没有出现自动补全类的提示?是我一个人的问题?

终于看见有这么用的了!

simple_form 什么的根本没必要嘛...

PS: 四年前...

先赞一个!!!

其次,上面的代码里面的 required,直接用 new_record?的返回值就好了

<%= f.image_file_field :image, required: @product.new_record?, class: 'form-control' %>

第三是个疑问:自定义了这些方法之后,实际上在项目里面不同的页面上的布局还是不一样的。那样的话就需要把布局那部分的col-sm-10分离到方法的参数里面,而这样的情况应该还会有很多。那如果我们在项目中不断的增加需要的参数之后,最终的结果会不会还是变成了 simple_form 或者bootstrap_form这样的东西呢?

哇⊙ω⊙,厉害的不行……回去搞一搞……

#6 楼 @blueplanet 有道理,不过我觉得不会变成 simple_form 类似的东西吧,哈哈,我的页面都基本一样,不喜欢太复杂,如果要搞得太复杂,也不会用这个了,见仁见智,上面的只是参考,还是要个人灵活发挥的,改成自己需要的就好了,其实如果有必要,col-sm-10分离一下也可以的,不过我是不需要的,就看你的需求,我觉得不要整得太复杂

这些都是属于 view 方面的改动,为啥不直接在 lib 中建立一个 view 文件夹?存放关于 form_builder 拓展的,application helper 中的不变。

赞,另外有个地方在 Rails5 中需要注意一下:autoload_paths 改为 eager_load_paths

config.autoload_paths << Rails.root.join('app', 'form_builders')

Rails 5 disables autoloading after booting the app in production

很不错的资料,只是我在想,Rails 的 view 层,如果和现在的纯前端 mvvm 比起来,还是有很多不方便,其实我想说采用前后分类是一个 中大型项目最好的架构方式和开发方式。所以对 Rails view 这种方式在战略层面已经是昨日黄花,没有必要化大力气学习。

既然说到这里了,推荐一个我写的 gem,已在生产环境中大量使用,一直想放出了,只是没来得及写文档。 链接地址

<%= default_form_for [:work, @company] do |f| %>
  <%= f.text_field :name, label: 'Company Name' %>
  <%= f.collection_select :country, @countries, :nation, :nation, { label: 'Country', prompt: 'Select Country' } %>
  <%= f.select :company_type, options_for_select(Company.options_i18n(:company_type), @company.company_type), { label: 'Company Type', prompt: 'Company Type' } %>
  <%= f.select :payment_method, options_for_select(Company.options_i18n(:payment_method), @company.payment_method), { label: 'Payment Method', prompt: 'Payment method' } %>
  <%= f.fields_for :contacts do |cf| %>
    <%= cf.email_field :email, label: 'Contact Email' %>
  <% end %>
  <%= f.text_area :comment, label: 'Comment'  %>
  <%= f.submit %>
<% end %>

我们代码里都很简洁,如果不用这个 gem,要实现图的效果,代码量至少要乘以 3

效果图:

最重要的,之前怎么写的 form_builder 和 text_filed 等,用了这个 gem 也就怎么写就行了,我的思路只是加上默认的参数。学习成本极低。

13 Floor has deleted

阁下装逼能力越来越炉火纯青

受教了,感谢分享。

谢谢分享。我就说嘛,Rails 怎么可能一个 form 验证还需要用到第三方 gem

#12 楼 @mingyuan0715 请问「simple_form 太难定制」具体是指什么能说说吗?我的项目正在用 simple_form ……

#18 楼 @FrankFang 准确的说是因为 simple_form 独立写了套自己的 Form Builder,跟 Rails 自带的 form builder 不兼容。而大部分是熟悉 Rails 自带的这套的,所以基于自己掌握的东西去定制一个东西更容易吧。

想大佬下跪。

Marc 在此😄

fuck 貌似这个按钮很眼熟

@hfpp2012 嗯,也对。 如果这个定制只是针对一个项目的话,基本上 col-sm-10 之类的应该都是类似的,确实不需要 simple_form 之类的了。 👍

厉害,虽然知道可以这么做,但是从来没有这么搞过

有一个疑问,添加了多语言,label 就会自动选择当前环境的语言了吗?我添加了多语言,但还是显示的英文,是要遵循什么格式吗?

zh-CN:
  activerecord:
    attributes:
      customer:
        phone: '手机'
        name: '姓名'
        province: '省份'
        province_code: '省份'
        city: '城市'
        city_code: '城市'
        street: '县或区'
        street_code: '县或区'
        score: '积分'
    models:
      customer: '客户'

类似这样,写在config/locales/zh-CN.yml文件中,把你的字段翻译一下。

Reply to hfpp2012

谢谢您对我的回复,这样写的确可以显示了,之前我自己写的,没按照这个层级来写,结果没显示出来。多谢

学习了

大白兔受教了,阁下 ML 功力匪浅,在下佩服。😈 😈 😈

可以可以 没想到在两年前还有这种精品教程 受教了😀

You need to Sign in before reply, if you don't have an account, please Sign up first.