16

高性能多级多选级联组件开发

最近在项目开发过程中,有个一个多级多选的公共组件开发需求,特在这里记录下开发过程中所做的一些优化以及分享一下我是如何从零开发并设计一个组件的思路,希望给阅读这篇文章的读者带来一点收获。

效果预览

单个项选中

单个选中项

多个部分项选中

多个选中项

需求分析

在拿到需求之后,我们首先要做的是需求分析;通过上面的效果预览我们可以初步知道我们所需要处理的核心逻辑:

  1. 默认加载第一层级数据
  2. 鼠标 hover

    1. 异步获取数据
    2. 切换下级渲染数据
  3. 鼠标点击

    1. 点击当前项状态改变:选中 or 未选中
    2. 当前项的父级状态改变:选中、半选、不选中,并且需要递归处理
    3. 当前项的子级状态改变:全选、全不选

组件设计

在设计组件之前,我们需要考虑组件的性能、通用型等问题;如何设计一个与业务解耦的组件,是我们需要首先考虑的问题;那么,如何将组件数据请求与业务解耦呢:

  • 组件提供一个 service 入参,service 是一个返回 Promise 的异步请求方法
  • 组件提供一个 dataMapper,用来做数据转换,将 service 请求返回的值转化为符合我们组件数据解构的数据
  • 组件内部通过调用外部传入的 service 来获取数据

入参设计如下:

interface Props {
  ...
  // 外部传入服务
  service: (args: { parentId: string }) => Promise<{ list: SelectorItemType[] }>;
  dataMapper?: (args: any) => { list: SelectorItemType[] };
  /**
   * 回显数据
   * @default []
   */
  data?: SelectorItemType[];
  onSubmit?: SubmitCallback;
  onCancel?: () => void;
}

try {
  const data = await service({ parentId: itemId });
  nextColumnList = dataMapper ? dataMapper(data).list : data.list;
} catch (error) {
  Notification.error(error);
  nextColumnList = [];
}

整体思路设计

通过上面的 UI 呈现,现在大家应该有个基础的认识,我们需要做什么样的需求了。

我们在接到一个需求的时候,先不要着急着码代码,更好的方式是先规划我们的组件方案设计,并且提前思考好各种逻辑分支;
这里给大家看下我的设计初稿,我习惯性的选择脑图来发散自己的思维:
脑图草稿

通过上图,我们能够在大脑中有个大概的清晰认识到我们需要做哪些核心模块的设计与开发,接下来就是规划我们的核心模块划分:

  • 数据缓存
  • 异步数据获取
  • 选中数据缓存
  • 渲染数据源设计

核心模块划分

数据缓存设计

要设计一个高性能多级多选组件,肯定离不开我们的数据优化部分:数据缓存

那么如果如何设计才能做到性能最优呢?通过上面的脑图,我们初步是通过一个 dataCaheMap 来缓存异步拉取回来的数据,这样子我们在取的时候,时间复杂度就是 O(1) ;既然是有 Map 来缓存数据,那么用什么作为 key 也是我们缓存的关键;
在这个组件里面,最终我选择的是:列索引+行索引+id 作为缓存 key

这样设计的目的是,防止后台出现同时操作增删改类目配置;通过这种方式,能避免因为后台在同步操作到新增加或者删除了某个类目之后,取的缓存数据还是旧数据,这点是很关键的!

// 数据缓存映射 Map
const [dataCacheMap, setDataCacheMap] = useState<{ [x: string]: SelectorItemType[] }>({});

/**
 * 获取缓存 key
 * @param itemId selectedItem id
 * @param itemIndex selectedItem 当前 item 索引
 * @param columnIndex 当前 column 索引
 */
const getCacheKey = (itemId: string, itemIndex: number, columnIndex: number) =>
  `${itemId}-${itemIndex}-${columnIndex}`;

// 取缓存值
async function getItemList() {
  const cacheKey = getCacheKey(itemId, itemIndex, columnIndex);

  let nextColumnList = dataCacheMap[cacheKey];
  let _selectedValues = { ...selectedValues };

  if (!nextColumnList) {
    setLoading(true);
    const data = await service({ parentId: itemId });
    // dataMapper 用来自定义数据转换
    nextColumnList = dataMapper ? dataMapper(data.list) : data.list;
  }

  setDataCacheMap((prev) => ({
    ...prev,
    [`${cacheKey}`]: nextColumnList,
  }));

  setLoading(false);

  ...
}

数据请求设计

如果我们组件要与业务解耦,那么必须要将数据请求与组件解耦;所以我们设计组件的是,提供了一个 service 属性作为异步数据请求服务传入;并且通过 TS 来约束 参数与响应体结构,让接口服务返回的数据符合我们的组件所需的数据结构:单个数据项必须含有 id, parentId, label 三个必须属性,其中 parentId 是我们处理级联依赖的关键;针对不同的业务,可能第一级的 parentId 不一样,所以我们也提供了一个 defaultParentId 作为属性供外部传入

如果服务层的数据无法改变,我们还提供了 dataMapper 回调函数来帮助我们格式化返回的数据

/**
 * 单个类目项
 */
export interface SelectorItemType {
  id: string;
  /**
   * @default '0'
   */
  parentId: string;
  /**
   * 是否可选
   * @default true
   */
  disabled?: boolean;
  /**
   * 选项文案
   * @default '-'
   */
  label: string;

  /**
   * 是否半选状态
   * @default false
   */
  indeterminate?: boolean;
  [x: string]: any;
}

interface Props {
  ...
  // 外部传入请求数据服务
  service: (args: { parentId: string }) => Promise<{ list: SelectorItemType[] }>;
  defaultParentId: string;
  dataMapper?: (args: any) => { list: SelectorItemType[] };
  /**
   * @default []
   */
  data?: SelectorItemType[];
  onSubmit?: SubmitCallback;
  onCancel?: () => void;
}

渲染数据源设计

在有了前面的『数据缓存』、『数据请求』之后,我们接下来设计渲染所需的数据结构;从交互层面,我们最容易想到的是二维数组数据结构;通过二维数组的方式,能方便的帮助我们渲染所需的 UI;

假设我们的数据是如下数据格式:

// 组件内部数据源
const [source, setSource] = useState<SelectorItemType[][]>([]);

但是因为我们的交互上面,是有个『部分选中』这个状态存在,但是这个状态与后台类目无关,只是前端展示需要用到的字段,所以我们需要对接口返回的数据做一个初始化的操作:将数据源项新增一个半选状态 indeterminate 标志位,后续我们在处理级联状态的时候,需要频繁的改动到这个状态值

categoryList.forEach((item) => {
  result.push({
    ...item,
    id: item.categoryId,
    label: item.title,
    // 半选状态标志位
    indeterminate: false,
  });
});

<div className={styles.selectorItemContainer}>
  {column.map((item, index) => {
    return (
      <div
        key={`${item.id}-${columnIndex}`}
        className={styles.selectorItem}
        onMouseEnter={() => debouncedHoverCallback(item.id, index, columnIndex)}
        >
        <Checkbox
          value={Boolean(selectedValues[item.id])}
          disabled={item.disabled}
          // 判断是否半选
          indeterminate={item.indeterminate}
          className={styles.checkbox}
          onClick={() => handleItemClick(index, columnIndex)}
          >
          <div className={styles.labelText}>{item.label || '-'}</div>
        </Checkbox>
        <Icon className={styles.iconRight} type="arrowright" />
      </div>
    );
  })}
</div>

已选数据设计

我们的组件是『多级多选』无限层级,在组件渲染的时候,如何判断当前 item 项是否选中,依靠的就是我们的已选数据 state:

// 已选择类目,组件内部维护状态
const [selectedValues, setSelectedValues] = useState<SelectedMap>({});

<Checkbox
  // 判断是否选中
  value={Boolean(selectedValues[item.id])}
  disabled={item.disabled}
  indeterminate={item.indeterminate}
  className={styles.checkbox}
  onClick={() => handleItemClick(index, columnIndex)}
  >
  <div className={styles.labelText}>{item.label || '-'}</div>
</Checkbox>

通过打平数据结构,我们无需关心渲染层级,时间复杂度层面也是保持 O(1);

交互逻辑详解

Hover 事件逻辑详情

鼠标 hover 操作,我们主要是需要:

  1. 处理异步数据的获取与缓存
  2. 处理当前项的子级数据状态;通过在 Hover 的时候来控制子级的状态,可以让我省去递归子级的操作来提高我们的整体性能

注意:在 Hover 事件过程中,我们需要对 debounce 操作

import { useDebouncedCallback } from 'use-debounce';

const [debouncedHoverCallback] = useDebouncedCallback(
    (itemId: string, itemIndex: number, columnIndex: number) => {
      setQueryData({
        itemId,
        columnIndex,
        itemIndex,
      });
    },
    100,
  );

<div
  key={`${item.id}-${columnIndex}`}
  className={styles.selectorItem}
  onMouseEnter={() =>
    debouncedHoverCallback(item.id, index, columnIndex)
  }
>
  ....
</div>

Hover Detail

多选项 Click 逻辑详情

鼠标 click 操作,核心逻辑:

  1. 改变当前点击项状态
  2. 改变子级状态
  3. 改变父级状态

HandleItemClick.png

数据回调

在我们选中操作完成之后,我们需要将用户选择的数据提交给后台,通常多级多选的数据结构设计是平级设计,所以当我们父级如果是选中的数据,那么它的子级数据就没有必要提交给后台了;

所以我们需要冲选中池中过滤出父级 parentId 不在选中池中的数据,这个就是我们最终需要返回给用户与后台的数据

const handleSubmit = () => {
  const result: SelectorItemType[] = Object.keys(selectedValues).map(
    (key) => selectedValues[key],
  );
  // 核心逻辑:过滤出当前 parentId 不在选中池中数据,就表示它的父级没有选中
  const filterData = result.filter((item) => !selectedValues[item.parentId] || !item.parentId);
  onSubmit && onSubmit(filterData);
};

Q&A

到这里我们就基本介绍完了如何从 0 到 1完整的设计一个多级多选的组件;该组件支持任意层级的数据,只需要满足我们的层级依赖关系的数据结构,将能复用这个组件

但是我们还有几个思考题:

  1. 如果多选组件还需要能展示禁选项,逻辑如何调整?
  2. 如何解耦 DOM 结构与 CSS 实现

这两个问题欢迎各位在下面讨论


离尘不理人
1.9k 声望732 粉丝