前端框架arco table源码遇到的问题解析
孟祥_成都 人气:0前言
先不说别的,上两个arco design table的bug。本来是写react table组件,然后看源码学习思路,结果看的我真的很想吐槽。(其他组件我在学习源码上受益匪浅,尤其是工程化arco-cli那部分,我自己尝试写的轮子也是受到很多启发,这个吐槽并不是真的有恶意,我对arco和腾讯的tdeisgn是有期待的,因为ant一家独大太久了,很期待新鲜的血液)
如果arco deisgn的团队看到这篇文章,请一定让写table的同学看一下!!!把多级表头的筛选 + 排序 + 固定逻辑好好梳理一下,目前的写法隐患太多了,我后面会写为什么目前的写法隐患很多,非常容易出bug!
1、这是在线bug demo codesandbox.io/s/jovial-ka…
bug显示
2、继续看,我筛选userInfo上,工资大于2000的行,根本没效果
在线bug 的demo codesandbox.io/s/competent…
说实话,我随便送给大家几个table的bug,都可以去给官方提pr了。(这个写table的人一定要好好的批评一下!!!!)
离谱的filter代码
filter是啥呢,我们看下图
这个表头的筛选我们简称为filter
首先官方把columns上所有的受控和非受控的filter收集起来,代码如下:
const { currentFilters, currentSorter } = getDefaultFiltersAndSorter(columns);
columns我们假设长成这样:
const columns = [ { title: "Name", dataIndex: "name", width: 140, }, { title: "User Info", filters: [ { text: "> 20000", value: "20000", }, { text: "> 30000", value: "30000", }, ], onFilter: (value, row) => row.salary > value, }, { title: "Information", children: [ { title: "Email", dataIndex: "email", }, { title: "Phone", dataIndex: "phone", }, ], }, ]
getDefaultFiltersAndSorter的代码如下,不想看细节的,我就说下结论,这个函数是把filters受控属性,filteredValue和非受控属性defaultFilters放到currentFilters对象里,然后导出,其中key可以简单认为是每个columns上的dataIndex,也就是每一列的唯一标识符。
currentSorter我们暂时不看,也是为排序的bug埋下隐患,我们这篇文章先不谈排序的bug。
function getDefaultFiltersAndSorter(columns) { const currentFilters = {} as Partial<Record<keyof T, string[]>>; const currentSorter = {} as SorterResult; function travel(columns) { if (columns && columns.length > 0) { columns.forEach((column, index) => { const innerDataIndex = column.dataIndex === undefined ? index : column.dataIndex; if (!column[childrenColumnName]) { if (column.defaultFilters) { currentFilters[innerDataIndex] = column.defaultFilters; } if (column.filteredValue) { currentFilters[innerDataIndex] = column.filteredValue; } if (column.defaultSortOrder) { currentSorter.field = innerDataIndex; currentSorter.direction = column.defaultSortOrder; } if (column.sortOrder) { currentSorter.field = innerDataIndex; currentSorter.direction = column.sortOrder; } } else { travel(column[childrenColumnName]); } }); } } travel(columns); return { currentFilters, currentSorter }; }
这里的已经为出bug埋下隐患了,大家看啊,它是递归收集所有columns上的filter相关的受控和非受控的属性,而且受控的属性会覆盖非受控。
这里没有单独区分受控的filter属性和非受控的属性就很奇怪。后面分析,因为arco deisgn有个专门处理受控和非受控的hooks,因为他现在不区分,还用错这个hooks,造成我看起来它的代码奇怪的要命!!
接着看!
然后,他用上面的currentFilters去
const [filters, setFilters] = useState<FilterType<T>>(currentFilters);
接着看一下useColunms,这个跟filters后面息息相关,所以我们必须要看下useColumns的实现
const [groupColumns, flattenColumns] = useColumns<T>(props);
简单描述一下useColumns的返回值 groupColumns, flattenColumns分别代表什么:
- groupColumns,它将columns按行存储到数组里面,啥是按行呢,看下图
- name、user info、Information、salary是第一行
- Birthday、address是第二行,Email,phone也是第二行
- city、road、no是第三行
flattenColumns是啥意思呢?就是columns叶子节点组成的数组,叶子节点是指所有columns中没有children属性的节点。以下是具体代码,有兴趣的可以看看,我们接着看,马上很奇怪的代码就要来了!
function useColumns<T>(props: TableProps<T>): [InternalColumnProps[][], InternalColumnProps[]] { const { components, // 覆盖原生表格标签 rowSelection, // 设置表格行是否可选,选中事件等 expandedRowRender, // 点击展开额外的行,渲染函数。返回值为 null 时,不会渲染展开按钮 expandProps = {}, // 展开参数 columns = [], // 外界传入的columns childrenColumnName, // 默认是children } = props; ![image.png](http://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/59dbcdab3b154494b751f61eeebe2432~tplv-k3u1fbpfcp-watermark.image?) // 下面有getFlattenColumns方法 // getFlattenColumns平铺columns,因为可能有多级表头,所以需要平铺 // getFlattenColumns,注意这个平铺只会搜集叶子节点!!!! const rows: InternalColumnProps[] = useMemo( () => getFlattenColumns(columns, childrenColumnName), [columns, childrenColumnName] ); // 是否是checkbox const isCheckbox = (rowSelection && rowSelection.type === 'checkbox') || (rowSelection && !('type' in rowSelection)); // 是否是radio const isRadio = rowSelection && rowSelection.type === 'radio'; // 展开按钮列的宽度 const { width: expandColWidth } = expandProps; // 是否有expand—row const shouldRenderExpandCol = !!expandedRowRender; const shouldRenderSelectionCol = isCheckbox || isRadio; // 获取到自定义的操作栏,默认是selectNode和expandNode const { getHeaderComponentOperations, getBodyComponentOperations } = useComponent(components); const headerOperations = useMemo( () => getHeaderComponentOperations({ selectionNode: shouldRenderSelectionCol ? 'holder_node' : '', expandNode: shouldRenderExpandCol ? 'holder_node' : '', }), [shouldRenderSelectionCol, shouldRenderExpandCol, getHeaderComponentOperations] ); const bodyOperations = useMemo( () => getBodyComponentOperations({ selectionNode: shouldRenderSelectionCol ? 'holder_node' : '', expandNode: shouldRenderExpandCol ? 'holder_node' : '', }), [shouldRenderSelectionCol, shouldRenderExpandCol, getBodyComponentOperations] ); // rowSelection.fixed 表示checkbox是否固定在左边 const selectionFixedLeft = rowSelection && rowSelection.fixed; // 选择列的宽度 const selectionColumnWidth = rowSelection && rowSelection.columnWidth; const getInternalColumns = useCallback( (rows, operations, index?: number) => { const operationFixedProps: { fixed?: 'left' | 'right' } = {}; const _rows: InternalColumnProps[] = []; rows.forEach((r, i) => { const _r = { ...r }; if (!('key' in r)) { _r.key = _r.dataIndex || i; } if (i === 0) { _r.$$isFirstColumn = true; if (_r.fixed === 'left') { operationFixedProps.fixed = _r.fixed; } } else { _r.$$isFirstColumn = false; } _rows.push(_r); }); const expandColumn = shouldRenderExpandCol && { key: INTERNAL_EXPAND_KEY, title: INTERNAL_EXPAND_KEY, width: expandColWidth, $$isOperation: true, }; const selectionColumn = shouldRenderSelectionCol && { key: INTERNAL_SELECTION_KEY, title: INTERNAL_SELECTION_KEY, width: selectionColumnWidth, $$isOperation: true, }; if (selectionFixedLeft) { operationFixedProps.fixed = 'left'; } if (typeof index !== 'number' || index === 0) { [...operations].reverse().forEach((operation) => { if (operation.node) { if (operation.name === 'expandNode') { _rows.unshift({ ...expandColumn, ...operationFixedProps }); } else if (operation.name === 'selectionNode') { _rows.unshift({ ...selectionColumn, ...operationFixedProps }); } else { _rows.unshift({ ...operation, ...operationFixedProps, title: operation.name, key: operation.name, $$isOperation: true, width: operation.width || 40, }); } } }); } return _rows; }, [ expandColWidth, shouldRenderExpandCol, shouldRenderSelectionCol, selectionColumnWidth, selectionFixedLeft, ] ); const flattenColumns = useMemo( () => getInternalColumns(rows, bodyOperations), [rows, getInternalColumns, bodyOperations] ); // 把表头分组的 columns 分成 n 行,并且加上 colSpan 和 rowSpan,没有表头分组的话是 1 行。 // 获取column的深度 const rowCount = useMemo( () => getAllHeaderRowsCount(columns, childrenColumnName), [columns, childrenColumnName] ); // 分行之后的rows const groupColumns = useMemo(() => { if (rowCount === 1) { return [getInternalColumns(columns, headerOperations, 0)]; } const rows: InternalColumnProps[][] = []; const travel = (columns, current = 0) => { rows[current] = rows[current] || []; columns.forEach((col) => { const column: InternalColumnProps = { ...col }; if (column[childrenColumnName]) { // 求出叶子结点的个数就是colSpan column.colSpan = getFlattenColumns(col[childrenColumnName], childrenColumnName).length; column.rowSpan = 1; rows[current].push(column); travel(column[childrenColumnName], current + 1); } else { column.colSpan = 1; // 这是 column.rowSpan = rowCount - current; rows[current].push(column); } }); rows[current] = getInternalColumns(rows[current], headerOperations, current); }; travel(columns); return rows; }, [columns, childrenColumnName, rowCount, getInternalColumns, headerOperations]); return [groupColumns, flattenColumns]; } export default useColumns; function getFlattenColumns(columns: InternalColumnProps[], childrenColumnName: string) { const rows: InternalColumnProps[] = []; function travel(columns) { if (columns && columns.length > 0) { columns.forEach((column) => { if (!column[childrenColumnName]) { rows.push({ ...column, key: column.key || column.dataIndex }); } else { travel(column[childrenColumnName]); } }); } } travel(columns); return rows; }
接下来这个函数求的是受控的filters的集合!
疑问1:
为啥你受控的集合不在上面我们提到的getDefaultFiltersAndSorter里面就求出来,非要自己单独再求一遍?
const controlledFilter = useMemo(() => { // 允许 filteredValue 设置为 undefined 表示不筛选 const flattenFilteredValueColumns = flattenColumns.filter( (column) => 'filteredValue' in column ); const newFilters = {}; // 受控的筛选,当columns中的筛选发生改变时,更新state if (flattenFilteredValueColumns.length) { flattenFilteredValueColumns.forEach((column, index) => { const innerDataIndex = column.dataIndex === undefined ? index : column.dataIndex; if (innerDataIndex !== undefined) { newFilters[innerDataIndex] = column.filteredValue; } }); } return newFilters; }, [flattenColumns]);
结果我们一看,flattenColumns里去拿受控的columns属性的值,而flattenColumns是拿的叶子节点,这么说来,这个controlledFilter还是跟之前的getDefaultFiltersAndSorter里的currentFilters是有区别的,一个是叶子节点,一个是全部的columns。
但是!问题来了,你只求叶子节点的受控属性,那非叶子节点的受控属性万一用户给你赋值了,岂不是没有作用了!!!
这就是我们最开始提到的第二个bug的根本原因,你自己最开始求得是所有columns中的filters的集合,现在用的是叶子节点的filters的属性,这不是牛头不对马嘴吗???
打不全补丁
接着看,上面的离谱逻辑导致后面的代码想去打补丁,结果就是打不全补丁!
const innerFilters = useMemo<FilterType<T>>(() => { return Object.keys(controlledFilter).length ? controlledFilter : filters; }, [filters, controlledFilter]);
你看,他去得到一个innerFilters,咋求的呢?如果controlledFilter有值,也就是叶子节点有filter的受控属性,那么就用叶子节点的受控属性作为我们要使用的filters,但是!!!!
如果没有叶子节点的受控属性的filters,他居然用的是filters,filters是咋求出来的,不就是最上面的getDefaultFiltersAndSorter吗,这个函数求的是所有columns里filters的集合。
这个函数就非常非常离谱,为啥逻辑不对啊,一个针对叶子节点,一个针对全部节点!!!
更大的问题
// stateCurrentFilter 标记了下拉框中选中的 filter 项目,在受控模式下它与 currentFilter 可以不同 const [currentFilter, setCurrentFilter, stateCurrentFilter] = useMergeValue<string[]>([], { value: currentFilters[innerDataIndex] || [], });
注意,这里有个useMergeValue的hooks,这个hooks 在arco deisgn中起着举足轻重的作用,我们必须好好说一下这个hooks,再看看写这个组件的同学为什么用错了!
我们简单解释一下这个hooks的目的,我们在用组件的时候一般会有两种模式,受控组件和非受控,这个hooks就是完美解决这个问题,你只要把value传入受控组件的属性,defaultValue传入非受控属性,这个hooks就自动接管了这两种状态的变化,非常棒的hooks,写的人真的很不错!
import React, { useState, useEffect, useRef } from 'react'; import { isUndefined } from '../is'; export default function useMergeValue<T>( defaultStateValue: T, props?: { defaultValue?: T; value?: T; } ): [T, React.Dispatch<React.SetStateAction<T>>, T] { const { defaultValue, value } = props || {}; const firstRenderRef = useRef(true); const [stateValue, setStateValue] = useState<T>( !isUndefined(value) ? value : !isUndefined(defaultValue) ? defaultValue : defaultStateValue ); useEffect(() => { // 第一次渲染时候,props.value 已经在useState里赋值给stateValue了,不需要再次赋值。 if (firstRenderRef.current) { firstRenderRef.current = false; return; } // 外部value等于undefined,也就是一开始有值,后来变成了undefined( // 可能是移除了value属性,或者直接传入的undefined),那么就更新下内部的值。 // 如果value有值,在下一步逻辑中直接返回了value,不需要同步到stateValue if (value === undefined) { setStateValue(value); } }, [value]); const mergedValue = isUndefined(value) ? stateValue : value; return [mergedValue, setStateValue, stateValue]; }
从这个hooks导出的[mergedValue, setStateValue, stateValue]
,我们简单分析下怎么用,mergedValue是以受控为准的,也就是外部发现如果用了受控属性,取这个值就行了,而且因为useEffect监听了value的变化,你就不用管受控属性的变化了,自动处理,多好啊!
然后setStateValue主要是手动去更新stateValue的,主要是在非受控的条件下去更新值,所以stateValue一般也是外部判断,如果是非受控条件,就取这个值!
我们接着看arco deisgn中这个人咋用的,完全浪费了这么一个好hook。
// stateCurrentFilter 标记了下拉框中选中的 filter 项目,在受控模式下它与 currentFilter 可以不同 const [currentFilter, setCurrentFilter, stateCurrentFilter] = useMergeValue<string[]>([], { value: currentFilters[innerDataIndex] || [], });
value传了一个currentFilters[innerDataIndex] ,currentFilters是指所有columns里有可能是filters受控的属性集合,有可能是非受控filters属性的集合,innerDataIndex值的当前列的dataindex,也就是唯一标识符key。
那么问题来了value明明人家建议你传的是受控!受控!受控属性的值啊,因为你currentFilters目前既可能是受控,也可能是非受控,所以你传给value是没有办法的办法,因为你传给defaultValue也不对!
错误的继续
useEffect(() => { setCurrentFilter(currentFilters[innerDataIndex] || []); }, [currentFilters, innerDataIndex]); useEffect(() => { if (currentFilter && currentFilter !== stateCurrentFilter) { setCurrentFilter(currentFilter); } }, [filterVisible]);
第一个useEffect是赋值了一个不知道是受控还是非受控的filters,然后第二个假设currentFilter存在,就是说如果受控的filters存在就赋值给优先级更高的受控属性!
显而易见的问题
上面造成两个useEffect的原因,不就是最开始在收集filters的时候,没有区分受控和非受控filters,然后后面代码再求一遍吗,而且求的逻辑让人不好看懂,对不起,我想说这代码写的,太容易出bug了,写的这个人真的是一己之力把table组件毁了!!!
然后我们看filters在确定筛选时的函数!
/** ----------- Filters ----------- */ function onHandleFilter(column, filter: string[]) { const newFilters = { ...innerFilters, [column.dataIndex]: filter, }; const mergedFilters = { ...newFilters, ...controlledFilter, }; if (isArray(filter) && filter.length) { setFilters(mergedFilters); const newProcessedData = getProcessedData(innerSorter, newFilters); const currentData = getPageData(newProcessedData); onChange && onChange(getPaginationProps(newProcessedData), innerSorter, newFilters, { currentData, action: 'filter', }); } else if (isArray(filter) && !filter.length) { onHandleFilterReset(column); } }
搞笑操作再次上演,innerFilters本来就是个奇葩,然后用
[column.dataIndex]: filter
去覆盖innerFilters里的dataIndex里的filter,这里的filter本来就是非受控的属性,你完全不区分受控非受控就上去一顿合并,万一是受控的属性呢??????
然后在mergedFilters里居然用controlledFilter再次去亡羊补牢,想用假如说有受控的filters,那么就优先用受控的值去覆盖innerFilters。
结尾
最开始不区分受控和非受控filters,后面全是一顿补丁!你开始区分不就代码逻辑就很清晰了吗,造成这么多次的遍历columns还有很多多余的更新react组件,让我忍不住想吐槽一下!!!
如何改进,有兴趣的同学可以去提pr
我简单写一下如何解决最开始写的第二个bug。
首先,getDefaultFiltersAndSorter要区分受控和非受控的情况,这是给后面的useMergeProps传递给受控和非受控属性做铺垫,题外话!大家写组件库的话可以copy一份useMergeProps这个hook,真的好东西!改进如下:
// currentFilteredValues代表非受控的filters的全部收集器 // currentDefaultFilters代表受控的filters的全部收集器 const { currentFilteredValues, currentDefaultFilters, currentSorter } = getDefaultFiltersAndSorter(columns); function getDefaultFiltersAndSorter(columns) { const currentFilteredValues = {} as Partial<Record<keyof T, string[]>>; const currentDefaultFilters = {} as Partial<Record<keyof T, string[]>>; const currentSorter = {} as SorterResult; function travel(columns) { if (columns && columns.length > 0) { columns.forEach((column, index) => { const innerDataIndex = column.dataIndex === undefined ? index : column.dataIndex; if (!column[childrenColumnName]) { // 筛选的非受控写法 if (column.defaultFilters) { currentDefaultFilters[innerDataIndex] = column.defaultFilters; } // 筛选的受控属性,值为筛选的value数组 string[] if (column.filteredValue) { currentFilteredValues[innerDataIndex] = column.filteredValue; } // 默认排序方式 'ascend' | 'descend' if (column.defaultSortOrder) { currentSorter.field = innerDataIndex; currentSorter.direction = column.defaultSortOrder; } // 排序的受控属性,可以控制列的排序,可设置为 ascend descend if (column.sortOrder) { currentSorter.field = innerDataIndex; currentSorter.direction = column.sortOrder; } } else { // 筛选的非受控写法 if (column.defaultFilters) { currentDefaultFilters[innerDataIndex] = column.defaultFilters; } // 筛选的受控属性,值为筛选的value数组 string[] if (column.filteredValue) { currentFilteredValues[innerDataIndex] = column.filteredValue; } // 默认排序方式 'ascend' | 'descend' if (column.defaultSortOrder) { currentSorter.field = innerDataIndex; currentSorter.direction = column.defaultSortOrder; } // 排序的受控属性,可以控制列的排序,可设置为 ascend descend if (column.sortOrder) { currentSorter.field = innerDataIndex; currentSorter.direction = column.sortOrder; } travel(column[childrenColumnName]); } }); } } travel(columns); return { currentFilteredValues, currentDefaultFilters, currentSorter }; }
然后初始化filters的时候,就要简单判断一下,我这里写的很烂,因为抽出一个函数的,主要是自己当初为了跑通代码,随便写了下,意思大家懂就行
const [filters, setFilters] = useState<FilterType<T>>( Object.keys(currentDefaultFilters).length ? currentDefaultFilters : Object.keys(currentFilteredValues).length ? currentFilteredValues : undefined );
然后在 columns文件里,useMergeValue做受控属性和非受控属性的收口,因为之前我们区分了受控和非受控让后面的代码逻辑清晰很多。
const innerDataIndex = dataIndex === undefined ? index : dataIndex; // stateCurrentFilter 标记了下拉框中选中的 filter 项目,在受控模式下它与 currentFilter 可以不同 // currentFilter是受控value, setCurrentFilter主要是给非受控value的, stateCurrentFilter 内部value const [currentFilter, setCurrentFilter] = useMergeValue<string[]>([], { value: currentFilteredValues[innerDataIndex], defaultValue: currentDefaultFilters[innerDataIndex], });
然后点击filters的时候如何排序呢,这里filters就是受控和非受控的合并体,再用 [column.dataIndex]: filter更新当前最新的filter,后面更新数据就很自然了,getProcessedData是计算filters后的列,这个函数也需要改一下,把只计算叶子节点的改为计算所有的columns
function onHandleFilter(column, filter: string[]) { const mergedFilters = { ...filters, [column.dataIndex]: filter, // 筛选项 }; if (isArray(filter) && filter.length) { setFilters(mergedFilters); const newProcessedData = getProcessedData(innerSorter, mergedFilters); const currentData = getPageData(newProcessedData); onChange && onChange(getPaginationProps(newProcessedData), innerSorter, mergedFilters, { currentData: getOriginData(currentData), action: 'filter', }); } else if (isArray(filter) && !filter.length) { onHandleFilterReset(column); } }
加载全部内容