首頁 > 軟體

前端框架arco table原始碼遇到的問題解析

2023-02-03 18:02:19

前言

先不說別的,上兩個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](https://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&lt;FilterType&lt;T&gt;&gt;(() =&gt; {
    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);
    }
  }

以上就是前端框架arco table原始碼遇到的問題解析的詳細內容,更多關於前端框架arco table解析的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com