组件的分割

什么样应该被封装为一个基础组件?什么是业务组件?如何确定组件的props?

基础组件

基础组件主要指那些本身不包含任何业务逻辑、可以被轻松复用的组件,例如 picker、timepicker、toast、dialog、tree 等等。

基于 React 或是 Vue 实现一套通用的基础组件库,打包所有基础组件,可以让开发使用非常方便。而对于基础组件的通讯,基本就是往组件传入 prop 即可,组件内部的状态操作和事件监听在组件内部完成。

如果实在不知道是不是,可以直接进入Ant Design等业内核心组件库进行查询,他们有的就是基础组件。

基础组件考虑到复用性,props需要尽量的丰富,且最好支持一定的样式自定义。这个后端会展开说说。

基础组件中不应该出现注释,且应该放置在项目中的通用目录下,或者使用包管理工具打包管理。

业务组件

业务组件主要指那些包含业务逻辑,也包括一些与后端接口通讯的逻辑。业务组件会包含若干个基础组件,通常我们会把一些业务逻辑的数据通过 类似 Redux 和 Vuex 等统一的状态管理库管理起来,然后组件内部读取数据和提交对数据修改的动作。

业务组件可放置在对应需求目录下。

组件的设计

首先应根据需求来考虑组件,比如需求中的功能需求、样式需求、使用平台等。

举个简单的例子,移动端和PC端的组件交互逻辑并不完全一致,就以选择器为例:

下面是微信使用的选择器:

image-20221119142610822

下面是Ant Design中的B端选择器:

image-20221119142649852

可以看到,由于屏幕分辨率的大小不同以及交互模式的差异,相同功能的组件在设计上有很大的差距。所以说,在编写 / 设计组件前,做好充分的调研是非常重要的。

设计先行,随后才是实现,不要代码都写完了才发现不符合需求所需,前功尽弃。

组件Props的制定

以本次我实现的选择器组件为例,通过参考AntD中完善的api文档和结合本次需求的需要,我保留了以下props

1
2
3
4
5
6
7
8
9
10
11
12
export interface ScrollerSelectOption<K, V> {
key: K
value: V
}
export interface ScrollSelectProps<K, V> {
options: Array<ScrollerSelectOption<K, V>>
itemsShowCount?: number
containerHeight?: number
onChange?: Function
type?: ScrollSelectType
value?: V
}

这里需要提到一个关于命名的最基本的原则:变量命名应为名词或者形容词 + 名词的短语,方法 / 函数命名应为动词或副词 + 动词的短语。

详情可以参考代码整洁之道(1) | 软件工程专业技术分享 (littleblack.cc)

同时考虑到TypeScript的类型约束,可以灵活的使用泛型来编写组件。

1
export function ScrollableSelect<K extends React.Key, V extends React.ReactElement | string | number> (props: ScrollSelectProps<K, V>)

以下内容摘自选择器 Select - Ant Design

参数 说明 类型 默认值 版本
allowClear 支持清除 boolean false
autoClearSearchValue 是否在选中项后清空搜索框,只在 modemultipletags 时有效 boolean true
autoFocus 默认获取焦点 boolean false
bordered 是否有边框 boolean true
clearIcon 自定义的多选框清空图标 ReactNode -
defaultActiveFirstOption 是否默认高亮第一个选项 boolean true
defaultOpen 是否默认展开下拉菜单 boolean -
defaultValue 指定默认选中的条目 string | string[] number | number[] LabeledValue | LabeledValue[] -
disabled 是否禁用 boolean false
popupClassName 下拉菜单的 className 属性 string - 4.23.0
dropdownMatchSelectWidth 下拉菜单和选择器同宽。默认将设置 min-width,当值小于选择框宽度时会被忽略。false 时会关闭虚拟滚动 boolean | number true
dropdownRender 自定义下拉框内容 (originNode: ReactNode) => ReactNode -
dropdownStyle 下拉菜单的 style 属性 CSSProperties -
fieldNames 自定义节点 label、value、options 的字段 object { label: label, value: value, options: options } 4.17.0
filterOption 是否根据输入项进行筛选。当其为一个函数时,会接收 inputValue option 两个参数,当 option 符合筛选条件时,应返回 true,反之则返回 false boolean | function(inputValue, option) true
filterSort 搜索时对筛选结果项的排序函数, 类似Array.sort里的 compareFunction (optionA: Option, optionB: Option) => number - 4.9.0
getPopupContainer 菜单渲染父节点。默认渲染到 body 上,如果你遇到菜单滚动定位问题,试试修改为滚动的区域,并相对其定位。示例 function(triggerNode) () => document.body
labelInValue 是否把每个选项的 label 包装到 value 中,会把 Select 的 value 类型从 string 变为 { value: string, label: ReactNode } 的格式 boolean false
listHeight 设置弹窗滚动高度 number 256
loading 加载中状态 boolean false
maxTagCount 最多显示多少个 tag,响应式模式会对性能产生损耗 number | responsive - responsive: 4.10
maxTagPlaceholder 隐藏 tag 时显示的内容 ReactNode | function(omittedValues) -
maxTagTextLength 最大显示的 tag 文本长度 number -
menuItemSelectedIcon 自定义多选时当前选中的条目图标 ReactNode -
mode 设置 Select 的模式为多选或标签 multiple tags -
notFoundContent 当下拉列表为空时显示的内容 ReactNode Not Found
open 是否展开下拉菜单 boolean -
optionFilterProp 搜索时过滤对应的 option 属性,如设置为 children 表示对内嵌内容进行搜索。若通过 options 属性配置选项内容,建议设置 optionFilterProp="label" 来对内容进行搜索。 string value
optionLabelProp 回填到选择框的 Option 的属性值,默认是 Option 的子元素。比如在子元素需要高亮效果时,此值可以设为 value示例 string children
options 数据化配置选项内容,相比 jsx 定义会获得更好的渲染性能 { label, value }[] -
placeholder 选择框默认文本 string -
placement 选择框弹出的位置 bottomLeft bottomRight topLeft topRight bottomLeft
removeIcon 自定义的多选框清除图标 ReactNode -
searchValue 控制搜索文本 string -
showArrow 是否显示下拉小箭头 boolean 单选为 true,多选为 false
showSearch 配置是否可搜索 boolean 单选为 false,多选为 true
size 选择框大小 large middle small
status 设置校验状态 ‘error’ | ‘warning’ - 4.19.0
suffixIcon 自定义的选择框后缀图标 ReactNode -
tagRender 自定义 tag 内容 render,仅在 modemultipletags 时生效 (props) => ReactNode -
tokenSeparators 自动分词的分隔符,仅在 mode="tags" 时生效 string[] -
value 指定当前选中的条目,多选时为一个数组。(value 数组引用未变化时,Select 不会更新) string | string[] number | number[] LabeledValue | LabeledValue[] -
virtual 设置 false 时关闭虚拟滚动 boolean true 4.1.0
onBlur 失去焦点时回调 function -
onChange 选中 option,或 input 的 value 变化时,调用此函数 function(value, option| Array -
onClear 清除内容时回调 function - 4.6.0
onDeselect 取消选中时调用,参数为选中项的 value (或 key) 值,仅在 multipletags 模式下生效 function(string | number | LabeledValue) -
onDropdownVisibleChange 展开下拉菜单的回调 function(open) -
onFocus 获得焦点时回调 function -
onInputKeyDown 按键按下时回调 function -
onMouseEnter 鼠标移入时回调 function -
onMouseLeave 鼠标移出时回调 function -
onPopupScroll 下拉列表滚动时的回调 function -
onSearch 文本框值变化时回调 function(value: string) -
onSelect 被选中时调用,参数为选中项的 value (或 key) 值 function(string | number | LabeledValue, option: Option) -

考虑到组件使用上不同平台仍有差别,所以需要辩证的参考api文档。

组件的编写

ScrollableSelect——滑动选择器

经过上级的开导和我自己查阅了一些资料,最终确定了如何设计这个ScrollableSelect组件。

核心难点在于:

  1. 样式如何设计?
  2. 如何模拟移动端的交互?(根据触点移动加速度计算列表滑动速度)
  3. 如何让它每次自动吸附在停止状态下最近的item?
  4. 如何实时跟踪当前选中值?
  5. 如何在合适的时机触发onChange?(防抖)

其实在写难点的时候,我已经有过排序,1、2、3均使用CSS解决,4、5则是TS编写的部分。

样式如何设计?

1
2
3
4
5
6
.box {
list-style: none;
overflow: auto;
scroll-snap-stop: y mandatory;
-webkit-overflow-scrolling: touch;
}

最核心的部分便在于以下四条css属性。

首先,通过list-style消除list默认样式,同时通过添加overflow:auto自动生成滑动条。

如何让它每次自动吸附在停止状态下最近的item?

通过添加scroll-snap-stop: y mandatory;属性,控制y方向的自动吸附。

scroll-snap-type - CSS: Cascading Style Sheets | MDN (mozilla.org)

1
mandatory

The visual viewport of this scroll container will rest on a snap point if it isn’t currently scrolled. That means it snaps on that point when the scroll action finished, if possible. If content is added, moved, deleted or resized the scroll offset will be adjusted to maintain the resting on that snap point.

1
proximity

The visual viewport of this scroll container may come to rest on a snap point if it isn’t currently scrolled considering the user agent’s scroll parameters. If content is added, moved, deleted or resized the scroll offset may be adjusted to maintain the resting on that snap point.

如何模拟移动端的交互?

通过添加-webkit-overflow-scrolling: touch,添加滚动回弹效果。

非标准: 该特性是非标准的,请尽量不要在生产环境中使用它!目前能力还不足以写出流畅的回弹滚动,不得已使用,在Edge移动端调试工具中运行正常。

1
auto

使用普通滚动,当手指从触摸屏上移开,滚动会立即停止。

1
touch

使用具有回弹效果的滚动,当手指从触摸屏上移开,内容会继续保持一段时间的滚动效果。继续滚动的速度和持续的时间和滚动手势的强烈程度成正比。同时也会创建一个新的堆栈上下文。

同时,关于列表项的样式,这里还有一个重要的属性:

1
2
3
li {
scroll-snap-align: start;
}

添加该属性使得滑动时以列表项的头部为基准。

1
none

The box does not define a snap position in that axis.

1
start

The start alignment of this box’s scroll snap area, within the scroll container’s snapport is a snap position in this axis.

1
end

The end alignment of this box’s scroll snap area, within the scroll container’s snapport is a snap position in this axis.

1
center

The center alignment of this box’s scroll snap area, within the scroll container’s snapport is a snap position in this axis.

如何实时跟踪当前选中值?

这里主要通过使用useScroll hook来实时跟踪滑动的y方向距离,并通过该距离除以每个列表的高度来计算当前对应列表项。

对于不同高度的列表项,通过累加也可以计算出,但本组件未实现该功能。

新建ref并挂载至useScroll hook。

1
2
const scrollRef = React.useRef<HTMLUListElement>(null)
const scroll = useScroll(scrollRef)

将ref与滑动列表关联

1
2
3
4
<ul className={Styles.box} style={{ height: containerHeight }} ref={scrollRef}>
{renderTopEmptyOptions()}
{renderNormalOptions()}
</ul>

计算得到对应的item

1
2
3
4
5
6
const calculateIndex = () => {
if (scroll?.top !== undefined) {
const calculatedIndex = Math.floor(scroll.top / itemHeight)
onChange?.(calculatedIndex)
}
}

如何在合适的时机触发onChange?

这里就涉及到一个很重要的知识:防抖

事件相应函数在一段时间后才执行,如果在这段事件内再次调用,则重新计算执行时间;当预定的时间内没有再次调用该函数,则执行事件相应函数

看下面我自己不自量力封装的一个useDebouncedEffect源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export function useDebouncedEffect (callback: Function, delay: number, deps?: DependencyList) {
const firstUpdate = React.useRef(true)
React.useEffect(() => {
if (firstUpdate.current) {
firstUpdate.current = false
return
}
const handler = setTimeout(() => {
callback()
}, delay)

return () => {
clearTimeout(handler)
}
},
[delay, deps?.values()],
)
}

export default useDebouncedEffect

其实核心在于利用了useEffect的一个特性,在下一个useEffect被触发时,会自动清理掉上一个useEffect,这样就保证了同时只存在一个计时器。

但是秉着不重复造轮子的原则,还是使用了aHooks中的useDebouncedEffect。

1
2
3
4
5
6
7
useDebounceEffect(calculateIndex, [scroll], { wait: 500 })
const calculateIndex = () => {
if (scroll?.top !== undefined) {
const calculatedIndex = Math.floor(scroll.top / itemHeight)
onChange?.(calculatedIndex)
}
}

至此,核心难题全部解决,剩下的就是一些基本的功能实现。

比如通过value设置初始值:

1
2
3
4
5
React.useEffect(() => {
if (scrollRef?.current) {
scrollRef.current.scroll(0, findIndexByValue() * itemHeight)
}
}, [])