最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 如何编写神奇的「插件机制」,优化基于 Antd Table 封装表格的混乱代码 | 技术点评

    正文概述 掘金(ssh_晨曦时梦见兮)   2021-03-03   715

    前言

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

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

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

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

    功能虽然已经实现了,也记录在了 给 Antd Table 组件编写缩进指引线、子节点懒加载等功能 这篇文章中。不过实现功能是最基本的,不感兴趣的同学完全可以跳过。

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

    重构思路

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

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

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

    export const TreeTable = (rawProps) => {
      function rewriteTree() {
        // ?加载更多逻辑
        // ? 分页逻辑
      }
    
      function rewriteColumns() {
        // ? 分页逻辑
        // ? 缩进线逻辑
      }
    
      const components = {
        // ? 缩进线逻辑
      }
    
      const onExpand = async (expanded, record) => {
        // ? 加载更多逻辑
      }
    
      return <Table />
    }
    

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

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

    
    // ? 分页逻辑
    const usePaginationPlugin = () => {}
    // ? 加载更多逻辑
    const useLazyloadPlugin = () => {}
    // ? 缩进线逻辑
    const useIndentLinePlugin = () => {}
    
    export const TreeTable = (rawProps) => {
      usePaginationPlugin()
    
      useLazyloadPlugin()
    
      useIndentLinePlugin()
    
      return <Table />
    }
    

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

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

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

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

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

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

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

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

    源码

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

    export interface TreeTablePlugin<T = any> {
      (props: ResolvedProps, context: TreeTablePluginContext): {
        /**
         * 可以访问到每一个 column 并修改
         */
        onColumn?(column: ColumnProps<T>): void
        /**
         * 可以访问到每一个节点数据
         * 在初始化或者新增子节点以后都会执行
         */
        onRecord?(record): void
        /**
         * 节点展开的回调函数
         */
        onExpand?(expanded, record): void
        /**
         * 自定义 Table 组件
         */
        components?: TableProps<T>['components'];
      }
    }
    
    export interface TreeTablePluginContext {
      forceUpdate: React.DispatchWithoutAction;
      addChildList(record, childList): void;
      expandedRowKeys: TableProps<any>['expandedRowKeys'];
      setExpandedRowKeys: (v: string[] | number[] | undefined) => void;
    }
    

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

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

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

    export const usePluginContainer = (
      props: ResolvedProps,
      context: TreeTablePluginContext
    ) => {
      const { plugins: rawPlugins } = props
    
      const plugins = rawPlugins.map((usePlugin) => usePlugin?.(props, context))
    
      const container = {
        onColumn(column: ColumnProps<any>) {
          for (const plugin of plugins) {
            plugin?.onColumn?.(column)
          }
        },
        onRecord(record, parentRecord, level) {
          for (const plugin of plugins) {
            plugin?.onRecord?.(record, parentRecord, level)
          }
        },
        onExpand(expanded, record) {
          for (const plugin of plugins) {
            plugin?.onExpand?.(expanded, record)
          }
        },
        /**
         * 暂时只做 components 的 deepmerge
         * 不处理自定义组件的冲突 后定义的 Cell 会覆盖前者
         */
        mergeComponents() {
          let components: TableProps<any>['components'] = {};
          for (const plugin of plugins) {
            components = deepmerge.all([
              components,
              plugin.components || {},
              props.components || {},
            ]);
          }
          return components;
        },
      }
    
      return container
    }
    

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

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

    export const TreeTable: React.FC<ITreeTableProps> = (props) => {
      const [_, forceUpdate] = useReducer((x) => x + 1, 0)
    
      const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([])
    
      const pluginContext = {
        forceUpdate,
        addChildList,
        expandedRowKeys,
        setExpandedRowKeys
      }
    
      // 这里拿到了 pluginContainer
      const pluginContainer = usePluginContainer(
        {
          ...props,
          plugins: [usePaginationPlugin, useLazyloadPlugin, useIndentLinePlugin],
        },
        pluginContext
      );
    }
    

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

    export const TreeTable: React.FC<ITreeTableProps> = props => {
      ...
    
      // 这里拿到了 pluginContainer
      const pluginContainer = usePluginContainer(
        {
          ...props,
          plugins: [usePaginationPlugin, useLazyloadPlugin, useIndentLinePlugin],
        },
        pluginContext
      );
    
      /**
       *  需要对 dataSource 进行一些改写 增加层级、父节点、loading 节点、分页等信息
       */
      function rewriteTree({
        dataSource,
        // 在动态追加子树节点的时候 需要手动传入 parent 引用
        parentNode = null,
      }) {
        pluginContainer.onRecord(parentNode);
    
        traverseTree(dataSource, childrenColumnName, (node, parent, level) => {
          // 这里执行插件的 onRecord 钩子
          pluginContainer.onRecord(node, parent, level);
        });
      }
    
      function rewriteColumns() {
        columns.forEach(column => {
          // 这里执行插件的 onColumn 钩子
          pluginContainer.onColumn(column);
        });
      }
    
      const onExpand = async (expanded, record) => {
        // 这里执行插件的 onExpand 钩子
        pluginContainer.onExpand(expanded, record);
      };
    
      // 这里获取合并后的 components 传递给 Table
      const components = pluginContainer.mergeComponents()
    }
    

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

    export const usePaginationPlugin: TreeTablePlugin = (
      props: ResolvedProps,
      context: TreeTablePluginContext
    ) => {
      const { forceUpdate, addChildList } = context
      const {
        childrenPagination,
        childrenColumnName,
        rowKey,
        indentLineDataIndex
      } = props
    
      const handlePagination = (node) => {
        // 先加入渲染分页器占位节点
      }
    
      const rewritePaginationRender = (column) => {
        // 改写 column 的 render
        // 渲染分页器
      }
    
      return {
        onRecord: handlePagination,
        onColumn: rewritePaginationRender
      }
    }
    

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

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

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

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

    export const useLazyloadPlugin: TreeTablePlugin = (
      props: ResolvedProps,
      context: TreeTablePluginContext
    ) => {
      const { childrenColumnName, rowKey, hasNextKey, onLoadMore } = props
      const { addChildList, expandedRowKeys, setExpandedRowKeys } = context
    
      // 处理懒加载占位节点逻辑
      const handleNextLevelLoader = (node) => {}
    
      const onExpand = async (expanded, record) => {
        if (expanded && record[hasNextKey] && onLoadMore) {
          // 处理懒加载逻辑
        }
      }
    
      return {
        onRecord: handleNextLevelLoader,
        onExpand: onExpand
      }
    }
    

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

    export const useIndentLinePlugin: TreeTablePlugin = (
      props: ResolvedProps,
      context: TreeTablePluginContext
    ) => {
      const { expandedRowKeys } = context;
      const onColumn = column => {
        column.onCell = record => {
          return {
            record,
            ...column,
          };
        };
      };
    
      const components = {
        body: {
          cell: cellProps => (
            <IndentCell
              {...props}
              {...cellProps}
              expandedRowKeys={expandedRowKeys}
            />
          ),
        },
      };
    
      return {
        components,
        onColumn,
      };
    };
    

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

    此时的目录结构:

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

    总结

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

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

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

    感谢大家

    我是 ssh,目前就职于字节跳动的 Web Infra 团队,目前团队还很缺人(北上广深杭,尤其是北京)。

    为此我组建了一个氛围特别好的招聘社群,大家在里面尽情的讨论面试相关的想法和问题,也欢迎你加入,随时投递简历给我。

    也欢迎和我交个朋友:sshsunlight,一起聊聊前端。

    本文正在参与「掘金 2021 春招闯关活动」, 点击查看 活动详情


    起源地下载网 » 如何编写神奇的「插件机制」,优化基于 Antd Table 封装表格的混乱代码 | 技术点评

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元