重构心得
前言
Element Plus 有两个下拉选择器,Select 是真实 Dom 选择器,继承自 Element UI;SelectV2 是虚拟选择器,后来新写的。两个组件在外观和内部实现差异都挺大的。
一开始我想参照新的 UI 设计修改组件样式,但后来发现从样式上修改上不能满足要求,越改越麻烦。非要修改只会到处是补丁和各种 Hard Code,以后也难维护。积重难返,索性重构一下吧,于是我花了一周时间来写代码。元旦三天,我有两天是在写 Element Plus(囧)。
希望这篇心得能给大家带来组件开发的一些启发,也欢迎讨论更加合理的方案。
重构内容
本次重构有近 60 个文件上千行代码的改动,包含组件、样式、测试、文档。下面我会挑选一些值得分享的部分来说,完整重构内容可以查看 Github PR。
外观交互
原先 Select 与 SelectV2 两个组件分别维护了样式文件,它们 class
名称不同,一个是 el-select
,一个是 el-select-v2
。为了让两个组件共用一套样式,首先需要调整组件的 Dom 结构一致。
以前的设计都是通过 line-height
撑起整个组件高度,这会有一些问题。比如文本的行高也会继承该属性,单行模式下还好,如果存在换行,文本之间的行间距会变得很大。而且 line-height
与 vertical-align
用来布局是一套落后的方案,基线对齐也是一个麻烦事。
我在组件设计上参考了最新的 原型设计,使用 flex
布局控制横纵方向的排列,让 min-height
和 padding
撑起组件高度。min-height
决定了组件最小高度,padding
决定了组件上下留白。使用 flex
的 gap
属性控制各元素的间距,代替以前的 margin
方案。这一属性最低兼容 Chrome 84 版本,浏览器兼容版本修改为 last 2 versions
。
Select 组件内部复用了 Input 组件,虽然复用了组件,实际上这是个坑。Select 可以分为左中右三个部分。原先 Input 提供了前缀、输入框、后缀这几个功能,但是 Select 多选模式下的 Tag 标签却没有办法通过 Input 组件显示。以前的做法是把 Tag 容器这一整块内容绝对定位放在 Input 上方,与 Select “融为一体”。下面是示意图,我将容器位置提高,以便看清。
绝对定位存在一些问题,需要手动计算容器的尺寸和位置,当容器撑高时,还需要同步更新 Input 的高度。以前的年代为了兼容旧版浏览器,需要手动调用方法更新 Dom 尺寸。这里贴一段原先计算输入框宽度的代码,下面的各个数字不明所以。猜测 15 是字符的宽度,20 是间距。由于这些数字是写死的,当用户修改字体大小和调整样式时,整个组件就乱了。
// 以上省略
const length = input.value!.value.length * 15 + 20
states.inputLength = props.collapseTags
? Math.min(50, length)
: length
// 以下省略
值得注意的是,SelectV2 的输入框采用的是 HTML 原生输入框,写法几乎一样,还避免了绝对定位,说换就换!Select 内部存在两个输入框,一个组件 Input,一个原生输入框。这两者功能不一样,组件 Input 用来展示外观,比如边框、前缀、后缀、单选模式输入值。原生输入框在多选模式下,接在 Tag 后面做隐藏式输入,如下图。可以看到输入框空间不够的情况下也不会自动换行。
SelectV2 也存在两个输入框,只不过是都是原生的,将单选与多选模式作了区分。我想合理的设计是能够只用一个输入框的,下面是我参考 SelectV2 改动后的设计。
所有的控件,像前缀、标签、输入框、占位符、后缀等,全部放在 wrapper
中,通过 flex 布局来定位。分为 3 块,从左到右是 prefix
、selection
、suffix
。selection
中包括 tag
、input
、placeholder
。当不存在 Tag 时,输入框最大宽度可以占满整个 selection
,当存在 Tag 时,又能紧跟其后。当输入内容过多,不能与 Tag 同行展示时,能够自动换行。此方案可以仅保留一个输入框。这里有个细节,我将输入框高度与 Tag 高度设为一致,在视觉上保持垂直居中,又给换行后的输入框留有适当间距。
想要做到输入框自适应宽度,仅仅使用样式是不能做到的。一般有两种方案:一是用 div
加上 contenteditable
属性,模拟输入框编辑功能。二是在用户看不到的地方设置一个 span
,同步输入框的值,将输入框宽度与 span
保持一致。这里采用第二种方式。监听 Dom 尺寸变化后,使用 getBoundingClientRect
获取 span
宽度赋值给输入框。
有人注意到 placeholder
与 input
并排显示吗?placeholder
原本是输入框的一个占位符属性。这里我将 placeholder
绝对定位,撑满整个 selection
。一是当做输入框的占位符,二是用来显示选中项的文本。
当 Tag 长度超过 selection
宽度时,需要隐藏部分文本,如下图。原本代码中有手动计算,只不过依旧有部分变量写死。这里推荐 VueUse 的 useResizeObserver
方法来做 Dom 监听,其底层调用了 ResizeObserver
API,最低支持 Chrome 64 版本。不过对于使用 Vue3 的场景,浏览器版本已经不是一个限制了。
上图存在一个问题,当末尾 Tag 占满一行后,输入框会自动换行。在 filterable
模式下这是正确的显示,但是非 filterable
模式不需要显示输入框。如果仅仅通过 v-if
或者 v-show
去处理,输入框是隐藏了,但是无法触发 focus
事件了。因为 focus
与 blur
的核心是输入框,输入框不存在了焦点也就不存在了。不过可以另辟蹊径,无非就是让用户看不见输入框,让其脱离文档流也是一种变通。下图中输入框依旧存在,一行 position: absolute;
就能轻松解决问题,不放心再设置一个 opacity: 0;
。
组件的 padding
是纵窄横宽的设计。在多选模式下选中 Tag,视觉上会觉得左侧边距有点大,不够协调。所以判断存在 Tag 以及没有 prefix
的情况下,将 selection
使用负边距整体左移。以下是调整前后的效果图。
除了 Select 本身,下拉菜单的选中样式上也可能看出 Select 与 SelectV2 的区别。SelectV2 选中选项无背景色、单选模式右侧无勾号,字体也小了一点。这些样式的调整倒是最方便的,这里就略过了。
我在 Discussions 讨论区,发现一些小需求也顺便加上。其中自定义 Tag 的呼声比较高。我比较了其他组件库的做法:Ant Design 提供了 tagRender
插槽,返回了一系列内置方法。Arco Design 提供了 tag-props
属性。tagRender
自由度适中,用户可以自定义单个 Tag 内容,tag-props
自由度偏低,只能调整 Tag 属性。我们讨论后决定给 Select 与 SelectV2 提供一个整体的 tag
插槽,所有内容均可自定义。这样自由度很高,开发也很方便,咳咳。这里放一个漂亮的彩虹 Demo。
架构逻辑
从 Vue3 开始,组合式 API(Composition API)是组件开发的首选。在众多新特性中,对组件开发最有利的是组合式函数,一种形似 useXXX()
的方法,一般也称之为钩子函数(Hooks)。从 Element UI 重构到 Element Plus 过程中,彼时还停留在 Vue2 的开发思维上,一些方法都是组件内部的,无法做到通用。随着 Element Plus 版本迭代,一些共用的方法也被提取成 Hooks。
后来写的 SelectV2 基本符合 Hooks 写法,而 Select 一部分逻辑在 Hooks 中,一部分分散在外部。首先要调整符合 Hooks 的写法,将逻辑全部放入 useSelect
中,整个 setup
只做引用和暴露。
setup(props, { emit }) {
const API = useSelect(props, emit)
provide(
selectKey,
... // 其它参数
)
return {
...API,
}
},
接着调整 Select 与 SelectV2 内部一些变量与方法的名称,尽量保持一致,一些逻辑上的不同则各自保留。有一部分参数命名有混淆,比如:inputLength
与 inputWidth
,cachedPlaceholder
与 currentPlaceholder
,isOnComposition
与 isComposing
。还有一些不清楚作用的命名:isSilentBlur
、softFocus
等。其中有一些是有明显差异的,有一部分溯源后发现根本没有引用,原本的作用可能已经淹没在历史的提交记录中了。这一步其实是最劝退的,动一个参数整个组件就不能正常运行了。好在这是一次重构,挂了不要紧,改完再修复吧。
原先 Select 的状态参数 states
没有整体暴露,而是挑选了外部所需的属性,这会造成一个结构上的问题。举个例子,在 Select 内部是通过 states.cachedOptions
获取缓存值,而在外部组件是通过 select.cachedOptions
获取值,中间不会经过 states
。本该与 states
平级的参数现在与 cachedOptions
平级了,以后想返回新的 states
参数还需重新暴露。其次还存在一个响应式问题,states
是通过 reactive
定义的,它的属性却是非响应式的,将 states
作为一个整体暴露是更好的。
整理完参数,移除冗余代码,接着考虑能否复用。原本 Select 与 SelectV2 绑定 focus
与 blur
的方法都是单独写的,需要判断繁琐的触发条件,也不利于维护。好在已经存在一个 useFocusController
方法。只需要绑定目标,以及触发前后的逻辑,将方法返回值绑定到合适的地方就可以了,不得不夸一句 Hooks 真好用。
const { wrapperRef, isFocused, handleFocus, handleBlur } = useFocusController(
inputRef,
{
afterFocus() { ... },
beforeBlur() { ... },
afterBlur() { ... },
}
)
原本组件内部有两个 focus
,一是控制组件整体的聚焦,最直观的就是组件聚焦时边框会变为蓝色。二是输入框的焦点。打个比方,可以在组件边框是蓝色时,输入框不聚焦。输入框聚焦时,组件边框一定是蓝色。这听着有点反直觉,为什么一个组件要搞两个 focus
。从原则上来说,组件的聚焦等同于输入框的聚焦。我参考了 Ant Design 的设计,只保留了一个聚焦状态,可过滤模式下组件聚焦会自动触发输入框聚焦。
对于 Dom 监听,Vueuse 封装了一系列方法。监听 Dom 尺寸,可以使用 useResizeObserver
方法。把相关代码分类排列,从方法名上就能看出各自功能。
useResizeObserver(selectionRef, resetSelectionWidth)
useResizeObserver(calculatorRef, resetCalculatorWidth)
useResizeObserver(menuRef, updateTooltip)
useResizeObserver(tagMenuRef, updateTagTooltip)
单选模式下的空值判断,Select 与 SelectV2 之前是存在差异的。重构后统一空值为 空字符串
、null
、undefined
。对于 空字符串
是否是空值,一直以来争论不休,这里沿用 Element UI 与大部分人的使用习惯认为是空值。更有甚者,有位外国人在 Issue 中建议 null
也是一个可选值。下面还有评论认为 undefined
也是可选值 😓。
我在开发过程中,看到 Github 中有相关的 Bug 就顺便改了,大大小小有好几十个 Issues,这里挑几个说说。
#13999 Select 组件使用 Group Option 并延时加载,分组无法实时更新。看了下代码,原先是在 onMounted
中初始化分组,这就导致后续不能实时更新。需要在 Dom 树变化的时候,同步更新分组。知道原因就好改了, VueUse 提供了监听 Dom 变化的方法 useMutationObserver
,将代码修改如下。
onMounted(() => {
updateChildren()
})
useMutationObserver(groupRef, updateChildren, {
attributes: true,
subtree: true,
childList: true,
})
#14396 当 Options 存在相同 label
时,Select 键盘方向键导航将会异常。看到这个 Bug 时,我已经大概猜到原因了。代码中使用了 label
查找高亮 Option,改为 value
匹配就行了。
不知道以前是否有人遇到过 Select 键盘导航乱跳的问题,这里也顺便讲一下。创建和销毁 Option 的过程与 Dom 渲染的时机有关,检测到变化会立即在 Map 中更新,并不能保证 Map 与 Dom 中的 Options 顺序一致。所以每次 Options 渲染时,都需要从真实 Dom 中获取一份顺序表,用来重排内部数据。SelectV2 不存在这个问题,它没有完整的 Dom,Dom 的更新是由数据控制的。
#14410 这不算一个 Bug,但是在直觉上很像一个 Bug。当使用中文输入法时,在 Select 中输入值,但是不按回车确认,此时点击某个 Option,会发现选中的值和鼠标点击的值不相同。这可能会让人困惑,其实原因在输入框和输入法上。当使用输入法时,输入框的 isComposing
值会变成 true
,表明当前是组合中状态。当你不按回车点击 Option 时,输入框认为你放弃输入了,就清空了输入框。在你点击的一瞬间,下拉菜单做了一次空查询,那自然就让你以为产生了 Bug。
#15323 一个 Maximum recursive updates exceeded
的报错。这是 Vue 3.4 引入的报错,同一时间内更新次数过多。之前筛选 Option 时,Select 会触发一个 shallowRef
, 每一个 Option 内部会通过 watch
监听这个变化,从而更新 Option 的值。也就是如果有 100 个 Option,那么同时会触发 100 次 watch
。其实对于性能来说还行,但是新版 Vue 认为这是不合理的。我在每个 Option 内部定义一个 updateOption
方法,当筛选时,循环调用每一个 updateOption
方法,规避了 Vue 的报错。
调整完代码结构,发现文档上有的 API 竟然是空的。比如 SelectV2 的 automatic-dropdown
,从一开始就没有作用,只留了一个入口。好吧,重构还得顺便改改以前的 Bug。对于已有的正确逻辑代码没有做过多调整。
单元测试
当代码改完,组件也跑通后,重头戏来了,单元测试。果不其然,几乎都失败了。有部分测试是变量名和结构调整导致的,这些很容易改完。还有一些是逻辑的变动,这就涉及到一个问题。是改代码还是改单元测试。优先保证能够平滑升级,让代码能够在旧单元测试中通过。这里会涉及到一些组件更新的逻辑。
比如把组件原先的 watch
方法改为函数调用或者 computed
方法。这些对用户手动操作来说可能看不出区别,但是单元测试时,组件各个部分先后更新的顺序都会影响结果。
调整完代码执行顺序,还有几个小的测试失败,基本上与 focus
与 blur
有关。以前 Select 与 SelectV2 对于焦点的触发逻辑并不一致。当组件内输入框聚焦时,比如键盘的 Tab 触发,如果没有 automatic-dropdown
参数,是不应该打开下拉菜单的。此时可以把测试中的 focus
改为 click
事件,点击后会一定打开下拉菜单。这些属于行为上的区别。
在其他组件如 TreeSelect、TimeSelect 中会引用 Select,此时也需要修改一下调用 Select 的参数。除此以外基本没有问题了。后续把文档整理了一下,给 SelectV2 加上了几个 Select 的 API。
总结
本次重构将在 2.5.0
版本发布,主要是对样式与结构的调整,深层次的逻辑没有特别大的改动。统一了一些变量的名称,复用了部分代码,顺带修复了一部分问题。有些不足值得考虑,比如 Select 与 SelectV2 内部有很多方法一模一样,是否可以抽离成统一的方法,甚至将 Select 与 SelectV2 合并为一个组件。原先未发现的 Bug,此次可能依旧存在。好在重构整理了代码,将来改动也更加方便了。
附注
本来快重构完成了,发现有个大问题。我一开始用 border
来控制 Select 组件的边框颜色,但是其他组件如 Input 是用 box-shadow: 0 0 0 1px $color inset;
控制边框的,这就导致 Form 组件无法控制 Select 校验的边框颜色了。为什么不用 border
而用这么神奇的写法,后来我明白了。假设给定组件最小高度为 32px
,同时设置 border: 1px solid;
, line-height: 32px;
,此时你看组件高度变成了 34px
。因为 border
会改变盒模型,而通过 box-shadow
模拟边框就没有这个问题了。