在antd.Tree的基础上封装“可编辑树”,支持树节点可增、删、修改。如下图:
图1. 含新增、编辑、删除的树节点
图2. 编辑状态的树节点:支持校验提交、取消
1. 技术知识点
技术栈:react、antd、typescript;
设计模式:责任链设计模式;
2. 组织结构
+-- editableTree
+-- index.less
+-- types.ts
+-- customInput.tsx
+-- renderTreeNodeTitle.tsx
+-- treeNode.tsx
+-- useCreateTree.tsx
+-- context.tsx
+-- index.tsx
3. 代码设计
- 使用antd的Tree.DirectoryTree,自定义树节点treeNode;
- 使用React.context,全局共享一个实例treeInstance,treeInstance;
- 自定义hook:useCreateTree,是treeInstance,包含一些方法:比如设置与获取搜索值、校验输入值是否合法等;
- 自定义树节点treeNode中,采用责任链设计模式,将事件请求处理沿着链传递到最顶层父组件处理,(可留意onEmit方法);
- 封装input组件,含确认提交、取消按钮;
3.1 tree组件
主要是一些tree方法的处理,含树节点展开收起、树节点点击处理、增删修改数据的处理与更新等。详见下面index.tsx代码。
3.2 自定义TreeNode组件
含输入框编辑、取消与完成提交状态,增加子节点,删除本节点的功能。组件props如下:
type TEditType =
| EEditType.create
| EEditType.modify
| EEditType.del
| EEditType.check
| EEditType.cancel;
interface IActions {
onCreate: (data: ITreeNode, editType: TEditType) => void; // 新增
onModify: (data: ITreeNode, editType: TEditType) => void; // 编辑修改
onDel: (data: ITreeNode, editType: TEditType) => void; // 删除
onCheck?: (data: ITreeNode, editType: TEditType) => void; // 提交值,用于提交接口
onCancel?: (data: ITreeNode, editType: TEditType) => void; // 取消
}
type IRenderTreeNodeTitle = type IRenderTreeNodeTitle = IActions & {
data: ITreeNode;
};
4. 附:代码内容参考
4.1 index
// index.tsx
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { Tree, Input, Empty, Button, message } from 'antd';
import useCreateTree from './useCreateTree';
import { v4 as uuid } from 'uuid';
import { cloneDeep } from 'lodash';
import { ENodeType, EEditType, ITreeNode } from './types';
import Style from './index.less';
interface IProps {
sourceData: ITreeNode[];
onSelect?: (node) => void;
disabled?: boolean;
onAdd: (data: {parentId: string; type: string}) => Promise<any>; // type: 树节点的标题文字 props.onAdd 新增树节点接口
onUpdate: (data: {id: string; type: string}) => Promise<any>; // props.onUpdate 更新节点内容接口
onDelete: (id: string) => Promise<any>; // props.onDelete 删除接口
onRefresh: () => Promise<any>; // props.onRefresh 查询树数据接口
}
const EditableTree = ({
sourceData,
onSelect,
disabled = false,
onAdd,
onUpdate,
onDel,
onRefresh
}: IProps) => {
const [innerTreeData, setInnerTreeData] = useState<ITreeNode[]>([]); // 内部树数据,含编辑状态
const [expandedKeys, setExpandedKeys] = useState<string[]>([
sourceData[0]?.key,
]); // 展开树节点的key数组
const [autoExpandParent, setAutoExpandParent] = useState<boolean>(true);
const { treeInstance } = useCreateTree(); // treeInstance: tree实例
const showTreeFlag = useMemo(() => {
return sourceData.length;
}, [sourceData]);
// 树节点点击
const onTreeSelect = (
selectedKeys,
{ selected, selectedNodes, node, event },
) => {
onSelect({ selected, selectedNodes, node, event });
};
// 树节点展开/收起
const onExpand = (expandedKeys, { expanded, node }) => {
setExpandedKeys(expandedKeys);
};
// 通过upperLevelIds递归查找对应的父级node,并且执行回调
const findParentNode = (
upperLevelIds: ITreeNode['upperLevelIds'],
arr: ITreeNode[],
cb: Function,
) => {
let parentNode = null;
while (upperLevelIds.length) {
if (parentNode) {
findParentNode(upperLevelIds, parentNode.children || [], cb);
}
const pId = upperLevelIds.shift();
parentNode = arr.find(ele => ele.id === pId);
}
parentNode && cb(parentNode);
};
// 树节点新增 (空数据时的新增)
const onCreateClick = () => {
const key = uuid();
const res = [
{
key,
parentId: '',
nodeType: ENodeType.input,
children: [],
},
];
setInnerTreeData(res);
};
// 责任链设计模式:事件向上传递到顶层元素后,数据新增、删除、修改处理
const onEmit = async ({ eventType, data }) => {
let res = cloneDeep(innerTreeData);
switch (eventType) {
case EEditType.create:
/**
* 从当前的父元素增加多一行空白数据,
* 且为input框,
* 且expandKeys增加,展开
*/
findParentNode(data.upperLevelIds, res, (parentNode: ITreeNode) => {
const children = cloneDeep(parentNode.children || []);
children.unshift(data);
parentNode.children = children;
// 增加expandKeys
setExpandedKeys([data.parentId.toString(), ...expandedKeys]);
setAutoExpandParent(true);
});
setInnerTreeData(res);
break;
case EEditType.modify:
if (!data.upperLevelIds.length) {
// 顶级元素
const idx = res.findIndex(ele => ele.id === data.id);
if (idx > -1) {
res[idx] = data;
}
} else {
findParentNode(data.upperLevelIds, res, (parentNode: ITreeNode) => {
const idx = parentNode.children.findIndex(
ele => ele.id === data.id,
);
if (idx > -1) {
parentNode.children[idx] = data;
}
});
}
setInnerTreeData(res);
break;
case EEditType.del:
await onDelete({ id: data.id });
await onRefresh();
break;
case EEditType.check:
await (!data.id
? onAdd({
parentId: data.parentId,
type: data.type,
})
: onUpdate({
id: data.id,
type: data.type,
})
)
await onRefresh();
break;
case EEditType.cancel:
await onRefresh(); // 接口请求刷新,或者,将树结构中为编辑状态的节点删除掉
break;
default:
break;
}
};
useEffect(() => {
setInnerTreeData(cloneDeep(sourceData));
}, [sourceData])
return (<TreeContext.Provider
value={{
treeInstance,
}}
>
<div className={Style.customTree}>
<div className={`${Style.boxWrapper} ${Style.flexRowCenter}`} style={!showTreeFlag ? {} : { display: 'none' }}>
<Empty
image={emptyImage}
imageStyle={{
height: 60,
}}
description={<span>暂无数据</span>}>
<Button type="primary" onClick={onCreateClick}>
新增树节点
</Button>
</Empty>
</div>
<div className={Style.treeWrapper} style={showTreeFlag ? {} : { display: 'none' }}>
<DirectoryTree
icon={false}
// height={treeHeight}
disabled={disabled}
onExpand={onExpand}
expandedKeys={expandedKeys}
autoExpandParent={autoExpandParent}
onSelect={onTreeSelect}>
{renderTree({
data: treeData,
onEmit,
})}
</DirectoryTree>
</div>
</div>
</TreeContext.Provider>)
}
4.2 context
// context.tsx
import React from 'react';
import { ITreeInstance } from './types';
export interface ITreeContext {
treeInstance: ITreeInstance;
}
const treeContext = React.createContext<ITreeContext>(null);
export default treeContext;
4.3 useCreateTree
// useCreateTree
import React, { useRef, useState } from 'react';
import { ITreeInstance, TEditType, EEditType } from './types';
enum tipMap {
lengthLimit = '限50个字符',
emptyLimit = '请填写!',
}
const createTree: () => {
treeInstance: ITreeInstance;
} = () => {
const operactionsStackRef = useRef<TEditType[]>([]);
const [searchValue, setSearchValue] = useState<string>('');
const getLastestRecord: ITreeInstance['getLastestRecord'] = () => {
const operactionsStack = operactionsStackRef.current;
return operactionsStack.length ? operactionsStack[0] : null;
};
const addRecord: ITreeInstance['addRecord'] = record => {
const operactionsStack = operactionsStackRef.current;
operactionsStack.unshift(record);
};
const setKeyword: ITreeInstance['setKeyword'] = val => {
setSearchValue(val);
};
const getKeyword: ITreeInstance['getKeyword'] = () => {
return searchValue;
};
const enableEdit: ITreeInstance['enableEdit'] = () => {
let flag = true;
const lastestRecord = getLastestRecord();
if ([EEditType.create, EEditType.modify].includes(lastestRecord)) {
flag = false;
}
return flag;
};
const validText: ITreeInstance['validText'] = val => {
let flag = true;
let message = '';
if (!val) {
flag = false;
message = tipMap.emptyLimit;
}
if (val.length > 50) {
flag = false;
message = tipMap.lengthLimit;
}
return {
flag,
message,
};
};
return {
treeInstance: {
getLastestRecord,
addRecord,
setKeyword,
getKeyword,
enableEdit,
validText,
},
};
};
export default createTree;
4.4 treeNode
// treeNode.tsx
import React from 'react';
import { Tree } from 'antd';
import RenderTreeNodeTitle from './renderTreeNodeTitle';
import { ITreeNode, EEditType, TEditType } from './types';
interface IRenderTree {
data: ITreeNode[];
onEmit: (payload: { eventType: TEditType; data: ITreeNode }) => void;
}
const { TreeNode } = Tree;
const renderTree = ({ data, onEmit }: IRenderTree) => {
const onTreeNodeOmit = (data, eventType) => {
onEmit({
eventType,
data,
});
};
return data.map((item, index) => {
if (
!item.children ||
(Array.isArray(item.children) && !item.children.length)
) {
return (
<TreeNode
key={`${item.id || item.key}`}
title={
<RenderTreeNodeTitle
data={item}
onCreate={onTreeNodeOmit}
onModify={onTreeNodeOmit}
onDel={onTreeNodeOmit}
onCheck={onTreeNodeOmit}
onCancel={onTreeNodeOmit}
/>
}
/>
);
} else {
return (
<TreeNode
key={`${item.id || item.key}`}
title={
<RenderTreeNodeTitle
data={item}
onCreate={onTreeNodeOmit}
onModify={onTreeNodeOmit}
onDel={onTreeNodeOmit}
onCheck={onTreeNodeOmit}
onCancel={onTreeNodeOmit}
/>
}
>
{renderTree({
data: item.children,
onEmit,
})}
</TreeNode>
);
}
});
};
export default renderTree;
// renderTreeNodeTitle.tsx
import React, { useState, useMemo, useContext } from 'react';
import { Tooltip, Popconfirm, message } from 'antd';
import { PlusOutlined, FormOutlined, DeleteOutlined } from '@ant-design/icons';
import HighLight from '@/components/highLight';
import CustomInput from './customInput';
import { cloneDeep } from 'lodash';
import { v4 as uuid } from 'uuid';
import { IActions, ITreeNode, ENodeType, EEditType } from './types';
import TreeContext, { ITreeContext } from './context';
import Style from './index.less';
type IRenderTreeNodeActions = IActions & {
data: ITreeNode;
};
type IRenderTreeNodeTitle = IActions & {
data: ITreeNode;
};
const renderTreeNodeActions = ({
data,
onCreate,
onModify,
onDel,
}: IRenderTreeNodeActions) => {
const { treeInstance } = useContext<ITreeContext>(TreeContext);
const [popConfirmVisible, setPopConfirmVisible] = useState<boolean>(false);
const onCreateClick = e => {
e.stopPropagation();
const enableEditFlag = treeInstance.enableEdit();
if (!enableEditFlag) {
message.warn('只能同时编辑一行');
return;
}
treeInstance.addRecord(EEditType.create);
const key = uuid();
const res = {
key,
parentId: data.id,
upperLevelIds: [...data.upperLevelIds, data.id],
nodeType: ENodeType.input,
type: '',
};
onCreate(res, EEditType.create);
};
const onModifyClick = e => {
e.stopPropagation();
const enableEditFlag = treeInstance.enableEdit();
if (!enableEditFlag) {
message.warn('只能同时编辑一行');
return;
}
treeInstance.addRecord(EEditType.modify);
const res: ITreeNode = cloneDeep(data);
res.nodeType = ENodeType.input;
onModify(res, EEditType.modify);
};
const onDelClick = e => {
e.stopPropagation();
// e.preventDefault();
treeInstance.addRecord(EEditType.del);
onDel(data, EEditType.del);
};
return (
<div className={`actions ${Style.actions}`}>
<Tooltip title="新增子级">
<span onClick={onCreateClick}>
<PlusOutlined />
</span>
</Tooltip>
<Tooltip title="编辑">
<span onClick={onModifyClick}>
<FormOutlined />
</span>
</Tooltip>
<Render condition={data.isFinalLevel}>
<Tooltip title="删除" zIndex={33}>
<span
onClick={() => {
setPopConfirmVisible(true);
}}
>
<DeleteOutlined />
</span>
</Tooltip>
</Render>
<Popconfirm
title="确认要删除此构件类型吗"
trigger="click"
onConfirm={onDelClick}
onCancel={() => {
setPopConfirmVisible(false);
}}
okText="确定"
cancelText="取消"
zIndex={66}
visible={popConfirmVisible}
/>
</div>
);
};
const renderTreeNodeTitle = ({
data,
onCreate,
onModify,
onDel,
onCheck,
onCancel,
}: IRenderTreeNodeTitle) => {
const [toggleActions, setToggleActionsFlag] = useState<boolean>(false);
const { treeInstance } = useContext<ITreeContext>(TreeContext);
// 是否展示 操作按钮
const onToggleActionsFlag = (flag: boolean) => {
if (!isEditing) {
setToggleActionsFlag(flag);
} else {
setToggleActionsFlag(false);
}
};
// 输入校验
const onInputCheck = (val: string) => {
const validObj = treeInstance.validText(val);
if (!validObj.flag) {
message.warn(validObj.message);
return;
}
treeInstance.addRecord(EEditType.check);
const res = cloneDeep(data);
res.nodeType = ENodeType.text;
res.type = val;
onCheck(res, EEditType.check);
};
// 取消输入
const onInputCancel = (val: string) => {
treeInstance.addRecord(EEditType.cancel);
const res = cloneDeep(data);
res.nodeType = ENodeType.text;
onCancel(res, EEditType.cancel);
};
// 是否正在编辑
const isEditing = useMemo(() => {
return data.nodeType === ENodeType.input;
}, [data]);
return (
<div
className={`${Style.flexRowFlexStart} ${Style.treeNodeWrapper}`}
onMouseEnter={() => {
onToggleActionsFlag(true);
}}
onMouseLeave={() => {
onToggleActionsFlag(false);
}}
>
{
data.nodeType === ENodeType.input ? (<CustomInput
initialVal={data.title}
onCheck={onInputCheck}
onCancel={onInputCancel}
/>) : null
}
{
data.nodeType === ENodeType.text ? (treeInstance.getKeyword().length ? (
<HighLight
value={data.title}
keyword={treeInstance.getKeyword()}
color="red"
/>
) : (
<span className={Style.title}>{data.title}</span>
)) : null
}
{
toggleActions ? (renderTreeNodeActions({
data,
onCreate,
onModify,
onDel,
})) : null
}
</div>
);
};
export default renderTreeNodeTitle;
4.5 封装input组件
// customInput.tsx
import React, { useState, useMemo } from 'react';
import { Input, message } from 'antd';
import { CloseOutlined, CheckOutlined } from '@ant-design/icons';
import Style from './index.less';
interface Props {
initialVal: string;
onCheck?: (val: string) => void;
onCancel?: (val: string) => void;
}
const CustomInput = ({ initialVal, onCheck, onCancel }: Props) => {
const [value, setValue] = useState<string>(initialVal);
const onChange = e => {
setValue(e.target.value);
};
const onCheckBtnClick = e => {
e.stopPropagation();
onCheck && onCheck(value);
};
const onCancelBtnClick = e => {
e.stopPropagation();
const newValue = isDelFlag ? value : initialVal;
setValue(newValue);
onCancel && onCancel(newValue);
};
// "取消操作"是删除,还是改回初始值
const isDelFlag = useMemo(() => {
return !!initialVal;
}, [initialVal]);
return (
<div className={`${Style.flexRowFlexStart} ${Style.customInput}`}>
<div className={Style.inputWrapper}>
<Input value={value} onChange={onChange} />
</div>
<span className={Style.btn} onClick={onCheckBtnClick}>
<CheckOutlined
style={{
color: '#52c41a',
fontSize: '12px',
}}
/>
</span>
<span className={Style.btn} onClick={onCancelBtnClick}>
<CloseOutlined
style={{
color: 'rgba(0,0,0,0.25)',
fontSize: '12px',
}}
/>
</span>
</div>
);
};
export default CustomInput;
4.6 类型文件
// types.ts
export interface ITreeNode {
key: string;
title?: string;
nodeType: TTreeNodeType; // 节点展示类型
upperLevelIds?: string[]; // 父节点id数组
level?: number; // 层级
isFinalLevel?: boolean; // 是否为末级节点
children?: ITreeNode[];
id: string;
type: string; // 值
// lastType?: string; // 上一个值,用于cancel按钮回撤
mainDataList: any[];
[propName: string]: any;
}
export interface IActions {
onCreate: (data: ITreeNode, editType: TEditType) => void; // 新增
onModify: (data: ITreeNode, editType: TEditType) => void; // 编辑修改
onDel: (data: ITreeNode, editType: TEditType) => void; // 删除
onCheck?: (data: ITreeNode, editType: TEditType) => void; // 提交值,用于提交接口
onCancel?: (data: ITreeNode, editType: TEditType) => void; // 取消
}
export enum EEditType {
create = 'create',
modify = 'modify',
del = 'del',
check = 'check',
cancel = 'cancel',
}
export enum ENodeType {
input = 'input',
text = 'text',
}
export type TTreeNodeType = ENodeType.input | ENodeType.text;
export type TEditType =
| EEditType.create
| EEditType.modify
| EEditType.del
| EEditType.check
| EEditType.cancel;
export interface ITreeInstance {
getLastestRecord: () => TEditType;
addRecord: (record: TEditType) => void; // 存储当前最新操作记录
setKeyword: (val: string) => void;
getKeyword: () => string;
enableEdit: () => boolean; // 是否可编辑:只能同时编辑一行
validText: (
val: string,
) => {
// 校验文字
message: string;
flag: boolean;
};
}