基於Antd庫實現可編輯樹組件

nojsja.gitee.io/blogs 更多內容已經在個人博客發佈,請知悉

I 前言


Antd是基於Ant Design設計體系的React UI組件庫,主要用於研發企業級中後臺產品,在前端很多項目中都有使用。除了提供一些比較基礎的例如ButtonFormInputModalList...組件,還有TreeUploadTable這幾個功能集成度比較高的複雜組件,其中Tree組件的應用場景挺多的,在一些涉及顯示樹形結構數據的功能中可以體現:目錄結構展示、族譜關係圖...,總之在需要呈現多個父子層級之間結構關係的場景中就可能用到這種Tree組件,Antd雖然官方提供了Tree組件但是它的功能比較有限,定位是主要負責對數據的展示工作,樹數據的增刪查改這些功能基本沒有支持,但是Antd Tree的屬性支持比較完善,我們可以基於Antd樹來實現支持編輯功能的EditableTree組件,源碼:nojsja/EditableTree

II 功能分析


  1. 非葉子節點的節點名不爲空,節點值爲空
  2. 葉子節點的節點名可爲空,節點值不可爲空
  3. 點擊樹節點進入節點編輯狀態,提交後實現節點數據更新
  4. 非葉子節點每一層級都支持兄弟節點添加、子節點添加、當前節點刪除以及節點名、節點值編輯
  5. 葉子節點只支持當前節點刪除和當前節點的節點名、節點值編輯
  6. 樹的每一層級的節點名和節點值是否可以編輯、節點是否可以刪除均可以通過傳入的節點數據屬性控制,默認情況下所有節點可編輯、可刪除
  7. 樹的層級深度支持屬性配置,子節點深度不能超過樹的最大深度值,默認爲50

III 實現解析


基於React / Antd / Mobx

Antd Tree文檔

文件結構

  • --- Tree.js - Tree類用於抽象化樹形數據的增刪查改操作,相當於Model
  • --- TreeNode.jsx - 單層樹節點組件,用於隔離每層節點狀態顯示和操作
  • --- index.jsx - 入口文件,數據初始化、組件生命週期控制、遞歸調用TreeNode進行數據渲染
  • --- index.less - 樣式文件,定製界面顯示效果

實現原理

  • 先來看下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.jsx

入口文件,數據初始化、組件生命週期控制、遞歸調用TreeNode進行數據渲染

  • 在生命週期componentDidMount中我們初始化一個Tree Model,並設置初始化state數據。

  • componentWillReceiveProps中我們更新這個Model和state以控制界面狀態更新,注意使用的Js數據深比較函數deepComparison用來避免不必要的數據渲染。

  • formatNodeData主要功能是將我們傳入的自定義樹數據遞歸 “翻譯” 成Antd Tree需要的原生樹數據。

[
  {
    nodeName: '出版者',
    id: '出版者',
    isInEdit: true,
    nodeValue: [
      {
        nodeName: '出版者描述',
        id: '出版者描述',
        nodeValue: [
          {
            nodeName: '出版者名稱',
            id: '出版者名稱',
            nodeValue: '出版者A',
          },
          {
            nodeName: '出版者地',
            id: '出版者地',
            nodeValue: '出版地B1',
          },
        ],
      }
    ],
  },
]
  • 代碼邏輯:
...

@inject('lang')
@observer
class EditableTree extends Component {
  state = {
    treeData: [], // Antd Tree 需要的結構化數據
    expandedKeys: [], // 將樹的節點展開/摺疊狀態納入控制
    focusKey: '',
    maxLevel: 50, ;// 默認最大樹深度
  };
  dataOrigin = []
  treeModel = null
  key=getRandomString()

  /* 組件掛載後初始化樹數據,生成treeModel,更新state */
  componentDidMount() {
    const { data, maxLevel = 50 } = this.props;
    if (data) {
      this.dataOrigin = toJS(data);
      TreeClass.defaultTreeValueWrapper(this.dataOrigin); // 生成默認值覆蓋tree數據的每個節點
      const formattedData = this.formatTreeData(this.dataOrigin); // 生成格式化後的Antd Tree數據
      this.updateTreeModel(); // 更新model
      const keys = TreeClass.getTreeKeys(this.dataOrigin); // 獲取各個層級的key,默認展開所有層級
      this.setState({
        treeData: formattedData,
        maxLevel,
        expandedKeys: keys,
      });
    }
  }

  /* 組件props數據更新後更新treeModel和state */
  componentWillReceiveProps(nextProps) {
    let { data, maxLevel = 50 } = nextProps;
    data = toJS(data);
    if (!deepComparison(this.dataOrigin, data)) { // 深比較函數避免不必要的樹更新
      this.dataOrigin = data;
      TreeClass.defaultTreeValueWrapper(this.dataOrigin);
      const formattedData = this.formatTreeData(this.dataOrigin);
      this.updateTreeModel();
      const keys = TreeClass.getTreeKeys(this.dataOrigin);
      this.onDataChange(this.dataOrigin); // 觸發onChange回調鉤子
      this.setState({
        treeData: formattedData,
        maxLevel,
        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
          setParent={this.setAttr}
          focusKey={this.state.focusKey}
          treeData={treeData}
          modifyNode={this.modifyNode}
          addSisterNode={this.addSisterNode}
          getInToEditable={this.getInToEditable}
          addSubNode={this.addSubNode}
          removeNode={this.removeNode}
          setFocus={this.setFocus}
        />);
      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;
  }

  /* 更新TreeModel */
  updateTreeModel = () => {
    this.treeModel = new TreeClass(
      this.dataOrigin, this.key, {
        maxLevel: this.state.maxLevel,
        overLevelTips: this.props.lang.lang.template_tree_max_level_tips,
        completeEditingNodeTips: this.props.lang.lang.pleaseCompleteTheNodeBeingEdited,
        addSameLevelTips: this.props.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>
    );
  }
}

關鍵點說明: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 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 JSON.parse(JSON.stringify(this.treeData));
  }
}

關鍵點說明:TreeNode.jsx

單層樹節點React組件

每個層級節點都可以添加子節點、添加同級節點、編輯節點名、編輯節點值、刪除當前節點(一併刪除子節點),nameEditable屬性控制節點名是否可編輯,valueEditable樹形控制節點值是否可編輯,nodeDeletable屬性控制節點是否可以刪除,默認值都是爲true

isInEdit屬性表明當前節點是否處於編輯狀態,處於編輯狀態時顯示輸入框,否則顯示文字,當點擊文字時當前節點變成編輯狀態。

簡單的頁面展示組件,具體實現見 源碼: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避免文字元素超出邊界。

V 結語


其實Tree組件已經不止寫過一次了,之前基於Semantic UI寫過一次,不過因爲Semantic UI沒有Tree的基礎實現,所以基本上是完全自己重寫的,基本思路其實跟這篇文章寫的大致相同,也是遞歸更新渲染節點,將各個節點的摺疊狀態放入state進行受控管理,不過這次實現的EditableTree最主要一點是分離了treeModel的數據管理邏輯,讓界面操作層TreeNode.jsx、數據管理層Tree.js和控制層index.jsx完全分離開來,結構明瞭,後期即使想擴展功能也未嘗不可,總之收穫很大!又是跟Antd鬥智鬥勇的一次呢(苦笑臉)...

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章