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

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

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

效果预览

单个项选中

单个选中项

多个部分项选中

多个选中项

需求分析

在拿到需求以后,咱们首先要作的是需求分析;经过上面的效果预览咱们能够初步知道咱们所须要处理的核心逻辑:前端

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

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

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

组件设计

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

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

入参设计以下:app

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 呈现,如今你们应该有个基础的认识,咱们须要作什么样的需求了。异步

咱们在接到一个需求的时候,先不要着急着码代码,更好的方式是先规划咱们的组件方案设计,而且提早思考好各类逻辑分支;
这里给你们看下个人设计初稿,我习惯性的选择脑图来发散本身的思惟:
脑图草稿async

经过上图,咱们可以在大脑中有个大概的清晰认识到咱们须要作哪些核心模块的设计与开发,接下来就是规划咱们的核心模块划分:函数

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

核心模块划分

数据缓存设计

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

那么若是如何设计才能作到性能最优呢?经过上面的脑图,咱们初步是经过一个 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 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 实现

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

相关文章
相关标签/搜索