对Cascader级联选择器的思考及实践

143 阅读4分钟

前言

对于级联选择器来说,在开发中常常使用,比如地区的选择分类的选择等。但是对于如何使用,我们通常是根据项目的需求来决定。

有几种情况

  1. 对于数据不多时。每次都查询接口来获取Cascader的全部数据。可以进一步优化,当第一次查询时将数据记录到redux中。后续使用时从redux中取出。这应该是常见的做法。
  2. 对于数据量大时。查询全部数据就不适用了。我们会使用动态加载选项。也可以进一步的优化,将每一次请求的数据保存到redux中,并进行拼接。在需要加载选项时,判断是否需要请求获取。

第一种情况不管是编辑还是回显,都很好处理。在第二种情况中,编辑也很好处理,但是回显不好处理,也有两种方式:

(1)当需要回显时,后端将选项的label也一并返回。

(2)后端提供一个接口,通过节点id,返回同级节点及其祖先同级节点,然后再和现有的节点进行拼接处理。还有一个注意点,在后面会提到

在前一段开发中,也是遇到的Cascader的问题。跟后端讨论了下最终选择了2-(2)这个方案。

后面有详细代码地址。

正文

初始化

redux中存储一个是否初始化的标志。在没有初始化时,进行初始化。

import { getBrothersAndAncestors, getChildrenData, ResData } from '@/components/Cascader/data';
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { RootState } from '.';

export interface Option {
  parentId: string,
  value: string,
  label: string,
  isLeaf: boolean,
  children?: Option[]
}

let isLoading = false;
// 初始化
export const initFetch = createAsyncThunk('data/initFetch', async () => {
  if (isLoading) {
    return Promise.reject('loading')
  }
  isLoading = true;
  const response = await getChildrenData()
  let result: Option[] = []

  response.forEach((item) => {
    result.push({
      parentId: '0',
      value: item.id,
      label: item.label,
      isLeaf: item.isLeaf,
    })
  })
  isLoading = false;

  return result;
});

const initialState: {
  isInit: boolean,
  treeData: Option[],
  loading: boolean
} = {
  isInit: false,
  treeData: [],
  loading: false
}

export const treeSlice = createSlice({
  name: 'treeSlice',
  initialState,
  reducers: {
    changeInit: (state, action) => {
      state.isInit = action.payload
    },
    setTree: (state, action) => {
      state.treeData = action.payload
    }
  },
  extraReducers: (builder) => {
    builder
      .addCase(initFetch.pending, (state) => {
        state.loading = true;
      })
      .addCase(initFetch.fulfilled, (state, action) => {
        state.isInit = true;
        state.treeData = action.payload;
        state.loading = false;
      })
  }
})

export const { changeInit, setTree } = treeSlice.actions
export default treeSlice.reducer

在封装组件中

  useEffect(() => {
    if (isInit === false) {
      dispatch(initFetch())
    }
  }, [])

获取子节点

在获取子节点时,我们需要一个方法来判断是否需要请求接口。

  /** 
   * @description 查找分类
   * @param {Option[]} list 需要查找的分类列表
   * @param {string} findId 要查找的分类id
   * @returns {Object} 包含data和listId的对象
   * @property {Option} data 找到的分类
   * @property {string[]} listId 找到的分类的id列表
  */
  const findItem = (list: Option[], findId: string) => {
    let result: Option = null as unknown as Option;
    let listId: string[] = [];

    const fn = (tempList: Option[], findId: string, tempListId: string[]) => {
      for (let i = 0; i < tempList.length; i++) {
        let item = tempList[i]
        if (item.value === findId) {
          listId = [...tempListId, item.value]
          result = item;
          break;
        }
      }

      for (let i = 0; i < tempList.length; i++) {
        let item = tempList[i]
        if (item.children && item.children.length > 0 && result === null) {
          fn(item.children, findId, [...tempListId, item.value])
        }
      }
    }

    fn(list, findId, listId)
    return {
      data: result,
      listId: listId
    }
  }

  /** 
 * @description 当前节点的是否查询过子节点
 * @param findId 要查找的id
 * @returns boolean 是否有查询过
*/
  const isQueryChild = useCallback((parentId: string) => {
    let parent = findItem(treeData, parentId).data
    if (!parent.isLeaf && !!parent.children && parent.children.length > 0) {
      return true
    }
    return false
  }, [treeData])
  
  /** 获取子数据*/
  const getChildData = useCallback((parentId: string) => {
    let tempList = _.cloneDeep(treeData)
    let result = findItem(tempList, parentId).data
    return getChildrenData(parentId).then(data => {
      let list = data.map(item => {
        return {
          parentId: parentId,
          value: item.id,
          label: item.label,
          isLeaf: item.isLeaf,
          children: [],
        };
      });
      result.children = list;
      dispatch(setTree(tempList))
    })
  }, [treeData])
  

在封装组件中判断是否需要请求接口

  const loadData = (selectedOptions: Option[]) => {
    const targetOption = selectedOptions[selectedOptions.length - 1];
    if (!isQueryChild(targetOption.value)) {
      getChildData(targetOption.value);
    }
  };

获取同级及祖先同级节点

检查节点是否存在

  const checkIdExists = useCallback((id: string): Promise<string[]> => {
    return new Promise((resolve, reject) => {
      let { data, listId } = findItem(treeData, id)
      if (data === null) {
        dispatch(parentsFetch(id)).then((data) => {
          if (data.type.toString() === parentsFetch.fulfilled.toString()) {
            resolve(findItem(data.payload as Option[], id).listId)
          }
        }).catch(() => {
        })
      } else {
        resolve(listId)
      }
    })
  }, [treeData])

查询同级节点及祖先同级节点,并和现有的节点进行拼接

/** 获取分类祖籍*/
export const parentsFetch = createAsyncThunk('data/parentsFetch', async (id: string, thunkAPI) => {
  if (isLoading) {
    return Promise.reject('loading')
  }
  isLoading = true;
  const state = thunkAPI.getState() as RootState;
  const mergeArrays = (arr1: Option[], arr2: ResData[], parentId: string = ''): Option[] => {
    const result: Option[] = [];
    let idSet = new Set();
    let uniqueIds: string[] = []
    arr1.forEach(item => {
      idSet.add(item.value)
    })
    arr2.forEach((item: any) => {
      idSet.add(item.id)
    })
    for (let id of idSet) {
      uniqueIds.push(id as string)
    }

    uniqueIds.forEach(id => {
      const item1 = arr1.find((item) => item.value === id);
      const item2 = arr2.find((item) => item.id === id);

      const mergedItem: Option = {
        parentId: parentId,
        value: id,
        label: item1 ? item1.label : item2!.label,
        children: [],
        isLeaf: item2 ? item2.isLeaf : item1!.isLeaf,
      };

      if (item1 && item1.children) {
        mergedItem.children = mergeArrays(item1.children, item2 ? item2.children || [] : [], id);
      } else if (item2 && item2.children) {
        mergedItem.children = mergeArrays(item1 ? item1.children || [] : [], item2.children, id);
      }

      result.push(mergedItem);
    });

    return result;
  }

  let response = await getBrothersAndAncestors(id)
  let result = mergeArrays(state.tree.treeData, response)
  isLoading = false;
  return result;
})

export const treeSlice = createSlice({
  name: 'treeSlice',
  initialState,
  reducers: {
    changeInit: (state, action) => {
      state.isInit = action.payload
    },
    setTree: (state, action) => {
      state.treeData = action.payload
    }
  },
  extraReducers: (builder) => {
    builder
      .addCase(parentsFetch.pending, (state) => {
        state.loading = true;
      })
      .addCase(parentsFetch.fulfilled, (state, action) => {
        state.treeData = action.payload;
        state.loading = false;
      })
  }
})

封装组件中

  //当初始化完成,并且value有值时,获取子节点数据
  useEffect(() => {
    if (!!value && isInit && selfValue[selfValue.length - 1] != value && !loading) {
      checkIdExists(`${value}`).then((data: string[]) => {
        setSelfValue(data);
      });
    }
  }, [value, isInit, loading]);

注意问题

在上面核心代码中可以看到,在初始化请求和查询同级节点及祖先同级节点时,会有这么一行代码。这是因为有可能一个页面中同时出现多个Cascader,这样防止 重复请求。

isLoading = true;

这一段和上面配合起一个队列的的作用,既避免重复请求,又防止漏请求。

 useEffect(() => {
    if (!!value && isInit && selfValue[selfValue.length - 1] != value && !loading) {
      checkIdExists(`${value}`).then((data: string[]) => {
        setSelfValue(data);
      });
    }
  }, [value, isInit, loading]);

结语

感兴趣的可以去试试。

仓库地址:function-realization: 实现一些有趣的功能