代码重构——表格组件的思考
背景
在进行需求开发时,看到了几段令我浑身难受的代码,先上图给大家看看。
const [total, setTotal] = useState(0);
const [data, setData] = useState<XXX[]>([]);
const [currentPage, setCurrentPage] = useState(INIT_PAGE);
const [isLoading, setIsLoading] = useState(false);
const [scrollHeight, setScrollHeight] = useState('');
useLayoutEffect(() => {
setScrollHeight(computedTableHeight());
}, [])
这些代码的作用是控制 Table 组件的相关状态(如数据源、总数、当前页码、加载、表格的固定高度等),这些状态在许多表格中都有用到。因此,不出意外的,这段代码重复出现在了多个包含表格的文件中。
那么,秉承着 DRY 的原则,我对这个表格的状态管理进行了一些封装,让我们往下看。
方案一:自定义 Hook
一提到可重用的组件状态管理,第一个出现在我脑海中的方案就是「自定义 Hook」。相信很多了解 React Hooks 的朋友们对它已经很熟悉了,自定义 Hook 是一个函数,其名称以 “use” 开头,函数内部可以调用其他的 Hook。 说白了,在这个场景下,就是将统一的的状态管理逻辑,封装到一个公共函数中。那么上述提到的表格的统一状态,都可以封装到同一个表格中,我们的自定义函数看起来就像下面这样:
export const useTable = () => {
const [total, setTotal] = useState(0);
const [data, setData] = useState<XXX[]>([]);
const [currentPage, setCurrentPage] = useState(INIT_PAGE);
const [isLoading, setIsLoading] = useState(false);
const [scrollHeight, setScrollHeight] = useState('');
// 在这里可以做一些统一的逻辑处理,比如在初始化时调整表格的高度
useLayoutEffect(() => {
setScrollHeight(computedTableHeight());
}, [])
// 更进一步的,可以做一些复杂的逻辑封装,比如远程获取数据,并改变表格的状态等
// 暴露出状态和修改状态的方法,这里只是简单地将所有内容暴露出去
return {
tableState: {
total,
data,
currentPage,
isLoading,
scrollHeight
},
setTableState: {
setTotal,
setData,
setCurrentPage,
setIsLoading,
setScrollHeight,
}
};
}
那么,有了上面这个简单的 useTable,我们在表格中就可以这样使用这个自定义 Hook:
const { tableState, setTableState } = useTable();
方案二:useReducer
在这里我想提一个容易被人忽略的官方 Hook —— useReducer。由于大多数人对 useState 的偏爱和习惯,useReducer 的出场率低的可怜,比如在这个项目中,useState 这个词出现了 169 次,而作为对比,useReducer 出现的次数是 0! 关于 useReducer 与 useState 的比较,可以参考《区别》和《何时使用》这两篇文章。课代表在这里简单总结一下它俩的关系:
- useReducer 与 useState 都可以用来做状态管理,实际上,查看源码我们能够看出,在 React 内部,useState 就是用 useReducer 实现的,useState 返回的函数内部封装了一个 dispatch
/**
* useState 源码
*/
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
return typeof action === 'function' ? action(state) : action;
}
export function useState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
return useReducer(
basicStateReducer,
// useReducer has a special case to support lazy useState initializers
(initialState: any),
);
}
- useState 适合用来做细粒度、简单类型(比如 number、string、boolean)的状态管理
- useReducer 适合用来做低成本的数据流,也可以用来管理较为复杂(object、array)、有关联的状态
- useReducer 常与 useContext 搭配使用,适合用来做简易的组件间数据流管理
那么回到这个场景,我们使用 useReducer 改造一下上述代码,看起来就像:
interface TableAction {
type: TableActionType;
payload: any;
}
export enum TableActionType {
UPDATE_TOTAL = 'UPDATE_TOTAL',
UPDATE_DATA = 'UPDATE_DATA',
UPDATE_CURRENT_PAGE = 'UPDATE_CURRENT_PAGE',
UPDATE_SCROLL_HEIGHT = 'UPDATE_SCROLL_HEIGHT',
UPDATE_IS_LOADING = 'UPDATE_IS_LOADING'
}
export const initTableState: TableState = {
total: 0,
data: [],
currentPage: INIT_PAGE,
isLoading: false,
scrollHeight: '',
};
export const tableReducer = (state: TableState, action: TableAction) => {
switch (action.type) {
case TableActionType.UPDATE_TOTAL:
return {
...state,
total: action.payload,
};
case TableActionType.UPDATE_DATA:
return {
...state,
data: [...action.payload],
};
case TableActionType.UPDATE_CURRENT_PAGE:
return {
...state,
currentPage: action.payload,
};
case TableActionType.UPDATE_SCROLL_HEIGHT:
return {
...state,
scrollHeight: action.payload,
};
case TableActionType.UPDATE_IS_LOADING:
return {
...state,
isLoading: action.payload,
};
default:
return state;
}
};
在表格组件中使用的姿势如下:
const [tableState, dispatch] = useReducer(tableReducer, initTableState);
const { total, data, currentPage, isLoading, scrollHeight } = tableState;
useLayoutEffect(() => {
dispatch({ type: TableActionType.UPDATE_SCROLL_HEIGHT, payload: computedTableHeight() });
}, []);
进阶:高级表格
上面两种方案都是为了解决表格组件内的冗杂状态问题,那么在此基础之上,我们是否能提高抽象的层级,将重复的逻辑范围搜索扩大到组件之间。 作为后台应用,下图是十分常见的列表页布局:(筛选查询表单 + 标题操作区 +展示表格 + 分页栏)。
基于这类布局的列表页面,我们不难想到,将筛选搜索、标题操作、分页操作等一系列常用的功能封装成组件的配置化参数的能力。 那么这个页面的代码看起来就像是:
<ProTable<CustomItem>
columns= { columns }
actionRef = { actionRef }
request = { async(params = {}, sort, filter) => {
return request<{
data: CustomItem[];
}>('https://proapi.azurewebsites.net/github/issues', {
params,
});
}}
columnsState = {{
persistenceKey: 'pro-table-singe-demos',
persistenceType: 'localStorage',
onChange(value) {
console.log('value: ', value);
},
}}
rowKey = "id"
search = {{
labelWidth: 'auto',
}}
form = {{
// 由于配置了 transform,提交的参与与定义的不同这里需要转化一下
syncToUrl: (values, type) => {
if (type === 'get') {
return {
...values,
created_at: [values.startTime, values.endTime],
};
}
return values;
},
}}
pagination = {{
pageSize: 5,
onChange: (page) => console.log(page),
}}
dateFormatter = "string"
headerTitle = "高级表格"
toolBarRender = {() => [
<Button key="button" icon = {< PlusOutlined />} type = "primary" >
新建
</Button>,
<Dropdown key = "menu" overlay = { menu } >
<Button>
<EllipsisOutlined />
</Button>
</Dropdown>,
]}
/>
只需要数十行代码,就能渲染出这样一个列表页。
总结
不论是使用自定义 Hook 还是使用 useReducer,其主旨都是将多个组件之间重复的逻辑进行抽离,以此达到 Don't Repeat Yourself 的原则。