使用React封装一个Tree树形组件的实例代码
作者:滑动变滚动的蜗牛
前言
为什么要造这样一个轮子呢?
最近在学习 next
,想用 next
重构一下自己的博客,而在 自己博客 的编辑页面中有使用到 antd
的一个树形的结构组件来展示文章的分类;
而我的 个人博客 (next版) 使用的是 next-ui
,但是里面并没有 tree
组件,看了下最近很火的 shadcn
也没有类似组件,我也不想为了 tree
又引入 antd
了,就想着自己封装一个玩玩,权当提升技术了(当然了非 next
版)。顺便还能为 我的组件库 添加一员。
当然我是对照 antd
作为模板开发的,但是他的 tree
是没有单独 check
的,当时我的旧版博客中为了实现该需求我可没少费工夫。
实现思路
我这里主要是根据 antd
的 Props
选择一部分,并按照自身需求来增减实现的。
下面我就讲述整个 tree
树形组件的核心部分吧,其他一些属性就不细讲了,感兴趣可以直接看 源码 。
Html 基本结构
下面是整个组件的基本结构,renderTreeList
函数递归调用渲染 tree
的 children
节点。
类名 node-content
中的就是节点的内容了,根据需求样式自定义即可。
const Tree = forwardRef<TreeInstance, TreeProps>((props, ref) => { const { checkable, treeData, checkedKeys, defaultExpandAll, multiple, singleSelected, selectable = true, selectedKeys: propsSelectedKeys, onCheck, onSelect, onRightClick, ...ret } = props // ... 省略部分内容,只展示核心结构 // 递归渲染 tree 的列表 const renderTreeList = (list?: TreeNode[]) => { // checkTree 的说明见下面 if(!checkTree) return null return list?.map(item => { const checkItem = checkTree![item.key] return ( <div key={item.key} className={`node`}> <div className={`node-content`}> // checkItem.show 用来判断展开 <div>↓</div> // checkItem.checked 用来处理是否 check <Checkbox /> <div>{item.title}</div> </div> <div className='children'> {renderTreeList(item.children)} </div> </div> ) }) } return ( <div className={`${classPrefix} ${ret.className ?? ''}`} style={ret.style}> {renderTreeList(treeData)} </div> ) })
实现交互的树形结构 (checkTree)
生成一个用于实现交互效果的树形结构 ( checkTree
)
export type CheckTreeItem = { /** 父节点的 key 值 */ parentKey?: string /** 子节点的 key 数组 */ childKeys?: string[] /** 是否展开 */ show: boolean /** 是否选中 */ checked: boolean /** 是否有 checkbox */ checkable?: boolean /** 禁用 checkbox */ disableCheckbox?: boolean /** 禁止整个节点的选择 */ disabled?: boolean } export type CheckTree = Record<string, CheckTreeItem> // ... const [checkTree, setCheckTree] = useState<CheckTree>();
整体是一个只有一层结构的对象,使用每一项数据中唯一的 key
值作为 checkTree
的 key
,通过 parentKey
和 childKeys
来查找该节点的 父子兄弟节点
。
例:
初始化树形结构
根据 generateCheckTree
函数的递归调用,将传入的 treeData
树状结构数据转变为组件需要的 checkTree
。
// ... useEffect(() => { if(!treeData?.length) return const generateCheckTree = (list: TreeNode[], parentKey?: string) => { return list?.reduce((pre, cur) => { // checkedKeys 就是默认传入 check 项,用于默认是否勾选 const curChecked = Boolean(checkedKeys?.includes(cur.key)); pre[cur.key] = { // 默认是否展开该树形结构 show: !!defaultExpandAll, checked: curChecked, parentKey, } // 一些属性的默认值 if(cur.checkable) pre[cur.key].checkable = true if(cur.disableCheckbox) pre[cur.key].disableCheckbox = true if(cur.disabled) pre[cur.key].disabled = true // 有孩子节点就递归调用,生成数据 if(cur.children?.length) { pre[cur.key].childKeys = cur.children.map(c => c.key) const treeChild = generateCheckTree(cur.children, cur.key) pre = {...pre, ...treeChild} } return pre }, {} as CheckTree) } const state = generateCheckTree(treeData) setCheckTree(state) // ... }, [treeData])
大致就是如下图所示,将 treeData
转变为 checkTree
。
点击 check 节点
对应上面 html 结构中的 CheckBox 位置, checkable 等属性就是用来判断是否展示禁用 CheckBox 的。
// ... {(checkable && item.checkable !== false) && ( <CheckBox checked={checkItem.checked} disabled={item.disabled || item.disableCheckbox} // 先忽略,用来判断当前是否有孩子节点被选中了,true 则代表需要展示 checkbox 的半选样式 indeterminate={getIsSomeChildCheck(checkItem, checkTree)} onChange={() => { if(item.disabled || item.disableCheckbox) return onNodeCheck(item.key) }} /> )}
先看 onChange
中触发的回调 onNodeCheck
函数,该函数主要是将 checkItem
中对应该项的 checked
取反一下。
/** 点击选中节点 */ const onNodeCheck = (key: string) => { const checkItem = checkTree![key] const curChecked = !checkItem.checked checkItem.checked = curChecked; // 先忽略,用来判断是否是单选的 if(singleSelected) onSingleCheck(key, curChecked) else onCheckChildAndParent(key, curChecked) setCheckTree({...checkTree}) // 先忽略,用来获取当前 check 的所有 key 值 const keys = getCheckKeys(checkTree!) // check 触发的组件回调 onCheck?.(keys, { key, // 这步判断主要是单选时,选择父节点时只会选中其子节点 checked: keys.includes(key) ? curChecked : false, parentKeys: getParentKeys(key, checkTree!), treeDataItem: getTreeDataItem(key, treeData), }) }
然后通过 onCheckChildAndParent
函数,处理对应的父子节点的选中状态。
子节点:
checkAllChild
递归将当前节点的子节点
全选或全不选。父节点:
checkAllParent
递归处理当前节点的父节点
的选中状态。兄弟节点:只有在单选节点的时候需要,选择同层节点,使
兄弟节点
取消选中
/** 处理父子节点的选中状态 */ const onCheckChildAndParent = (key: string, curChecked: boolean, cTree = checkTree!) => { const checkItem = cTree[key]; // 全选/不选所有子节点 (function checkAllChild(childKeys?: string[]) { childKeys?.forEach(childKey => { cTree[childKey].checked = curChecked checkAllChild(cTree[childKey].childKeys) }) })(checkItem.childKeys); // 处理父节点的选中状态 (function checkAllParent(parentKey?: string) { if(!parentKey) return if(!curChecked) { // 取消所有父节点的选中 cTree[parentKey].checked = false checkAllParent(cTree[parentKey].parentKey) } else { // 将所有子节点被全选的父节点也选中 const isSiblingCheck = !!cTree[parentKey].childKeys?.every(childKey => cTree[childKey].checked) if(isSiblingCheck) { // 判断兄弟节点是否也全被选中 cTree[parentKey].checked = true checkAllParent(cTree[parentKey].parentKey) } } })(checkItem.parentKey); // 同层单选时,使兄弟节点取消选中 if(singleSelected && curChecked) { const keys = cTree[key].parentKey ? cTree[cTree[key].parentKey!].childKeys : firstNodeKeys keys?.forEach(siblingKey => { if(siblingKey !== key) { cTree[siblingKey].checked = false } }) } }
子节点的展开实现
html 结构和 css 简单样式如下,通过 show
属性给 children
节点赋高度,由于定义了 transition
属性,所以当高度变化时,就会触发节点的 展开/收缩
动画。
<div className={`node-children`} // height: fit-content; 无法触发过渡效果,需要准确的值 // 也可通过 maxHeight 设置一个很大的值来解决,但值过大又会使过度效果难看,所以这里需要获取一个准确的高度 style={{maxHeight: checkItem.show ? `${getTreeChildHeight(item.children!)}px` : 0}} > {renderTreeList(item.children)} </div>
.node-children { padding-left: 24px; overflow-y: hidden; transition: max-height 0.3s ease; }
这里有一个点要注意,就是无法直接给子节点定义一个由内容撑开的高度 height: fit-content;
,这样会使 transition
无法正常触发。当然可以通过给一个比较大的 maxHeight
来设置最大高度,这样 transition
就会以 maxHeight
的高度实现动画效果,但是这样当子节点总高度和 maxHeight
出入过大时就会使动画效果很不好看。
所以我这里最终通过 getTreeChildHeight
函数来准确计算孩子节点的总高度了。
首先等待 checkTree
完成构建以及树形结构渲染完成,然后准确获取每个节点的高度,因为每个节点的 title
都是 ReactNode
,所以需要都获取一遍他们的高度。
/** 标题的最小高度 */ const TITLE_MIN_HEIGHT = 24; /** 每个标题的下边距 */ const TITLE_MB = 6; // 等待树形结构渲染完毕,获取 title 的高度 useEffect(() => { if(!checkTree || !isTreeRender.current) return const info: TitleNodeInfo = {}; for(let key in checkTree) { // 每个标题渲染的内容,都要根据 key 给一个唯一的类名。 const titleNode = document.querySelector(`.node-title-${key}`) if(titleNode) { info[key] = {height: Math.max(titleNode.clientHeight, TITLE_MIN_HEIGHT) + TITLE_MB} } } setTitleNodeInfo(info) isTreeRender.current = false }, [checkTree])
此时每个节点的 children 节点的高度,就能通过 getTreeChildHeight
函数递归计算得出了。
const getTreeChildHeight = (list: TreeNode[]) => { return list?.reduce((pre, cur) => { pre += (titleNodeInfo[cur.key]?.height ?? (TITLE_MIN_HEIGHT + TITLE_MB)) if(checkTree![cur.key].show && cur.children?.length) { pre += getTreeChildHeight(cur.children) } return pre }, 0) ?? 0 }
ref 方法
然后我在组件里面实现了一些用于获取 treeData
数据的一些方法,简单来说都是一些递归调用等方法。
属性名 | 描述 | 类型 |
---|---|---|
getCheckTree | 获取当前选中的树形结构 | () => CheckTree | undefined |
getParentKeys | 根据 key 值获取其父节点,从 key 节点的最亲关系开始排列 | (key: string) => string[] | undefined |
getSiblingKeys | 根据 key 值获取其兄弟节点,会包括自身节点 | (key: string) => string[] | undefined |
getChildKeys | 根据 key 值获取其子节点 | (key: string) => string[] | undefined |
getCheckKeys | 获取当前 check 中的所有 key | () => string[] |
getTreeDataItem | 获取当前 treeData 中的节点数据 | (key: string) => TreeNode | undefined |
最终实现的 Props
其他属性的功能实现我就不一一叙述了,感兴趣可以直接看 源码
属性名 | 描述 | 类型 | 默认值 |
---|---|---|---|
checkable | 是否有选择框 | boolean | false |
checkedKeys | (受控)选中复选框的树节点的key,当不在数组中的父节点需要被选中时,对应节点也将选中,触发 onCheck 回调,使该值保持正确 | string[] | null |
defaultExpandAll | 默认展开所有树节点 | boolean | false |
multiple | 支持点选多个节点(节点本身) | boolean | false |
singleSelected | 是否只能单选一个节点 | boolean | false |
selectable | 是否可选中 | boolean | true |
selectedKeys | (受控)设置选中的树节点,多选需设置 multiple 为 true | string[] | "-" |
treeData | 树形结构的数据 | TreeNode[] | -- |
onCheck | 点击复选框触发 | (checkedKeys: string[], params?: OnCheckParams) => void | -- |
onSelect | 点击树节点触发 | (selectKeys: string[], params: OnSelectParams) => void | -- |
onRightClick | 点击右键触发 | (params: onRightClickParams) => void | -- |
className | 类名 | string | -- |
style | style样式 | {} | -- |
children | children节点 | ReactNode | -- |
ref | - | TreeInstance | -- |
以上就是使用React封装一个Tree树形组件的实例代码的详细内容,更多关于React封装Tree组件的资料请关注脚本之家其它相关文章!