前言
之前遇到一个需求,可对于任意节点添加或删除子节点。首先技术栈是基于react+ant design,ant提供了Tree组件,但都是根据固定的数据渲染出树结构,如果需要新增或删除节点,官网并未提供。
实现过程
新增节点
首先,要记录选中节点,在有选中的情况下点击全局的新增按钮,就相当于在选中的节点下新增子节点,否则直接在最外层节点添加新的节点(此时的情况就是有多个并列的根节点)。当然也可以直接点击节点出现下拉菜单,选择操作
然后,实现新增功能,在点击新增按钮之后,相应的节点位置出现输入框,按回车或者输入框失去焦点代表输入完成。找到插入位置,将新增的节点插入。
输入状态:输入完成后:需要自定义节点,点击节点(ant Dropdown组件也支持右键)显示下拉弹窗。 这里的DropdownInput是自定义的组件,因为需要校验输入内容
// DropdownInput组件 import { Dropdown, Input } from "antd"; import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState, } from "react"; import type { InputProps } from "antd"; import _ from "lodash"; interface DropdownInputType extends InputProps { errorInfo?: string; initValue?: string; } const DropdownInputFun: React.ForwardRefRenderFunction< unknown, DropdownInputType > = (props, ref) => { const { errorInfo, initValue, onChange, onBlur, onPressEnter } = props; const [open, setOpen] = useState<boolean>(false); const [errorText, setErrorText] = useState<string>("请输入中英文数字及下划线"); const [value, setValue] = useState<string>(""); // 值 const inputRef = useRef<any>(null); useImperativeHandle(ref, () => inputRef?.current); useEffect(() => { if (initValue) setValue(initValue); }, [initValue]); useEffect(() => { if (errorInfo) setErrorText(errorInfo); }, [errorInfo]); /** 监听输入报错 */ const handleChange = _.debounce((e: any, isSure = false) => { const { value } = e?.target; const reg = /^[a-zA-Z0-9_\u4e00-\u9fa5]+$/; if (!reg.test(value)) { setOpen(true); } else { setOpen(false); onChange?.(value); } }, 300); return ( <Dropdown overlay={ <div style={{ background: "#fff", padding: "8px 12px", height: 20, boxShadow: "0px 2px 12px 0px rgba(0,0,0,0.06)", }} > {errorText} </div> } open={open} > <Input ref={inputRef} value={value} onChange={(e) => { e?.persist(); setValue(e?.target?.value); handleChange(e); }} onBlur={(e) => { !open && onBlur?.(e); }} onPressEnter={(e: any) => { !open && onPressEnter?.(e); }} style={{ width: 272, borderColor: open ? "red" : "" }} /> </Dropdown> ); }; const DropdownInput = forwardRef(DropdownInputFun); export default DropdownInput;
// 自定义节点 const titleRender = (node: any) => { const { title, icon, key, isInput } = node; const paddingLeft = 16 * (node.level - 1); if (isInput) return ( <DropdownInput ref={refInput} initValue={title} onPressEnter={(e) => onEnter(e, node)} onBlur={(e) => onEnter(e, node)} /> ); return ( <Dropdown overlay={() => ( <Menu onClick={(e) => { if (e?.key === "add") addItem(node); if (e?.key === "edit") editItem(node); if (e?.key === "del") { const data = mergeChildrenToParent1(treeData, node?.key); setTreeData(data); // 更新树 数据 } }} > <Menu.Item key="del">刪除</Menu.Item> <Menu.Item key="add">新增</Menu.Item> <Menu.Item key="edit">编辑</Menu.Item> </Menu> )} trigger={["click"]}> <div key={key} style={{ paddingLeft, display: "flex" }} className="titleRoot" > {icon} <div>{title}</div> </div> </Dropdown> ); };
添加节点的addItem函数
// 添加节点 const addItem = (node: any) => { const len = _.isEmpty(node?.children) ? 0 : node?.children?.length; // 插入节点isInput为true,渲染节点的判断条件 const newChild = _.isEmpty(node.children) ? [{ isInput: true, key: `${node?.key}-${len}` }] : [ { isInput: true, key: `${node?.key}-${len}`, }, ...node.children, ]; const data = updateTreeData(treeData, node, newChild); setTreeData(data); const expands = expandedKeys?.includes(node?.key) ? expandedKeys : [node?.key, ...expandedKeys]; setExpandedKeys(expands); setIsAdd(true); }; const updateTreeData = (tree: any, target: any, children: any) => { return tree.map((node: any) => { if (node.key === target.key) { return { ...node, children }; } else if (node?.children) { return { ...node, children: updateTreeData(node?.children, target, children), }; } return node; }); };
输入完成后的onEnter函数
// 监听添加节点的输入 const onEnter = (e: any, node: any) => { const value = e?.target?.value; setIsAdd(false); if (!value) { // 输入内容为空就回车,直接删除编辑框的节点 const dele = deleteNodeByKey(treeData, node?.key); setTreeData(dele); return; } // 有输入内容就跟新 const data = updateItem(treeData, node?.key, value); setTreeData(data); }; // deleteNodeByKey // 根据key 找到要删除的节点 const deleteNodeByKey: any = (treeData: any, keyToDelete: string) => { return _.map(treeData, (node) => { if (node.key === keyToDelete) { // 如果节点的key匹配要删除的key,则返回undefined,表示不包括该节点 return undefined; } else if (node.children) { // 如果节点有子节点,则递归处理子节点 return { ...node, children: deleteNodeByKey(node.children, keyToDelete), }; } return node; // 其他情况下返回原始节点 }).filter(Boolean); // 过滤掉undefined的节点 }; // updateItem // 根据key 找到正在输入的节点,将输入内容跟新到title(显示节点的名字),并删除之前的isInput属性 const updateItem: any = (tree: any, key: string, data: any) => { return _.map(tree, (item: any) => { if (item?.key === key) { item.title = data; return _.omit(item, "isInput"); } else if (item?.children) { return { ...item, children: updateItem(item?.children, key, data) }; } return item; }); };
这样一个新增节点的功能就完成了。
编辑节点
有了上面的新增功能,编辑就简单多啦,在将节点替换成编辑框时,只需要带上节点的title为输入框的默认值
const editItem = (node: any) => { const data = editTreeItem(treeData, node?.key); setTreeData(data); setIsAdd(true); }; // 节点呈编辑状态 export const editTreeItem: any = (tree: any, key: string) => { return _.map(tree, (item: any) => { if (item?.key === key) { item.isInput = true; console.log("进来啦",item); return item; } else if (item?.children) { return { ...item, children: editTreeItem(item?.children, key) }; } return item; }); };
后面的逻辑就和新增一样啦,监听输入框的回车和失焦事件,完成编辑功能。
删除节点
删除节点要考虑是否删除节点下的子节点,如果直接删除子节点,逻辑就简单了,如果需要把删除节点的子节点给删除节点父节点,需要额外处理
// 直接删除 const deleteNodeByKey: any = (treeData: any, keyToDelete: string) => { return _.map(treeData, (node) => { if (node.key === keyToDelete) { // 如果节点的key匹配要删除的key,则返回undefined,表示不包括该节点 return undefined; } else if (node.children) { // 如果节点有子节点,则递归处理子节点 return { ...node, children: deleteNodeByKey(node.children, keyToDelete), }; } return node; // 其他情况下返回原始节点 }).filter(Boolean); // 过滤掉undefined的节点 }; // 删除节点,子节点合并到上级 const mergeChildrenToParent: any = ( treeData: any, keyToDelete: string ) => { return _.flatMap(treeData, (node) => { if (node.key === keyToDelete) { // 如果节点的key匹配要删除的key if (node.children) { // 如果有子节点,将子节点合并到当前节点的父节点中 const parent = _.find(treeData, (parentNode) => { return _.some(parentNode.children, { key: keyToDelete }); }); if (parent) { parent.children = [ ...(parent.children || []), ...(node.children || []), ]; } return undefined; // 返回undefined,表示删除当前节点 } else { return undefined; // 如果没有子节点,直接删除当前节点 } } else if (node.children) { // 如果节点有子节点,则递归处理子节点 return { ...node, children: mergeChildrenToParent(node.children, keyToDelete), }; } return node; // 其他情况下返回原始节点 }).filter(Boolean); // 过滤掉undefined的节点 };
附上Tree组件。里面的函数,上面都有,就不一一写完成了
import React, { useEffect, useRef, useState } from "react"; import { Button, Dropdown, Menu, Tree } from "antd"; import { DownOutlined } from "@ant-design/icons"; import DropdownInput from "@/components/DropdownInput"; const DemoTree = () => { const [visible, setVisible] = useState<boolean>(false); const [treeData, setTreeData] = useState([ { title: "根节点1", key: "1-0", children: [ { title: "子节点1", key: "1-0-0", }, { title: "子节点2", key: "1-0-1", }, { title: "子节点3", key: "1-0-2", }, ], }, { title: "根节点2", key: "2-1", children: [ { title: "子节点4", key: "2-1-0", }, { title: "子节点5", key: "2-1-1", }, ], }, { title: "根节点3", key: "3-1", children: [ { title: "子节点6", key: "3-1-0", children:[{ title:'jjj', key:'dfv' }] }, { title: "子节点7", key: "3-1-1", }, ], }, ]); const refInput = useRef<any>(null); const [expandedKeys, setExpandedKeys] = useState<any[]>([]); const editItem = (node: any) => {}; // 添加节点 const addItem = (node: any) => {}; // 监听添加节点的输入 const onEnter = (e: any, node: any) => {}; // 自定义节点 const titleRender = (node: any) => { const { title, icon, key, isInput } = node; const paddingLeft = 16 * (node.level - 1); if (isInput) return ( <DropdownInput ref={refInput} initValue={title} onPressEnter={(e) => onEnter(e, node)} onBlur={(e) => onEnter(e, node)} /> ); return ( <Dropdown overlay={() =>( <Menu onClick={(e) => { if (e?.key === "add") addItem(node); if (e?.key === "edit") editItem(node); if (e?.key === "del") { const data = mergeChildrenToParent1(treeData, node?.key); setTreeData(data); } }} > <Menu.Item key="del">刪除</Menu.Item> <Menu.Item key="add">新增</Menu.Item> <Menu.Item key="edit">编辑</Menu.Item> </Menu> )} trigger={["click"]}> <div key={key} style={{ paddingLeft, display: "flex" }} className="titleRoot" > {icon} <div>{title}</div> </div> </Dropdown> ); }; return ( <div> <Tree treeData={treeData} expandedKeys={expandedKeys} switcherIcon={<DownOutlined />} titleRender={titleRender} onExpand={(keys: any[]) => setExpandedKeys(keys)} /> </div> ); }; export default DemoTree;
本文仅供参考,个人观点。