如何編寫神奇的「插件機(jī)制」,優(yōu)化基于 Antd Table 封裝表格的混亂代碼
前言
最近在一個業(yè)務(wù)需求中,我通過在 Antd Table 提供的回調(diào)函數(shù)等機(jī)制中編寫代碼,實(shí)現(xiàn)了這些功能:
- 每個層級縮進(jìn)指示線
- 遠(yuǎn)程懶加載子節(jié)點(diǎn)
- 每個層級支持分頁
最后實(shí)現(xiàn)的效果大概是這樣的:
最終效果
這篇文章我想聊聊我在這個需求中,對代碼解耦,為組件編寫插件機(jī)制的一些思考。
重構(gòu)思路
隨著編寫功能的增多,邏輯被耦合在 Antd Table 的各個回調(diào)函數(shù)之中,
- 指引線的邏輯分散在 rewriteColumns, components中。
- 分頁的邏輯被分散在 rewriteColumns 和 rewriteTree 中。
- 加載更多的邏輯被分散在 rewriteTree 和 onExpand 中
至此,組件的代碼行數(shù)也已經(jīng)來到了 300 行,大概看一下代碼的結(jié)構(gòu),已經(jīng)是比較混亂了:
- export const TreeTable = rawProps => {
- function rewriteTree() {
- // 🎈加載更多邏輯
- // 🔖 分頁邏輯
- }
- function rewriteColumns() {
- // 🔖 分頁邏輯
- // 🏁 縮進(jìn)線邏輯
- }
- const components = {
- // 🏁 縮進(jìn)線邏輯
- };
- const onExpand = async (expanded, record) => {
- // 🎈 加載更多邏輯
- };
- return <Table />;
- };
這時候缺點(diǎn)就暴露出來了,當(dāng)我想要改動或者刪減其中一個功能的時候變得異常痛苦,經(jīng)常在各個函數(shù)之間跳轉(zhuǎn)查找。
有沒有一種機(jī)制,可以讓代碼按照功能點(diǎn)聚合,而不是散落在各個函數(shù)中?
- // 🔖 分頁邏輯
- const usePaginationPlugin = () => {};
- // 🎈 加載更多邏輯
- const useLazyloadPlugin = () => {};
- // 🏁 縮進(jìn)線邏輯
- const useIndentLinePlugin = () => {};
- export const TreeTable = rawProps => {
- usePaginationPlugin();
- useLazyloadPlugin();
- useIndentLinePlugin();
- return <Table />;
- };
沒錯,就是很像 VueCompositionAPI 和 React Hook 在邏輯解耦方面所做的改進(jìn),但是在這個回調(diào)函數(shù)的寫法形態(tài)下,好像不太容易做到?
這時候,我回想到社區(qū)中一些開源框架提供的插件機(jī)制,好像就可以在不深入源碼的情況下注入各個回調(diào)時機(jī)的用戶邏輯。
比如 Vite 的插件[1]、Webpack 的插件[2] 甚至大家很熟悉的 Vue.use()[3],它們本質(zhì)上就是對外暴露出一些內(nèi)部的時機(jī)和屬性,讓用戶去寫一些代碼來介入框架運(yùn)行的各個時機(jī)之中。
那么,我們是否可以考慮把「處理每個節(jié)點(diǎn)、column、每次 onExpand」 的時機(jī)暴露出去,這樣讓用戶也可以介入這些流程,去改寫一些屬性,調(diào)用一些內(nèi)部方法,以此實(shí)現(xiàn)上面的幾個功能呢?
我們設(shè)計插件機(jī)制,想要實(shí)現(xiàn)這兩個目標(biāo):
- 邏輯解耦,把每個小功能的代碼整合到插件文件中去,不和組件耦合起來,增加可維護(hù)性。
- 用戶共建,內(nèi)部使用的話方便同事共建,開源后方便社區(qū)共建,當(dāng)然這要求你編寫的插件機(jī)制足夠完善,文檔足夠友好。
不過插件也會帶來一些缺點(diǎn),設(shè)計一套完善的插件機(jī)制也是非常復(fù)雜的,像 Webpack、Rollup、Redux 的插件機(jī)制都有設(shè)計的非常精良的地方可以參考學(xué)習(xí)。
接下來,我會試著實(shí)現(xiàn)的一個最簡化版的插件系統(tǒng)。
源碼
首先,設(shè)計一下插件的接口:
- export interface TreeTablePlugin<T = any> {
- (props: ResolvedProps, context: TreeTablePluginContext): {
- /**
- * 可以訪問到每一個 column 并修改
- */
- onColumn?(column: ColumnProps<T>): void;
- /**
- * 可以訪問到每一個節(jié)點(diǎn)數(shù)據(jù)
- * 在初始化或者新增子節(jié)點(diǎn)以后都會執(zhí)行
- */
- onRecord?(record): void;
- /**
- * 節(jié)點(diǎn)展開的回調(diào)函數(shù)
- */
- onExpand?(expanded, record): void;
- /**
- * 自定義 Table 組件
- */
- components?: TableProps<T>['components'];
- };
- }
- export interface TreeTablePluginContext {
- forceUpdate: React.DispatchWithoutAction;
- replaceChildList(record, childList): void;
- expandedRowKeys: TableProps<any>['expandedRowKeys'];
- setExpandedRowKeys: (v: string[] | number[] | undefined) => void;
- }
我把插件設(shè)計成一個函數(shù),這樣每次執(zhí)行都可以拿到最新的 props 和 context。
context 其實(shí)就是組件內(nèi)一些依賴上下文的工具函數(shù)等等,比如 forceUpdate, replaceChildList 等函數(shù)都可以掛在上面。
接下來,由于插件可能有多個,而且內(nèi)部可能會有一些解析流程,所以我設(shè)計一個運(yùn)行插件的 hook 函數(shù) 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 函數(shù)調(diào)用一下,然后提供對外的包裝接口。mergeComponent 使用deepmerge[4] 這個庫來合并用戶傳入的 components 和 插件中的 components,暫時不做沖突處理。
接著就可以在組件中調(diào)用這個函數(shù),生成 pluginContainer:
- export const TreeTable = React.forwardRef((props, ref) => {
- const [_, forceUpdate] = useReducer((x) => x + 1, 0)
- const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([])
- const pluginContext = {
- forceUpdate,
- replaceChildList,
- expandedRowKeys,
- setExpandedRowKeys
- }
- // 對外暴露工具方法給用戶使用
- useImperativeHandle(ref, () => ({
- replaceChildList,
- setNodeLoading,
- }));
- // 這里拿到了 pluginContainer
- const pluginContainer = usePluginContainer(
- {
- ...props,
- plugins: [usePaginationPlugin, useLazyloadPlugin, useIndentLinePlugin],
- },
- pluginContext
- );
- })
之后,在各個流程的相應(yīng)位置,都通過 pluginContainer 執(zhí)行相應(yīng)的鉤子函數(shù)即可:
- export const TreeTable = React.forwardRef((props, ref) => {
- // 省略上一部分代碼……
- // 這里拿到了 pluginContainer
- const pluginContainer = usePluginContainer(
- {
- ...props,
- plugins: [usePaginationPlugin, useLazyloadPlugin, useIndentLinePlugin],
- },
- pluginContext
- );
- // 遞歸遍歷整個數(shù)據(jù) 調(diào)用鉤子
- const rewriteTree = ({
- dataSource,
- // 在動態(tài)追加子樹節(jié)點(diǎn)的時候 需要手動傳入 parent 引用
- parentNode = null,
- }) => {
- pluginContainer.onRecord(parentNode);
- traverseTree(dataSource, childrenColumnName, (node, parent, level) => {
- // 這里執(zhí)行插件的 onRecord 鉤子
- pluginContainer.onRecord(node, parent, level);
- });
- }
- const rewrittenColumns = columns.map(rawColumn => {
- // 這里把淺拷貝過后的 column 暴露出去
- // 防止污染原始值
- const column = Object.assign({}, rawColumn);
- pluginContainer.onColumn(column);
- return column;
- });
- const onExpand = async (expanded, record) => {
- // 這里執(zhí)行插件的 onExpand 鉤子
- pluginContainer.onExpand(expanded, record);
- };
- // 這里獲取合并后的 components 傳遞給 Table
- const components = pluginContainer.mergeComponents()
- });
之后,我們就可以把之前分頁相關(guān)的邏輯直接抽象成 usePaginationPlugin:
- export const usePaginationPlugin: TreeTablePlugin = (
- props: ResolvedProps,
- context: TreeTablePluginContext
- ) => {
- const { forceUpdate, replaceChildList } = context;
- const {
- childrenPagination,
- childrenColumnName,
- rowKey,
- indentLineDataIndex,
- } = props;
- const handlePagination = node => {
- // 先加入渲染分頁器占位節(jié)點(diǎn)
- };
- const rewritePaginationRender = column => {
- // 改寫 column 的 render
- // 渲染分頁器
- };
- return {
- onRecord: handlePagination,
- onColumn: rewritePaginationRender,
- };
- };
也許機(jī)智的你已經(jīng)發(fā)現(xiàn),這里的插件是以 use 開頭的,這是自定義 hook的標(biāo)志。
沒錯,它既是一個插件,同時也是一個 自定義 Hook。所以你可以使用 React Hook 的一切能力,同時也可以在插件中引入各種社區(qū)的第三方 Hook 來加強(qiáng)能力。
這是因?yàn)槲覀兪窃?usePluginContainer 中通過函數(shù)調(diào)用執(zhí)行各個 usePlugin,完全符合 React Hook 的調(diào)用規(guī)則。
而懶加載節(jié)點(diǎn)相關(guān)的邏輯也可以抽象成 useLazyloadPlugin:
- export const useLazyloadPlugin: TreeTablePlugin = (
- props: ResolvedProps,
- context: TreeTablePluginContext
- ) => {
- const { childrenColumnName, rowKey, hasNextKey, onLoadMore } = props;
- const { replaceChildList, expandedRowKeys, setExpandedRowKeys } = context;
- // 處理懶加載占位節(jié)點(diǎn)邏輯
- const handleNextLevelLoader = node => {};
- const onExpand = async (expanded, record) => {
- if (expanded && record[hasNextKey] && onLoadMore) {
- // 處理懶加載邏輯
- }
- };
- return {
- onRecord: handleNextLevelLoader,
- onExpand: onExpand,
- };
- };
而縮進(jìn)線相關(guān)的邏輯則抽取成 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,
- };
- };
至此,主函數(shù)被精簡到 150 行左右,新功能相關(guān)的函數(shù)全部被移到插件目錄中去了,無論是想要新增或者刪減、開關(guān)功能都變的非常容易。
此時的目錄結(jié)構(gòu):
目錄結(jié)構(gòu)
總結(jié)
本系列通過講述擴(kuò)展 Table 組件的如下功能:
- 每個層級縮進(jìn)指示線
- 遠(yuǎn)程懶加載子節(jié)點(diǎn)
- 每個層級支持分頁
以及開發(fā)過程中出現(xiàn)代碼的耦合,難以維護(hù)問題,進(jìn)而延伸探索插件機(jī)制在組件中的設(shè)計和使用,雖然本文設(shè)計的插件還是最簡陋的版本,但是原理大致上如此,希望能夠?qū)δ阌兴鶈l(fā)。
本文轉(zhuǎn)載自微信公眾號「前端從進(jìn)階到入院」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系前端從進(jìn)階到入院公眾號。