Blog Logo

使用Ant Design UI库实现异步加载树形结构文件夹管理

写于2020-04-22 10:50 阅读耗时34分钟 阅读量


上一篇文章介绍如何实现类似百度网盘功能,这一篇文章将继续围绕文件/文件夹相关内容,实现文件/文件夹权限管理功能。

文章标题可能让大家一头雾水,说的是啥意思呢?异步加载?树形结构?文件夹管理?

直接上图来示意:

tree

简单解释:将树形结构的文件夹一层一层加载出资源,展示给用户,同时能够取消和选择文件或文件夹,对其进行权限操作。


1.功能点剖析

看似简单的操作,但实际却麻烦的一逼。 话不多说,先仔细分析其功能点有哪些: 功能一:默认展示出一级目录的文件及文件夹,文件夹里有文件则展示出左侧点击节点,空文件夹或文件则不展示左侧点击节点。

auth_func1


功能二:点击节点可展开该文件夹,且动态加载出该文件夹下的所有文件及文件夹。

auth_fun2


功能三:支持层层点击展开子文件夹。

auth_fun3


功能四:支持重选文件或文件夹,重选的时候,多选框会出现全选、半选、取消状态,简单解释就是父子节点选中状态会有关联。

auth_fun4


功能五:当重选文件或文件夹时,除了父子节点选中状态有改变外,还需动态改变选择的数据。 比如当选择一个文件夹里所有文件的时候,相当于选择该文件夹。 又比如:当从一个选中的文件夹里,少选一个文件的时候,选中的应该是当前文件夹下除开这个文件的所有文件

auth_fun5

举例: 现在有1个文件夹,里面有2个文件夹,1个文件。 id为1的文件夹里,有id为2、3的文件夹,id为4的文件; id为2的文件夹里,有id为5、6的文件; id为3的文件夹里,有id为7,8的文件。

如果我逐个选择id为4、5、6、7、8的文件,相当于选择id为1的文件夹,需要将5个id(4、5、6、7、8)合成1个id(1); 如果我选择id为1的文件夹后,在它的子文件夹,id为3的文件夹里,取消id为7的文件,需要将1个id(1)分成4个id(4、5、6、8);


功能六:支持动态展示已选择的文件数及所有文件夹下的文件总数。(1/5) 展示的都是文件数,而非文件夹数。

auth_fun5

举例: 继续上面的例子,情况一逐个选择文件的时候,展示从1/5...一直到5/5;情况二从选择的文件夹中取消一个文件的时候,展示从5/5变成了4/5。 假如取消child2文件夹,child2文件夹里有2个文件,所以展示从5/5变成3/5。


功能七:当重选文件或文件夹时,依据功能五动态改变选择的数据,展示出已选择文件或文件夹的tag标签信息。一个文件夹名可能会被分解成多个文件名,多个文件名可能会被合成一个文件夹名。同时还支持删除操作,点击该tag标签右上角的删除图标,可以影响该树形结构的文件夹,多选框的全选、半选、取消状态。

auth_fun7


功能八:支持回显已选择的文件或文件夹,且需同步该树形结构的文件夹多选框的选中状态(全选、半选、取消状态)。

auth_fun8

举例: 继续上面的例子,比如我选择(4、5、6、8)的文件,在回显的时候,正确的显示是: tag标签显示:4文件,2文件夹,8文件这三个标签。因为5、6文件,相当于2文件夹; 一级目录显示:id为1的文件夹为半选中状态,展开后,id为2的文件夹为全选状态(2里面的所有文件5、6都已选择),id为3的文件夹为半选状态(尽管3里面的文件8已选择,但7未选择),id为4、5、6、8的文件为全选状态,id为7的文件为取消状态。

auth_func8_1


还有更变态的情况: 比如:id为1的文件夹,该文件夹有三层(1 -> 2 -> 3),文件夹id依次为1、2、3,里面有很多文件,且id为3的文件夹里面有1个id为4的文件。 我只选择该文件夹下子文件夹里id为4的文件。 当我回显的时候,默认只能获取一层的文件夹信息,因为文件夹是异步加载的,接口只允许一层一层加载出资源,如果一次性把资源给出来,性能会有问题。 没问题呀?有问题! 在回显的时候,只能获取文件夹id为1里面的文件和文件夹信息,并不能获取该文件夹的子文件夹的子文件夹id为3的id为4的文件信息,所以选择id为3的文件夹里面id为4的文件时,并不能确定一级目录、二级目录、三级目录的选中状态,无法实现回显功能。怎么解决这个问题呢?后面会提到。

auth_fun8_2

正确的显示是: tag标签显示:4文件这一个标签; 一级目录显示:id为1的文件夹为半选状态,id为2的子文件夹为半选状态,id为3的子文件夹为半选状态,id为4的文件为全选状态。


是不是光看功能点就已经够复杂了? 这个业务如果不维护父子节点关联,多选框的全选、半选、取消状态不支持回显树形结构的数据一次性给完没有文件和文件夹来回转换的话其实挺简单的。但偏偏都必须实现,为了用户体验和性能,那么只能硬着干了。 为了实现这些功能点,于是乎才有了标题说到的异步加载树形结构状态关联文件夹管理。 整块涉及的Antd UI组件有:树形控件Tree、目录树形控件DirectoryTree、树形节点TreeNode。 下面对这些功能点,进行一个一个的详细讲解。


2.展示出一级目录

默认展示出一级目录的文件及文件夹,文件夹里有文件则展示出左侧点击节点,空文件夹或文件则不展示左侧点击节点。

auth_func1

import { Tree } from 'antd'
const { TreeNode, DirectoryTree } = Tree
...
render() {
    const { tree: { treeData } } = this.props
    return(
        <DirectoryTree checkable>
            {
            treeData.map(item => (
              <TreeNode key={item.key}
                isLeaf={item.isLeaf}
                title={item.title}
                icon={<img src={item.icon} className={styles.icon}/>}
                dataRef={item} />))
          }
        </DirectoryTree>  
    )
}

这些属性的内容可参考 TreeNode 文档:

treenode_api

值得注意的 TreeNode 属性有: key属性一定是字符串,并非整数,如果id是整型,需要转成id字符串。 isLeaf属性可以控制左侧点击节点是否显示,false则显示左侧节点,true则隐藏左侧节点。 icon属性是ReactNode,及react组件,并非字符串。 dataRef不属于 TreeNode 文档中的属性,为自定义属性,可以在treeNode.props.dataRef中获取到存储的值。

值得注意的 Tree 属性有: checkable属性,显示出节点前面的复选框。


3.点击节点展开文件夹

点击节点可展开该文件夹,且动态加载出该文件夹下的所有文件及文件夹。

auth_fun2

要想实现这个功能,首先明确数据结构treeData是怎样的?

[{
    title: "img_test4",
    key: "1",
    icon: "xxx.png",
    isLeaf: false
},{
    title: "img_test3",
    key: "2",
    icon: "xxx.png",
    isLeaf: false
},{
    title: "img_test2",
    key: "3",
    icon: "xxx.png",
    isLeaf: false
},{
    title: "img_test1",
    key: "4",
    icon: "xxx.png",
    isLeaf: true
}]

当展开 img_test4 文件夹的时候,相当于在该数组中,找到对应的文件夹对象,然后新增一个children字段,存放当前文件夹下的子文件夹或文件。

[{
    title: "img_test4",
    key: "1",
    icon: "xxx.png",
    isLeaf: false,
    // 新增的children
    children: [{
        title: "child",
        key: "5",
        icon: "xxx.png",
        isLeaf: false
    },{
        title: "child2",
        key: "6",
        icon: "xxx.png",
        isLeaf: false
    },{
        title: "child3",
        key: "7",
        icon: "xxx.png",
        isLeaf: false
    },{
        title: "test_word.docx",
        key: "8",
        icon: "xxx.png",
        isLeaf: true
    }]
},{
    title: "img_test3",
    key: "2",
    icon: "xxx.png",
    isLeaf: false
},{
    title: "img_test2",
    key: "3",
    icon: "xxx.png",
    isLeaf: false
},{
    title: "img_test1",
    key: "4",
    icon: "xxx.png",
    isLeaf: true
}]

数据改变后,我们需要使用递归算法,去生成相应的树形DOM结构。

render() {
    const { tree: { treeData } } = this.props
    return(
        <DirectoryTree checkable>
            {{this.renderTreeNodes(treeData)}}
        </DirectoryTree>  
    )
}
...
  renderTreeNodes = data => data.map(item => {
    if (item.children) {
      return (
        <TreeNode key={item.key}
        isLeaf={item.isLeaf}
        title={item.title}
        icon={<img src={item.icon} className={styles.icon}/>}>
          {this.renderTreeNodes(item.children)}
        </TreeNode>
      );
    }
    return <TreeNode key={item.key}
        isLeaf={item.isLeaf}
        title={item.title}
        icon={<img src={item.icon} className={styles.icon}/>} 
        />;
  });

思路解析:判断当前是否有children字段,有则一定有子文件夹或文件,因此isLeaf一定为flase,有左侧节点。isLeaf默认是false,因此可以去掉定义的isLeaf属性。 子文件夹可能还会有子子文件夹,因此需要继续递归renderTreeNodes方法。 递归的停止条件是判断当前文件夹对象中是否拥有children字段,没有则停止。


4.支持层层点击展开子文件夹

支持层层点击展开子文件夹。

auth_fun3

想实现这个功能,除了渲染树形DOM结构时使用递归外(上一节已经实现),还需要在获取单层数据时使用递归,组装出数据结构treeData。 因为我们会不停的展开文件夹,同层展开也好,一层一层展开也好,都需要新增children字段存放单层数据。 比如:同层依次展开img_test4、img_test3、img_test2、img_test1文件夹, 数据结构treeData变化:

[{},{},{},{}]

[{ children:[...] },{},{},{}]

[{ children:[...] },{ children:[...] },{},{}]

[{ children:[...] },{ children:[...] },{ children:[...] },{}]

[{ children:[...] },{ children:[...]},{ children:[...] },{ children:[...] }]

再比如:上图中层层展开img_test4、child、child2、child2_1、child3文件夹,数据结构treeData变化:

[{},{},{},{}]

[{children:[{},{},{},{}] },{},{},{}]

[{children:[{ children:[...] },{},{},{}] },{},{},{}]

[{children:[{ children:[...] },{ children:[...] },{},{}]},{},{},{}]

[{children:[{ children:[...] },{ children:[ {children:[...] }, {}, {} ] },{},{}]},{},{},{}]

[{children:[{ children:[...] },{ children:[ {children:[...] }, { children:[...] }, {} ] },{},{}]},{},{},{}]

思路分析的差不多了,来看下获取和组装treeData的具体实现: 首先页面上DirectoryTree新增loadData方法,该方法展开节点时触发,注意这个方法不是普通的function,而是Promise

render() {
    const { tree: { treeData } } = this.props
    return(
        <DirectoryTree checkable
        loadData={this.onLoadData}>
            {{this.renderTreeNodes(treeData)}}
        </DirectoryTree>
    )
}
...
componentDidMount() {
    // 获取根目录数据,也就是treeData第一层数据
    this.fetchGetFile(0)
}
...
fetchGetFile = (folder_id) => {
    const {
      dispatch,
    } = this.props
    dispatch({
      type: 'tree/getFolderTreeList',
      payload: {
        folder_id,
      },
    })
}
...
onLoadData = treeNode => new Promise(resolve => {
    const { children, eventKey } = treeNode.props
    // 判断节点是否有children属性,有的话,就不需要异步加载
    if (children) {
      resolve();
      return;
    }
    // 增加500ms延迟,加载圈出来,优化用户体验
    // 让用户知道子文件或文件是异步加载出来的
    setTimeout(() => {
      // 获取当前节点的key,能获取点击目录这层的数据
      this.fetchGetFile(parseInt(eventKey, 10))
      resolve()
    }, 500);
});
...
namespace: 'tree',
effects:{
    // 获取一层的目录数据
    * getFolderTreeList({ payload }, { call, put, select }) {
      const url = '/partner/folder/get'
      const response = yield call(sendPostRequest, url, payload)
      if (response && response.retcode === 'success') {
        const { children } = response.data
        let treeData = yield select(state => state.tree.treeData);
        if (treeData.length === 0) {
          // 如果是第一层,则是获取根目录数据的操作
          // 将后台的数据转成tree格式的数据,放入treeData
          treeData = dataToTree(children)
        } else {
          // 如果不是第一层,则是展开目录的操作
          // 递归treeData数据,找到点击的目录id对象
          // 新增children字段存放单层数据
          treeData = mapTree(treeData, payload.folder_id, dataToTree(children))
        }
        yield put({
          type: 'save',
          payload: {
            treeData,
          },
        })
      } else {
        notification.error({ message: response.description });
      }
    },
}
...
// 数据换成树形结构数据
const dataToTree = data => {
  const treeData = []
  if (data.length > 0) {
    data.map(item => {
      let tree = {}
      if (item.file_count === 0 && item.folder_count === 0) {
        tree.isLeaf = true
      } else {
        tree.isLeaf = false
      }
      tree.title = item.name
      tree.key = item.id
      tree.icon = item.icon_url
      treeData.push(tree)
    })
  }
  return treeData
}
...
// 递归遍历存children
const mapTree = (treeData, id, childTreeData) => {
  treeData.map(tree => {
    // 如果有文件夹或文件
    if (!tree.isLeaf) {
      // 判断该文件夹id是否等于点击的目录id
      // 等于则新增children
      if (tree.key === id) {
        tree.children = childTreeData
      } else if (tree.children) {
      // 判断当前文件夹是否还有子文件夹或文件,有的话,继续递归
        mapTree(tree.children, id, childTreeData)
      }
    }
  })
  return treeData
}

判断递归的开始条件是当前目录是否有子文件夹或文件,有则开始递归。 判断递归的停止条件是在treeData数据中,找到点击的目录id对象,找到则停止。


5.父子节点选中状态会有关联

支持重选文件或文件夹,重选的时候,多选框会出现全选、半选、取消状态,简单解释就是父子节点选中状态会有关联。

auth_fun4

这个功能实现起来其实不难,在DirectoryTree新增onCheck方法和checkedKeys属性,两者配合实现该功能。

render() {
    const { tree: { treeData, checkedKeys } } = this.props
    return(
        <DirectoryTree checkable
        loadData={this.onLoadData}
        checkedKeys={checkedKeys}
        onCheck={this.onCheck}
        >
            {{this.renderTreeNodes(treeData)}}
        </DirectoryTree>
    )
}
...
onCheck = (checkeds) => {
    const { dispatch } = this.props
    dispatch({
      type: 'tree/save',
      payload: {
        checkedKeys: checkeds,
      },
    })
};
...
namespace: 'tree',
reducers:{
    save(state, { payload }) {
      return {
        ...state,
        ...payload,
      }
    },
}

Antd Tree默认就实现父子节点选中状态有关联的状态,如果想取消父子节点的关联,加上checkStrictly属性。


6.动态改变选择的数据

当重选文件或文件夹时,除了父子节点选中状态有改变外,还需动态改变选择的数据。

介绍该功能点的时候,可能不理解这句话是什么含义。直接举例看效果: 我们先在onCheck方法中输出antd返回的选择的id数组checkeds:

onCheck = (checkeds) => {
    const { dispatch } = this.props
    dispatch({
      type: 'tree/save',
      payload: {
        checkedKeys: checkeds,
      },
    })
};

图1是我未展开img_test_1文件夹,全选img_test_1文件夹,打印的结果:

tree_dg0

图2是我展开img_test_1文件夹后,全选img_test_1文件夹,打印的结果:

tree_dg1

图3是我展开img_test_1文件夹后,再展开child_3文件夹后,全选img_test_1文件夹,打印的结果:

tree_dg2


可以发现尽管我们操作的都是同一个文件夹img_test_1,全选后,打印的结果却不尽相同,返回选中的id个数分别是1、5、8。我们需要把5个、8个的数组合成1个。 因为当选择一个文件夹里所有文件的时候,相当于选择该文件夹。因此,我们需要获取全选的父节点id,其下面的子节点id都可以忽略


我们继续图3的操作,取消子文件夹child3的其中一个文件,打印的结果:

tree_dg4

明明只取消了一个文件,id怎么从8变成5了?去除了哪3个id呢? 分别去除了全选的img_test_1文件夹父节点id、全选的child_3文件夹节点id、及取消的pdf_8文件id。因此,该逻辑,没毛病。

如何在onCheck中,将多个id合成一个父id,成为了解决该功能的关键。 这只是一个img_test_1文件夹下的操作,还有其他文件夹下都需要合父id的操作,因此又需要使用递归。

具体实现:

onCheck = (checkeds) => {
    const { dispatch, tree: { treeData } } = this.props
    let checkedKeys = []
    const mapTree = (trees, keys, list) => trees.map(tree => {
      const key = tree.key.toString()
      // 从treeData里面找到包含有选中的id
      // 找到的话将这个id,push到list,且不需要递归它的children
      if (keys.includes(key)) {
        list.push(key)
        checkedKeys = list
      } else if (tree.children) {
        // 判断当前文件夹是否还有子文件夹或文件,有的话,继续递归
        // 继续找id
        mapTree(tree.children, keys, list)
      }
    })
    // 递归方法
    mapTree(treeData, checkeds, [])
    dispatch({
      type: 'tree/save',
      payload: {
        checkedKeys,
      },
    })
};

7.动态展示已选择的文件数

支持动态展示已选择的文件数及所有文件夹下的文件总数。 展示的都是文件数,而非文件夹数。

auth_fun5

实现这个功能也比较简单,需要后台能返回,文件类型(file 或 folder)和文件数量(file_count)。

在转换成treeData数据时,新增一个count字段:

// 数据换成树形结构数据
const dataToTree = data => {
  const treeData = []
  if (data.length > 0) {
    data.map(item => {
      let tree = {}
      ...
      // 类型如果是文件夹,则文件数量为后台计算的文件总数
      // 类似如果是文件,则文件数量为1
      tree.count = item.obj_type === 'folder' ? item.file_count : 1
      ...
      treeData.push(tree)
    })
  }
  return treeData
}

在onCheck的递归算法中,将选中的文件数量count加起来,则是已选择的总文件数。

render() {
    const { tree: { checkedCount, total } } = this.props
    return(
        <div className={styles.title}>
            已选择授权文件({checkedCount || 0}/{total || 0})
        </div>
    )
}
...
onCheck = (checkeds) => {
    const { dispatch, tree: { treeData } } = this.props
    let checkedKeys = []
    let checkedCount = 0
    const mapTree = (trees, keys, list) => trees.map(tree => {
      const key = tree.key.toString()
      if (keys.includes(key)) {
        list.push(key)
        checkedKeys = list
        // 新增的逻辑
        checkedCount += tree.count
      } else if (tree.children) {
        mapTree(tree.children, keys, list)
      }
    })
    mapTree(treeData, checkeds, [])
    dispatch({
      type: 'tree/save',
      payload: {
        checkedKeys,
        checkedCount, // 新增的checkedCount
      },
    })
};

那所有文件夹的总数total呢? 获取第一层文件夹的file_count,即为总数量。

namespace: 'tree',
effects:{
    // 获取一层的目录数据
    * getFolderTreeList({ payload }, { call, put, select }) {
      const url = '/partner/folder/get'
      const response = yield call(sendPostRequest, url, payload)
      if (response && response.retcode === 'success') {
        const { children } = response.data
        let treeData = yield select(state => state.tree.treeData);
        if (treeData.length === 0) {
          treeData = dataToTree(children)
          // 新增的逻辑
          yield put({
            type: 'save',
            payload: {
              total: file_count,
            },
          })
        } else {
          ...
        }
        ...
      } else {
        notification.error({ message: response.description });
      }
    },
}

8.动态展示已选择文件或文件夹的tag标签信息及删除操作

当重选文件或文件夹时,依据动态改变选择的数据,展示出已选择文件或文件夹的tag标签信息。一个文件夹名可能会被分解成多个文件名,多个文件名可能会被合成一个文件夹名。同时还支持删除操作,点击该tag标签右上角的删除图标,可以影响该树形结构的文件夹,多选框的全选、半选、取消状态。

auth_fun7

只要实现核心的在选择的时候,动态要么合并要么分解id的功能后,该功能点就不难实现了:

render() {
    const { tree: { checkedTags } } = this.props
    return(
        <div className={styles.tags}>
              {checkedTags.map(file => (
                <div className={styles.tag_container} key={file.id}>
                  <div className={styles.tag}>{file.name}</div>
                  <div className={styles.close} onClick={() => { this.delChecked(file.id) }}></div>
                </div>
              ))}
        </div>
    )
}
...
onCheck = (checkeds) => {
    const { dispatch, tree: { treeData } } = this.props
    let checkedKeys = []
    let checkedCount = 0
    let checkedTags = []
    const mapTree = (trees, keys, list) => trees.map(tree => {
      const key = tree.key.toString()
      if (keys.includes(key)) {
        list.push(key)
        checkedKeys = list
        checkedCount += tree.count
        // 新增的逻辑
        checkedTags.push({ id: key, name: tree.title, count: tree.count })
      } else if (tree.children) {
        mapTree(tree.children, keys, list)
      }
    })
    mapTree(treeData, checkeds, [])
    dispatch({
      type: 'tree/save',
      payload: {
        checkedKeys,
        checkedCount,
        checkedTags, // 新增的checkedTags
      },
    })
};
...
// 删除的逻辑,通过findIndex找到点击删除的id
// 匹配到,则将其删除,删除的还挺多的
// 包括treeData选中checkedKeys,标签信息checkedTags,及已选择数量checkedCount
delChecked = (id) => {
    const { tree: { checkedTags, checkedKeys }, dispatch } = this.props
    let checkedCount = 0
    checkedKeys.splice(checkedKeys.findIndex(item => item === id), 1)
    checkedTags.splice(checkedTags.findIndex(item => item.id === id), 1)
    checkedTags.map(checked => {
      checkedCount += checked.count
    })
    dispatch({
      type: 'tree/save',
      payload: {
        checkedKeys,
        checkedTags,
        checkedCount,
      },
    })
  }

9.支持回显已选择的文件或文件夹到树形中

支持回显已选择的文件或文件夹,且需同步该树形结构的文件夹多选框的选中状态(全选、半选、取消状态)。 首先明确,回显的三个地方: 1.树形DOM结构 2.标签信息 3.已选择数量

回显标签信息和已选择数量,应该不难,难点在于树形DOM结构的回显。

其次明确,树形DOM结构回显的两个条件: 1.需要已选择的文件夹或文件夹id,有checkedKeys 2.需要完整的树形DOM结构,有treeData

想实现树形DOM结构的回显功能,需使用checkedKeys属性,与treeData数据生成相应的树形DOM结构去作比较,就能实现回显。 但目前难就难在,数据是一层一层给的,treeData渲染出的树形DOM其实是不完整的

auth_fun8_1


auth_fun8_2

当时分析出三种方案: 方案一:使用原生DOM直接去操作该Tree,人为去添加选中状态(全选、半选、取消选择) 方案二:递归异步请求,一层一层获取,每个文件所在的父目录都请求一遍 方案三:找到所有文件的父目录,去重后都请求一遍

当然三种方案都试过,方案一会出来各种奇葩问题。理由也很简单,Antd组件自身封装一系列DOM事件及属性,有它自身的逻辑,强加自己的逻辑在DOM上,逻辑会混乱。方案二递归去实现,会重复请求一个目录很多次,导致出来有很多无效请求。最终方案用的方案三去实现,找到所有文件的父目录,然后去重,模拟一层一层展开的方式去请求数据。

思路分析:必须渲染出完整的树形DOM结构,该完整不是说把所有目录所有子目录都请求一遍,不是递归,而是根据选中的文件夹或文件集合,找到它们相应的公共父目录id,和自己的父目录id,这样会减少很多无效请求。 实现稍微有些复杂,首先后台会返回一个list,比如:

grant_list = [
    { id:xxx, name:xx, parent_path_list:["1035", "1410", "1416", "1440"] },
    { id:xxx, name:xx, parent_path_list:["1035", "1388", "1393", "1396"] },
    { id:xxx, name:xx, parent_path_list:["1035", "1363", "1372", "1396"] },
]

parent_path_list表示当前文件夹或文件的父级id集合。

再来看看JS实现:

namespace: 'tree',
effects:{
    // 获取一层的目录数据
    * getFolderTreeList({ payload }, { call, put, select }) {
      const url = '/partner/folder/get'
      const response = yield call(sendPostRequest, url, payload)
      if (response && response.retcode === 'success') {
        const { children } = response.data
        let treeData = yield select(state => state.tree.treeData);
        if (treeData.length === 0) {
          treeData = dataToTree(children)
          // 新增的逻辑
          yield put({
            type: 'getGrantInfo',
            payload: {
              target_id: payload.target_id,
            },
          })
        } else {
          ...
        }
        yield put({
          type: 'save',
          payload: {
            treeData,
          },
        })
      } else {
        notification.error({ message: response.description });
      }
    },
}
...
// 可以获取到选中的文件夹或文件信息
* getGrantInfo({ payload }, { call, put }) {
      const url = '/partner/folder/grant-info'
      const response = yield call(sendPostRequest, url, payload)
      if (response && response.retcode === 'success') {
        let grant = response.data
        let checkedCount = 0
        const checkedKeys = []
        const checkedTags = []
        let needRequestIds = []
        grant.grant_list.map(e => {
          let count = e.obj_type === 'folder' ? e.file_count : 1
          checkedCount += count
          checkedTags.push({ id: e.id.toString(), name: e.name, count })
          checkedKeys.push(e.id.toString())
          // 获取选中的文件或文件夹的父目录id,将其放在一个目录里面
          needRequestIds.push(...e.parent_path_list)
        })
        // 遍历去请求
        // 将所有父目录id去重
        needRequestIds = Array.from(new Set(needRequestIds))
        // 删除当前父目录id,可以不用请求
        needRequestIds.splice(needRequestIds.findIndex(item => item === payload.pid.toString()), 1)
        const delay = (timeout) => new Promise((resolve) => {
          setTimeout(resolve, timeout);
        })
        // 得到最终的父目录id集合,同步请求它们
        for (let i = 0; i < needRequestIds.length; i++) {
          // 同步的时候延迟50ms
          yield call(delay, 50);
          yield put({
            type: 'getFolderTreeList',
            payload: {
              partner_id: localStorage.getItem('partnerId'),
              folder_id: parseInt(needRequestIds[i], 10),
              target_id: payload.target_id,
            },
          })
        }
        // treeData生成树形DOM结构需要时间,再延迟200ms
        yield call(delay, 200);
        // 最后赋值checkedKeys,渲染出全选、半选、取消状态
        // 赋值checkedTags,出来标签信息
        // 赋值checkedCount,出来选中数量
        yield put({
          type: 'save',
          payload: {
            checkedCount,
            checkedKeys,
            checkedTags,
          },
        })
      } else {
        notification.error({ message: response.description });
      }
},

总结一下步骤: 1.筛选出父级目录,(有序的一层一层的目录)

// 所有选中文件夹或文件的父级目录集合
["1035", "1410", "1416", "1440", "1035", "1388", "1393", "1396", "1035", "1363", "1372"]

// 父级目录去重后集合
["1035", "1410", "1416", "1440", "1388", "1393", "1396", "1363", "1372"]

// 删除当前目录的id
["1410", "1416", "1440", "1388", "1393", "1396", "1363", "1372"]

2.同步请求父级目录,将请求数据挨个放入渲染树形结构的treeData 3.赋值treeData成功,等待树形DOM结构渲染好 4.赋值checkedKeys,实现选中状态的渲染; 赋值checkedCount实现已选择数量的渲染; 赋值checkedTags实现标签信息的渲染。


这个方案还不是最佳的解决方案,因为体验不够好,因为必须等待所有请求完成后的treeData,等待渲染后(延迟200ms),才能集体出效果。

优化前:

tree_before

优化后:

tree_after

优化后方案: 1.筛选出父级目录,(有序的一层一层的目录) 2.赋值checkedCount实现已选择数量的渲染 3.同步请求父级目录,将请求数据挨个放入渲染树形结构的treeData,同时 赋值checkedKeys、checkedTags,实现选中状态和标签信息的实时渲染。

这个方案的体验就比较好了,能实时看到选中状态和标签信息的变化,不需要等待treeData全部赋值成功,才看到渲染效果。

看看JS最终实现:

namespace: 'tree',
effects:{
    // 获取一层的目录数据
    * getFolderTreeList({ payload }, { call, put, select }) {
      const url = '/partner/folder/get'
      const response = yield call(sendPostRequest, url, payload)
      if (response && response.retcode === 'success') {
        const { children } = response.data
        let treeData = yield select(state => state.tree.treeData);
        if (treeData.length === 0) {
          treeData = dataToTree(children)
          // 新增的逻辑,多传个children
          yield put({
            type: 'getGrantInfo',
            payload: {
              target_id: payload.target_id,
              children,
            },
          })
        } else {
          // 新增的逻辑,判断当前children中是否存在选中的ids
          // 存在则赋值checkedKeys、checkedTags渲染出选中状态和标签信息
          const defaultCheckedKeys = yield select(state => state.tree.defaultCheckedKeys);
          checkedKeys = yield select(state => state.tree.checkedKeys);
          checkedTags = yield select(state => state.tree.checkedTags);
          children.map(child => {
            if (defaultCheckedKeys.some(id => id === child.id)) {
              let count = child.obj_type === 'folder' ? child.file_count : 1
              checkedKeys.push(child.id.toString())
              checkedTags.push({ id: child.id.toString(), name: child.name, count })
            }
          })
        }
        yield put({
          type: 'save',
          payload: {
            treeData,
          },
        })
      } else {
        notification.error({ message: response.description });
      }
    },
}
...
// 获取资源管理所有文件列表
    * getGrantInfo({ payload }, { call, put }) {
      const url = '/partner/folder/grant-info'
      const response = yield call(sendPostRequest, url, payload)
      if (response && response.retcode === 'success') {
        let grant = response.data
        let checkedCount = 0
        const checkedKeys = []
        const checkedTags = []
        const defaultCheckedKeys = []
        let needRequestIds = []
        // 新增的逻辑,判断当前children中是否存在选中的ids
        // 存在则赋值checkedKeys、checkedTags渲染出选中状态和标签信息
        payload.children.map(child => {
          if (grant.grant_list.some(item => item.id === child.id)) {
            let count = child.obj_type === 'folder' ? child.file_count : 1
            checkedKeys.push(child.id.toString())
            checkedTags.push({ id: child.id.toString(), name: child.name, count })
          }
        })
        grant.grant_list.map(e => {
          let count = e.obj_type === 'folder' ? e.file_count : 1
          checkedCount += count
          defaultCheckedKeys.push(e.id)
          // 获取选中的文件或文件夹的父目录id,将其放在一个目录里面
          needRequestIds.push(...e.parent_path_list)
        })
        // 赋值checkedCount,渲染选中数量
        yield put({
          type: 'save',
          payload: {
            checkedCount,
            defaultCheckedKeys,
            checkedKeys,
            checkedTags,
          },
        })
        // 遍历去请求
        // 将所有父目录id去重
        needRequestIds = Array.from(new Set(needRequestIds))
        // 删除当前父目录id,可以不用请求
        needRequestIds.splice(needRequestIds.findIndex(item => item === payload.pid.toString()), 1)
        const delay = (timeout) => new Promise((resolve) => {
          setTimeout(resolve, timeout);
        })
        // 得到最终的父目录id集合,同步请求它们
        for (let i = 0; i < needRequestIds.length; i++) {
          // 同步的时候延迟50ms
          yield call(delay, 50);
          yield put({
            type: 'getFolderTreeList',
            payload: {
              partner_id: localStorage.getItem('partnerId'),
              folder_id: parseInt(needRequestIds[i], 10),
              target_id: payload.target_id,
            },
          })
        }
      } else {
        notification.error({ message: response.description });
      }
},

至此,所有功能的实现就介绍到这了。

Headshot of Maxi Ferreira

怀着敬畏之心,做好每一件事。