首发于Ant Design

Ant Design 4.0 的一些杂事儿 - Select 篇

前几篇:

聊完了 Table 和 Form 两个重型组件,我们来继续聊聊看起来不那么重的 Select 组件。它在 Ant Design 4.0 中有哪些变化。如果你读过 《Ant Design 4.0 进行时》,那你应该已经有了大概的印象。当然,即便没有读过也不用担心。之后会为你细细道来 Select 背后的一些故事~

“滚滚的车轮”

如果你关注过底层实现,你会发现 rc-selectrc-tree-select 在选择框部分有大量相似而又有一点点不同的地方。这有一段有趣的历史,当年先有的 Select 组件之后结合了 Tree 做了一个 TreeSelect 组件。由于这两个组件有少部分代码相同,所以 TreeSelect 复制了一部分 Select 的代码,继续开发。但是随着时间推移,两者自分裂之日起,来自社区的 issue 以及人类的忘性。两者新增的一些 API 开始出现了矛盾。

一个典型例子就是 inputValuesearchValue 之争:

最早,Select 和 TreeSelect 对于搜索框内的文字都不支持受控模式。之后社区提了 issue 希望对其进行控制,于是乎 rc-select 就多了一个 inputValue

然后又过了一段时间,社区同学提出 TreeSelect 的搜索框内容在选择一个值后会被清空。这在单选没有问题,但是如果是多选的话,就会遇到需要重复输入搜索内容的情况。嗯嗯,这的确不好。于是我们就在 3.7.0 版本中添加了 autoClearSearchValue

那么,既然 TreeSelect 有了。Select 也应该有啊。于是在 3.10.0 里我们给 Select 也添加了 autoClearSearchValue

你们发现问题了吗?rc-select 把搜索框的值叫做 inputValue 但是自动清除却叫做 autoClearSearchValue。因为两个 API 各自发展最后又合到了一块儿。

于是,我们在之后的版本进行了调整,统一使用 searchValue ,而遗留的 inputValue 在之后的版本会收到 warning 信息。

样式谁为先?

如果你是一个设计师,你会发现 v3 版本的 Select 和 TreeSelect 在单选开启搜索的时候样子有点不太一样:

TreeSelect 搜索框在下面
Select 搜索框在同级

于是我就去做了一下考古。发现在 0.12.x 版本中,Select 的输入框是在弹窗内部的:

而在之后 1.x 版本中,Select 改成了现在的设计,但是 TreeSelect 却没有更新。 于是乎,TreeSelect 随着 0.12.x 的尾巴一直保留了这个设计。

我们在 issue 中对此有所讨论。从 v3 的设计角度来说,下拉框内包含搜索框更容易让用户明白这个输入框是用于搜索而非当前的值。但是在 v4 中,我们经过讨论认为 Select 作为一个更加常用的组件。用户已经培养了使用习惯,搜索框位置已经不再是一个会让人纠结的问题。所以新版设计中,改为 TreeSelect 的搜索框移出于 Select 保持一致。

多米诺骨牌

Select 底层的 rc-select 有着非常古老的历史,追溯到第一次提交是 2015 年 1 月。 当年,React 还是 0.12.x 的版本。大家对生命周期的理解也不像现在拥有大量文章进行介绍。所以一些陈年代码片段因为一直运行良好就保留至今,其中一个困扰大家很久的是一段不知道为什么直接设置 state 的代码。 @Mack 在编写测试的时候,看到它毅然决然写下了一个注释:

对啊,为啥不用 `setState`?

@陈帅等13w人 在重构成 Typescript 的时候,看到它,毅然决然的改成了 setState

事后,我们获得了一个 bug:

https://github.com/ant-design/ant-design/issues/14262

于是, @陈帅等13w人 默默进行了回滚并添加了额外的 comment:

当然,我们在重写代码的时候已经抛开了这些历史包袱。在 v4 版本中,Select 没有这些直接操作 state or DOM or style 的脏代码。转而使用纯粹的生命周期来进行管理,以准备迎接未来的 concurrent 模式。

谁先谁后

在日常维护生活中,我们还收到过一个 issue:

https://github.com/ant-design/ant-design/issues/17630

第一反应估计大家都是 onChange 然后清空一下 value,但是正如我们上面所说的。AutoComplete 实际上是一个输入框。所以用户输入的时候是会触发 onChange 的,所以在 AutoComplete 中如果要触发选择应该使用 onSelect 事件。 但是,我们的顺序是先 onSelectonChange 。这导致,如果用户用 onSelect 清空了值,又会被 onChange 给赋值回来。于是,我们做了调整,onChange 变成了最先触发的。小伙伴本终于可以轻松的通过其他事件来修改 value 啦~

原生的不香吗?

原生的 select 组件简单好用。不过如果你想要实现一些额外的效果,它却又不尽如人意。我们暂时不提 Option 不能接受 ReactNode 这类问题。先来一个简单的例子,如果你为原生的 select 设置一个空值,你会期望页面中是一个空选项:

<select value="">
  <option>one</option>
</select>

而实际上却是:

更麻烦的是,原生的 select 由于会默认设置一个值到选项里。所以如果你的 select 只有一个 option 时,你就无法触发 onChange 了。

为此,大部分使用原生 select 组件为了保留空值不得不提供一个补位:

此外,如果你的 select 需要多选,select 会从一行变成多行展示。其对应的 onChange 事件获得的 value 也不是你所预期的。

为此,几乎每一个组件库总是会着手解决 Select 组件的问题。 将其进行提炼、抽象变得简单、好看且“好用”。

value 的同步问题

在我们的日常维护中,一个关于 value 的常见提问就是:“为什么 value 不在 options 中却可以展示。这是一个 bug!”

的确,它的行为和原生的 select 不太一样,但是这其实是有意为之。从交互角度看,如果你给一个组件赋值。那么无论是用户还是开发者,都会存在一个预期。即:

  • 用户:我看到了 Select 的值为 a,我不需要打开这个 Select 看一圈 options ,我也知道它的值是 a
  • 开发者:我为这个 Select 设置了值为 a,那么我应该对我赋的值负责。组件不应该在我的预期外修改这个值。

这是一个很有趣的问题,试想一下有个 Select ,它的 options 是异步加载的。那么在其 value 加载完成且 options 还没有准备好的时候。你预期 Select 的值应该是什么?很显然,如果是没有值,那么受控的 value 就和展示的值不同步了。更有甚者,如果你的 options 是依赖于另一个控件来动态加载。那么你的 Select 值就会反复处于 value => null => value 的状态。对于用户而言,它处于即是有值又是无值的状态。 即便我并不是想设置这个 Select,我只是打开这个页面看看配置而已。

所以,无论 options 是什么状态。Select 的展示值与其内部值都应该跟随 value 受控。

combobox 是不是一个 Select?

在 v3 初期,我们的 Select 提供了一个 combobox 模式,你可以理解为现在的 AutoComplete 组件。combobox 模式下,Select 相当于一个 Input 组件,只是它提供了一个自动完成的下拉选项卡,其输入框内的值就是 value

但是,这也正是让人困扰的地方。开发者很难分清 combobox 模式到底和其他的模式有什么区别:

于是,我们不得不反复告知 combobox 设计上的区别:

然后收获了

为此,我们最终决定将 combobox 从 Select 中抽离出来。转成一个新的组件 AutoComplete。它虽然在内部与 Select 共享了大量代码,但是仍然毅然决然的与 Select 分道扬镳以降低开发者的理解成本。

杂谈:我们推荐你可以看一下今年 SEE Conf 中 @林外 关于 《Ant Design 4.0:创造快乐工作》的演讲。Ant Design 对于组件设计会保持克制,因而我们会从设计与实现的角度共同去探索一个组件所代表的意义。也正是于此,AutoComplete 应该与 Select 进行拆分以达到开发者更好的开发体验。减少困扰就是减少工作量,减少工作量就是早点下班~

选择生成器

上面我们已经描述过了 TreeSelect 和 Select 在选择框部分非常相似,而 API 更是几乎 70% 都相同。在解决了布局样式统一的问题后,我们终于可以将这一部分进行提炼。

在新版的 rc-select 中,我们抽取了一个 generate 方法。它主要接收一个 OptionList 的自定义组件用于渲染下拉框部分。这样我们就可以直接复用选择框部分的代码,而自定义 Select 和 TreeSelect 对应的列表或者树形结构了。

键盘的小把戏

为了让 Selct 更贴近原生组件,我们需要考虑到一些键盘交互的问题。它应该有且唯一有一个能获取焦点的元素来进行交互处理,否则就会遇到 Tab 到 Select 时需要多次 Tab 才能切换到下一个组件的问题。也会遇到 v3 时期,DatePicker 中 onFocusonBlur 多次触发的问题:

因而在设计之初,我们给 Select 预留了一个全组件唯一的焦点入口 input。这个 input 在设置 showSearch 的时候,是那个搜索框;在未设置的时候,是一个 opacity: 0 的幽灵元素;在禁用时,它只需要一个 disabled 就可以略过键盘交互。 从而我们将一些组合的键盘操作简化为了对 input 的操作,我们不再需要面对多个焦点组件之间进行焦点的切换。

v3 中打开面板按 ESC 会导致焦点无法还原的问题

此外,我们还对键盘交互进行了一些细节调整。

比如在 v3 版本中,通过 tab 选中 Select 后必须通过 SpaceEnter 打开下拉框才能进行输入过滤。在 v4 版本中,利用 input 总是存在的特性。获取焦点的 Select 可以直接输入触发过滤,减少了用户额外的操作步骤。

又如 v3 中,多选模式下长按删除键会出现把已经选中的值误删的情况。在新版中,我们为此添加了一个额外的状态来确定你是否正在输入。如果是输入状态,删除至最左时不会触发删除选中值的操作。当停止输入一段时间后,释放该状态以允许用户删除:

v3 的手抖误删
v4 的输入检测防止误删


同样的,我们将这套规则也应用于 Tree 组件上。v4 版本的 Tree 也拥有一个隐藏且唯一的焦点组件用于处理键盘交互。更 Cool 的是,由于这个焦点组件是无法被鼠标点击的。从而我们可以精确的知道,其交互是来自于用户点击还是键盘聚焦:

Tree 拥有键盘 only 的交互样式


看到这里,你或许会想到一个问题。既然下拉内容是自定义的,那么如何确定自定义内容支持哪些操作呢?其中的奥秘在于,我们规定的 OptionList 需要提供一个键盘交互接口。在上层 Select 消费完毕后仍有未处理的键盘事件则会通过该接口传递给 OptionList。也因此,我们可以完全复用 rc-tree 组件自身已经完成的键盘交互。

差不多了~

是的,又到了结尾的时候。这次文章本来还想写一写 Tree 更多的故事,不过只是作为一部分的话又略为可惜。其中 Ant Design 支持动画的虚拟滚动技术可以额外写一篇大文章,让大家理解我们为了一些交互细节额外做了什么事情。而它和 Tree 也非常有关系。所以,这里就不做重复功。下次有机会我们再细聊吧。

PS:虽然这里不提虚拟滚动。但是 Select 也已经接入了虚拟滚动,你可以通过这个例子体验一下 v3 与 v4 版本的性能差异。

编辑于 01-20

文章被以下专栏收录