背景
综合某些业务的使用场景,理想的交互方式是类似于下面这样的。
需求整理
- 直接呈现可操作可选择的级联选择器,而非
antd类似于表单中的选择器一样。 - 高度自定义内容
- 不同
tabs下的级联选择联动 - 自定义节点渲染
- 子节点自定义合并
- 不同
思路梳理
由于没有合适拿来即用的组件来实现,起初设想是在antd的基础上,通过一些css和js简单的二次封装一下来使用。
然而...
项目中的antd版本的级联选择器不支持可选项。
那就只能自己搞一个了。捋一下思路,其实也没那么难
最核心的逻辑:某个节点状态checked|unchecked发生变更后引起其他状态的变化
- 对于当前节点所有子节点的状态变更
- 对当前节点的所有祖先节点的变更
具体实现
Props
为了我们能通过表单元素方便的包裹起来,我们沿用value+onChange的参数,此外还需要如下一些参数。
interface NodeItemProps {
/* 标签名称 */
label: string
value: string
/* 子节点 */
children?: Array<NodeItemProps>
/* 层级 */
levelType: number
/* 父节点值 */
parentValue: string
render?: (...arg: any) => ReactElement
}
interface IProps {
/* 数据源 */
treeData: Array<NodeItemProps>
/* 初始值 */
value?: Array<string>
onChange?: (list: Array<any>) => void
disableKeys?: Array<string>
defaultExpend?:boolean
}
层级展示
级联选择器的数据源和树🌲形组件的数据源是如出一辙的,只不过不同于树的交互和呈现:树的任意节点都可以进行折叠、收缩、选中等操作,而级联选择器的只呈现了当前展开节点的数据。因此我们只需要维护一个数据,用来存放当前展开的key就行。
比如上述状态,第一列是始终存在的,点击第一列数据(0105)的的时候,在我们维护的
expendKeys中更新点击的值[0105],点击010501时,更新包含自身010501的祖先节点[0105,010501]以此类推
即 当我们点击某个节点时,需要把当前节点及当前节点的所有祖先节点更新。
那我们在渲染的时候,就对着节点进行遍历,渲染出节点下的childen即可。
<Row type='flex'>
<Col>
<div>{treeData.map((node: NodeItemProps) => renderOneNode(node))}</div>
</Col>
{expendKeys.map((val, parentIndex) => {
const list = treeDataMap.get(val)?.children || []
return (
<Col key={val}>
<div>
{list.map((node: NodeItemProps) => renderOneNode(node))}
</div>
</Col>
)
})}
</Row>
节点选中的逻辑
其中比较比较繁琐的是当前节点选中后对于祖先节点的影响。
eg:
在上述场景下,选中了01040201后=》当前兄弟节点全部选中=》父节点选中,父节点选中后=》父节点的所有兄弟节点选中=〉父节点的父节点选中...
因此这里需要拿到该节点的所有祖先节点,然后对祖先节点进行遍历,来获取需要选中的父节点。
const parentKeys = getAllParent(node, treeData)
const appendParentKeys = []
/* 对当前节点选中后,所有父节点的状态是否选中进行判断,得到需要添加的父节点的key */
parentKeys.forEach(item => {
const currentNode = treeDataMap.get(item)
const sublingKeys = currentNode?.children?.map(v => v.value)
const isParentCheck = sublingKeys?.every(v => [...value, item, ...appendParentKeys].includes(v))
isParentCheck && appendParentKeys.push(item)
})
节点半选中状态
所有的checkbox是受控的,即选中、非选中、 半选中、不可选等状态均是通过计算来设置的。
半选中的状态也比较好算
- 子节点半选中,组件节点必定半选中
- 子节点未没有全选中,也没有一个没选中。人话:选了一部分
const getIndeterminate = (node: NodeItemProps, keys: Array<string>): boolean => {
/* 子节点半选中,祖先节点也半选中 */
if (isEmpty(node.children)) return false
const isIncludeParent = keys.find(item => {
return node.value !== item && item.startsWith(node.value)
})
if (isIncludeParent) return true
const checkList = node.children?.filter((item: NodeItemProps) => {
return keys.includes(item.value)
})
const unCheckList = node.children?.filter((item: NodeItemProps) => {
return !keys.includes(item.value)
})
return !isEmpty(checkList) && !isEmpty(unCheckList)
}
// 获取节点状态
const getStatusByNode = useCallback(
(node: NodeItemProps): NodeStatusProps => {
const checked = allKeys.includes(node.value)
const indeterminate = getIndeterminate(node, value)
const disabled = disableKeys.includes(node.value)
return {
checked,
indeterminate,
disabled,
}
},
[value, disableKeys, allKeys]
)
其他
前面有提到过,当前项目中的antd版本不支持选中的功能。那我们结合Popover和自定义级联选择器,也可以实现类似于antd中的Cascader组件。
实现思路也比较简单包装一层Popover在层级选择器状态变化的时候,来处理显示就好了。
<div id='cascaderForm' className={cls(Style.container)}>
<Popover content={<DefCascader treeData={treeData} onChange={onCheckedChange} value={value} />} trigger='click'>
<section className={cls(Style.mockInput)}>
{value.length ? (
value.map((item: string, index: number) => {
if (index > maxCount) {
return null
}
if (index === maxCount) {
return (
<span className={cls(Style.baseTag)} key={item}>
... +{value.length - maxCount}
</span>
)
}
return (
<div className={cls(Style.baseTag)} key={item}>
{item}
<Icon
type='close'
className={cls(Style.closeIcon)}
onClick={e => {
e.stopPropagation()
onDelete(item)
}}
/>
</div>
)
})
) : (
<span className={cls(Style.mockPlaceholder)}>{placeholder}</span>
)}
</section>
</Popover>
</div>
最后
实现的过程中,有一个体会就是:在数据计算比较复杂的场景下,一定要提取出一些工具类纯函数来处理。另外Map的结构也是比较好用的。
比如说一个树的结构
我们可以先flat拍平、在生成Map,那我们就可以很方便的查找节点。
/* 树=>平铺数组 */
@computed
get flatTags() {
return this.allTag.map((item: NodeItemProps) => getNodesFromRoot(item)).flat()
}
/* 标签Map<string,NodeItemProps>,方便查找 */
@computed
get allTagCodeMap() {
return new Map(this.flatTags.map(it => [it.value, it]))
}
/* 层级分组 */
@computed
get groupKeys(){
return lodash.groupBy(nodes, 'levelType')
}
上述组件,虽然没有antd中的那么强大,不过在一些定制程度比较高的交互、呈现场景下,使用体验还是不错的。