现在市面的组件库,我们用的越发熟练,越发爆嗨,只需cv就能完成需求,为何不爆嗨!!!
常用库有 element、antd、iView、antd pro
,这些组件库都是在以往的需求开发当中进行总结提炼,一次次符合我们的需求,只要有文档,只要有示例,页面开发都是小问题,对吧,各位优秀开发前端工程师。
接下来,根据开发需求,一步步完成一个组件的开发,当然可能自己的思路,并不是最好的,欢迎大家留言讨论,一起进步
。
需求:
- 完成一个树形的数据左右动态筛选,就是我们组件当中的
穿梭框
效果展示
左右侧 进行数据联动
右侧数据 单点操作 删除
右侧数据 一键清空
左右侧 支持搜索
左侧 支持一键全选
功能点划分
- 左侧数据单点操作
- 右侧数据单点操作
- 左侧数据 一键全选-反选-取消全选
- 右侧数据一键清空
- 左右侧支持搜索
使用到的组件(Antd Design组件库~)
- Modal
- Tree
- Input
- Checkbox
- Button
树形穿梭框组件区域划分
- 头部标题
- 头部提示语
- 核心内容区
-
- 核心区域头部功能按钮
-
- 搜索区域
-
- 左边所有内容项
-
- 待选内容项
树形穿梭框最终支持配置项
支持配置项
interface TreeTransferModalProps {
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
modalTitle?: string | React.ReactNode // 标题
modalWidth?: number // 宽度
modalHeadContent?: React.ReactNode // 头部内容
leftHeadContent?: React.ReactNode | string // 内容区域-左侧头部
rightHeadContent?: React.ReactNode | string // 内容区域-右侧头部
modalBodyStyle?: any // modal body样式
searchPlaceholder?: string // 搜索框提示语
modalOk?: (val) => void // 外部确认函数
isReturnTileData?: boolean // 是否需要返回平铺数据
}
组件布局
- 这里使用了Tailwind CSS
组件布局
<Modal
title={modalTitle}
centered
width={modalWidth}
open={open}
onOk={handleOk}
confirmLoading={confirmLoading}
onCancel={handleCancel}
destroyOnClose
bodyStyle={{ height: '600px', ...modalBodyStyle }}
>
<div>
// 头部内容-提示语
<div>{modalHeadContent}</div>
<div className="content-box flex">
// 核心区域-左侧
<div className="content-left-box flex-1">
<div className="content-left-head">
// 核心区域-左侧-一键操作功能区
<div>
<Checkbox onChange={e => onCheckBoxChange([], e)} checked={checkAll} indeterminate={indeterminate}>
全选
</Checkbox>
</div>
// 核心区域-左侧-头部 -- 业务场景 可以是 外部注入一下数据操作 例如 单选组
// 每切换一个 更换注入的数据原 重新渲染
<div>{leftHeadContent || ''}</div>
</div>
// 核心区域 左侧-内容区域
<div className="content-left-main">
// 搜索区域
{childSearchRender({
treeData: leftTreeData,
dataKeyList: dataList,
setExpandedKeyFn: setExpandedKeys,
setAutoExpandParentFn: setAutoExpandParent,
})}
// 核心区域-左侧树
<Tree
onExpand={onExpand}
expandedKeys={expandedKeys}
autoExpandParent={autoExpandParent}
checkable
switcherIcon={<DownOutlined />}
showIcon
onCheck={onCheck}
checkedKeys={checkedKeys}
treeData={leftTreeData}
blockNode
/>
</div>
</div>
<div className="content-right-box flex-1">
// 右侧
<div className="content-right-head flex">
// 右侧头部
<div className="flex-1">{rightHeadContent || ''}</div>
// 右侧-一键操作按钮
<div className="content-right-head-clear" onClick={() => handleRightClearSelectData()}>
清空
</div>
</div>
<div className="content-right-main">
// 右侧-搜索
{childSearchRender({
treeData: rightTreeData,
dataKeyList: dataRightList,
setExpandedKeyFn: setExpandedRightKeys,
setAutoExpandParentFn: setAutoExpandRightParent,
})}
// 右侧-树
<Tree
onExpand={onExpandRight}
expandedKeys={expandedRightKeys}
autoExpandParent={autoExpandRightParent}
switcherIcon={<DownOutlined />}
showIcon
treeData={rightTreeData}
blockNode
/>
</div>
</div>
</div>
</div>
</Modal>
组件-搜索渲染
搜索渲染
const childSearchRender = (childSearchProps: any) => {
// eslint-disable-next-line react/prop-types
const { treeData, dataKeyList, setExpandedKeyFn, setAutoExpandParentFn } = childSearchProps
return (
<Search
style={{ marginBottom: 8 }}
placeholder={searchPlaceholder || '请输入'}
onSearch={e => {
onSearch(e, treeData, dataKeyList, setExpandedKeyFn, setAutoExpandParentFn)
}}
allowClear
/>
)
}
组件样式
组件样式
.content-box {
width: 100%;
height: 550px;
border: 1px solid #d9d9d9;
}
.content-left-box {
border-right: 1px solid #d9d9d9;
}
.content-left-head {
padding: 16px 20px;
background: #f5f8fb;
height: 48px;
box-sizing: border-box;
}
.content-right-head {
padding: 16px 20px;
background: #f5f8fb;
height: 48px;
box-sizing: border-box;
&-clear {
color: #f38d29;
cursor: pointer;
}
}
.content-right-box {
}
.content-left-main {
padding: 10px 20px 0 20px;
overflow-y: auto;
box-sizing: border-box;
}
.content-right-main {
padding: 10px 20px 0 20px;
overflow-y: auto;
box-sizing: border-box;
}
.ant-tree-node-content-wrapper {
display: flex;
width: 100%;
}
.ant-tree-title {
flex: 1;
}
.right-head-content {
font-weight: 700;
color: #151e29;
font-size: 14px;
}
组件状态-定义
组件状态-定义
const [confirmLoading, setConfirmLoading] = useState(false)
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([])
const [leftTreeAllKey, setLeftTreeAllKey] = useState<React.Key[]>([])
const [expandedRightKeys, setExpandedRightKeys] = useState<React.Key[]>([])
const [autoExpandParent, setAutoExpandParent] = useState(true)
const [autoExpandRightParent, setAutoExpandRightParent] = useState(true)
const [leftTreeData, setLeftTreeData] = useState([
// 这里只是测试数据 看自己后端数据 需要进行转化~
{
title: 'okok',
titleText: 'okok',
key: '0-0',
icon: textIcon,
children: [
{
title: '0-0-0',
key: '0-0-0',
icon: folderIcon,
children: [{ title: 'ok1', titleText: 'ok1', key: '0-0-0-0', icon: <MehOutlined /> }],
},
{
title: '0-0-1',
key: '0-0-1',
icon: <MehOutlined />,
children: [
{ title: '0-0-1-0', key: '0-0-1-0', titleText: '0-0-1-0' },
{ title: '优程', titleText: '优程', key: '0-0-1-1' },
{ title: '0-0-1-2', key: '0-0-1-2', titleText: '0-0-1-2' },
],
},
{
title: '0-0-2',
titleText: '0-0-2',
key: '0-0-2',
// eslint-disable-next-line react/no-unstable-nested-components
icon: ({ selected }) => (selected ? <FrownFilled /> : <FrownOutlined />),
},
],
},
{
title: 'haha1',
titleText: 'haha1',
key: '0-1',
children: [{ title: '你好', key: '0-1-0-0', titleText: '你好' }],
},
{
title: '0-2',
titleText: '0-2',
key: '0-2',
},
])
const [rightTreeData, setRightTreeData] = useState([])
const [checkedKeys, setCheckedKeys] = useState<React.Key[]>([])
const [dataList, setDataList] = useState([])
const [dataRightList, setDataRightList] = useState([])
const [indeterminate, setIndeterminate] = useState(false)
const [checkAll, setCheckAll] = useState(false)
功能点逐一拆解
左右侧数据进行联动
- 核心:
过 checkKey 状态 进行控制左侧选中数据 右侧追加数据
- 左侧数据支持勾选,` 组件里面配置一下就得
左侧数据支持勾选
<Tree
onExpand={onExpand}
expandedKeys={expandedKeys}
autoExpandParent={autoExpandParent}
checkable // 配置即可能勾选
switcherIcon={<DownOutlined />}
showIcon
onCheck={onCheck}
checkedKeys={checkedKeys}
treeData={leftTreeData}
blockNode
/>
- 勾选以后 进行事件监听啦
勾选事件
const onCheck = (checkedKeysValue: React.Key[], e) => {
// 通过 checkKey 进行控制左侧选中数据 右侧追加数据
setCheckedKeys(checkedKeysValue)
}
- 再通过监听 checkKey 去处理数据
监听
useEffect(() => {
const filterData = filterTreeData(leftTreeData, checkedKeys) // 获取左边选中的数据
addLabelButton(filterData) // 把原本的标题 改造成 文本+删除按钮
setRightTreeData(filterData) // 更新右侧数据
handleAddDataList(filterData, setDataRightList) // 多存一份 改变数据结构 {key, title}用于搜索
setExpandedRightKeys(checkedKeys) // 设置右边树 展开的节点
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [checkedKeys, leftTreeData])
filterTreeData
const filterTreeData = (data, keys) => {
// 据选中的key 进行数据转成一棵树
return data.reduce((filteredData, currentItem) => {
const { children, ...rest } = currentItem
// 如果当前item key 或者是 当前的item 含有children
if (keys.includes(currentItem.key) || children) {
// 进行下一步 有 children 递归 ... 这里就是 一直递归到子节点 返回一个子树
const filteredChildren = filterTreeData(children || [], keys)
// 如果 当前的 item 符合 或者 递归返回的数据存在children 进行数据组装
if (keys.includes(currentItem.key) || filteredChildren.length > 0) {
rest.children = filteredChildren
filteredData.push(rest)
}
}
return filteredData
}, [])
}
addLabelButton
const addLabelButton = nodes => {
const arr = nodes
arr.forEach((node, idx) => {
if (node.children?.length) addLabelButton(node.children)
arr[idx].title = (
<div className="flex justify-between text-right">
<span>{node.title}</span>
<Button type="text" className="text-right" onClick={() => deleteRightData(node.key)} size="small">
<DeleteOutlined />
</Button>
</div>
)
})
return arr
}
handleAddDataList
const handleAddDataList = (searchOriginData, setChangeList) => {
const tempArr = []
const generateList = (data = []) => {
data?.forEach(item => {
const { key, titleText } = item
tempArr.push({ key, titleText: titleText as string })
if (item.children && item.children.length) {
generateList(item.children)
}
})
}
generateList(searchOriginData)
setChangeList(tempArr)
}
右侧数据单点操作
- 刚才我们已经对 标题进行改造 新增删除按钮
- 每个删除·按钮绑定了
deleteRightData
事件
deleteRightData
const deleteRightData = key => {
const list = flattenData(leftTreeData)
const delKeys = Array.from(new Set([...getDelKeysByParentId([key], list), ...getDelKeysByChildren(key, list)]))
// 最终更新选择的key
setCheckedKeys(preArr => {
const curList = preArr.filter(item => !delKeys.includes(item))
return curList
})
}
- 用户点击的时候 也只是能拿到当前的节点key,由于是树结构 所以要考虑它所处在的节点父子级关系
- 这里是分开操作 找父级 找子级 独立出来
- 往父级递归得到要删除的所有key
- PS:
这里没有考虑当前删除的节点的兄弟节点 一股脑把父节点干掉 是因为在前面 选择key的时候 已经重新构造树数据,所以无需考虑
getDelKeysByParentId
// 往父级递归得到要删除的所有key
const getDelKeysByParentId = (initialKeys, list) => {
const delKeys = [...initialKeys]
const stack = [...delKeys]
while (stack.length > 0) {
// 把删除的key数据最后一个丢出来
const key = stack.pop()
// 去平铺数组当中找 满足条件的树节点
const delRow = list.find(item => item.folderId === key)
// 如果找到的树节点 存在父级
if (delRow?.parentId) {
// 也把它父级找出来
const parentRow = list.find(item => item.folderId === delRow.parentId)
if (parentRow) {
const parentKey = parentRow.folderId
// 只要父节点不存在于 删除的key push进去
if (!delKeys.includes(parentKey)) {
stack.push(parentKey)
delKeys.push(parentKey)
}
}
}
}
return Array.from(new Set(delKeys))
}
- 往子级children递归得到要删除的所有key
getDelKeysByChildren
// 往子级children递归得到要删除的所有key
const getDelKeysByChildren = (key, list, childList = []) => {
let delKeys = [...childList, key]
// 找到符合条件的 节点
const delRow = list.find(item => item.key === key)
// 如果有children 就一直递归到最后一个叶子节点
if (delRow.children?.length) {
delRow.children.forEach(item => {
delKeys = delKeys.concat(getDelKeysByChildren(item.key, list, delKeys))
})
}
// 去重
return Array.from(new Set(delKeys))
}
右侧数据一键清空
- 全部恢复初始值即可
暴力一键清空
// 清空选择的内容
const handleRightClearSelectData = () => {
setCheckedKeys([])
setRightTreeData([])
setExpandedRightKeys([])
}
左右侧支持搜索
onSearch
// 搜索逻辑
const onSearch = (value: string, originTree, dataOriginList, setExpand, setAutoExpand) => {
// 没有搜索值 不展开
if (!value) {
setExpand([])
setAutoExpand(false)
return ''
}
const newExpandedKeys = dataOriginList // 已经经过处理的平铺 {titleText: string, key: string}
.map(item => {
// 如果文本符合 找找其父级
if (item.titleText?.indexOf(value) > -1) {
return getParentKey(item.key, originTree)
}
return null
})
.filter((item, i, self) => item && self.indexOf(item) === i) // 去重&&过滤掉null
setExpand(newExpandedKeys as React.Key[]) // 更新展开key
setAutoExpand(true) // 开启自动展开
return ''
}
getParentKey
const getParentKey = (key: React.Key, tree: DataNode[]): React.Key => {
let parentKey: React.Key
// 遍历整个树
for (let i = 0; i < tree.length; i++) {
const node = tree[i]
// 如果某个树 存在孩子
// 不存在孩子 不做判断 是因为他是一级节点 无需展开处理
if (node.children) {
// 看看孩子是否包含 search 传下来的key
if (node.children.some(item => item.key === key)) {
parentKey = node.key
// 看看 孩子里面的孩子
// 外面是平铺处理 即使返回的一级/二级树 等到下一次 遍历的时候 也能找到 一级作为父级展开喔
} else if (getParentKey(key, node.children)) {
parentKey = getParentKey(key, node.children)
}
}
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return parentKey
}
左侧数据 一键全选-反选-取消全选
- 核心:就是当selectKey变化的时候 我们去做判断
- 有两种方式 改变selectKey
- 直接操作核心区域选项
- 直接操作一键全选按钮
- 那我们直接监听 selectKey 不就完成了嘛
- 在此之前 先把一键全选逻辑补充
onCheckBoxChange
const onCheckBoxChange = (dataArr = [], e = null) => {
const allLen = leftTreeAllKey?.length
const checkLen = e ? checkedKeys?.length : dataArr?.length
// 全选
const isAllSelect = allLen === checkLen
// 半选
const isHalfSelect = allLen > checkLen
// 这个就是 区分用户在哪里操作 selectKey了
// 如果存在这个e 代表在操作 一键全选按钮
// 否则就在操作 选项
if (!e) {
// 如果长度为0 未选态
if (checkLen === 0) {
setCheckAll(false)
setIndeterminate(false)
return ''
}
// 如果为全选 全选态
if (isAllSelect) {
setCheckAll(true)
setIndeterminate(false)
}
// 半选态
if (isHalfSelect) {
setIndeterminate(true)
setCheckAll(false)
}
}
if (e) {
// 反选
if (checkLen === 0) {
setCheckAll(true)
setIndeterminate(false)
setCheckedKeys(leftTreeAllKey)
}
// 取消全选
if (isAllSelect) {
setCheckAll(false)
setIndeterminate(false)
setCheckedKeys([])
}
// 反选
if (isHalfSelect) {
setCheckAll(true)
setIndeterminate(false)
setCheckedKeys(leftTreeAllKey)
}
}
}
结束
都第二篇了,还不留点痕迹,是怕我发现么?