编者按:本文是蚂蚁集团 Ant Design Mobile 工程师卡晨在 SEE Conf 2022 的演讲内容,包括演讲视频及文字内容,欢迎享用。
hi 大家好,我是卡晨,来自蚂蚁集团。在最近的一年里我主要在做 antd-mobile 5.0 版本的开发,这是一个移动端组件库。今天,我们来聊一聊其中的一个组件——选择器 Picker,它的实现思路和技术选型。
这个小分享大概会包括以下几个部分:API 设计、手势和动画、性能、无障碍。
API 设计
让我们从 API 设计开始,先来定义一下 Picker 组件的属性。Picker 是一个数据录入型的组件,所以它需要 value onConfirm 这两个属性来和外界进行数据的交互。然后它还需要一个 columns 属性,用来配置 Picker 中每一列的选项列表。这里我们省略了一些属性,只保留了这几个最关键的。
需要注意的是,columns 属性是一个二维的数组,第一级对应的是选项列,第二级对应的是每一列中的选项列表。
定义好属性之后,我们来拆解一个这个组件的元素构成。首先呢,我们在这里先放一个 Popup 组件,这是 antd-mobile 中的一个基础布局组件,我们可以直接用它来作为弹层的容器。然后我们往里面填充一些内容:我们在头部放两个操作按钮;在主内容区,根据 columns 属性渲染出对应的选项列表,也就是“拨轮”。拨轮的本质就是一个长长的列表,我们会通过 CSS 的 transform: translate 让它进行上下的偏移,并且把溢出的部分隐藏起来,这样看起来就像是一个轮盘在被转动了。最后,我们来增加一个遮罩层,在拨轮的上半部分和下半部分都盖上一层渐变的白色遮罩,这样用户能就看清当前选中的是哪一项了。
由于拨轮这块的逻辑会比较复杂,所以我们可以把它独立拆一个组件出来。这样我们往下的讨论也可以更聚焦一些。
Wheel 只有三个属性:column 选项列表;value 当前选中的值;onSelect 当用户进行选择时会触发的事件。
⼿势 & 动画
接下来呢,我们为它增加一些交互。在用户对 Picker 进行操作时,有两种可能的方式。第一种是直接点击选项,另外一种是通过手势拖拽。为了更好的描述我们如何去实现手势和动画,这里我们把这个问题抽象成一个最简化的版本。这里有一个小方块,我们可以按住鼠标,把它拖拽起来,当我们松开鼠标的时候,小方块会落回原位。
那这里首先呢是需要实现手势的处理,手势的本质就是鼠标或者是手指在 x 轴、y 轴方向上的移动距离。在 React 的生态社区之中,有很多的手势库,我们选择了 use-gesture。我们可以使用它提供的 useDrag hook 来定义一个手势,当触发了拖拽事件时,我们将滑块的 y 轴位移 translateY 设置为当前手势的移动量。接下来,我们需要在拖拽结束的时候,让小球落回原位,这里就涉及到了动画。相信大家提到动画,首先都会想到 CSS transition,因为它实在是太简单直接了。通过这行 CSS 代码,我们可以让滑块的 transform 属性(也就是它的位移)自动的实现动画过渡。但是我们其实只希望它在回落的时候有这个动画效果,而在拖拽中滑块应该是立即跟随鼠标位置的。所以这里我们在手势中增加一个判断,如果用户还正在进行拖拽,那我们给它增加一个 grabbing 的 class name,从而禁用掉 transition。当手势结束时,我们移除掉 grabbing 这个 class name,并且将它的位置设置回原位。这样就回出现一个回落的动画效果。来看一下效果,似乎还是不错的。但是,我们需要再考虑一种边界情况。如果我们在这个滑块正在下落的过程中,再次点击了它,那么我们应该需要实现的是能够在半空中“接住”这个滑块,并且继续拖动它。而事实上现在的效果是,我们一点这个滑块,它就会重新从原点开始被拖拽。这是因为,对于 CSS 动画来说,我们很难获取到它的动画进行到了哪个状态。如果我们希望得到在鼠标按下时滑块当前的位置,那可能就需要通过一些比较特殊的方法来实现了,比如说 getBoundingRect。所以,CSS 动画在某些场景下的确简单好用,但是当我们需要对动画状态做很多的干预和处理的时候,我们很容易会遇到一些它的短板。所以呢,我们需要换一种方案,也就是基于 js 的动画。让我们以 react-spring 为例,它是一个有点像物理引擎的动画库。
首先,我们使用它提供的 useSpring 创建出一个弹簧动画,动画的初始位置是 y 等于 0。然后我们通过 animated.div 这样一个特殊的元素,把刚刚得到的动画量 y 和 slider 的位移量绑定在一起。接下来我们对手势的处理逻辑进行一下调整,当手势结束时,我们把 y 重置为 0。而如果手势还在拖拽中,我们把 y 立即更新为当前的手势偏移量。然后这里需要特别注意的是,我们在手势的配置中增加了一个 from 参数,它表示的是每一次手势开始的时候,初始的偏移量是多少。而大家可以看到,这里我们给它设置的初始偏移量是 y 这个动画变量的当前值。也就是说,我们会让手势从动画当前进行到位置继续,而不是强制它从原点开始。现在,我们就可以拥有完美的手势体验了。基于同样的思路,我们可以给 Picker 也增加上手势和动画效果,当然会更复杂一些,但是思路是相通的。说完手势和动画,我们再来考虑另一个问题。当我们在使用选择器时,很多个拨轮之间可能是有联动关系的。比如省市区的选择。但是就目前的数据结构而言,Picker 组件的多个拨轮之间是彼此独立的。所以我们需要能够在第一列变化时动态调整第二列的选项列表。OK,那我们来改一下 columns 属性,让它允许用户传入函数动态生成 columns。然后我们基于 Picker 的这个能力,封装出一个 CascadePicker。
所以我们现在有了两个组件:一个通用的 Picker 和一个专用于级联场景的 CascadePicker。他们的属性几乎是完全一样的,但是 Picker 的选项是通过 columns 配置的,columns 是一个二维的数组。而 CascadePicker 的选项是通过 options 配置的。和 columns 不同的是,它是一个树形的结构,更准确的说,是多个树形节点组成的数组。
大家可以看到,这两个组件,有 16 个完全相同的属性,却只有一个不同的属性那为什么不把这两个组件合二为一呢?就像这样:
我们可以让 columns 属性同时支持两种类型的数据格式,然后在 Picker 组件中去做判断,看用户当前的数据是不是级联模式,然后用对应的逻辑去进行处理。听起来也不难实现。但是这种方案也存在着一些弊端:首先,左右两种数据结构看起来只是增加了一个可选的 children 属性,但是数据类型已经不一致了,左边是一个两级的数组,而右边是一个一级的数组。如果不特别注意,非常容易搞混和写错。第二点就是 columns 属性已经不再名副其实了,如果说 columns 是一个数组,代表的是很多列选项,那这个数组其中的每一个元素应该是一列选项才对,就像左边这样,第一列是省,第二列是市。而对于右边这种级联的数据结构,数组中的第一个元素是浙江节点,第二个元素是江苏节点,我们能说浙江是一列、江苏是另一列么?显然是不可以的。如果我们给这种结构的数据起个名字,那也应该叫做 tree、treeNodes 或者是 options。第三点,是这样做会导致很多逻辑被塞在 Picker 组件中,而过度的逻辑耦合可能会对组件的后续改动造成一些限制。假如说我们想给 Picker 组件增加一个新属性,那就要同时考虑级联和非级联的两种情况。而如果我们希望做一些内部实现上的优化,复杂的参数结构也可能会带来额外的成本。我们的原则是,可以选择重复,但不应该去选择错误的抽象。优秀的代码结构和 API 设计不应该以组件数量、属性数量来进行评判。如果我们不去谨慎地处理抽象,仅仅是依靠外在的“形似”来做聚合,那反而可能会导致代码越来越难以维护。基于类似的思路,我们还可以制作日期选择器组件。它最核心的部分也是这个 columns 的生成函数。
性能
我们来试一下:感觉还是很流畅的。不过,如果我们把 CPU 的性能做一些限制,让它变慢 6 倍,就会发现有一点点的卡顿,在 Chrome 的性能面板中,我们可以很明显的观察到有几帧丢失了。
那我们来排查一下,这里我们用到了 React dev tools 的 profiler 工具,我们对刚刚这个操作进行一下录制和分析,会发现 Wheel 组件被频繁的渲染了。如果我们把被渲染的组件高亮一下,就会发现,当滑动第三个拨轮的时候,可以看到五个拨轮全部都被重新渲染了,这显然是不符合预期的。解决方案自然是给 Wheel 加个 memo,但是这里直接套一层 memo 是不会有效果的,我们还需要做一些特殊的处理,包括调整 Wheel 的属性、自定义 props 的对比逻辑。因为今天时间有限,这些我们就不具体展开了,感兴趣的朋友可以在 GitHub 上阅读 antd-mobile 的源代码。OK,经过这波优化之后,我们再来测试一下效果,可以看到转动第三个拨轮,只会触发它自己的重新渲染。React Profiler 中也可以看到组件的渲染有明显的减少。丢帧的情况也不存在了。
⽆障碍
大家可能会觉得,现在这个 Picker 组件总该算是做的差不多了吧?很遗憾,并不是。在我们的身边,有很多的失明人士,根据 2018 年的统计,在中国,每 80 个人中就有一位视力残疾患者。他们没有办法清楚地看到屏幕上显示的文字和按钮,那对于他们来说,想要操作手机,就需要借助于屏幕阅读器。大家可以想象出来,一位盲人,该如何使用这个 Picker 组件吗?我们来直接试验一下:相信这个体验不用多说,正常人都没办法忍受,那,又该怎么优化呢?作为开发者,我们大多数人应该都对无障碍这件事情挺头大的,特别是对于 Picker 这种看起来交互就很复杂的组件。无障碍能力本身并不复杂,通过屏幕阅读器,我们能做的操作就是非常简单的上一项、下一项、点击一下当前项,这本身再简单不过了。但是我们的网页是在一个二维平面上呈现的,而屏幕阅读器却只能把它识别为一个一维的线性的信息结构,这之间的差异,才是导致无障碍能力实现困难的根源。我们尝试了很多办法,最终,选择了去为屏幕阅读器单独实现一套交互。
首先,我们让下面的这层拨轮被屏幕阅读器忽略掉,然后我们再往上面叠加一层内容,包括一段文字,表示当前这一列选择了哪个选项,以及两个操作按钮,用来选择上一项和选择下一项。最后,我们把上面这一层内容设置为完全透明,让我们来看一下实际效果。说了这么多,我们已经差不多要结束了,但在最后,我想替整个 antd-mobile 的社区,以及所有 antd-mobile v5 的使用者感谢参与过 v5 版本建设的同学,从 21 年 7 月推送到 GitHub 开始,到年底为止,在这短短半年的时间里,我们收到了来自 50 位社区同学的 325 个 PR , 460 个 commit 和 416 个 issue。不论是参与代码的编写,还是反馈问题或者是参与讨论,这些都对 antd-mobile 的建设都有着非常非常重要的意义,感谢大家对 antd-mobile 的支持。人们常说,“一个人可以走的更快,但一群人可以走的更远”,我想或许这也是对开源精神的最佳诠释吧。如果大家对 antd-mobile 中的 Picker 组件感兴趣的话,欢迎在 GitHub 上翻阅我们的代码仓库。谢谢大家。