react-rc-tree
2023年11月10日
一、认识
Tree
组件的核心思路是将原始的嵌套 children
数据结构平铺成一维数组,然后通过计算每个节点的深度(deep
)、层级关系等信息,在渲染时动态计算缩进宽度、连接线等,从而实现树形结构的可视化。
1.1 Tree 组件如何实现高性能大数据渲染?
-
将原始树形数据平铺为一维数组,便于后续计算
-
计算出实际需要渲染的节点数据,过滤隐藏的节点
-
利用虚拟列表技术只渲染可视区域的数据,实现大数据量的高效渲染
function flattenTreeData(treeData = [], parent = null) {
const nodes = [];
treeData.forEach((node) => {
const newNode = {
...node,
parent,
};
nodes.push(newNode);
if (newNode.children) {
nodes.push(...flattenTreeData(newNode.children, newNode));
}
});
return nodes;
}
1.2 如何计算 Tree 组件中节点的各种状态(展开/折叠、选中等)?
-
展开/折叠状态根据
ExpandedKeys
计算 -
复选框选中状态需要考虑受控/非受控,严格受控模式,及父子节点关联
-
需要递归计算父节点和子节点的状态
-
利用平铺后的索引进行相关节点查询
function flattenTreeData(treeData = [], parent = null) {
const nodes = [];
treeData.forEach((node) => {
const newNode = {
...node,
parent,
};
nodes.push(newNode);
if (newNode.children) {
nodes.push(...flattenTreeData(newNode.children, newNode));
}
});
return nodes;
}
1.3 Tree 组件的交互如何实现?点击节点展开折叠,复选框状态切换等
-
点击展开折叠通过更新节点自身状态、可视状态及
ExpandedKeys
实现 -
点击复选框需要递归更新父子节点的状态,及相关
keys
-
计算并保存实时状态,通过回调函数通知外部
function toggleExpanded(nodes, node) {
return nodes.map((currentNode) => {
if (currentNode === node) {
return {
...currentNode,
expanded: !currentNode.expanded,
};
}
return currentNode;
});
}
// 在渲染时计算缩进:
function renderNode(node) {
const indentLevel = getIndentLevel(node);
const style = {
paddingLeft: `${indentLevel * 16}px`,
};
return (
<div style={style} onClick={() => handleNodeClick(node)}>
{node.label}
</div>
);
}
二、实现
- App.jsx
- Tree.jsx
- NodeList.jsx
- TreeNode.jsx
- Ident.jsx
- Tree.scss
- treeUtil.js
import Tree from './components/Tree/tree';
const treeData = [
{
title: '0-0-title',
key: '001',
children: [
{
title: '0-0-0-title',
key: '002',
children: [
{ title: '0-0-0-0-title', key: '003' },
{ title: '0-0-0-1-title', key: '004' },
{ title: '0-0-0-2-title', key: '005' }
]
},
{
title: '0-0-1-title',
key: '006',
children: [
{ title: '0-0-1-0-title', key: '007' },
{ title: '0-0-1-1-title', key: '008' },
{ title: '0-0-1-2-title', key: '009' }
]
},
{
title: '0-0-2-title',
key: '010'
}
]
},
{
title: '0-1-title',
key: '011',
children: [
{ title: '0-1-0-0', key: '012' },
{ title: '0-1-0-1', key: '013' },
{ title: '0-1-0-2', key: '014' }
]
},
{
title: '0-2-title',
key: '015'
}
];
function App() {
return (
<div>
<Tree treeData={treeData} />
</div>
);
}
export default App;
import './tree.scss';
import NodeList from './nodeList';
import { useEffect, useState } from 'react';
import {
arrAdd,
arrDel,
flattenTreeData,
convertDataToEntities
} from './treeUtil';
function Tree(props) {
const {
treeData,
fieldNames = { key: 'key', title: 'title', children: 'children' }
} = props;
const [keyEntities, setKeyEntities] = useState({});
const [expandedKeys, setExpandedKeys] = useState([]);
const [flattenNodes, setFlattenNodes] = useState([]);
const setExpandedKeysAndFlattenNodes = expandedKeys => {
const flattenNodes = flattenTreeData(treeData, expandedKeys, fieldNames);
setExpandedKeys(expandedKeys);
setFlattenNodes(flattenNodes);
};
const onNodeExpand = (e, treeNode) => {
const { expanded } = treeNode;
const key = treeNode[fieldNames.key];
const targetExpanded = !expanded;
if (targetExpanded) {
setExpandedKeysAndFlattenNodes(arrAdd(expandedKeys, key));
} else {
setExpandedKeysAndFlattenNodes(arrDel(expandedKeys, key));
}
};
const initExpandedKeys = () => {
let expandedKeys = [];
setExpandedKeys(expandedKeys);
return expandedKeys;
};
const initKeyEntities = () => {
const entitiesMap = convertDataToEntities(treeData, { fieldNames });
setKeyEntities(entitiesMap.keyEntities);
return entitiesMap.keyEntities;
};
const initFlattenNodes = expandedKeys => {
const flattenNodes = flattenTreeData(
treeData,
expandedKeys,
fieldNames || {}
);
setFlattenNodes(flattenNodes);
};
const initData = () => {
const keyEntities = initKeyEntities();
const expandedKeys = initExpandedKeys(keyEntities);
initFlattenNodes(expandedKeys);
};
useEffect(() => {
initData();
}, []);
return (
<div className={`tree`}>
<NodeList
data={flattenNodes}
keyEntities={keyEntities}
expandedKeys={expandedKeys}
onNodeExpand={onNodeExpand}
/>
</div>
);
}
export default Tree;
import TreeNode from './treeNode';
import { getKey, getTreeNodeProps } from './treeUtil';
function NodeList(props) {
const { data, keyEntities, expandedKeys, onNodeExpand } = props;
return (
<div className={`tree-node-list`}>
{data.map(treeNode => {
const { key, pos } = treeNode;
const mergedKey = getKey(key, pos);
const treeNodeProps = getTreeNodeProps(mergedKey, {
keyEntities,
expandedKeys
});
return (
<TreeNode
{...treeNode}
{...treeNodeProps}
keyEntities={keyEntities}
onNodeExpand={onNodeExpand}
/>
);
})}
</div>
);
}
export default NodeList;
import './tree.scss';
import Indent from './indent';
import { getEntity, convertNodePropsToEventData } from "./treeUtil"
function TreeNode(props) {
const {
isEnd,
title,
level,
isStart,
eventKey,
expanded,
prefixCls,
keyEntities,
onNodeExpand
} = props;
const { children } = getEntity(keyEntities, eventKey) || {};
const onExpand = (e)=>{
onNodeExpand(e,convertNodePropsToEventData(props));
}
const isHasChildren = () => {
return !!(children || []).length;
};
const isLeafFun = () => {
const hasChildren = isHasChildren();
return !hasChildren;
};
const renderSwitcher = () => {
if (isLeafFun()) {
return (
<span
className={`switcher switcher-noop`}
></span>
);
}
const switcherIconDom = expanded ? '-' : '+';
return (
<span
className={`switcher switcher-${
expanded ? 'open' : 'close'
}`}
onClick={onExpand}
>
{switcherIconDom}
</span>
);
};
const renderCheckbox = () => {
return null;
};
const renderSelector = () => {
return title;
};
return (
<div className={`tree-node`}>
<Indent
isEnd={isEnd}
level={level}
isStart={isStart}
prefixCls={prefixCls}
/>
{renderSwitcher()}
{renderCheckbox()}
{renderSelector()}
</div>
);
}
export default TreeNode;
import './tree.scss';
function Indent(props) {
const list = [];
const { level, isEnd, isStart } = props;
for (let i = 0; i < Number(level); i++) {
let className = '';
if (isStart[i]) {
className += `indent-unit-start`;
}
if (isEnd[i]) {
className += `indent-unit-end`;
}
list.push(<span key={i} className={`indent-unit ${className}`}></span>);
}
return <div className={`indent`}>{list}</div>;
}
export default Indent;
.tree{
.tree-node-list{
}
.tree-node{
display: flex;
align-items: flex-start;
padding: 0 0 4px 0;
outline: none;
.indent{
height: 0;
display: inline-block;
vertical-align: bottom;
.indent-unit{
width: 16px;
display: inline-block;
}
.indent-unit-start{
}
.indent-unit-end{
}
}
.switcher{
position: relative;
flex: none;
align-self: stretch;
width: 24px;
margin: 0;
line-height: 24px;
text-align: center;
cursor: pointer;
user-select: none;
box-sizing: border-box;
&.switcher-noop{
cursor: auto;
}
&.switcher-open{
}
&.switcher-close{
}
}
}
}
export function arrAdd(list, value) {
const clone = (list || []).slice();
if (clone.indexOf(value) === -1) {
clone.push(value);
}
return clone;
}
export function arrDel(list, key) {
if (!list) {
return [];
}
const clone = list.slice();
const index = clone.indexOf(key);
if (index >= 0) {
clone.splice(index, 1);
}
return clone;
}
export function getKey(key, pos) {
if (key !== null || key !== undefined) {
return key;
}
return pos;
}
export function getPosition(level, index) {
return `${level}-${index}`;
}
export function fillFieldNames(fieldNames) {
const { key, title, children } = fieldNames || {};
return {
key: key || 'key',
title: title || 'title',
children: children || 'children'
};
}
function traverseDataNodes(treeData, callback, config) {
let mergedConfig = {};
if (typeof config === 'object') {
mergedConfig = config;
}
const { fieldNames, childrenPropName } = mergedConfig;
const { key: fieldKey, children: fieldChildren } = fillFieldNames(fieldNames);
const mergedChildrenPropName = childrenPropName || fieldChildren;
const processNode = (node, index, parent, pathNodes) => {
const connectNodes = node ? [...pathNodes, node] : [];
const pos = node ? getPosition(parent.pos, index) : '0';
const mergedKey = node ? getKey(node[fieldKey], pos) : '0';
const children = node ? node[mergedChildrenPropName] : treeData;
if (node) {
const data = {
node,
index,
pos,
key: mergedKey,
nodes: connectNodes,
level: parent.level + 1,
parentPos: parent.node ? parent.pos : null
};
callback(data);
}
if (children) {
children.forEach((subNode, subIndex) => {
processNode(
subNode,
subIndex,
{ pos, node, level: parent ? parent.level + 1 : -1 },
connectNodes
);
});
}
};
processNode(null);
}
export function convertDataToEntities(dataNodes, config = {}) {
let mergedConfig = {};
if (typeof config === 'object') {
mergedConfig = config;
}
const { fieldNames, childrenPropName } = mergedConfig;
const posEntities = {};
const keyEntities = {};
const wrapper = { posEntities, keyEntities };
traverseDataNodes(
dataNodes,
item => {
const { node, index, pos, key, parentPos, level, nodes } = item;
const entity = {
node,
nodes,
index,
key,
pos,
level
};
posEntities[pos] = entity;
keyEntities[key] = entity;
entity.parent = posEntities[parentPos];
if (entity.parent) {
entity.parent.children = entity.parent.children || [];
entity.parent.children.push(entity);
}
},
{ fieldNames, childrenPropName }
);
return wrapper;
}
export function flattenTreeData(treeNodeList, expandedKeys, fieldNames) {
const {
key: fieldKey,
title: fieldTitle,
children: fieldChildren
} = fillFieldNames(fieldNames);
const flattenList = [];
const expandedKeySet = new Set(expandedKeys === true ? [] : expandedKeys);
const recursion = (list, parent = null) => {
return list.map((treeNode, index) => {
const pos = getPosition(treeNode[fieldKey], index);
const mergedKey = getKey(treeNode[fieldKey], pos);
const flattenNode = {
pos,
parent,
data: treeNode,
key: mergedKey,
children: null,
title: treeNode[fieldTitle],
isStart: [...(parent ? parent.isStart : []), index === 0],
isEnd: [...(parent ? parent.isEnd : []), index === list.length - 1]
};
flattenList.push(flattenNode);
if (expandedKeys === true || expandedKeySet.has(mergedKey)) {
flattenNode[fieldChildren] = recursion(
treeNode[fieldChildren] || [],
flattenNode
);
} else {
flattenNode[fieldChildren] = [];
}
return flattenNode;
});
};
recursion(treeNodeList);
return flattenList;
}
export function getEntity(keyEntities, key) {
return keyEntities[key];
}
export function getTreeNodeProps(key, { keyEntities, expandedKeys }) {
const entity = getEntity(keyEntities, key);
const treeNodeProps = {
eventKey: key,
level: entity ? entity.level : 0,
pos: String(entity ? entity.pos : ''),
expanded: expandedKeys.indexOf(key) !== -1
};
return treeNodeProps;
}
export function convertNodePropsToEventData(props) {
const { pos, active, eventKey, data, expanded, selected, checked } = props;
const eventData = {
...data,
pos,
data,
active,
eventKey,
checked,
expanded,
selected
};
return eventData;
}