一、思路
开始实现的时候采了很多坑,最初的实现思路是我们仿佛只需要维护一个树的数据结构就行了,但是发现操作比较复杂,写到一半就放弃了,想再找找一些好点的实现思路,后面一位老哥的思路及时点醒了我,大概实现方式如下。
我们先要清楚 tree
组件的每个节点的状态
父节点:有是否展开状态、是否选中、是否半选、是否选中该节点
子节点:是否选中、是否选中该节点(缺少半选 和 展开 状态)
那我们就可以知道,节点一共有四种状态,那我们就可以用四个数组去维护这个状态。
halfCheckedKeys
:当节点处于半选状态,那么就会在此数组中。
defaultSelectedKeys
: 当节点被选中的时候,就会出现在此数组中。
defaultExpandedKeys
: 当节点展开的时候,就会出现在此数组。
defaultCheckedKeys
: 当节点被选中(复选框), 就会出现在此数组。
我们在点击 展开、节点(复选框)、节点的时候只需要处理好每个数组中该存在些什么,不就可以了吗?
二、文件结构
--src
--components
----Tree
------index.css //样式
------index.js // 主要逻辑 + tree组件
------status.js //状态值
------tree-node.js // node 节点组件
--mock
----data.js // 树节点数据
--Tree.js // demo
三、代码详情
1、index.css
.container {
padding: 30px;
}
/* height:30px */
.tree-node {
height: 30px;
display: flex;
align-items: center;
}
.tree-switcher {
cursor: pointer;
margin: 0 8px 0 0;
width: 14px;
height: 14px;
border: 1px solid #bfc3c7;
border-radius: 2px;
position: relative;
}
.tree-switcher::before {
position: absolute;
content: '';
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 10px;
border-top: 2px solid #bfc3c7;
border-radius: 1px;
}
.tree-switcher::after {
position: absolute;
content: '';
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
height: 10px;
border-left: 2px solid #bfc3c7;
border-radius: 1px;
}
.tree-switcher-open .tree-switcher::after {
visibility: hidden;
}
.tree-switcher-close .tree-switcher {
visibility: hidden;
}
.tree-switcher-close .tree-switcher::after,
.tree-switcher-close .tree-switcher::before {
visibility: hidden;
}
.tree-checkbox-inner {
position: relative;
cursor: pointer;
margin: 0px 4px 0 0;
width: 14px;
height: 14px;
border: 1px solid #bfc3c7;
border-radius: 2px;
}
.tree-checkbox-inner-checked .tree-checkbox-inner {
background-color: #1890ff;
border-color: #1890ff;
}
.tree-checkbox-inner-checked .tree-checkbox-inner::after {
position: absolute;
display: table;
width: 5px;
height: 8px;
border: 2px solid #fff;
border-top: none;
border-left: none;
top: 20%;
left: 40%;
transform: rotate(40deg) scale(1) translateX(-50%);
opacity: 1;
transition: all 0.2s cubic-bezier(0.12, 0.4, 0.29, 1.46) 0.1s;
content: ' ';
}
.tree-checkbox-indeterminate .tree-checkbox-inner::after {
position: absolute;
content: ' ';
top: 50%;
left: 50%;
width: 8px;
height: 8px;
background-color: #1890ff;
border: 0;
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
.tree-checkbox-disabled .tree-checkbox-inner {
cursor: no-drop !important;
background-color: #d9d9d9 !important;
}
.tree-title {
cursor: pointer;
font-family: PingFangSC-Regular;
font-size: 12px;
color: #33383d;
}
.tree-title-select {
background-color: #eee;
}
2、index.js
import React, { Component } from 'react';
import TreeNode from './tree-node';
import './index.css';
import { NO, SOME, ALL } from './status';
function copyData(data) {
return JSON.parse(JSON.stringify(data));
}
class Tree extends Component {
constructor(props) {
super(props);
const {
defaultCheckedKeys,
defaultExpandedKeys,
defaultSelectedKeys,
} = this.props;
this.state = {
treeMap: {},
halfCheckedKeys: [],
defaultSelectedKeys, // cr: 用解构赋值
defaultExpandedKeys,
defaultCheckedKeys,
};
}
async componentDidMount() {
const { defaultCheckedKeys } = this.state;
for (let i = 0; i < defaultCheckedKeys.length; i++) {
await this.handleHalfCheckedKeys(defaultCheckedKeys[i]);
}
}
// 初始化 treeMap 变为键值对的形式
static getDerivedStateFromProps(props, state) {
// cr: 生成treemap的逻辑抽离到生命周期方法外面去;
const treeToMap = (tree, map = {}, level = 0, parent) => {
level++;
tree.forEach(current => {
const { children } = current;
current.level = level;
current.parent = parent ? parent : null;
map[current.key] = current;
if (children) {
return treeToMap(children, map, level, current.key);
}
});
return map;
};
const { treeData } = props;
const map = treeToMap(copyData(treeData));
const newState = {};
newState.treeMap = map;
return newState;
}
// 更新父节点
updateParentNode = key => {
return new Promise((resolve, reject) => {
const { halfCheckedKeys, defaultCheckedKeys } = this.state;
const parentKeys = this.handleParentNodes(key); // cr: 命名有get开头比较合适
// 没有父节点
if (parentKeys.length === 0) {
resolve();
}
// 有父节点,处理每个父节点
for (let i = 0; i < parentKeys.length; i++) {
// cr: 用es5的数组方法去遍历,或者for of
let flag = this.getNodeStatus(parentKeys[i]); // cr: 命名有get开头比较合适
// 部分子节点被选中
let checkIndex = defaultCheckedKeys.indexOf(parentKeys[i]);
let halfIndex = halfCheckedKeys.indexOf(parentKeys[i]);
switch (flag) {
case SOME:
checkIndex !== -1 && defaultCheckedKeys.splice(checkIndex, 1);
halfIndex === -1 && halfCheckedKeys.push(parentKeys[i]);
break;
// 全部子节点被选中
case ALL:
halfIndex !== -1 && halfCheckedKeys.splice(halfIndex, 1);
checkIndex === -1 && defaultCheckedKeys.push(parentKeys[i]);
break;
// 没有子节点被选中
case NO:
halfIndex !== -1 && halfCheckedKeys.splice(halfIndex, 1);
checkIndex !== -1 && defaultCheckedKeys.splice(checkIndex, 1);
break;
default:
}
this.setState(
{
defaultCheckedKeys: defaultCheckedKeys,
halfCheckedKeys: halfCheckedKeys,
},
() => {
resolve();
}
);
}
});
};
// 更新子节点
updateNode = key => {
return new Promise((resolve, reject) => {
// 处理该节点子节点 1、选中 2、未选中
const { defaultCheckedKeys, halfCheckedKeys } = this.state;
const nodeKeys = this.handleGetNodes(key);
// 没有子节点
if (nodeKeys.length === 0) {
resolve();
}
let updateCheckedKeys = copyData(defaultCheckedKeys);
//key被选中时候,子节点全部被选中
if (defaultCheckedKeys.includes(key)) {
// cr: 1. 上面已经取值,这里直接使用; 2. 下面的逻辑直接用else即可;3. setState逻辑可抽离共用,这里有冗余情况;
let halfIndex = halfCheckedKeys.indexOf(key);
halfIndex !== -1 && halfCheckedKeys.splice(halfIndex, 1);
updateCheckedKeys = [...new Set(updateCheckedKeys.concat(nodeKeys))];
} else {
// key不被选中时,子节点全部不被选中
for (let i = 0; i < nodeKeys.length; i++) {
// cr: 用es5的数组方法
let index = updateCheckedKeys.indexOf(nodeKeys[i]);
let halfIndex = halfCheckedKeys.indexOf(nodeKeys[i]);
halfIndex !== -1 && halfCheckedKeys.splice(halfIndex, 1);
index !== -1 && updateCheckedKeys.splice(index, 1);
}
}
// 保存数据
this.setState(
{
defaultCheckedKeys: updateCheckedKeys,
halfCheckedKeys: halfCheckedKeys,
},
() => {
resolve();
}
);
});
};
// 处理更新节点key
handleHalfCheckedKeys = async key => {
return new Promise(async (resolve, reject) => {
// 更新它的子节点
await this.updateNode(key);
// 再更新它的父节点
await this.updateParentNode(key);
resolve();
});
};
// key节点的子节点被选中情况
getNodeStatus = key => {
const { defaultCheckedKeys } = this.state;
const keyNodes = this.handleGetNodes(key);
let flag = 0;
for (let i = 0; i < keyNodes.length; i++) {
if (defaultCheckedKeys.includes(keyNodes[i])) {
flag++;
}
}
if (flag === 0) return NO; // cr: 状态常量,抽离出去;
if (flag === keyNodes.length) return ALL;
return SOME;
};
// 父节点集合
handleParentNodes = key => {
let parentKeys = [];
const { treeMap } = this.state;
while (treeMap[key].parent) {
// cr: treeMap提前做变量缓存
key = treeMap[key].parent;
parentKeys.push(key);
}
return parentKeys;
};
// 子节点集合
handleGetNodes = key => {
const map = this.state.treeMap[key];
if (!map.children) return [];
let help = (children, arr = []) => {
children.forEach(current => {
arr.push(current.key);
if (current.children) {
return help(current.children, arr);
}
});
return arr;
};
let arr = help(map.children);
return arr;
};
// key节点处理
handleChecked = key => {
const { defaultCheckedKeys } = this.state;
const index = defaultCheckedKeys.indexOf(key);
if (index === -1) {
defaultCheckedKeys.push(key);
} else {
defaultCheckedKeys.splice(index, 1);
}
this.setState(
{
defaultCheckedKeys: defaultCheckedKeys,
},
async () => {
await this.handleHalfCheckedKeys(key);
// emit onCheck 事件
this.props.onCheck(this.state.defaultCheckedKeys); // cr: defaultCheckedKeys 可以从参数中获取
}
);
};
// 处理key节点展开
handleExpanded = key => {
const updateExpandedKeys = copyData(this.state.defaultExpandedKeys);
const index = updateExpandedKeys.indexOf(key);
if (index === -1) {
updateExpandedKeys.push(key);
} else {
updateExpandedKeys.splice(index, 1);
}
this.setState({
defaultExpandedKeys: updateExpandedKeys,
});
};
// emit onSelect 方法
handleSelectNode = key => {
const { defaultSelectedKeys } = this.state;
defaultSelectedKeys.pop();
defaultSelectedKeys.push(key);
this.setState(
{
defaultSelectedKeys: defaultSelectedKeys,
},
() => {
this.props.onSelect(this.state.defaultSelectedKeys);
}
);
};
renderTreeNode = treeData => {
const {
treeMap,
defaultExpandedKeys,
defaultCheckedKeys,
defaultSelectedKeys,
halfCheckedKeys,
} = this.state;
return (
<div>
{treeData.map(current => {
// cr: key,title在这里提前进行解构赋值;
const { key, title, children } = current;
return (
<div key={key}>
<TreeNode
// 标题
title={title}
// key
id={key}
// deep
level={treeMap[key].level}
// 有无子节点
isSingle={!children}
// 是否展开
isExpanded={defaultExpandedKeys.includes(key)} // cr: isExpanded,isChecked等的状态也可以在节点中存储一份
// 全选中
isChecked={defaultCheckedKeys.includes(key)}
// 半选中
isHalfChecked={halfCheckedKeys.includes(key)}
// 是否被选中
isSelect={defaultSelectedKeys.includes(key)}
// 复选框点击
handleChecked={this.handleChecked}
// 节点点击
handleSelectNode={this.handleSelectNode}
// 展开点击
handleExpanded={this.handleExpanded}
/>
{children &&
defaultExpandedKeys.includes(key) &&
this.renderTreeNode(children)}
</div>
);
})}
</div>
);
};
render() {
const { treeData } = this.props;
return <div className='container'>{this.renderTreeNode(treeData)}</div>;
}
}
export default Tree;
3、status.js
将状态抽离出来
export const NO = 'NO';
export const ALL = 'ALL';
export const SOME = 'SOME';
4、tree-node.js
import React from 'react';
import './index.css';
function TreeNode(props) {
const {
id,
title,
level,
isSingle,
isExpanded,
isChecked,
isHalfChecked,
isSelect,
handleExpanded,
handleChecked,
handleSelectNode,
} = props;
const style = {
marginLeft: (level - 1) * 18 + 'px',
};
return (
<div className='tree-node' style={style}>
<div
className={
isSingle
? 'tree-switcher-close'
: isExpanded
? 'tree-switcher-open'
: ''
}
>
<div className='tree-switcher' onClick={() => handleExpanded(id)}></div>
</div>
<div
className={
isChecked
? 'tree-checkbox-inner-checked'
: isHalfChecked
? 'tree-checkbox-indeterminate'
: ''
}
>
<div
className='tree-checkbox-inner'
onClick={() => handleChecked(id)}
></div>
</div>
<div
className={isSelect ? 'tree-title-select tree-title' : 'tree-title'}
onClick={() => handleSelectNode(id)}
>
{title}
</div>
</div>
);
}
export default TreeNode;
5、data.js
// cr: mock数据放在组件外层,用单独的文件去存放
export const treeData = [
{
title: 'parent 1',
key: '0-0',
children: [
{
title: 'parent 1-0',
key: '0-0-0',
children: [
{
title: 'leaf1',
key: '0-0-0-0',
},
{
title: 'leaf2',
key: '0-0-0-1',
},
{
title: 'leaf3',
key: '0-0-0-2',
},
],
},
{
title: 'parent 1-1',
key: '0-0-1',
children: [
{
title: 'SSR',
key: '0-0-1-0',
},
],
},
],
},
];
6、Tree.js
import Tree from './components/Tree';
import { treeData } from './mock/data';
const TreeDemo = () => {
// cr: 1. 没有用到的参数不要带上;2. 函数作为子组件的props时,得考虑性能,用useCallback;
const onSelect = (selectedKeys, info) => {
console.log('selected', selectedKeys);
};
const onCheck = (checkedKeys, info) => {
console.log('onCheck', checkedKeys);
};
// cr: 默认值用状态去控制,如果有外部传入的值,优先使用外部的值
return (
<Tree
checkable
//默认展开指定的树节点
defaultExpandedKeys={['0-0', '0-0-0', '0-0-1']}
//默认选中的树节点
defaultSelectedKeys={['0-0']}
//默认选中复选框的树节点
defaultCheckedKeys={['0-0-1', '0-0-0-1']}
onSelect={onSelect}
onCheck={onCheck}
treeData={treeData}
/>
);
};
export default TreeDemo;
以上就是tree组件所有代码了!
中途遇到一个小问题,就是当我们点击过快的时候,由于 react
的 setState()
方法是异步的,数组在更新操作可能还没有完成就被下一个需要这个的数组的事件拿走了,拿到的肯定就是更新之前的,所以就会出问题。这里暂且用的Promise
解决, 如果有好的方法欢迎交流。
你的关注是我最大的动力!