用 f.datalist 在 Rails 表单中实现原生自动补全(无需 JavaScript)

Note: f.datalist 将在 Rails 8.2 中登场。它已经合并进 main,但尚未发布。你可以查看这个 pull request、把 Gemfile 指向 main 分支来试用,或者克隆演示应用并运行 bin/rails server。如果不想等,底层的 datalist_tag 从 Rails 8.0 起就已可用;下文会细说。另外也值得一看:has_json 实现类型安全的 JSON 属性,这是 8.2 的又一项新增。


在「我想给这个文本框加上建议」和「我刚刚引入了一个 JavaScript 依赖、一个控制器 action 和一个 Stimulus 控制器」之间,存在一道恼人的鸿沟。浏览器多年前就有了原生方案,也就是 <datalist> 元素,而 Rails 8.2 为此新增了一个 FormBuilder 辅助方法。

先说清楚,这并不是一项新能力。你之前就能用 datalist_tag(Rails 8.0)渲染 datalist,再早还能用纯 HTML。f.datalist 带来的是 ID 的连线,让输入框和它的列表不会脱节。事情虽小,但这是我真的发布到线上过的 bug,而修复只需两行而非三行。

我们来做一个注册表单,其中的国家字段会随着你的输入给出匹配建议。没有 JS 库,没有 fetch,没有客户端状态。

我们要构建什么

一个带有 country 输入框的 Profile 表单,背后是一个原生建议列表。浏览器负责输入提示,而我们始终不离开 form_with 块。

Rails 的这项功能:f.datalist

f.datalist 包装了已有的 datalist_tag,并推导出元素的 ID,从而让输入框和列表保持相连:

# actionview/lib/action_view/helpers/form_options_helper.rb
def datalist(method, choices = nil, html_options = {})
  @template.datalist_tag(field_id(method, "datalist"), choices, html_options)
end

关键就在 field_id(method, "datalist")。只有当输入框的 list 属性与 datalist 的 id 相匹配时,<datalist> 才会生效。下面是不用辅助方法时的同一个表单:

<%# 这个 ID 是一个字面字符串,如今你要在两处一模一样地敲出来 %>
<%= f.text_field :country, list: "profile_country_datalist" %>
<%= datalist_tag "profile_country_datalist", Profile::COUNTRIES %>

它能用,但 ID 是在两处硬编码的字符串。一旦你重命名字段或模型,它们就会脱节,自动补全会悄无声息地失效,连个报错都没有。field_id 在两侧都用字段名构建出同一个 ID,所以它们永远一致。

datalist_tag 从 Rails 8.0 起就在正式发布版里了,所以手动版今天就能用,不需要 edge 版 Rails。f.datalist 只是去掉了维护 ID 的那点琐事。

动手实现

先从一个普通模型开始:

class Profile < ApplicationRecord
  validates :country, presence: true
end

然后是表单。真正重要的是 text_fielddatalist 这两行:

<%= form_with model: @profile do |f| %>
  <div>
    <%= f.label :country %>
    <%= f.text_field :country, list: f.field_id(:country, :datalist) %>
    <%= f.datalist :country, ["Argentina", "Brazil", "Canada", "Chile", "Japan"] %>
  </div>

  <%= f.submit %>
<% end %>

输入框的 list:f.datalist 内部产生的 ID 都是 field_id(:country, :datalist),所以浏览器会把两者配成一对。

它渲染为:

<input list="profile_country_datalist" type="text"
       name="profile[country]" id="profile_country" />
<datalist id="profile_country_datalist">
  <option value="Argentina">Argentina</option>
  <option value="Brazil">Brazil</option>
  <option value="Canada">Canada</option>
  <option value="Chile">Chile</option>
  <option value="Japan">Japan</option>
</datalist>

看它运行起来

在国家字段里输入,浏览器会显示一个经过筛选的下拉列表。输入「C」会收窄到 Canada 和 Chile,输入「Ca」会收窄到 Canada。选中一项即可填入输入框;你也可以无视这个列表随便打字,因为 datalist 是给出建议,而不是限制取值。

没有任何请求打到服务器,也没有运行 JavaScript。筛选是浏览器自己完成的。

这里没有截图,因为下拉列表是由操作系统而非页面绘制的,所以不会出现在截图里。启动演示应用亲眼看看吧。

用数据库填充选项

硬编码的数组用于演示足够了。真正的用处是用你的数据来供给建议。choices 参数接受与 options_for_select 相同的格式,所以查询结果可以直接放进去:

<%= f.text_field :tag, list: f.field_id(:tag, :datalist) %>
<%= f.datalist :tag, Tag.where.not(name: [nil, ""]).distinct.order(:name).pluck(:name) %>

distinct 和对空值的剔除能把重复项和空字符串挡在列表之外。在真实代码里,请把这个查询挪进一个 scope 或模型方法。而且由于该字段就是一个普通的 text_field,编辑已有记录无需任何额外处理:它会以保存的值预先填好,同时依然提供列表。

[label, value] 形式也能用,就像 select 一样:

<%= f.datalist :country_code,
      [["Argentina", "AR"], ["Brazil", "BR"], ["Canada", "CA"]] %>

<datalist> 不是 <select>。它渲染出 <option value="AR">Argentina</option>,而 datalist 会拿你输入的内容去和 value(“AR”)匹配、插入 value、并把 value 作为建议显示。文本(“Argentina”)顶多是个次要提示,有些浏览器还会忽略它。select 里那种「显示名称、提交代码」的行为在这里并不适用,输入「Arg」也找不到「Argentina」。把你希望用户输入的内容放进 value,这通常意味着朴素的数组形式才是你想要的。

更进一步

几个值得探索的方向:

  • 同一表单上的多个列表。 每个 ID 都由各自的方法名推导而来,所以 f.datalist :countryf.datalist :city 永远不会冲突。想加多少都行。
  • 逐项的 HTML。 该格式接受一个末尾的选项哈希,所以 ["Chile", "CL", { disabled: true }] 会渲染出一个被禁用的选项。
  • 共享列表。 若有多个输入框共享同一组取值,可退回到朴素的 datalist_tag('shared_id', choices),并把每个输入框的 list: 都指向 "shared_id"

需要注意的地方

datalist 只给出建议,并不会阻止任何人输入列表之外的内容。最终留在字段里的值,就是抵达你控制器的值。所以仍要在服务端校验:validates :country, presence: true,如果你只接受一个已知集合,再加一个 inclusion 检查。datalist 是为了输入更舒服;正确性依旧由模型来把守。

有个坑要注意:不要给输入框加 autocomplete="off"。为了关掉浏览器自带的自动填充,这是常见的习惯做法,但 Safari 和其他 WebKit 浏览器会把它解读为「没有建议」,从而把 datalist 藏起来。如果列表在 Chrome 里出现、在 Safari 里却不出现,几乎都是这个原因。把 autocomplete 从输入框上去掉,或给它一个真实的取值,列表就会回来。

另一个要注意的:每个选项都会随 HTML 一起发送,并且没有服务端筛选。几百条还好。「全国所有城市」或「全部 4 万名用户」就意味着臃肿的负载,以及每次敲键都迟钝的列表。请把 f.datalist 用于你乐意整批发送的、规模有限的集合。超出这个范围,你就需要一个由端点支撑的真正自动补全了。

下拉列表本身也有局限。你无法为它设置样式,屏幕阅读器的支持因浏览器而异,移动端的表现也因设备而异。对于一个已知的建议集合,这些都不太要紧;但如果你需要样式、完整的无障碍支持或远程筛选,那正是 JavaScript combobox 大显身手的地方。

小结

如今在 Rails 表单里实现原生自动补全只需两行:一个带 list: 属性的 text_field,外加一个会推导出相匹配 ID 的 f.datalist。对于「从已知集合中给出建议」这种常见情形,这就是全部工作。实现细节参见 PR #57318