如何编写神奇的「插件机制」,优化基于 Antd Table 封装表格的混乱代码

开发 前端
以及开发过程中出现代码的耦合,难以维护问题,进而延伸探索插件机制在组件中的设计和使用,虽然本文设计的插件还是最简陋的版本,但是原理大致上如此,希望能够对你有所启发。

[[385120]]

前言

最近在一个业务需求中,我通过在 Antd Table 提供的回调函数等机制中编写代码,实现了这些功能:

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

最后实现的效果大概是这样的:

最终效果

这篇文章我想聊聊我在这个需求中,对代码解耦,为组件编写插件机制的一些思考。

重构思路

随着编写功能的增多,逻辑被耦合在 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 />; 
  21. }; 

这时候缺点就暴露出来了,当我想要改动或者删减其中一个功能的时候变得异常痛苦,经常在各个函数之间跳转查找。

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

  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 />; 
  16. }; 

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

这时候,我回想到社区中一些开源框架提供的插件机制,好像就可以在不深入源码的情况下注入各个回调时机的用户逻辑。

比如 Vite 的插件[1]、Webpack 的插件[2] 甚至大家很熟悉的 Vue.use()[3],它们本质上就是对外暴露出一些内部的时机和属性,让用户去写一些代码来介入框架运行的各个时机之中。

那么,我们是否可以考虑把「处理每个节点、column、每次 onExpand」 的时机暴露出去,这样让用户也可以介入这些流程,去改写一些属性,调用一些内部方法,以此实现上面的几个功能呢?

我们设计插件机制,想要实现这两个目标:

  1. 逻辑解耦,把每个小功能的代码整合到插件文件中去,不和组件耦合起来,增加可维护性。
  2. 用户共建,内部使用的话方便同事共建,开源后方便社区共建,当然这要求你编写的插件机制足够完善,文档足够友好。

不过插件也会带来一些缺点,设计一套完善的插件机制也是非常复杂的,像 Webpack、Rollup、Redux 的插件机制都有设计的非常精良的地方可以参考学习。

接下来,我会试着实现的一个最简化版的插件系统。

源码

首先,设计一下插件的接口:

  1. export interface TreeTablePlugin<T = any> { 
  2.   (props: ResolvedProps, context: TreeTablePluginContext): { 
  3.     /** 
  4.      * 可以访问到每一个 column 并修改 
  5.      */ 
  6.     onColumn?(column: ColumnProps<T>): void; 
  7.     /** 
  8.      * 可以访问到每一个节点数据 
  9.      * 在初始化或者新增子节点以后都会执行 
  10.      */ 
  11.     onRecord?(record): void; 
  12.     /** 
  13.      * 节点展开的回调函数 
  14.      */ 
  15.     onExpand?(expanded, record): void; 
  16.     /** 
  17.      * 自定义 Table 组件 
  18.      */ 
  19.     components?: TableProps<T>['components']; 
  20.   }; 
  21.  
  22. export interface TreeTablePluginContext { 
  23.   forceUpdate: React.DispatchWithoutAction; 
  24.   replaceChildList(record, childList): void; 
  25.   expandedRowKeys: TableProps<any>['expandedRowKeys']; 
  26.   setExpandedRowKeys: (v: string[] | number[] | undefined) => void; 

我把插件设计成一个函数,这样每次执行都可以拿到最新的 props 和 context。

context 其实就是组件内一些依赖上下文的工具函数等等,比如 forceUpdate, replaceChildList 等函数都可以挂在上面。

接下来,由于插件可能有多个,而且内部可能会有一些解析流程,所以我设计一个运行插件的 hook 函数 usePluginContainer:

  1. export const usePluginContainer = ( 
  2.   props: ResolvedProps, 
  3.   context: TreeTablePluginContext 
  4. ) => { 
  5.   const { plugins: rawPlugins } = props; 
  6.  
  7.   const plugins = rawPlugins.map(usePlugin => usePlugin?.(props, context)); 
  8.  
  9.   const container = { 
  10.     onColumn(column: ColumnProps<any>) { 
  11.       for (const plugin of plugins) { 
  12.         plugin?.onColumn?.(column); 
  13.       } 
  14.     }, 
  15.     onRecord(record, parentRecord, level) { 
  16.       for (const plugin of plugins) { 
  17.         plugin?.onRecord?.(record, parentRecord, level); 
  18.       } 
  19.     }, 
  20.     onExpand(expanded, record) { 
  21.       for (const plugin of plugins) { 
  22.         plugin?.onExpand?.(expanded, record); 
  23.       } 
  24.     }, 
  25.     /** 
  26.      * 暂时只做 components 的 deepmerge 
  27.      * 不处理自定义组件的冲突 后定义的 Cell 会覆盖前者 
  28.      */ 
  29.     mergeComponents() { 
  30.       let components: TableProps<any>['components'] = {}; 
  31.       for (const plugin of plugins) { 
  32.         components = deepmerge.all([ 
  33.           components, 
  34.           plugin.components || {}, 
  35.           props.components || {}, 
  36.         ]); 
  37.       } 
  38.       return components; 
  39.     }, 
  40.   }; 
  41.  
  42.   return container; 
  43. }; 

目前的流程很简单,只是把每个 plugin 函数调用一下,然后提供对外的包装接口。mergeComponent 使用deepmerge[4] 这个库来合并用户传入的 components 和 插件中的 components,暂时不做冲突处理。

接着就可以在组件中调用这个函数,生成 pluginContainer:

  1. export const TreeTable =  React.forwardRef((props, ref) => { 
  2.   const [_, forceUpdate] = useReducer((x) => x + 1, 0) 
  3.  
  4.   const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]) 
  5.  
  6.   const pluginContext = { 
  7.     forceUpdate, 
  8.     replaceChildList, 
  9.     expandedRowKeys, 
  10.     setExpandedRowKeys 
  11.   } 
  12.  
  13.   // 对外暴露工具方法给用户使用 
  14.   useImperativeHandle(ref, () => ({ 
  15.     replaceChildList, 
  16.     setNodeLoading, 
  17.   })); 
  18.  
  19.   // 这里拿到了 pluginContainer 
  20.   const pluginContainer = usePluginContainer( 
  21.     { 
  22.       ...props, 
  23.       plugins: [usePaginationPlugin, useLazyloadPlugin, useIndentLinePlugin], 
  24.     }, 
  25.     pluginContext 
  26.   ); 
  27.  
  28. }) 

之后,在各个流程的相应位置,都通过 pluginContainer 执行相应的钩子函数即可:

  1. export const TreeTable = React.forwardRef((props, ref) => { 
  2.   // 省略上一部分代码…… 
  3.  
  4.   // 这里拿到了 pluginContainer 
  5.   const pluginContainer = usePluginContainer( 
  6.     { 
  7.       ...props, 
  8.       plugins: [usePaginationPlugin, useLazyloadPlugin, useIndentLinePlugin], 
  9.     }, 
  10.     pluginContext 
  11.   ); 
  12.  
  13.   // 递归遍历整个数据 调用钩子 
  14.   const rewriteTree = ({ 
  15.     dataSource, 
  16.     // 在动态追加子树节点的时候 需要手动传入 parent 引用 
  17.     parentNode = null
  18.   }) => { 
  19.     pluginContainer.onRecord(parentNode); 
  20.  
  21.     traverseTree(dataSource, childrenColumnName, (node, parent, level) => { 
  22.       // 这里执行插件的 onRecord 钩子 
  23.       pluginContainer.onRecord(node, parent, level); 
  24.     }); 
  25.   } 
  26.  
  27.   const rewrittenColumns = columns.map(rawColumn => { 
  28.     //  这里把浅拷贝过后的 column 暴露出去 
  29.     //  防止污染原始值 
  30.     const column = Object.assign({}, rawColumn); 
  31.     pluginContainer.onColumn(column); 
  32.     return column
  33.   }); 
  34.  
  35.   const onExpand = async (expanded, record) => { 
  36.     // 这里执行插件的 onExpand 钩子 
  37.     pluginContainer.onExpand(expanded, record); 
  38.   }; 
  39.  
  40.   // 这里获取合并后的 components 传递给 Table 
  41.   const components = pluginContainer.mergeComponents() 
  42. }); 

之后,我们就可以把之前分页相关的逻辑直接抽象成 usePaginationPlugin:

  1. export const usePaginationPlugin: TreeTablePlugin = ( 
  2.   props: ResolvedProps, 
  3.   context: TreeTablePluginContext 
  4. ) => { 
  5.   const { forceUpdate, replaceChildList } = context; 
  6.   const { 
  7.     childrenPagination, 
  8.     childrenColumnName, 
  9.     rowKey, 
  10.     indentLineDataIndex, 
  11.   } = props; 
  12.  
  13.   const handlePagination = node => { 
  14.     // 先加入渲染分页器占位节点 
  15.   }; 
  16.  
  17.   const rewritePaginationRender = column => { 
  18.     // 改写 column 的 render 
  19.     // 渲染分页器 
  20.   }; 
  21.  
  22.   return { 
  23.     onRecord: handlePagination, 
  24.     onColumn: rewritePaginationRender, 
  25.   }; 
  26. }; 

也许机智的你已经发现,这里的插件是以 use 开头的,这是自定义 hook的标志。

没错,它既是一个插件,同时也是一个 自定义 Hook。所以你可以使用 React Hook 的一切能力,同时也可以在插件中引入各种社区的第三方 Hook 来加强能力。

这是因为我们是在 usePluginContainer 中通过函数调用执行各个 usePlugin,完全符合 React Hook 的调用规则。

而懒加载节点相关的逻辑也可以抽象成 useLazyloadPlugin:

  1. export const useLazyloadPlugin: TreeTablePlugin = ( 
  2.   props: ResolvedProps, 
  3.   context: TreeTablePluginContext 
  4. ) => { 
  5.   const { childrenColumnName, rowKey, hasNextKey, onLoadMore } = props; 
  6.   const { replaceChildList, expandedRowKeys, setExpandedRowKeys } = context; 
  7.  
  8.   // 处理懒加载占位节点逻辑 
  9.   const handleNextLevelLoader = node => {}; 
  10.  
  11.   const onExpand = async (expanded, record) => { 
  12.     if (expanded && record[hasNextKey] && onLoadMore) { 
  13.       // 处理懒加载逻辑 
  14.     } 
  15.   }; 
  16.  
  17.   return { 
  18.     onRecord: handleNextLevelLoader, 
  19.     onExpand: onExpand, 
  20.   }; 
  21. }; 

而缩进线相关的逻辑则抽取成 useIndentLinePlugin:

  1. export const useIndentLinePlugin: TreeTablePlugin = ( 
  2.   props: ResolvedProps, 
  3.   context: TreeTablePluginContext 
  4. ) => { 
  5.   const { expandedRowKeys } = context; 
  6.   const onColumn = column => { 
  7.     column.onCell = record => { 
  8.       return { 
  9.         record, 
  10.         ...column
  11.       }; 
  12.     }; 
  13.   }; 
  14.  
  15.   const components = { 
  16.     body: { 
  17.       cell: cellProps => ( 
  18.         <IndentCell 
  19.           {...props} 
  20.           {...cellProps} 
  21.           expandedRowKeys={expandedRowKeys} 
  22.         /> 
  23.       ), 
  24.     }, 
  25.   }; 
  26.  
  27.   return { 
  28.     components, 
  29.     onColumn, 
  30.   }; 
  31. }; 

至此,主函数被精简到 150 行左右,新功能相关的函数全部被移到插件目录中去了,无论是想要新增或者删减、开关功能都变的非常容易。

此时的目录结构:

目录结构

总结

本系列通过讲述扩展 Table 组件的如下功能:

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

以及开发过程中出现代码的耦合,难以维护问题,进而延伸探索插件机制在组件中的设计和使用,虽然本文设计的插件还是最简陋的版本,但是原理大致上如此,希望能够对你有所启发。

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

 

责任编辑:武晓燕
相关推荐

2011-06-09 17:26:17

Qt 插件 API

2021-06-22 06:52:46

Vite 插件机制Rollup

2023-11-07 10:19:08

2009-12-11 10:29:03

PHP插件机制

2010-09-08 14:39:35

2011-01-21 15:02:14

jQuerywebJavaScript

2023-06-15 08:01:01

Vite插件机制

2024-02-23 08:00:00

2024-07-17 09:23:58

Vite插件机制

2020-06-23 07:50:13

Python开发技术

2022-06-07 08:59:58

hookuseRequestReact 项目

2020-05-22 09:10:10

前端框架插件

2021-12-19 07:21:48

Webpack 前端插件机制

2021-03-03 08:32:09

开源子节点组件

2012-07-11 10:51:37

编程

2019-12-19 08:56:21

MybatisSQL执行器

2011-03-28 11:20:11

Nagios 插件

2011-04-06 16:02:26

Nagios插件

2021-12-03 15:59:30

Nuxt3插件机制

2022-06-07 09:30:35

JavaScript变量名参数
点赞
收藏

51CTO技术栈公众号