使用React封裝一個(gè)Tree樹形組件的實(shí)例代碼
前言
為什么要造這樣一個(gè)輪子呢?
最近在學(xué)習(xí) next ,想用 next 重構(gòu)一下自己的博客,而在 自己博客 的編輯頁(yè)面中有使用到 antd 的一個(gè)樹形的結(jié)構(gòu)組件來(lái)展示文章的分類;
而我的 個(gè)人博客 (next版) 使用的是 next-ui ,但是里面并沒(méi)有 tree 組件,看了下最近很火的 shadcn 也沒(méi)有類似組件,我也不想為了 tree 又引入 antd 了,就想著自己封裝一個(gè)玩玩,權(quán)當(dāng)提升技術(shù)了(當(dāng)然了非 next 版)。順便還能為 我的組件庫(kù) 添加一員。
當(dāng)然我是對(duì)照 antd 作為模板開發(fā)的,但是他的 tree 是沒(méi)有單獨(dú) check 的,當(dāng)時(shí)我的舊版博客中為了實(shí)現(xiàn)該需求我可沒(méi)少費(fèi)工夫。
實(shí)現(xiàn)思路
我這里主要是根據(jù) antd 的 Props 選擇一部分,并按照自身需求來(lái)增減實(shí)現(xiàn)的。
下面我就講述整個(gè) tree 樹形組件的核心部分吧,其他一些屬性就不細(xì)講了,感興趣可以直接看 源碼 。
Html 基本結(jié)構(gòu)
下面是整個(gè)組件的基本結(jié)構(gòu),renderTreeList 函數(shù)遞歸調(diào)用渲染 tree 的 children 節(jié)點(diǎn)。
類名 node-content 中的就是節(jié)點(diǎn)的內(nèi)容了,根據(jù)需求樣式自定義即可。
const Tree = forwardRef<TreeInstance, TreeProps>((props, ref) => {
const { checkable, treeData, checkedKeys, defaultExpandAll, multiple, singleSelected, selectable = true, selectedKeys: propsSelectedKeys, onCheck, onSelect, onRightClick, ...ret } = props
// ... 省略部分內(nèi)容,只展示核心結(jié)構(gòu)
// 遞歸渲染 tree 的列表
const renderTreeList = (list?: TreeNode[]) => {
// checkTree 的說(shuō)明見下面
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 用來(lái)判斷展開
<div>↓</div>
// checkItem.checked 用來(lái)處理是否 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>
)
})
實(shí)現(xiàn)交互的樹形結(jié)構(gòu) (checkTree)
生成一個(gè)用于實(shí)現(xiàn)交互效果的樹形結(jié)構(gòu) ( checkTree )
export type CheckTreeItem = {
/** 父節(jié)點(diǎn)的 key 值 */
parentKey?: string
/** 子節(jié)點(diǎn)的 key 數(shù)組 */
childKeys?: string[]
/** 是否展開 */
show: boolean
/** 是否選中 */
checked: boolean
/** 是否有 checkbox */
checkable?: boolean
/** 禁用 checkbox */
disableCheckbox?: boolean
/** 禁止整個(gè)節(jié)點(diǎn)的選擇 */
disabled?: boolean
}
export type CheckTree = Record<string, CheckTreeItem>
// ...
const [checkTree, setCheckTree] = useState<CheckTree>();
整體是一個(gè)只有一層結(jié)構(gòu)的對(duì)象,使用每一項(xiàng)數(shù)據(jù)中唯一的 key 值作為 checkTree 的 key,通過(guò) parentKey 和 childKeys 來(lái)查找該節(jié)點(diǎn)的 父子兄弟節(jié)點(diǎn)。
例:

初始化樹形結(jié)構(gòu)
根據(jù) generateCheckTree 函數(shù)的遞歸調(diào)用,將傳入的 treeData 樹狀結(jié)構(gòu)數(shù)據(jù)轉(zhuǎn)變?yōu)榻M件需要的 checkTree。
// ...
useEffect(() => {
if(!treeData?.length) return
const generateCheckTree = (list: TreeNode[], parentKey?: string) => {
return list?.reduce((pre, cur) => {
// checkedKeys 就是默認(rèn)傳入 check 項(xiàng),用于默認(rèn)是否勾選
const curChecked = Boolean(checkedKeys?.includes(cur.key));
pre[cur.key] = {
// 默認(rèn)是否展開該樹形結(jié)構(gòu)
show: !!defaultExpandAll,
checked: curChecked,
parentKey,
}
// 一些屬性的默認(rèn)值
if(cur.checkable) pre[cur.key].checkable = true
if(cur.disableCheckbox) pre[cur.key].disableCheckbox = true
if(cur.disabled) pre[cur.key].disabled = true
// 有孩子節(jié)點(diǎn)就遞歸調(diào)用,生成數(shù)據(jù)
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 轉(zhuǎn)變?yōu)?nbsp;checkTree。

點(diǎn)擊 check 節(jié)點(diǎn)
對(duì)應(yīng)上面 html 結(jié)構(gòu)中的 CheckBox 位置, checkable 等屬性就是用來(lái)判斷是否展示禁用 CheckBox 的。
// ...
{(checkable && item.checkable !== false) && (
<CheckBox
checked={checkItem.checked}
disabled={item.disabled || item.disableCheckbox}
// 先忽略,用來(lái)判斷當(dāng)前是否有孩子節(jié)點(diǎn)被選中了,true 則代表需要展示 checkbox 的半選樣式
indeterminate={getIsSomeChildCheck(checkItem, checkTree)}
onChange={() => {
if(item.disabled || item.disableCheckbox) return
onNodeCheck(item.key)
}}
/>
)}
先看 onChange 中觸發(fā)的回調(diào) onNodeCheck 函數(shù),該函數(shù)主要是將 checkItem 中對(duì)應(yīng)該項(xiàng)的 checked 取反一下。
/** 點(diǎn)擊選中節(jié)點(diǎn) */
const onNodeCheck = (key: string) => {
const checkItem = checkTree![key]
const curChecked = !checkItem.checked
checkItem.checked = curChecked;
// 先忽略,用來(lái)判斷是否是單選的
if(singleSelected) onSingleCheck(key, curChecked)
else onCheckChildAndParent(key, curChecked)
setCheckTree({...checkTree})
// 先忽略,用來(lái)獲取當(dāng)前 check 的所有 key 值
const keys = getCheckKeys(checkTree!)
// check 觸發(fā)的組件回調(diào)
onCheck?.(keys, {
key,
// 這步判斷主要是單選時(shí),選擇父節(jié)點(diǎn)時(shí)只會(huì)選中其子節(jié)點(diǎn)
checked: keys.includes(key) ? curChecked : false,
parentKeys: getParentKeys(key, checkTree!),
treeDataItem: getTreeDataItem(key, treeData),
})
}
然后通過(guò) onCheckChildAndParent 函數(shù),處理對(duì)應(yīng)的父子節(jié)點(diǎn)的選中狀態(tài)。
子節(jié)點(diǎn):
checkAllChild遞歸將當(dāng)前節(jié)點(diǎn)的子節(jié)點(diǎn)全選或全不選。父節(jié)點(diǎn):
checkAllParent遞歸處理當(dāng)前節(jié)點(diǎn)的父節(jié)點(diǎn)的選中狀態(tài)。兄弟節(jié)點(diǎn):只有在單選節(jié)點(diǎn)的時(shí)候需要,選擇同層節(jié)點(diǎn),使
兄弟節(jié)點(diǎn)取消選中
/** 處理父子節(jié)點(diǎn)的選中狀態(tài) */
const onCheckChildAndParent = (key: string, curChecked: boolean, cTree = checkTree!) => {
const checkItem = cTree[key];
// 全選/不選所有子節(jié)點(diǎn)
(function checkAllChild(childKeys?: string[]) {
childKeys?.forEach(childKey => {
cTree[childKey].checked = curChecked
checkAllChild(cTree[childKey].childKeys)
})
})(checkItem.childKeys);
// 處理父節(jié)點(diǎn)的選中狀態(tài)
(function checkAllParent(parentKey?: string) {
if(!parentKey) return
if(!curChecked) { // 取消所有父節(jié)點(diǎn)的選中
cTree[parentKey].checked = false
checkAllParent(cTree[parentKey].parentKey)
} else { // 將所有子節(jié)點(diǎn)被全選的父節(jié)點(diǎn)也選中
const isSiblingCheck = !!cTree[parentKey].childKeys?.every(childKey => cTree[childKey].checked)
if(isSiblingCheck) { // 判斷兄弟節(jié)點(diǎn)是否也全被選中
cTree[parentKey].checked = true
checkAllParent(cTree[parentKey].parentKey)
}
}
})(checkItem.parentKey);
// 同層單選時(shí),使兄弟節(jié)點(diǎn)取消選中
if(singleSelected && curChecked) {
const keys = cTree[key].parentKey ? cTree[cTree[key].parentKey!].childKeys : firstNodeKeys
keys?.forEach(siblingKey => {
if(siblingKey !== key) {
cTree[siblingKey].checked = false
}
})
}
}
子節(jié)點(diǎn)的展開實(shí)現(xiàn)
html 結(jié)構(gòu)和 css 簡(jiǎn)單樣式如下,通過(guò) show 屬性給 children 節(jié)點(diǎn)賦高度,由于定義了 transition 屬性,所以當(dāng)高度變化時(shí),就會(huì)觸發(fā)節(jié)點(diǎn)的 展開/收縮 動(dòng)畫。
<div
className={`node-children`}
// height: fit-content; 無(wú)法觸發(fā)過(guò)渡效果,需要準(zhǔn)確的值
// 也可通過(guò) maxHeight 設(shè)置一個(gè)很大的值來(lái)解決,但值過(guò)大又會(huì)使過(guò)度效果難看,所以這里需要獲取一個(gè)準(zhǔn)確的高度
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;
}
這里有一個(gè)點(diǎn)要注意,就是無(wú)法直接給子節(jié)點(diǎn)定義一個(gè)由內(nèi)容撐開的高度 height: fit-content;,這樣會(huì)使 transition 無(wú)法正常觸發(fā)。當(dāng)然可以通過(guò)給一個(gè)比較大的 maxHeight 來(lái)設(shè)置最大高度,這樣 transition 就會(huì)以 maxHeight 的高度實(shí)現(xiàn)動(dòng)畫效果,但是這樣當(dāng)子節(jié)點(diǎn)總高度和 maxHeight 出入過(guò)大時(shí)就會(huì)使動(dòng)畫效果很不好看。
所以我這里最終通過(guò) getTreeChildHeight 函數(shù)來(lái)準(zhǔn)確計(jì)算孩子節(jié)點(diǎn)的總高度了。
首先等待 checkTree 完成構(gòu)建以及樹形結(jié)構(gòu)渲染完成,然后準(zhǔn)確獲取每個(gè)節(jié)點(diǎn)的高度,因?yàn)槊總€(gè)節(jié)點(diǎn)的 title 都是 ReactNode ,所以需要都獲取一遍他們的高度。
/** 標(biāo)題的最小高度 */
const TITLE_MIN_HEIGHT = 24;
/** 每個(gè)標(biāo)題的下邊距 */
const TITLE_MB = 6;
// 等待樹形結(jié)構(gòu)渲染完畢,獲取 title 的高度
useEffect(() => {
if(!checkTree || !isTreeRender.current) return
const info: TitleNodeInfo = {};
for(let key in checkTree) {
// 每個(gè)標(biāo)題渲染的內(nèi)容,都要根據(jù) key 給一個(gè)唯一的類名。
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])
此時(shí)每個(gè)節(jié)點(diǎn)的 children 節(jié)點(diǎn)的高度,就能通過(guò) getTreeChildHeight 函數(shù)遞歸計(jì)算得出了。
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 方法
然后我在組件里面實(shí)現(xiàn)了一些用于獲取 treeData 數(shù)據(jù)的一些方法,簡(jiǎn)單來(lái)說(shuō)都是一些遞歸調(diào)用等方法。
| 屬性名 | 描述 | 類型 |
|---|---|---|
| getCheckTree | 獲取當(dāng)前選中的樹形結(jié)構(gòu) | () => CheckTree | undefined |
| getParentKeys | 根據(jù) key 值獲取其父節(jié)點(diǎn),從 key 節(jié)點(diǎn)的最親關(guān)系開始排列 | (key: string) => string[] | undefined |
| getSiblingKeys | 根據(jù) key 值獲取其兄弟節(jié)點(diǎn),會(huì)包括自身節(jié)點(diǎn) | (key: string) => string[] | undefined |
| getChildKeys | 根據(jù) key 值獲取其子節(jié)點(diǎn) | (key: string) => string[] | undefined |
| getCheckKeys | 獲取當(dāng)前 check 中的所有 key | () => string[] |
| getTreeDataItem | 獲取當(dāng)前 treeData 中的節(jié)點(diǎn)數(shù)據(jù) | (key: string) => TreeNode | undefined |
最終實(shí)現(xiàn)的 Props
其他屬性的功能實(shí)現(xiàn)我就不一一敘述了,感興趣可以直接看 源碼
| 屬性名 | 描述 | 類型 | 默認(rèn)值 |
|---|---|---|---|
| checkable | 是否有選擇框 | boolean | false |
| checkedKeys | (受控)選中復(fù)選框的樹節(jié)點(diǎn)的key,當(dāng)不在數(shù)組中的父節(jié)點(diǎn)需要被選中時(shí),對(duì)應(yīng)節(jié)點(diǎn)也將選中,觸發(fā) onCheck 回調(diào),使該值保持正確 | string[] | null |
| defaultExpandAll | 默認(rèn)展開所有樹節(jié)點(diǎn) | boolean | false |
| multiple | 支持點(diǎn)選多個(gè)節(jié)點(diǎn)(節(jié)點(diǎn)本身) | boolean | false |
| singleSelected | 是否只能單選一個(gè)節(jié)點(diǎn) | boolean | false |
| selectable | 是否可選中 | boolean | true |
| selectedKeys | (受控)設(shè)置選中的樹節(jié)點(diǎn),多選需設(shè)置 multiple 為 true | string[] | "-" |
| treeData | 樹形結(jié)構(gòu)的數(shù)據(jù) | TreeNode[] | -- |
| onCheck | 點(diǎn)擊復(fù)選框觸發(fā) | (checkedKeys: string[], params?: OnCheckParams) => void | -- |
| onSelect | 點(diǎn)擊樹節(jié)點(diǎn)觸發(fā) | (selectKeys: string[], params: OnSelectParams) => void | -- |
| onRightClick | 點(diǎn)擊右鍵觸發(fā) | (params: onRightClickParams) => void | -- |
| className | 類名 | string | -- |
| style | style樣式 | {} | -- |
| children | children節(jié)點(diǎn) | ReactNode | -- |
| ref | - | TreeInstance | -- |
以上就是使用React封裝一個(gè)Tree樹形組件的實(shí)例代碼的詳細(xì)內(nèi)容,更多關(guān)于React封裝Tree組件的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
react native帶索引的城市列表組件的實(shí)例代碼
本篇文章主要介紹了react-native城市列表組件的實(shí)例代碼,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-08-08
react 項(xiàng)目 中使用 Dllplugin 打包優(yōu)化技巧
在用 Webpack 打包的時(shí)候,對(duì)于一些不經(jīng)常更新的第三方庫(kù),比如 react,lodash,vue 我們希望能和自己的代碼分離開,這篇文章主要介紹了react 項(xiàng)目 中 使用 Dllplugin 打包優(yōu)化,需要的朋友可以參考下2023-01-01
react組件實(shí)例屬性props實(shí)例詳解
這篇文章主要介紹了react組件實(shí)例屬性props,本文結(jié)合實(shí)例代碼給大家簡(jiǎn)單介紹了props使用方法,代碼簡(jiǎn)單易懂,需要的朋友可以參考下2023-01-01
詳解使用React.memo()來(lái)優(yōu)化函數(shù)組件的性能
本文講述了開發(fā)React應(yīng)用時(shí)如何使用shouldComponentUpdate生命周期函數(shù)以及PureComponent去避免類組件進(jìn)行無(wú)用的重渲染,以及如何使用最新的React.memo API去優(yōu)化函數(shù)組件的性能2019-03-03
JavaScript React如何修改默認(rèn)端口號(hào)方法詳解
這篇文章主要介紹了JavaScript React如何修改默認(rèn)端口號(hào)方法詳解,文中通過(guò)步驟圖片解析介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2020-07-07

