使用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组件的资料请关注脚本之家其它相关文章!
