基于Antd.Tree实现可编辑树组件

3,133 阅读4分钟

在antd.Tree的基础上封装“可编辑树”,支持树节点可增、删、修改。如下图:

图1. 含新增、编辑、删除的树节点

tree_001.png

图2. 编辑状态的树节点:支持校验提交、取消

tree_002.png

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;
  };
}