组件系列--无限层级联动选择

项目背景

使用第三方UI框架 ant vue3 的UI框架。如果了解这个框架知道,有一个组件:# Cascader,是能够实现级联选择的。控件样式:

该死的产品设计如下:

因为这个原因,所以有了联动组件的开发。

前言

做项目,我一般会优先选择一套成熟的UI组件库。除非这套库的内容不能实现相关的UI,我才会动手写。 开发该组件相关:

  • vu3 + ant design vue + ts
  • 组件的设计思考
  • 你自己的一个业务组件

先看效果

设计

  1. 设计组件,首先要考虑的是数据的交互。
  • 数据源结构:

    js 复制代码
    // 选择数据
    export type SelectEntity = {
      value: string // 标识
      label: string // 名称
      children?: SelectEntity[]
    }

    数据源是,嵌套关系。理论上支持无限层级。只要UI显示没有问题。

  • 数据变更:

    js 复制代码
    // 通知调用组件更新数据
    emit('onSelectEvent', {
    fieldNames: props.fieldNames,
    selectValues: refData.value,
    })

数据选择变更后,通过事件【onSelectEvent】通知父组件。

  1. 组件使用需要涉及到的属性
属性 说明 类型 默认值 必填
modelValue 绑定数据 string\[\] \[\] 是
fieldNames 绑定的字段名称数组 string\[\] \[\] 是
placeholder 输入提示 string\[\] \[\] 否
label 控件描述 string '' 否
optionData 控件数据源 SelectEntity\[\] \[\] 是
layout 布局模式(跟Form控件的布局模式同步) string 'vertical' 否
required 是否必填 boolean false 否
rules 控件校验规则(同ant 中 form控件的校验) any\[\] \[\] 否

控件是根据属性【fieldNames】,来决定是多少级的联动。如果是两个字段,则是两级联动。

代码实现

代码

js 复制代码
<script lang="ts" setup>
import { reactive, watch } from 'vue'
import { Col, FormItem, Row, Select } from 'ant-design-vue'
import type { SelectEntity } from '.'

type Props = {
  modelValue: string[] // 绑定的数据
  fieldNames: string[] // 控件绑定的字段
  placeholder?: string[] //  输入描述
  label?: string // 控件描述
  optionData: SelectEntity[] // 获取数据
  layout?: string // 表单布局模式 默认:vertical
  required?: boolean // 是否必填
  rules?: any[] // 验证规则
}
const props = defineProps<Props>()
const selectDataType: Record<string, SelectEntity[] | undefined> = {}
const refData = reactive({
  value: props.modelValue,
  options: props.optionData,
  selectData: selectDataType,
})

// 获取控件的数据源
const getOptionData = (index: number) => {
  const result: any[] = []
  if (index === 0) {
    refData.options.forEach((item) => {
      result.push({
        label: item.label,
        value: item.value,
      })
    })
  } else {
    const key = refData.value[index - 1]
    if (refData.selectData && refData.selectData[key]) {
      ; (refData.selectData[key] as SelectEntity[]).forEach((item) => {
        result.push({
          label: item.label,
          value: item.value,
        })
      })
    }
  }

  return result
}

const emit = defineEmits(['onSelectEvent'])

// 选项数据时
const selectChange = (value: any, index: number, isClear: boolean) => {
  if (index === 0) {
    if (!Object.hasOwnProperty.call(refData.selectData, value)) {
      const sourceItems = refData.options.find((item) => item.value === value)
      refData.selectData[value] = sourceItems?.children
    }
  } else {
    const key = refData.value[index - 1]
    if (
      !Object.hasOwnProperty.call(refData.selectData, value) &&
      refData.selectData &&
      refData.selectData[key]
    ) {
      const sourceItems = (refData.selectData[key] as SelectEntity[]).find(
        (item) => item.value === value
      )

      refData.selectData[value] = sourceItems?.children
    }
  }

  // 清除后续的选择
  if (isClear) {
    for (let i = index + 1; i < refData.value.length; i++) {
      refData.value[i] = ''
    }
  }

  // 通知调用组件更新数据
  emit('onSelectEvent', {
    fieldNames: props.fieldNames,
    selectValues: refData.value,
  })
}

// 设置默认值
const setDefaultVal = () => {
  if (props.modelValue && props.modelValue.length > 0) {
    for (let index = 0; index < props.modelValue.length; index++) {
      selectChange(props.modelValue[index], index, false)
    }
  }
}

// 监控数据变化
watch(
  () => props.modelValue,
  () => {
    setDefaultVal()
  },
  { immediate: true }
)
</script>

<template>
  <div class="flex justify-content w-[100%]">
    <Row :gutter="4" class="flex-grow">
      <Col :span="24 / props.fieldNames.length" v-for="(item, index) in props.fieldNames">
      <FormItem :label="index === 0 && props.label ? props.label : ''" :required="props.required ? props.required : false"
        :rules="props.rules
            ? props.rules
            : [
              {
                required: props.required ? props.required : false,
                message: `请选择${props.placeholder && props.placeholder[index]
                    ? props.placeholder[index]
                    : ''
                  }`,
                trigger: 'change',
              },
            ]
          " :class="index != 0 && (!props.layout || props.layout === 'vertical')
      ? 'mt-[30px]'
      : ''
    " :name="item">
        <Select v-model:value="refData.value[index]" :placeholder="props.placeholder && props.placeholder[index]
            ? props.placeholder[index]
            : '请选择'
          " style="width: 100%" :options="getOptionData(index)"
          @change="(value: any) => selectChange(value, index, true)"></Select>
      </FormItem>
      </Col>
    </Row>
  </div>
</template>

<style scoped lang="scss"></style>

设计解读

如果仔细看【template】的内容,你会发现代码很简单,就是一个【Row】中包多个【Col】,每个【Col】中,设置一个【FormItem】和一个【Select】。

从代码中看,控件是支持无限层级的,只要UI显示满足需求。

动态数据

所谓动态数据,是第一级的数据选择后,给第二级的控件设置数据源,以此类推。这里实现的关键是两个:

  1. 数据结构

上述的【设计】章节,说到数据结构是无限嵌套的。这是实现动态数据的第一步。

  1. 选择事件

这里通过一个数组,来存储每个层级对应的子选项的集合。发生变更时,进行子项的设置。

js 复制代码
// 选项数据时
const selectChange = (value: any, index: number, isClear: boolean) => {
  if (index === 0) {
    if (!Object.hasOwnProperty.call(refData.selectData, value)) {
      const sourceItems = refData.options.find((item) => item.value === value)
      refData.selectData[value] = sourceItems?.children
    }
  } else {
    const key = refData.value[index - 1]
    if (
      !Object.hasOwnProperty.call(refData.selectData, value) &&
      refData.selectData &&
      refData.selectData[key]
    ) {
      const sourceItems = (refData.selectData[key] as SelectEntity[]).find(
        (item) => item.value === value
      )

      refData.selectData[value] = sourceItems?.children
    }
  }

  // 清除后续的选择
  if (isClear) {
    for (let i = index + 1; i < refData.value.length; i++) {
      refData.value[i] = ''
    }
  }

  // 通知调用组件更新数据
  emit('onSelectEvent', {
    fieldNames: props.fieldNames,
    selectValues: refData.value,
  })
}
  1. 模板中绑定
js 复制代码
 <Select v-model:value="refData.value[index]" :placeholder="props.placeholder && props.placeholder[index]
            ? props.placeholder[index]
            : '请选择'
          " style="width: 100%" :options="getOptionData(index)"
          @change="(value: any) => selectChange(value, index, true)"></Select>

可以看到模板中,下拉选择的数据源是【getOptionData】

js 复制代码
// 获取控件的数据源
const getOptionData = (index: number) => {
  const result: any[] = []
  if (index === 0) {
    refData.options.forEach((item) => {
      result.push({
        label: item.label,
        value: item.value,
      })
    })
  } else {
    const key = refData.value[index - 1]
    if (refData.selectData && refData.selectData[key]) {
      ; (refData.selectData[key] as SelectEntity[]).forEach((item) => {
        result.push({
          label: item.label,
          value: item.value,
        })
      })
    }
  }

  return result
}

通过这个函数,就能动态获取变更时,设置的子选项。

调用样例

js 复制代码
 <BankSubBranch
    :modelValue="refData.publicSelect"
    :label="'开户支行'"
    :fieldNames="props.branchFields"
    :placeholder="['省/市', '市', '区/县']"
    :optionData="selectCityOpition"
    @onSelectEvent="refSelectHandle"
  >
  </BankSubBranch>

设置选择的数据。

js 复制代码
// 联动选择的处理
const refSelectHandle = (selectData: CascadeSelectResult) => {
  if (!selectData.fieldNames || selectData.fieldNames.length < 1) {
    return
  }

  for (let i = 0; i < selectData.fieldNames.length; i++) {
    const itemKey = selectData.fieldNames[i]
    if (Object.hasOwnProperty.call(refData.formState, itemKey)) {
      refData.formState[itemKey] = selectData.selectValues[i]
    }
  }
}

结语

如果看到设计是三层级联选择,就写死三级级联选择。那么,从设计角度来说,就是很失败的。本组件,实现了无限层级的级联选择。且,本组件把获取数据源的业务逻辑,让父组件完成,把级联组件变成了一个与业务无关的通用组件。

该控件还有很多需要修改的细节,如:存储子选项的对象换成weakMap等,各位看官,可以在评论区给出建议。谢谢。

相关推荐
miss24 分钟前
AI 改完不敢上线?OpenSpec 规约驱动开发实战
前端·代码规范·前端工程化
AiMan1 小时前
腾讯ima还是不可以导出左侧所有对话,只能单条导出,方法不对吗
前端
去伪存真1 小时前
并发量一高就装死?Agent 全链路“防猝死”手册
前端·agent
Moment1 小时前
AI 编程时代,聊聊我的 Mac mini 双屏桌面
前端·后端
AiMan1 小时前
先确认能不能一次把Microsofat Copilot所有内容打包一个文件导出
前端
总要学点什么1 小时前
第97篇:三维数据加载-城市建筑白模自定义着色器——让一条光带沿高度扫上去,快慢却由帧率说了算
前端·gis·webgl·cesium·着色器·空间分析·白模
上海懒图科技2 小时前
工厂厂区电子地图定位导航:厂区人车精准定位,安全合规双保障
前端·arcgis·vue
西安小哥2 小时前
端侧跑大模型,真正的门槛不在参数量:一次 596 秒到 10 秒的压缩路径复盘
前端·人工智能
陈随易3 小时前
Cloudflare 出钱,Vite 团队挑人,44 个维护者分 30 万美元
前端·后端·程序员
恋猫de小郭3 小时前
Flutter THLC 的 WebView 滚动终于从 70 FPS 提升到 120 FPS
android·前端·flutter