Browse Source

perf: improve the performance of Cascader under leafOnly, searchable, and controlled situation(#1999)

* perf: improve the performance of Cascader under leafOnly, Multi-select searchable, and controlled situation

* test: fix Cascader e2e test error

---------

Co-authored-by: pointhalo <[email protected]>
YyumeiZhang 1 year ago
parent
commit
dd2d532a4f

+ 3 - 1
packages/semi-foundation/cascader/constants.ts

@@ -24,4 +24,6 @@ export {
     cssClasses,
     strings,
     numbers
-};
+};
+
+export const VALUE_SPLIT = '_SEMI_CASCADER_SPLIT_';

+ 11 - 13
packages/semi-foundation/cascader/foundation.ts

@@ -1,4 +1,4 @@
-import { isEqual, get, difference, isUndefined, assign, cloneDeep, isEmpty, isNumber, includes, isFunction } from 'lodash';
+import { isEqual, get, difference, isUndefined, assign, cloneDeep, isEmpty, isNumber, includes, isFunction, isObject } from 'lodash';
 import BaseFoundation, { DefaultAdapter } from '../base/foundation';
 import {
     filter,
@@ -12,10 +12,10 @@ import {
 import { Motion } from '../utils/type';
 import {
     convertDataToEntities,
-    findKeysForValues,
     normalizedArr,
     isValid,
-    calcMergeType
+    calcMergeType,
+    getKeysByValuePath
 } from './util';
 import { strings } from './constants';
 import isEnterPress from '../utils/isEnterPress';
@@ -439,14 +439,14 @@ export default class CascaderFoundation extends BaseFoundation<CascaderAdapter,
         const loadingKeys = this._adapter.getLoadingKeyRefValue();
         const filterable = this._isFilterable();
         const loadingActive = [...activeKeys].filter(i => loadingKeys.has(i));
-
-        const valuePath = onChangeWithObject ? normalizedArr(value).map(i => i.value) : normalizedArr(value);
-        const selectedKeys = findKeysForValues(valuePath, keyEntities);
+        const normalizedValue = normalizedArr(value);
+        const valuePath = onChangeWithObject && isObject(normalizedValue[0]) ? normalizedValue.map(i => i.value) : normalizedValue;
+        const selectedKeys = getKeysByValuePath(valuePath);
         let updateStates: Partial<BasicCascaderInnerData> = {};
 
-        if (selectedKeys.length) {
-            const selectedKey = selectedKeys[0];
-            const selectedItem = keyEntities[selectedKey];
+        const selectedKey = selectedKeys.length > 0 ? selectedKeys[0] : undefined;
+        const selectedItem = selectedKey ? keyEntities[selectedKey] : undefined;
+        if (selectedItem) {
             /**
              * When changeOnSelect is turned on, or the target option is a leaf option,
              * the option is considered to be selected, even if the option is disabled
@@ -874,10 +874,8 @@ export default class CascaderFoundation extends BaseFoundation<CascaderAdapter,
         const { keyEntities } = this.getStates();
         const values: (string | number)[] = [];
         keys.forEach(key => {
-            if (!isEmpty(keyEntities) && !isEmpty(keyEntities[key])) {
-                const valueItem = keyEntities[key].data.value;
-                values.push(valueItem);
-            }
+            const valueItem = keyEntities[key]?.data?.value;
+            valueItem !== undefined && values.push(valueItem);
         });
         const formatValue: number | string | Array<string | number> = values.length === 1 ?
             values[0] :

+ 21 - 10
packages/semi-foundation/cascader/util.ts

@@ -3,7 +3,7 @@ import {
     isUndefined,
     isEqual
 } from 'lodash';
-import { strings } from './constants';
+import { strings, VALUE_SPLIT } from './constants';
 
 function getPosition(level: any, index: any) {
     return `${level}-${index}`;
@@ -30,7 +30,7 @@ function traverseDataNodes(treeNodes: any, callback: any) {
         let item: any = null;
         // Process node if is not root
         if (node) {
-            const key = parent ? getPosition(parent.key, ind) : `${ind}`;
+            const key = parent ? `${parent.key}${VALUE_SPLIT}${node.value}` : node.value;
             item = {
                 data: { ...node },
                 ind,
@@ -55,6 +55,25 @@ function traverseDataNodes(treeNodes: any, callback: any) {
     processNode(null);
 }
 
+export function getKeysByValuePath(valuePath: (string | number)[][] | (string | number)[]) {
+    if (valuePath?.length) {
+        if (Array.isArray(valuePath[0])) {
+            return valuePath.map((item) => getKeyByValuePath(item));
+        } else {
+            return [getKeyByValuePath(valuePath as (string | number)[])];
+        }
+    }
+    return [];
+}
+
+export function getKeyByValuePath(valuePath: (string | number)[]) {
+    return valuePath.join(VALUE_SPLIT);
+}
+
+export function getValuePathByKey(key: string) {
+    return key.split(VALUE_SPLIT);
+}
+
 export function convertDataToEntities(dataNodes: any) {
     const keyEntities: any = {};
 
@@ -74,14 +93,6 @@ export function convertDataToEntities(dataNodes: any) {
     return keyEntities;
 }
 
-export function findKeysForValues(value: any, keyEntities: any) {
-    const valuePath = normalizedArr(value);
-    const res = Object.values(keyEntities)
-        .filter((item: any) => isEqual(item.valuePath, valuePath))
-        .map((item: any) => item.key);
-    return res;
-}
-
 export function calcMergeType(autoMergeValue: boolean, leafOnly: boolean): string {
     let mergeType: string;
     if (leafOnly) {

+ 2 - 2
packages/semi-ui/cascader/__test__/cascader.test.js

@@ -1257,7 +1257,7 @@ describe('Cascader', () => {
         const args = firstCall.args[0]; 
         /* check arguments of triggerRender */
         expect(args.value.size).toEqual(1);
-        expect(args.value).toEqual(new Set('0'));
+        expect(args.value).toEqual(new Set(['Asia']));
         cascaderAutoMerge.unmount();
 
         const spyTriggerRender2 = sinon.spy(() => <span>123</span>);
@@ -1272,7 +1272,7 @@ describe('Cascader', () => {
         const args2 = firstCall2.args[0]; 
         /* check arguments of triggerRender */
         expect(args2.value.size).toEqual(4);
-        expect(args2.value).toEqual(new Set(['0','0-0','0-0-1','0-0-0']));
+        expect(args2.value).toEqual(new Set(["Asia","Asia_SEMI_CASCADER_SPLIT_China","Asia_SEMI_CASCADER_SPLIT_China_SEMI_CASCADER_SPLIT_Beijing","Asia_SEMI_CASCADER_SPLIT_China_SEMI_CASCADER_SPLIT_Shanghai"]));
         cascaderNoAutoMerge.unmount();
     });
 

+ 81 - 1
packages/semi-ui/cascader/_story/cascader.stories.jsx

@@ -1626,7 +1626,7 @@ export const DynamicTreeData = () => {
 
 
 export const SuperLongList = () => {
-    let treeData = new Array(100).fill().map(() => ({ label: '浙江省', value: 'zhejiang' }));
+    let treeData = new Array(100).fill().map((item, index) => ({ label: `浙江省${index}`, value: `zhejiang${index}` }));
     treeData.push({ label: '到底啦', value: 'bottom' })
     return (
         <Cascader
@@ -2184,3 +2184,83 @@ export const VirtualizeInSearch = () => {
       />
   );
 };
+
+function generateOptions(arr, level, frontKey) {
+  const realLevel = level ?? 0;
+  const notLeaf = realLevel !== arr.length - 1;
+  const realFrontKey = frontKey ? `${frontKey}-` : '';
+  return new Array(arr[realLevel])
+    .fill(0)
+    .map((_item, index) => {
+      const data = {
+        label: `label-${realFrontKey}${index}`,
+        value: `value-${realFrontKey}${index}`,
+      };
+      if (notLeaf) {
+        data.children = generateOptions(
+          arr,
+          realLevel + 1,
+          `${realFrontKey}${index}`,
+        );
+      }
+      return data;
+    });
+}
+
+export const LeafOnlyPF = () => {
+  const treeData = useMemo(() => {
+    return generateOptions([4, 10, 10, 10]);
+  }, []);
+
+  return (
+    <Cascader
+      multiple
+      leafOnly
+      maxTagCount={4}
+      treeData={treeData}
+      style={{ width: 200 }}
+    />
+  );
+};
+
+export const SearchPF = () => {
+  const treeData = useMemo(() => {
+    return generateOptions([4, 10, 10, 10]);
+  }, []);
+
+  return (
+    <Cascader
+      filterTreeNode
+      multiple
+      leafOnly
+      maxTagCount={4}
+      treeData={treeData}
+      style={{ width: 200 }}
+    />
+  );
+};
+
+export const ControlledPF = () => {
+  const [cValue, setCValue] = useState([]);
+  const onCascaderChange = useCallback(value => {
+    // console.log('cValue', value);
+    setCValue(value);
+  }, []);
+
+  const treeData = useMemo(() => {
+    return generateOptions([4, 10, 10, 10, 10]);
+  }, []);
+
+  return (
+    <Cascader
+      value={cValue}
+      onChange={onCascaderChange}
+      filterTreeNode
+      leafOnly
+      multiple
+      maxTagCount={4}
+      treeData={treeData}
+      style={{ width: 200 }}
+    />
+  )
+}

+ 17 - 29
packages/semi-ui/cascader/index.tsx

@@ -14,10 +14,10 @@ import CascaderFoundation, {
 } from '@douyinfe/semi-foundation/cascader/foundation';
 import { cssClasses, strings } from '@douyinfe/semi-foundation/cascader/constants';
 import { numbers as popoverNumbers } from '@douyinfe/semi-foundation/popover/constants';
-import { isSet, isEqual, isString, isEmpty, isFunction, isNumber, noop, flatten } from 'lodash';
+import { isSet, isEqual, isString, isEmpty, isFunction, isNumber, noop, flatten, isObject } from 'lodash';
 import '@douyinfe/semi-foundation/cascader/cascader.scss';
 import { IconClear, IconChevronDown } from '@douyinfe/semi-icons';
-import { findKeysForValues, convertDataToEntities, calcMergeType } from '@douyinfe/semi-foundation/cascader/util';
+import { convertDataToEntities, calcMergeType, getKeyByValuePath } from '@douyinfe/semi-foundation/cascader/util';
 import { calcCheckedKeys, normalizeKeyList, calcDisabledKeys } from '@douyinfe/semi-foundation/tree/treeUtil';
 import ConfigContext, { ContextValue } from '../configProvider/context';
 import BaseComponent, { ValidateStatus } from '../_base/baseComponent';
@@ -424,31 +424,27 @@ class Cascader extends BaseComponent<CascaderProps, CascaderState> {
             return firstInProps || treeDataHasChange;
         };
         const getRealKeys = (realValue: Value, keyEntities: Entities) => {
-            // normallizedValue is used to save the value in two-dimensional array format
-            let normallizedValue: SimpleValueType[][] = [];
+            // normalizedValue is used to save the value in two-dimensional array format
+            let normalizedValue: SimpleValueType[][] = [];
             if (Array.isArray(realValue)) {
-                normallizedValue = Array.isArray(realValue[0])
+                normalizedValue = Array.isArray(realValue[0])
                     ? (realValue as SimpleValueType[][])
                     : ([realValue] as SimpleValueType[][]);
             } else {
                 if (realValue !== undefined) {
-                    normallizedValue = [[realValue]];
+                    normalizedValue = [[realValue]];
                 }
             }
             // formatValuePath is used to save value of valuePath
             const formatValuePath: (string | number)[][] = [];
-            normallizedValue.forEach((valueItem: SimpleValueType[]) => {
-                const formatItem: (string | number)[] = onChangeWithObject ?
+            normalizedValue.forEach((valueItem: SimpleValueType[]) => {
+                const formatItem: (string | number)[] = onChangeWithObject && isObject(valueItem[0]) ?
                     (valueItem as CascaderData[]).map(i => i?.value) :
                     valueItem as (string | number)[];
                 formatValuePath.push(formatItem);
             });
             // formatKeys is used to save key of value
-            const formatKeys: any[] = [];
-            formatValuePath.forEach(v => {
-                const formatKeyItem = findKeysForValues(v, keyEntities);
-                !isEmpty(formatKeyItem) && formatKeys.push(formatKeyItem);
-            });
+            const formatKeys = formatValuePath.map(v => getKeyByValuePath(v));
             return formatKeys;
         };
         const needUpdateTreeData = needUpdate('treeData') || needUpdateData();
@@ -478,7 +474,7 @@ class Cascader extends BaseComponent<CascaderProps, CascaderState> {
                 if (isSet(realKeys)) {
                     realKeys = [...realKeys];
                 }
-                const calRes = calcCheckedKeys(flatten(realKeys), keyEntities);
+                const calRes = calcCheckedKeys(realKeys, keyEntities);
                 const checkedKeys = new Set(calRes.checkedKeys);
                 const halfCheckedKeys = new Set(calRes.halfCheckedKeys);
                 // disableStrictly
@@ -505,7 +501,7 @@ class Cascader extends BaseComponent<CascaderProps, CascaderState> {
 
     componentDidUpdate(prevProps: CascaderProps) {
         let isOptionsChanged = false;
-        if (!isEqual(prevProps.treeData, this.props.treeData)) {
+        if (!isEqual(prevProps.treeData, this.props.treeData) && !this.props.multiple) {
             isOptionsChanged = true;
             this.foundation.collectOptions();
         }
@@ -527,13 +523,12 @@ class Cascader extends BaseComponent<CascaderProps, CascaderState> {
         this.handleTagRemove(null, keyEntities[key].valuePath);
     }
 
-    renderTagItem = (value: string | Array<string>, idx: number, type: string) => {
+    renderTagItem = (nodeKey: string, idx: number) => {
         const { keyEntities, disabledKeys } = this.state;
         const { size, disabled, displayProp, displayRender, disableStrictly } = this.props;
-        const nodeKey = type === strings.IS_VALUE ? findKeysForValues(value, keyEntities)[0] : value;
         const isDsiabled =
             disabled || keyEntities[nodeKey].data.disabled || (disableStrictly && disabledKeys.has(nodeKey));
-        if (!isEmpty(keyEntities) && !isEmpty(keyEntities[nodeKey])) {
+        if (keyEntities[nodeKey]) {
             const tagCls = cls(`${prefixcls}-selection-tag`, {
                 [`${prefixcls}-selection-tag-disabled`]: isDsiabled,
             });
@@ -567,25 +562,18 @@ class Cascader extends BaseComponent<CascaderProps, CascaderState> {
         const { size, disabled, placeholder, maxTagCount, showRestTagsPopover, restTagsPopoverProps } = this.props;
         const { inputValue, checkedKeys, keyEntities, resolvedCheckedKeys } = this.state;
         const tagInputcls = cls(`${prefixcls}-tagInput-wrapper`);
-        const tagValue: Array<Array<string>> = [];
         const realKeys = this.mergeType === strings.NONE_MERGE_TYPE ? checkedKeys : resolvedCheckedKeys;
-        [...realKeys].forEach(checkedKey => {
-            if (!isEmpty(keyEntities[checkedKey])) {
-                tagValue.push(keyEntities[checkedKey].valuePath);
-            }
-        });
         return (
             <TagInput
                 className={tagInputcls}
                 ref={this.inputRef as any}
                 disabled={disabled}
                 size={size}
-                // TODO Modify logic, not modify type
-                value={(tagValue as unknown) as string[]}
+                value={[...realKeys]}
                 showRestTagsPopover={showRestTagsPopover}
                 restTagsPopoverProps={restTagsPopoverProps}
                 maxTagCount={maxTagCount}
-                renderTagItem={(value, index) => this.renderTagItem(value, index, strings.IS_VALUE)}
+                renderTagItem={(value, index) => this.renderTagItem(value, index)}
                 inputValue={inputValue}
                 onInputChange={this.handleInputChange}
                 // TODO Modify logic, not modify type
@@ -747,7 +735,7 @@ class Cascader extends BaseComponent<CascaderProps, CascaderState> {
         const hiddenTag: Array<ReactNode> = [];
         [...realKeys].forEach((checkedKey, idx) => {
             const notExceedMaxTagCount = !isNumber(maxTagCount) || maxTagCount >= idx + 1;
-            const item = this.renderTagItem(checkedKey, idx, strings.IS_KEY);
+            const item = this.renderTagItem(checkedKey, idx);
             if (notExceedMaxTagCount) {
                 displayTag.push(item);
             } else {
@@ -795,7 +783,7 @@ class Cascader extends BaseComponent<CascaderProps, CascaderState> {
 
         if (!searchable) {
             if (multiple) {
-                if (isEmpty(checkedKeys)) {
+                if (checkedKeys.size === 0) {
                     return <span className={`${prefixcls}-selection-placeholder`}>{placeholder}</span>;
                 }
                 return this.renderMultipleTags();