• 作者:老汪软件技巧
  • 发表时间:2024-09-02 17:02
  • 浏览量:

一、背景

项目中有一个下图这样的选择组件需要实现,数据是有层级关系的,需要支持单选多选。

看起来很简单,我们可以直接复用 antd 的级联组件,已经支持大部分级联组件需要的功能了。

但是在实际应用中,因为数据请求方式的限制,还是遇到了很多问题,一起看看都是怎么解决的。

二、遇到的问题1. 怎么查数据

级联数据有 10万+ 条,那是一次查所有数据还是一级一级查呢?

项目中有 2 种类型的级联数据,由于上游的限制,类型 1 的级联数据支持一次查所有数据也支持一级一级查,而类型 2 则只支持一级一级查。

而类型 1 的级联数据,必须支持搜索,不然 10万+ 数据没法挨个找。

因此,我们的级联组件要同时支持一次查所有数据和一级一级查这 2 种查数据的模式。

1.1 一次查所有数据

一次查所有就很简单,请求一次接口就行。

1.2 一级一级查数据

一级一级查数据则是先查第一级,这时查到后写死第一级的每一个 children的isLeaf: false,因为只有 isLeaf 为 false 时,才能继续查下一级,直到查不到。

2. 初始化渲染

由于我们的组件要同时支持一次查所有数据和一级一级查这 2 种查数据的模式,导致后面所有的逻辑都要兼容这 2 种数据方式。

初始化渲染,指的是页面详情,组件初始是有选中态的,而这个选中态能在级联组件中渲染出来的条件是,选中了的 id 的数据都已经查到了。

2.1 一次查所有数据

一次查所有的初始化渲染不需要做特别的处理,因为所有数据都有,级联选择器的选中态能正常渲染出来。

2.2 一级一级查数据

一级一级查这种模式,需要根据组件的选中状态,逐步加载数据来渲染选中态。

同时需要处理不同选中模式(单选、多选)的数据请求和渲染逻辑。

3. 级联列表数据渲染

上面已经介绍了,怎么新建时查数据,怎么详情渲染时查数据。

这时我们注意到,组件渲染所需的数据结构和接口返回的数据结构需要互相转换,才能实现正确的数据渲染和选中:

_小小的级联选件我遇到了哪些问题_小小的级联选件我遇到了哪些问题

级联列表数据渲染:组件列表的数据结构 接口返回的数据结构级联选中态数据渲染:组件选中态的数据结构 接口返回的数据结构3.1 一次查所有数据

在一次查所有数据的模式下,直接将接口返回的数据结构转换为组件需要的数据结构就行。

3.2 一级一级查数据

一级一级查,根据组件的 value 列表逐级请求数据,并更新组件的树形结构。

3.2.1 获取级联数据

1. 单选

单选时,级联组件的value的数据结构:cat1-cate2-cat3,多级id用-连接。

所以单选就是把 cat1-cate2-cat3转成级联的 id 列表,逐一查询对应级联数据。

2. 多选

多选时,级联组件的value的数据结构:string[][],如[['1', '173', '542']]。需要查 1、173的数据,这样级联组件才能正常渲染选中态。

所以多选就是将所有的倒数第一级之外的节点去重后,逐一查询对应级联数据。

3.2.2 渲染级联数据

有了每一级的级联数据,怎么更新到树结构中呢。

id 根据 level 排序,小的在前先查第一级并更新再查剩下的 id,每查到一个,如果能查到这个 id 的 chidren,就找到 tree 中的这个节点,更新这个节点的 chidren,如果查不到,更新这个节点的 isLeaf 为 true4. 选中态数据渲染4.1 单选4.2 多选5. 单应用有多个级联组件

上面提到,级联数据有 10w+ 条,单应用多个页面存在多个级联组件,我们应该把数据缓存下来,避免每次使用组件都重新加载数据。

5.1 一次查所有数据

一次查所有数据,使用状态管理工具(如 Zustand)存储数据,避免重复请求。

5.2 一级一级查数据

当一个页面用了 5 次级联组件,而且都是多选,每个都选了 5 个数据,每个数据都有 5 层,这时我们通过 id 查对应的级联数据需要请求多少次接口?这其中会不会有 id 重复,如果重复了,数据是不是被覆盖了。

那我们怎么解决这个问题呢?

实现一个RequestCollector 类,用于处理大量请求的防抖和去重。这个类确保了在短时间内对相同的请求进行去重,并按顺序逐个处理。这里的关键点是:

type RequestFn = (e: { catId: string, level: number }) => Promise<void>
interface RequestItem {
  requestFn: RequestFn
  requestParam: {
    catId: string
    level: number
  }
}
export default class RequestCollector {
  private interval: number
  private queue: RequestItem[]
  private timer: NodeJS.Timeout | null
  constructor(interval = 100) {
    this.interval = interval
    this.queue = []
    this.timer = null
  }
  /**
   * 添加请求到队列
   * @param {RequestItem} item - 请求项,包含 requestFn 和 requestParam
   */
  public addRequest(item: RequestItem): void {
    this.queue.push(item)
    // 如果计时器不存在,则设置计时器
    if (this.timer === null) {
      this.timer = setTimeout(() => this.processRequests(), this.interval)
    }
  }
  /**
   * 处理队列中的请求
   */
  private async processRequests(): Promise<void> {
    // 排序并去重
    const uniqueSortedRequests = this.sortAndDeduplicate(this.queue)
    // 清空队列
    this.queue = []
    // 逐一处理请求
    for (const item of uniqueSortedRequests) {
      const { requestFn, requestParam } = item
      try {
        await requestFn(requestParam)
      } catch (error) {
        console.error('Request failed:', error)
      }
    }
    // 处理完成后重置计时器
    this.timer = null
  }
  /**
   * 根据 requestParam 的 level 对数组进行排序并去重
   * @param {RequestItem[]} requests - 包含 { requestFn, requestParam } 的数组
   * @returns {RequestItem[]} - 排序并去重后的数组
   */
  private sortAndDeduplicate(requests: RequestItem[]): RequestItem[] {
    // 排序:先根据 level 升序排序
    const sorted = requests
      .slice()
      .sort((a, b) => a.requestParam.level - b.requestParam.level)
    // 去重:保留 level 较小的 requestParam
    const unique = sorted.filter(
      (item, index, self) =>
        index ===
        self.findIndex((t) => t.requestParam.catId === item.requestParam.catId)
    )
    return unique
  }
}

可以看到,请求管理器里用到了 setTimeout,setTimeout 里的回调是闭包,因此它是拿不到最新的状态的,所以我们还需要借助一个应用级通用的 Ref 来存储数据。

总结

该组件涵盖了数据获取、渲染、缓存及选中状态管理等多个方面,关键点如下:

通过合理选择数据加载策略、优化初始化渲染流程、准确进行数据结构转换以及高效的请求管理,可以有效地解决复杂的级联选择组件中的各种挑战。实现了在高效的数据处理和优良的用户体验之间的平衡,满足了用户对组件性能和交互的高要求。