基于Antd库实现可编辑树组件

I 前言


Antd是基于Ant Design设计体系的React UI组件库,主要用于研发企业级中后台产品,在前端很多项目中都有使用。除了提供一些比较基础的例如ButtonFormInputModalList...组件,还有TreeUploadTable这几个功能集成度比较高的复杂组件,其中Tree组件的应用场景挺多的,在一些涉及显示树形结构数据的功能中可以体现:目录结构展示、族谱关系图...,总之在需要呈现多个父子层级之间结构关系的场景中就可能用到这种Tree组件,Antd虽然官方提供了Tree组件但是它的功能比较有限,定位是主要负责对数据的展示工作,树数据的增删查改这些功能基本没有支持,但是Antd Tree的属性支持比较完善,我们可以基于Antd树来实现支持编辑功能的EditableTree组件。

源码:nojsja/EditableTree

已经发布为npm组件,可以直接安装:

$: npm install editable-tree-antd
# or
$: yarn add editable-tree-antd

预览

EditableTree_zh_CN.png

II 功能分析


  1. 非叶子节点的节点名不为空,节点值为空或数组
  2. 叶子节点的节点名可为空,节点值不可为空
  3. 点击树节点进入节点编辑状态,提交后实现节点数据更新
  4. 非叶子节点每一层级都支持兄弟节点添加、子节点添加、当前节点删除以及节点名、节点值编辑
  5. 叶子节点只支持当前节点删除和当前节点的节点名、节点值编辑
  6. 树的每一层级的节点名和节点值是否可以编辑、节点是否可以删除均可以通过传入的节点数据属性控制,默认情况下所有节点可编辑、可删除
  7. 树的层级深度支持属性配置,子节点深度不能超过树的最大深度值,默认为50层子级
  8. 新增支持:将一段yaml字符串解析为多个树节点

III 实现解析


基于React / Antd

Antd Tree文档

文件结构

--- index.js -- 入口文件,数据初始化、组件生命周期控制、递归调用TreeNode进行数据渲染
--- Tree.js -- Tree类用于抽象化树形数据的增删查改操作,相当于Model
--- lang.js -- 多语言文件
--- TreeNode.jsx -- 单层树节点组件,用于隔离每层节点状态显示和操作
------- TreeNodeDisplay.jsx -- 非编辑状态下树数据的展示
------- TreeNodeNormalEditing.jsx -- 普通节点处于编辑状态下时
------- TreeNodeYamlEditing.jsx -- yaml节点处于编辑状态下时
------- TreeNodeActions.jsx -- 该层级树节点的所有功能按钮组
--- styles / editable-tree.css -- 树样式
--- styles / icon-font / * -- 图标依赖的iconfont文件

实现原理

  • 先来看下Antd原生需要Tree数据格式:
[
  {
    title: 'parent 1',
    key: '0-0',
    children: [
      {
        title: 'parent 1-0',
        key: '0-0-0',
        disabled: true,
        children: [
          {
            title: 'leaf',
            key: '0-0-0-0',
            disableCheckbox: true,
          },
          {
            title: 'leaf',
            key: '0-0-0-1',
          }
        ]
      },
      {
        title: 'parent 1-1',
        key: '0-0-1',
        children: [{ title: <span style={{ color: '#1890ff' }}>sss</span>, key: '0-0-1-0' }]
      }
    ]
  }
]
  • 每一层级节点除了需要基本的title(文字label)、key(节点唯一标识)、children(子结点列表)属性外,还有其它很多自定义参数比如配置节点是否选中等等,这里就不对其它功能配置项做细研究了,感兴趣可以查看官方文档。

  • 在官方说明中title值其实不只是一个字符串,还可以是一个ReactNode,也就是说Antd官方为我们提供了一个树改造的后门,我们可以用自己的渲染逻辑来替换官方的title渲染逻辑,所以关键点就是分离这个title渲染为一个独立的React组件,在这个组件里我们独立管理每一层级的树节点数据展示,同时又向这个组件暴露操作整个树形数据的方法。另一方面Tree型数据一般都需要使用递归逻辑来进行节点渲染和数据增删查改,这里TreeNode.js就是递归渲染的Component对象,而增删查改逻辑我们把它分离到Tree.jsModel里面进行管理,这样子思路就比较清晰了。

关键点说明:index.js

入口文件,用于:数据初始化、组件生命周期控制、递归调用TreeNode进行数据渲染、加载lang文件等等

  • 在生命周期componentDidMount中我们初始化一个Tree Model,并设置初始化state数据。

  • componentWillReceiveProps中我们更新这个Model和state以控制界面状态更新,注意使用的Js数据深比较函数deepComparison用来避免不必要的数据渲染,数据深比较时要使用与树显示相关的节点属性裸数据(见方法getNudeTreeData),比如nodeNamenodeValue等属性,其它的无关属性比如iddepth需要忽略。

  • formatNodeData主要功能是将我们传入的自定义树数据递归 “翻译” 成Antd Tree渲染需要的原生树数据。

[
  {
    nodeName: '出版者',
    id: '出版者', // unique id, required
    nameEditable: true, // is level editable (name), default true
    valueEditable: true, // is level editable (value), default true
    nodeDeletable: false, // is level deletable, default true
    nodeValue: [
      {
        nodeName: '出版者描述',
        isInEdit: true, // is level in edit status
        id: '出版者描述',
        nodeValue: [
          {
            nodeName: '出版者名称',
            id: '出版者名称',
            nodeValue: '出版者A',
          },
          {
            nodeName: '出版者地',
            id: '出版者地',
            valueEditable: false,
            nodeValue: '出版地B1',
          },
        ],
      }
    ],
  },
  ...
];
  • 代码逻辑:
...
class EditableTree extends Component {
  state = {
    treeData: [], // Antd Tree 需要的结构化数据
    expandedKeys: [], // 将树的节点展开/折叠状态纳入控制
    maxLevel: 50, ;// 默认最大树深度
    enableYaml: false,
    lang: 'zh_CN'
  };
  dataOrigin = []
  treeModel = null
  key=getRandomString()

  /* 组件挂载后初始化树数据,生成treeModel,更新state */
  componentDidMount() {
    const { data, maxLevel = 50, enableYaml, lang="zh_CN" } = this.props;

    if (data) {
      this.dataOrigin = data;
      TreeClass.defaultTreeValueWrapper(this.dataOrigin); // 树节点添加默认值
      TreeClass.levelDepthWrapper(this.dataOrigin); // 添加层级深度属性
      const formattedData = this.formatTreeData(this.dataOrigin); // 生成格式化后的Antd Tree数据
      this.updateTreeModel({ data: this.dataOrigin, key: this.key }); // 更新model
      const keys = TreeClass.getTreeKeys(this.dataOrigin); // 获取各个层级的key,默认展开所有层级
      this.setState({
        treeData: formattedData,
        expandedKeys: keys,
        enableYaml: !!enableYaml,
        maxLevel,
        lang,
      });
    }
  }

  /* 组件props数据更新后更新treeModel和state */
  componentWillReceiveProps(nextProps) {
    const { data, maxLevel = 50, enableYaml, lang="zh_CN" } = nextProps;
    this.setState({ enableYaml: !!enableYaml, lang, maxLevel });
    // 深比较函数避免不必要的树更新
    if (
      !deepComparison(
          TreeClass.getNudeTreeData(deepClone(this.dataOrigin)),
          TreeClass.getNudeTreeData(deepClone(data))
        )
    ) {
      this.dataOrigin = data;
      TreeClass.defaultTreeValueWrapper(this.dataOrigin);
      TreeClass.levelDepthWrapper(this.dataOrigin);
      const formattedData = this.formatTreeData(this.dataOrigin);
      this.updateTreeModel({ data: this.dataOrigin, key: this.key });
      const keys = TreeClass.getTreeKeys(this.dataOrigin);
      this.onDataChange(this.dataOrigin); // 触发onChange回调钩子
      this.setState({
        treeData: formattedData,
        expandedKeys: keys
      });
    }
  }

  /* 修改节点 */
  modifyNode = (key, treeNode) => {
    const modifiedData = this.treeModel.modifyNode(key, treeNode); // 更新model
    this.setState({
      treeData: this.formatTreeData(modifiedData), // 更新state,触发数据回调钩子
    }, () => this.onDataChange(this.dataOrigin));
  }

  /**
   * 以下省略的方法具有跟modifyNode相似的逻辑
   * 调用treeModel修改数据然后更新state
   **/

  /* 进入编辑模式 */
  getInToEditable = (key, treeNode) => { ... }
  /* 添加一个兄弟节点 */
  addSisterNode = (key) => { ... }
  /* 添加一个子结点 */
  addSubNode = (key) => { ... }
  /* 移除一个节点 */
  removeNode = (key) => { ... }

  /* 递归生成树节点数据 */
  formatNodeData = (treeData) => {
    let tree = {};
    const key = `${this.key}_${treeData.id}`;
    if (treeData.toString() === '[object Object]' && tree !== null) {
      tree.key = key;
      treeData.key = key;
      tree.title = /* 关键点 */
        (<TreeNode
          maxLevel={this.maxLevel}
          focusKey={this.state.focusKey}
          treeData={treeData}
          enableYaml={this.state.enableYaml}
          modifyNode={this.modifyNode}
          addSisterNode={this.addSisterNode}
          addExpandedKey={this.addExpandedKey}
          getInToEditable={this.getInToEditable}
          addSubNode={this.addSubNode}
          addNodeFragment={this.addNodeFragment}
          removeNode={this.removeNode}
          lang={lang(this.state.lang)}
        />);
      if (treeData.nodeValue instanceof Array) tree.children = treeData.nodeValue.map(d => this.formatNodeData(d));
    } else {
      tree = '';
    }
    return tree;
  }

  /* 生成树数据 */
  formatTreeData = (treeData) => {
    let tree = [];
    if (treeData instanceof Array) tree = treeData.map(treeNode => this.formatNodeData(treeNode));
    return tree;
  }

  /* 更新 tree model */
  updateTreeModel = (props) => {
    if (this.treeModel) {
      this.treeModel.update(props);
    } else {
      const _lang = lang(this.state.lang);
      this.treeModel = new TreeClass(
        props.data,
        props.key,
        {
          maxLevel: this.state.maxLevel,
          overLevelTips: _lang.template_tree_max_level_tips,
          completeEditingNodeTips: _lang.pleaseCompleteTheNodeBeingEdited,
          addSameLevelTips: _lang.extendedMetadata_same_level_name_cannot_be_added,
        }
      );
    }
  }


  /* 树数据更新钩子,提供给上一层级调用 */
  onDataChange = (modifiedData) => {
    const { onDataChange = () => {} } = this.props;
    onDataChange(modifiedData);
  }

  ...

  render() {
    const { treeData } = this.state;
    return (
      <div className="editable-tree-wrapper">
      {
        (treeData && treeData.length) ?
          <Tree
            showLine
            onExpand={this.onExpand}
            expandedKeys={this.state.expandedKeys}
            // defaultExpandedKeys={this.state.expandedKeys}
            defaultExpandAll
            treeData={treeData}
          />
        : null
      }
      </div>
    );
  }
}

EditableTree.propTypes = {
  data: PropTypes.array.isRequired, // tree data, required
  onDataChange: PropTypes.func, // data change callback, default none
  maxLevel: PropTypes.number, // tree max level, default 50
  lang: PropTypes.string, // lang - zh_CN/en_US, default zh_CN
  enableYaml: PropTypes.bool // enable it if you want to parse yaml string when adding a new node, default false
};

关键点说明:Tree.js

Tree类用于抽象化树形数据的增删查改操作,相当于Model

逻辑不算复杂,很多都是递归树数据修改节点,具体代码不予赘述:

export default class Tree {
  constructor(data, treeKey, {
    maxLevel,
    overLevelTips = '已经限制模板树的最大深度为:',
    addSameLevelTips = '同层级已经有同名节点被添加!',
    completeEditingNodeTips = '请完善当前正在编辑的节点数据!',
  }) {
    this.treeData = data;
    this.treeKey = treeKey;
    this.maxLevel = maxLevel;
    this.overLevelTips = overLevelTips;
    this.completeEditingNodeTips = completeEditingNodeTips;
    this.addSameLevelTips = addSameLevelTips;
  }

  ...

  /* 为输入数据覆盖默认值 */
  static defaultTreeValueWrapper() { ... }

  /* 查询是否有节点正在编辑 */
  static findInEdit(items) { ... }

  /* 进入编辑模式 */
  getInToEditable(key, { nodeName, nodeValue, id, isInEdit } = {}) { ... }

  /* 修改一个节点数据 */
  modifyNode(key, {
    nodeName = '', nodeValue = '', nameEditable = true,
    valueEditable = true, nodeDeletable = true, isInEdit = false,
  } = {}) { ... }

  /* 添加一个目标节点的兄弟结点 */
  addSisterNode(key, {
    nodeName = '', nameEditable = true, valueEditable = true,
    nodeDeletable = true, isInEdit = true, nodeValue = '',
  } = {}) { ... }

  /* 添加一个目标节点的子结点 */
  addSubNode(key, {
    nodeName = '', nameEditable = true, valueEditable = true,
    nodeDeletable = true, isInEdit = true, nodeValue = '',
  } = {}) { ... }

  /* 移除节点 */
  removeNode(key) { ... }

  /* 获取树数据 */
  getTreeData() {
    return deepClone(this.treeData);
  }

  /* 更新树数据 */
  update({ data, key }) {
    this.treeData = data;
    this.treeKey = key;
  }
}

关键点说明:TreeNode.jsx

表示单个树节点的React组件,以下均为其子组件,用于展示各个状态下的树层级

  • TreeNodeDisplay.jsx -- 非编辑状态下树数据的展示

  • TreeNodeNormalEditing.jsx -- 普通节点处于编辑状态下时

  • TreeNodeYamlEditing.jsx -- yaml节点处于编辑状态下时

  • TreeNodeActions.jsx -- 该层级树节点的所有功能按钮组

每个层级节点都可以添加子节点、添加同级节点、编辑节点名、编辑节点值、删除当前节点(一并删除子节点),nameEditable属性控制节点名是否可编辑,valueEditable树形控制节点值是否可编辑,nodeDeletable属性控制节点是否可以删除,默认值都是为true

tree_add_sister.png
tree_add_sub.png

isInEdit属性表明当前节点是否处于编辑状态,处于编辑状态时显示输入框,否则显示文字,当点击文字时当前节点变成编辑状态。

tree_in_edit.png

简单的页面展示组件,具体实现见 源码:TreeNode.jsx

IV 遇到的问题&解决办法


树数据更新渲染导致的节点折叠状态重置

  • 想象我们打开了树的中间某个层级进行节点名编辑,编辑完成后点击提交,树重新渲染刷新,然后之前编辑的节点又重新折叠起来了,我们需要重新打开那个层级看是否编辑成功,这种使用体验无疑是痛苦的。

  • 造成树节点折叠状态重置的原因就是树的重新渲染,且这个折叠状态的控制数据并没有暴露到每个TreeNode上,所以在我们自己实现的TreeNode中无法独立控制树节点的折叠/展开。

  • 查看官方文档,传入树的expandedKeys属性可以显式指定整颗树中需要展开的节点,expandedKeys即需要展开节点的key值数组,为了将每个树节点折叠状态变成受控状态,我们将expandedKeys存在state或mobx store中,并在树节点折叠状态改变后更新这个值。

...
render() {
    const { treeData } = this.state;
    return (
      <div className="editable-tree-wrapper">
      {
        (treeData && treeData.length) ?
          <Tree
            showLine
            onExpand={this.onExpand}
            expandedKeys={this.state.expandedKeys}
            treeData={treeData}
          />
        : null
      }
      </div>
    );
  }

Antd格子布局塌陷

  • TreeNode.jsx组件中有一个比较严重的问题,如上文提到的EditableTree的某一层级处于编辑状态时,该层级中的文字展示组件<span>会变成输入组件<input>,我发现在编辑模式下Antd的Row/Col格子布局正常工作,在非编辑模式下由于节点内容从块元素input变成了内联元素span,格子布局塌陷了,这种情况下即使声明了Col占用的格子数量,内容依旧使用最小宽度展示,即文字占用的宽度。

  • 推测原因是Antd的Row/Col格子布局自身的问题,没有深究,这边只是将<span>元素换成了<div>元素,并且在样式中声明div占用的最小宽度min-width,同时设置max-widthoverflow避免文字元素超出边界。

tree_in_edit.png

V 结语


其实Tree组件已经不止写过一次了,之前基于Semantic UI写过一次,不过因为Semantic UI没有Tree的基础实现,所以基本上是完全自己重写的,基本思路其实跟这篇文章写的大致相同,也是递归更新渲染节点,将各个节点的折叠状态放入state进行受控管理,不过这次实现的EditableTree最主要一点是分离了treeModel的数据管理逻辑,让界面操作层TreeNode.jsx、数据管理层Tree.js和控制层index.jsx完全分离开来,结构明了,后期即使想扩展功能也未尝不可。又是跟Antd斗智斗勇的一次(苦笑脸)...

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335