使用 React 封装一个 Tree 树形组件

1,784 阅读6分钟

前言

为什么要造这样一个轮子呢?

最近在学习 next ,想用 next 重构一下自己的博客,而在 自己博客 的编辑页面中有使用到 antd 的一个树形的结构组件来展示文章的分类;

而我的 个人博客 (next版) 使用的是 next-ui ,但是里面并没有 tree 组件,看了下最近很火的 shadcn 也没有类似组件,我也不想为了 tree 又引入 antd 了,就想着自己封装一个玩玩,权当提升技术了(当然了非 next 版)。顺便还能为 我的组件库 添加一员。

当然我是对照 antd 作为模板开发的,但是他的 tree 是没有单独 check 的,当时我的旧版博客中为了实现该需求我可没少费工夫。

线上 Demo

源码

博客编辑页

下面简单展示我在博客编辑页中使用 tree 组件的图片。

旧版 (Antd 的 tree)Next 版 (自己封装的 tree)
image.pngimage.png

码上掘金 demo 体验

实现思路

我这里主要是根据 antdProps 选择一部分,并按照自身需求来增减实现的。

下面我就讲述整个 tree 树形组件的核心部分吧,其他一些属性就不细讲了,感兴趣可以直接看 源码

Html 基本结构

下面是整个组件的基本结构,renderTreeList 函数递归调用渲染 treechildren 节点。

类名 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 值作为 checkTreekey,通过 parentKeychildKeys 来查找该节点的 父子兄弟节点

例:

image.png

初始化树形结构

根据 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

image.png

点击 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是否有选择框booleanfalse
checkedKeys(受控)选中复选框的树节点的key,当不在数组中的父节点需要被选中时,对应节点也将选中,触发 onCheck 回调,使该值保持正确string[]null
defaultExpandAll默认展开所有树节点booleanfalse
multiple支持点选多个节点(节点本身)booleanfalse
singleSelected是否只能单选一个节点booleanfalse
selectable是否可选中booleantrue
selectedKeys(受控)设置选中的树节点,多选需设置 multiple 为 truestring[]"-"
treeData树形结构的数据TreeNode[]--
onCheck点击复选框触发(checkedKeys: string[], params?: OnCheckParams) => void--
onSelect点击树节点触发(selectKeys: string[], params: OnSelectParams) => void--
onRightClick点击右键触发(params: onRightClickParams) => void--
className类名string--
stylestyle样式{}--
childrenchildren节点ReactNode--
ref-TreeInstance--