给 Antd Table 组件编写缩进指引线、子节点懒加载等功能,如何二次封装开源组件?

开源
在业务需求中,有时候我们需要基于 antd 之类的组件库定制很多功能,本文就以我自己遇到的业务需求为例,一步步实现和优化一个树状表格组件。

[[384776]]

在业务需求中,有时候我们需要基于 antd 之类的组件库定制很多功能,本文就以我自己遇到的业务需求为例,一步步实现和优化一个树状表格组件,这个组件会支持:

  • 每个层级缩进指示线
  • 远程懒加载子节点
  • 每个层级支持分页

本系列分为两篇文章,这篇只是讲这些业务需求如何实现。

而下一篇,我会讲解怎么给组件也设计一套简单的插件机制,来解决代码耦合,难以维护的问题。

功能实现

层级缩进线

antd 的 Table 组件默认是没有提供这个功能的,它只是支持了树状结构:

  1. const treeData = [ 
  2.   { 
  3.     function_name: `React Tree Reconciliation`, 
  4.     count: 100, 
  5.     children: [ 
  6.       { 
  7.         function_name: `React Tree Reconciliation2`, 
  8.         count: 100 
  9.       } 
  10.     ] 
  11.   } 

展示效果如下:

antd-table

 

可以看出,在展示大量的函数堆栈的时候,没有缩进线就会很难受了,业务方也确实和我提过这个需求,可惜之前太忙了,就暂时放一边了。😁

参考 VSCode 中的缩进线效果,可以发现,缩进线是和节点的层级紧密相关的。

vscode

 

比如 src 目录对应的是第一级,那么它的子级 client 和 node 就只需要在 td 前面绘制一条垂直线,而 node 下的三个目录则绘制两条垂直线。

  1. 第 1 层: | text 
  2. 第 2 层: | | text 
  3. 第 3 层: | | | text 

只需要在自定义渲染单元格元素的时候,得到以下两个信息。

  1. 当前节点的层级信息。
  2. 当前节点的父节点是否是展开状态。

所以思路就是对数据进行一次递归处理,把层级写在节点上,并且要把父节点的引用也写上,之后再通过传给 Table 的 expandedRowKeys 属性来维护表格的展开行数据。

这里我是直接改写了原始数据,如果需要保证原始数据干净的话,也可以参考 React Fiber 的思路,构建一颗替身树进行数据写入,只要保留原始树节点的引用即可。

  1. /** 
  2.  * 递归树的通用函数 
  3.  */ 
  4. const traverseTree = ( 
  5.   treeList, 
  6.   childrenColumnName, 
  7.   callback 
  8. ) => { 
  9.   const traverse = (list, parent = nulllevel = 1) => { 
  10.     list.forEach(treeNode => { 
  11.       callback(treeNode, parent, level); 
  12.       const { [childrenColumnName]: next } = treeNode; 
  13.       if (Array.isArray(next)) { 
  14.         traverse(next, treeNode, level + 1); 
  15.       } 
  16.     }); 
  17.   }; 
  18.   traverse(treeList); 
  19. }; 
  20.  
  21. function rewriteTree({ dataSource }) { 
  22.   traverseTree(dataSource, childrenColumnName, (node, parent, level) => { 
  23.     // 记录节点的层级 
  24.     node[INTERNAL_LEVEL] = level 
  25.     // 记录节点的父节点 
  26.     node[INTERNAL_PARENT] = parent 
  27.   }) 

之后利用 Table 组件提供的 components 属性,自定义渲染 Cell 组件,也就是 td 元素。

  1. const components = { 
  2.   body: { 
  3.     cell: (cellProps) => ( 
  4.       <TreeTableCell 
  5.         {...props} 
  6.         {...cellProps} 
  7.         expandedRowKeys={expandedRowKeys} 
  8.       /> 
  9.     ) 
  10.   } 

之后,在自定义渲染的 Cell 中,只需要获取两个信息,只需要根据层级和父节点的展开状态,来决定绘制几条垂直线即可。

  1. const isParentExpanded = expandedRowKeys.includes( 
  2.   record?.[INTERNAL_PARENT]?.[rowKey] 
  3. // 只有当前是展示指引线的列 且父节点是展开节点 才会展示缩进指引线 
  4. if (dataIndex !== indentLineDataIndex || !isParentExpanded) { 
  5.   return <td className={className}>{children}</td> 
  6.  
  7. // 只要知道层级 就知道要在 td 中绘制几条垂直指引线 举例来说: 
  8. // 第 2 层: | | text 
  9. // 第 3 层: | | | text 
  10. const level = record[INTERNAL_LEVEL] 
  11.  
  12. const indentLines = renderIndentLines(level

这里的实现就不再赘述,直接通过绝对定位画几条垂直线,再通过对 level 进行循环时的下标 index 决定 left 的偏移值即可。

效果如图所示:

缩进线

 

远程懒加载子节点

这个需求就需要用比较 hack 的手段实现了,首先观察了一下 Table 组件的逻辑,只有在有children 的子节点上才会展示「展开更多」的图标。

所以思路就是,和后端约定一个字段比如 has_next,之后预处理数据的时候先遍历这些节点,加上一个假的占位 children。

之后在点击展开的时候,把节点上的这个假 children 删除掉,并且把通过改写节点上一个特殊的 is_loading 字段,在自定义渲染 Icon 的代码中判断,并且展示 Loading Icon。

又来到递归树的逻辑中,我们加入这样的一段代码:

  1. function rewriteTree({ dataSource }) { 
  2.   traverseTree(dataSource, childrenColumnName, (node, parent, level) => { 
  3.     if (node[hasNextKey]) { 
  4.       // 树表格组件要求 next 必须是非空数组才会渲染「展开按钮」 
  5.       // 所以这里手动添加一个占位节点数组 
  6.       // 后续在 onExpand 的时候再加载更多节点 并且替换这个数组 
  7.       node[childrenColumnName] = [generateInternalLoadingNode(rowKey)] 
  8.     } 
  9.   }) 

之后我们要实现一个 forceUpdate 函数,驱动组件强制渲染:

  1. const [_, forceUpdate] = useReducer((x) => x + 1, 0) 

再来到 onExpand 的逻辑中:

  1. const onExpand = async (expanded, record) => { 
  2.   if (expanded && record[hasNextKey] && onLoadMore) { 
  3.     // 标识节点的 loading 
  4.     record[INTERNAL_IS_LOADING] = true 
  5.     // 移除用来展示展开箭头的假 children 
  6.     record[childrenColumnName] = null 
  7.     forceUpdate() 
  8.     const childList = await onLoadMore(record) 
  9.     record[hasNextKey] = false 
  10.     addChildList(record, childList) 
  11.   } 
  12.   onExpandProp?.(expanded, record) 
  13.  
  14. function addChildList(record, childList) { 
  15.   record[childrenColumnName] = childList 
  16.   record[INTERNAL_IS_LOADING] = false 
  17.   rewriteTree({ 
  18.     dataSource: childList, 
  19.     parentNode: record 
  20.   }) 
  21.   forceUpdate() 

这里 onLoadMore 是用户传入的获取更多子节点的方法,

流程是这样的:

  1. 节点展开时,先给节点写入一个正在加载的标志,然后把子数据重置为空。这样虽然节点会变成展开状态,但是不会渲染子节点,然后强制渲染。
  2. 在加载完成后赋值了新的子节点 record[childrenColumnName] = childList 后,我们又通过 forceUpdate 去强制组件重渲染,展示出新的子节点。

需要注意,我们递归树加入逻辑的所有逻辑都在 rewriteTree 中,所以对于加入的新的子节点,也需要通过这个函数递归一遍,加入 level, parent 等信息。

新加入的节点的 level 需要根据父节点的 level 相加得出,不能从 1 开始,否则渲染的缩进线就乱掉了,所以这个函数需要改写,加入 parentNode 父节点参数,遍历时写入的 level 都要加上父节点已有的 level。

  1. function rewriteTree({ 
  2.   dataSource, 
  3.   // 在动态追加子树节点的时候 需要手动传入 parent 引用 
  4.   parentNode = null 
  5. }) { 
  6.   // 在动态追加子树节点的时候 需要手动传入父节点的 level 否则 level 会从 1 开始计算 
  7.   const startLevel = parentNode?.[INTERNAL_LEVEL] || 0 
  8.  
  9.   traverseTree(dataSource, childrenColumnName, (node, parent, level) => { 
  10.       parent = parent || parentNode; 
  11.       // 记录节点的层级 
  12.       node[INTERNAL_LEVEL] = level + startLevel; 
  13.       // 记录节点的父节点 
  14.       node[INTERNAL_PARENT] = parent; 
  15.  
  16.     if (node[hasNextKey]) { 
  17.       // 树表格组件要求 next 必须是非空数组才会渲染「展开按钮」 
  18.       // 所以这里手动添加一个占位节点数组 
  19.       // 后续在 onExpand 的时候再加载更多节点 并且替换这个数组 
  20.       node[childrenColumnName] = [generateInternalLoadingNode(rowKey)] 
  21.     } 
  22.   }) 

自定义渲染 Loading Icon 就很简单了:

  1. // 传入给 Table 组件的 expandIcon 属性即可 
  2. export const TreeTableExpandIcon = ({ 
  3.   expanded, 
  4.   expandable, 
  5.   onExpand, 
  6.   record 
  7. }) => { 
  8.   if (record[INTERNAL_IS_LOADING]) { 
  9.     return <IconLoading style={iconStyle} /> 
  10.   } 

功能完成,看一下效果:

远程懒加载

 

每个层级支持分页

这个功能和上一个功能也有点类似,需要在 rewriteTree 的时候根据外部传入的是否开启分页的字段,在符合条件的时候往子节点数组的末尾加入一个占位 Pagination 节点。

之后在 column 的 render 中改写这个节点的渲染逻辑。

改写 record:

  1. function rewriteTree({ 
  2.   dataSource, 
  3.   // 在动态追加子树节点的时候 需要手动传入 parent 引用 
  4.   parentNode = null 
  5. }) { 
  6.   // 在动态追加子树节点的时候 需要手动传入父节点的 level 否则 level 会从 1 开始计算 
  7.   const startLevel = parentNode?.[INTERNAL_LEVEL] || 0 
  8.  
  9.   traverseTree(dataSource, childrenColumnName, (node, parent, level) => { 
  10.     // 加载更多逻辑 
  11.     if (node[hasNextKey]) { 
  12.       // 树表格组件要求 next 必须是非空数组才会渲染「展开按钮」 
  13.       // 所以这里手动添加一个占位节点数组 
  14.       // 后续在 onExpand 的时候再加载更多节点 并且替换这个数组 
  15.       node[childrenColumnName] = [generateInternalLoadingNode(rowKey)] 
  16.     } 
  17.  
  18.     // 分页逻辑 
  19.     if (childrenPagination) { 
  20.       const { totalKey } = childrenPagination; 
  21.       const nodeChildren = node[childrenColumnName] || []; 
  22.       const [lastChildNode] = nodeChildren.slice?.(-1); 
  23.       // 渲染分页器,先加入占位节点 
  24.       if ( 
  25.         node[totalKey] > nodeChildren?.length && 
  26.         // 防止重复添加分页器占位符 
  27.         !isInternalPaginationNode(lastChildNode, rowKey) 
  28.       ) { 
  29.         nodeChildren?.push?.(generateInternalPaginationNode(rowKey)); 
  30.       } 
  31.     } 
  32.   }) 

改写 columns:

  1. function rewriteColumns() { 
  2.   /** 
  3.    * 根据占位符 渲染分页组件 
  4.    */ 
  5.   const rewritePaginationRender = (column) => { 
  6.     column.render = function ColumnRender(text, record) { 
  7.       if ( 
  8.         isInternalPaginationNode(record, rowKey) && 
  9.         dataIndex === indentLineDataIndex 
  10.       ) { 
  11.         return <Pagination /> 
  12.       } 
  13.       return render?.(text, record) ?? text 
  14.     } 
  15.   } 
  16.  
  17.   columns.forEach((column) => { 
  18.     rewritePaginationRender(column
  19.   }) 

来看一下实现的分页效果:

 

重构和优化

随着编写功能的增多,逻辑被耦合在 Antd Table 的各个回调函数之中,

  • 指引线的逻辑分散在 rewriteColumns, components中。
  • 分页的逻辑被分散在 rewriteColumns 和 rewriteTree 中。
  • 加载更多的逻辑被分散在 rewriteTree 和 onExpand 中

至此,组件的代码行数也已经来到了 300 行,大概看一下代码的结构,已经是比较混乱了:

  1. export const TreeTable = (rawProps) => { 
  2.   function rewriteTree() { 
  3.     // 🎈加载更多逻辑 
  4.     // 🔖 分页逻辑 
  5.   } 
  6.  
  7.   function rewriteColumns() { 
  8.     // 🔖 分页逻辑 
  9.     // 🏁 缩进线逻辑 
  10.   } 
  11.  
  12.   const components = { 
  13.     // 🏁 缩进线逻辑 
  14.   } 
  15.  
  16.   const onExpand = async (expanded, record) => { 
  17.     // 🎈 加载更多逻辑 
  18.   } 
  19.  
  20.   return <Table /> 

 

有没有一种机制,可以让代码按照功能点聚合,而不是散落在各个函数中?

  1. // 🔖 分页逻辑 
  2. const usePaginationPlugin = () => {} 
  3. // 🎈 加载更多逻辑 
  4. const useLazyloadPlugin = () => {} 
  5. // 🏁 缩进线逻辑 
  6. const useIndentLinePlugin = () => {} 
  7.  
  8. export const TreeTable = (rawProps) => { 
  9.   usePaginationPlugin() 
  10.  
  11.   useLazyloadPlugin() 
  12.  
  13.   useIndentLinePlugin() 
  14.  
  15.   return <Table /> 

没错,就是很像 VueCompositionAPI 和 React Hook 在逻辑解耦方面所做的改进,但是在这个回调函数的写法形态下,好像不太容易做到?

下一篇文章,我会聊聊如何利用自己设计的插件机制来优化这个组件的耦合代码。

记得关注后加我好友,我会不定期分享前端知识,行业信息。2021 陪你一起度过。

 本文转载自微信公众号「前端从进阶到入院」,可以通过以下二维码关注。转载本文请联系前端从进阶到入院公众号。

 

责任编辑:武晓燕 来源: 前端从进阶到入院
相关推荐

2022-10-17 08:03:47

封装vue组件

2024-03-20 09:31:00

图片懒加载性能优化React

2021-03-04 08:19:29

插件机制代码

2017-03-28 10:11:12

Webpack 2React加载

2021-09-16 14:22:06

微软WinUI 2.7InfoBadge

2022-05-13 08:46:46

jsoneditorjson编辑器

2023-04-10 08:30:30

json编辑器typescript

2021-11-22 10:00:33

鸿蒙HarmonyOS应用

2013-11-12 10:46:04

ChromeChrome32 be

2019-04-24 16:12:59

iOSSiriMacOS

2021-02-04 17:04:22

Python编程语言代码

2021-06-08 11:31:11

WineWaylandVulkan

2010-01-13 13:53:32

VB.NET组件封装

2024-03-13 13:39:21

2022-01-25 10:34:37

微软Edge Cana侧边栏

2021-02-05 07:03:17

微软Edge浏览器

2021-12-05 21:05:49

前端JSON API

2020-10-12 18:42:22

ChromeiPad更新

2017-09-25 14:22:26

Swift4.0正式发布添加归档

2021-07-28 11:40:31

微信借条腾讯
点赞
收藏

51CTO技术栈公众号