Element-Plus源码分析-select组件

引言

再对Elements-Plus的设计框架有了初步了解过后 也通过对最基础的组件elIcon的设计源码进行分析之后,理解了其中组件设计的流程。那么再继续提高组件的复杂度继续看看elButton组建的实现。 组件目录如下

js 复制代码
├── packages
│   ├── components
│   │   ├── button
│   │   │   ├── __tests__       # 测试目录
│   │   │   ├── src             # 组件入口目录
│   │   │   │   ├── option-group.vue   # option组件
│   │   │   │   └── option.vue    # option
│   │   │   │   └── option.ts    # button-group组件模板内容
│   │   │   │   └── button.ts       #组件属性与 TS 类型
│   │   │   │   └── select-dropdown.vue  下拉面板
│   │   │   │   └── select.ts    #方法变量类型申明
│   │   │   │   └── select.vue   #select组件
│   │   │   │   └── token.ts     #声明了provide的key
│   │   │   │   └── useOption.ts  #组件hook
│   │   │   │   └── useSelect.ts  #组件hook
│   │   │   ├── style           # 组件样式目录
│   │   │   └── index.ts        # 组件入口文件
│   │   └── package.json

 
options组件源码分析(js变量以及方法定义)
js 复制代码
 setup(props) {
    const ns = useNamespace('select')
    const id = useId()

    const states = reactive({
      index: -1,
      groupDisabled: false, // 用于 el-option-group 下 判断当前el-option是否被禁用
      visible: true, // 控制el-option的显示隐藏,当allow-create为true时候
      hover: false, // 动态添加hover类名
    })

    const {
      currentLabel, // li 标签内的文字内容
      itemSelected, // li 是否被选中
      isDisabled, // li 是否被禁用
      select, // 在 select.vue 中提供的数据源
      hoverItem, // 鼠标移入 li 的事件
      updateOption,
    } = useOption(props, states)

    const { visible, hover } = toRefs(states)

    // 当前组件实例的代理对象
    const vm = getCurrentInstance().proxy as unknown as SelectOptionProxy
    // 调用 useSelect.ts 中的 onOptionCreate 方法,传递当前组件的数据,以便统一管理
    select.onOptionCreate(vm)

    // 销毁前钩子函数
    // 当传递了 allow-create 之后,输入的内容没匹配到就会创建新的选项,此时会进行 el-option的创建,输入过程中会一直创建、取消,前提是没有匹配项
    onBeforeUnmount(() => {
      // 检查要销毁的项是否被选中
      const key = vm.value
      const { selected } = select.states
      const selectedOptions = select.props.multiple ? selected : [selected]
      const doesSelected = selectedOptions.some((item) => {
        return item.value === vm.value
      })
      // if option is not selected, remove it from cache
      nextTick(() => {
        if (select.states.cachedOptions.get(key) === vm && !doesSelected) {
          select.states.cachedOptions.delete(key)
        }
      })
      select.onOptionDestroy(key, vm)
    })

    // 处理 el-option 的点击
    function selectOptionClick() {
      // 不是禁用的情况下
      if (!isDisabled.value) {
        select.handleOptionSelect(vm)
      }
    }
option.js

option.js 主要定义了option.vue 的选项过滤方法

js 复制代码
 return () => {
       // 这里的children  也就是我们在使用el-select 时候 传递的 内容。比如

         /**
       *      <el-select ref="selectRef" v-model="value" filterable>
              <el-option label="1" value="1"></el-option>
              <el-option label="1" value="2"></el-option>
            </el-select>
        **/
     // 那么 children  的到的就是这个默认插槽的内容,如果没任何内容,拿到就是空[], 注意这里的children 不是 指的 这两个el-option 组成的内容
      // 而是默认插槽组成的内容, 实际的节点在 children 的 children  里
      // 这里可以打印看一下里面的内容
 
      /**
       * 大致是这样
       * [
       *  {
       *    key: "_default",
       *    type: Symbol,
       *    children: [ el-option, el-option]  el-option 实际是在这里面
       *  }
       * ]
       **/
      const children = slots.default?.()!
      const valueList: any[] = []
      // 递归过滤el-option 和 el-option-group 收集所有el-Option 的value
      function filterOptions(children?: VNodeNormalizedChildren) {
        if (!isArray(children)) return
        ;(children as VNode[]).forEach((item) => {
          // 获取子元素的类型名称,判断它是否是 'ElOptionGroup' 或 'ElOption'

          const name = ((item?.type || {}) as Component)?.name
          // 如果是 'ElOptionGroup',则递归查找其子节点中的 el-option
          if (name === 'ElOptionGroup') {
            filterOptions(
               // 这里处理的是 el-option-group 中的内容,确保能找到它的子项
              !isString(item.children) &&
                !isArray(item.children) &&
                isFunction(item.children?.default)
                ? item.children?.default() // 如果有默认插槽,调用默认插
                : item.children // 否则直接使用 children
            )
            // 如果是 'ElOption',则提取该选项的值,并将其加入 valueList
          } else if (name === 'ElOption') {
            // 将 value 属性添加到 valueList 中
            valueList.push(item.props?.value)
          } else if (isArray(item.children)) {
           // 如果是其他类型的子元素,继续递归处理它们的子元素
            filterOptions(item.children)
          }
        })
      }
       // 首先判断有没有传递默认插槽,也就是el-select标签下有没有内容
      if (children.length) {
        // 如果有内容,取children 第一个 的 children 进行渲染, 比如el-select下写了 2个 el-option, 那么这里就是 长度为2的 VNode
        filterOptions(children[0]?.children)
      }
     // 第四步,新旧值比较,不相同 更新 optionValues
      if (!isEqual(valueList, cachedValueList)) {
        cachedValueList = valueList
        if (select) {
          select.states.optionValues = valueList
        }
      }

      return children
    }
  },
select-dropdown.vue

select-dropdown.vue 定义下拉面板样式与 监听元素变化改变下拉面板宽度

js 复制代码
<template>
  <!-- 渲染el-select-dropdown -->
  <div
    :class="[ns.b('dropdown'), ns.is('multiple', isMultiple), popperClass]"
    :style="{ [isFitInputWidth ? 'width' : 'minWidth']: minWidth }"
  >
    <div v-if="$slots.header" :class="ns.be('dropdown', 'header')">
      <slot name="header" />
    </div>
    <slot />
    <div v-if="$slots.footer" :class="ns.be('dropdown', 'footer')">
      <slot name="footer" />
    </div>
  </div>
</template>
 
<script lang="ts">
import { computed, defineComponent, inject, onMounted, ref } from 'vue'
import { useResizeObserver } from '@vueuse/core'
import { useNamespace } from '@element-plus/hooks'
import { selectKey } from './token'
 
export default defineComponent({
  name: 'ElSelectDropdown',
 
  componentName: 'ElSelectDropdown',
 
  setup() {
    const select = inject(selectKey)!
    const ns = useNamespace('select')
 
    // computed
    const popperClass = computed(() => select.props.popperClass)
    const isMultiple = computed(() => select.props.multiple)
 
    // 根据 props 传递的 fitInputWidth 决定 el-select-dropdown 的宽度样式
    // 如果该值为 true,则使用 width 样式;否则使用 minWidth 样式
    const isFitInputWidth = computed(() => select.props.fitInputWidth)
 
    // 定义宽度的值
    const minWidth = ref('')
 
    // 更新 el-select-dropdown 的宽度值
    function updateMinWidth() {
      // 这里的select.selectRef?.offsetWidth,实际就是el-select的宽度值
      minWidth.value = `${select.selectRef?.offsetWidth}px`
    }
 
    // 初始化时设置下拉框的宽度与 el-select 的宽度对齐
    onMounted(() => {
      // TODO: updatePopper
      // popper.value.update()
      updateMinWidth()
      // 开始监听 el-select 元素的变化,可能 el-select 元素会发生变化
      // 当元素变化后,同步更新 el-select-dropdown 的宽度,保证美观
      useResizeObserver(select.selectRef, updateMinWidth)
    })
 
    return {
      ns,
      minWidth,
      popperClass,
      isMultiple,
      isFitInputWidth,
    }
  },
})
</script>
useSelect.ts

定义核心方法hook

js 复制代码
 const states = reactive({
    inputValue: '', // 当前input输入框的输入的内容
    options: new Map(), // 当前下拉框的所有选项
    cachedOptions: new Map(), // 缓存下拉框选项
    optionValues: [] as any[], // sorted value of options  所有el-option 的 value 值
    selected: [] as any[], // 选择的el-option项的数据
    selectionWidth: 0,
    calculatorWidth: 0, // 控制 el-select__input-calculator 和 el-select__input 的宽度
    collapseItemWidth: 0,
    selectedLabel: '', // 单选选中需要展示在 placeholder位置的label值
    hoveringIndex: -1, // 鼠标移入的 el-option 索引
    previousQuery: null, // 上一次输入的值
    inputHovering: false, // 鼠标是否移入到 el-select__wrapper上,动态添加移入样式
    // 假如不在 toggle 里去判断的话,当 automaticDropdown 为 true 时候,此时点击下拉框,会造成下拉框的闪烁
    // 原因是因为,当点击了 select 框之后,会先触发 handleFocus 的事件,在该事件中,会把 expanded 设置为true
    // 但是事件并没有停止,而是会继续运行 click 事件,也就走到了 toggleMenu 方法中,如果不加 if 判断
    // expanded 会直接取反,导致 tooltip 立刻被关闭
    // 所以这是这个值的作用
    // 保证在 automaticDropdown 为 true 的情况下,点击 select 框不会闪现
    // 但是也出现了一个缺点,那就是打开下拉框后,第一次点击输入框不会进行关闭 tooltip。之后在点击才能正常关闭
    // 这里要注意一下js事件的执行顺序,点击一个元素后,会依次执行元素的 mousedown,focus,mouseup,click 事件,
    menuVisibleOnFocus: false,
    isBeforeHide: false, // tooltip隐藏时 ,但并未完成隐藏 ,------目前不知道这个值的作用,----
  })
 
  // template refs 定义了一系列的 ref 引用
  const selectRef = ref<HTMLElement>(null)
  const selectionRef = ref<HTMLElement>(null)
  const tooltipRef = ref<InstanceType<typeof ElTooltip> | null>(null)
  const tagTooltipRef = ref<InstanceType<typeof ElTooltip> | null>(null)
  const inputRef = ref<HTMLInputElement | null>(null)
  const calculatorRef = ref<HTMLElement>(null)
  const prefixRef = ref<HTMLElement>(null)
  const suffixRef = ref<HTMLElement>(null)
  const menuRef = ref<HTMLElement>(null)
  const tagMenuRef = ref<HTMLElement>(null)
  const collapseItemRef = ref<HTMLElement>(null)
  const scrollbarRef = ref<{
    handleScroll: () => void
  } | null>(null)
js 复制代码
/**
   * 这里的 useFocusController hook主要是处理元素聚焦相关的,传入 input 实例
   * 返回一个 wrapperRef,作用在了el-select__wrapper,后面对select 执行的聚焦、失焦 其实都是针对的  el-select__wrapper
   * isFocused 代表 wrapperRef 是否聚焦
   * handleBlur 调用可以进行失去焦点
   */
  const { wrapperRef, isFocused, handleBlur } = useFocusController(inputRef, {
    beforeFocus() {
      // 聚焦前调用, 如果元素被设置了禁用
      return selectDisabled.value
    },
    // 聚焦之后的逻辑
    afterFocus() {
      // 这里是在对 automatic-dropdown 适配, 如果传递了该属性,那么在输入框获得焦点后自动弹出选项菜单
      if (props.automaticDropdown && !expanded.value) {
        expanded.value = true
        states.menuVisibleOnFocus = true
      }
    },
    beforeBlur(event) {
      // 失去焦点前判断
      // 如果当触发的事件是 tooltip 区域,则不应该失去焦点,这里也就解释了为什么点击 toolip 区域不会收起下拉框的原因
      return (
        tooltipRef.value?.isFocusInsideContent(event) ||
        tagTooltipRef.value?.isFocusInsideContent(event)
      )
    },
    afterBlur() {
      // 失去焦点后 关闭 toolip 的显示
      expanded.value = false
      states.menuVisibleOnFocus = false
    },
  })
 
  // the controller of the expanded popup  控制 tooltip 显示隐藏
  const expanded = ref(false)
  // 鼠标移入或者键盘激活了哪一个 el-option
  const hoverOption = ref()
 
  const { form, formItem } = useFormItem()
  const { inputId } = useFormItemInputId(props, {
    formItemContext: formItem,
  })
  const { valueOnClear, isEmptyValue } = useEmptyValues(props)
 
  // 判断是否被禁用, 如果直接给 el-select 设置禁用 或者给表单设置了禁用
  const selectDisabled = computed(() => props.disabled || form?.disabled)
 
  // 判断是否有值
  // 判断 el-select v-model 绑定的值是不是数组
  // 如果是数组判断数组长度
  // 如果不是,判断是不是空值
  const hasModelValue = computed(() => {
    return isArray(props.modelValue)
      ? props.modelValue.length > 0
      : !isEmptyValue(props.modelValue)
  })
 
  // 判断是否展示 清除按钮
  // 1、手动传递 clearable 属性
  // 2、不是禁用状态
  // 3、鼠标移入 select 中
  // 4、el-select v-model 绑定的值存在
  const showClose = computed(() => {
    return (
      props.clearable &&
      !selectDisabled.value &&
      states.inputHovering &&
      hasModelValue.value
    )
  })
 
  // 后缀图标展示
  // 当传递了 remote 和 filterable  并且 remoteShowSuffix 为false( 默认为 false) 时 不显示后缀图标
  // 否则展示默认的 ArrowDown
  const iconComponent = computed(() =>
    props.remote && props.filterable && !props.remoteShowSuffix
      ? ''
      : props.suffixIcon
  )
 
 // 当tooltip被打开时候,为 后缀的 icon 添加 reverse 的 class 类名
  const iconReverse = computed(() =>
    nsSelect.is('reverse', iconComponent.value && expanded.value)
  )
 
  // 与form表单一起用时展示校验状态
  const validateState = computed(() => formItem?.validateState || '')
  // 与form表单一起用时候,展示校验图标
  const validateIcon = computed(
    () => ValidateComponentsMap[validateState.value]
  )
 
  // 当remote为true时,对input的搜索进行一个防抖的时间处理
  const debounce = computed(() => (props.remote ? 300 : 0))
 
 // 空状态展示文本
  const emptyText = computed(() => {
    // 是否正在加载中
    if (props.loading) {
      // 加载中返回 loadingText 或者 默认的加载中提示
      return props.loadingText || t('el.select.loading')
    } else {
      // 如果当前是远程搜索 &&  输入框的值不存在 && 没有下拉选项时返回 false
      // 这里返回的false  会应用在 dropdownMenuVisible 的 get 中, 返回false 会让下拉框关闭
      if (props.remote && !states.inputValue && states.options.size === 0)
        return false
      // 过滤模式 && 有输入内容 && 选项不为空且无匹配选项时返回 noMatchText 或默认的无匹配提示
      if (
        props.filterable &&
        states.inputValue &&
        states.options.size > 0 &&
        filteredOptionsCount.value === 0
      ) {
        return props.noMatchText || t('el.select.noMatch')
      }
      // 如果没有下拉选项 返回 noDataText 或者 默认空数据提示
      if (states.options.size === 0) {
        return props.noDataText || t('el.select.noData')
      }
    }
    return null
  })
 
  // 筛选出所有el-option 的 visible 为true 的长度
  const filteredOptionsCount = computed(
    () => optionsArray.value.filter((option) => option.visible).length
  )
 
  // 把el-option 的map 转为数组数据,同时对数据进行比对
  // states.options 的值会在 option.vue 创建后调用 onOptionCreate 来赋值
  // 保证数据正确匹配
  const optionsArray = computed(() => {
    // 把 Map 转为数组
    const list = Array.from(states.options.values())
    const newList = []
    states.optionValues.forEach((item) => {
      const index = list.findIndex((i) => i.value === item)
      if (index > -1) {
        newList.push(list[index])
      }
    })
    return newList.length >= list.length ? newList : list
  })
 
  const cachedOptionsArray = computed(() =>
    Array.from(states.cachedOptions.values())
  )
 
  // 用于是否创建新的 el-option 也就是 allow-create  为 true 时候
  const showNewOption = computed(() => {
    // 判断输入的内容是否已存在于所有 el-option 中,匹配 label 值
    const hasExistingOption = optionsArray.value
      .filter((option) => {
        return !option.created
      })
      .some((option) => {
        return option.currentLabel === states.inputValue
      })
    return (
      props.filterable &&
      props.allowCreate &&
      states.inputValue !== '' &&
      !hasExistingOption
    )
  })
 
  // 更新 el-option 的显示隐藏
  const updateOptions = () => {
    // 如果是自定义筛选不执行
    if (props.filterable && isFunction(props.filterMethod)) return
    // 如果是远程搜索也不执行
    if (props.filterable && props.remote && isFunction(props.remoteMethod))
      return
    // 循环调用,判断 el-option 展示的内容是否匹配当前输入的值
    optionsArray.value.forEach((option) => {
      option.updateOption?.(states.inputValue)
    })
  }
 
  // select 的size
  const selectSize = useFormSize()
 
  const collapseTagSize = computed(() =>
    ['small'].includes(selectSize.value) ? 'small' : 'default'
  )
 
  // 实际控制tooltip的显示隐藏
  const dropdownMenuVisible = computed({
    get() {
      return expanded.value && emptyText.value !== false
    },
    set(val: boolean) {
      expanded.value = val
    },
  })
 
  // 是否应该显示 placeholder 的容器
  const shouldShowPlaceholder = computed(() => {
    // 当为多选 并且 值不为 undefined 时候
    if (props.multiple && !isUndefined(props.modelValue)) {
      // ensureArray 判断传入的值是不是数组,不是就包装成数组
      // 如果 转换后的数组长度为 0  并且 input 输入框内也无内容  则展示 placeholder
      return ensureArray(props.modelValue).length === 0 && !states.inputValue
    }
 
    // 获取当前 v-model 绑定的值,如果是数组就取数组第一个元素, 否则就取 v-model的值
    const value = isArray(props.modelValue)
      ? props.modelValue[0]
      : props.modelValue
 
    // !!! 这里要注意一下  运算符的优先级,  ||  优先级 要大于 三元 !!!!!!!
 
    // 所以这里实际运行的是 (props.filterable || isUndefined(value)) ? !states.inputValue : true
    // 要先运行 props.filterable || isUndefined(value)
 
    // 如果 传递了filterable ,那么 就判断  input 有没有输入内容,有输入就隐藏掉 placeholder 的容器, 没有输入就展示 placeholder 容器
    // 如果 没有传递  filterable,那就对 值进行 undefined 判断,
    // 值如果不是 undefined 也要展示 placeholder 容器。
    // 这里可能会有疑惑,为啥值不是 undefined 也要展示 placeholder 容器,是因为在选择内容后,会把你选择的label值渲染到 el-select__placeholder 元素的子元素 span 里,
 
    // 如果值是 undefined, 要判断 input 内有无内容,有内容 不展示 ,否则展示
    return props.filterable || isUndefined(value) ? !states.inputValue : true
  })
 
  // 渲染 placeholder 的内容
  const currentPlaceholder = computed(() => {
    // 读取 是否传入了placeholder,否则使用默认
    const _placeholder = props.placeholder ?? t('el.select.placeholder')
    // 先运行 (props.multiple || !hasModelValue.value)
    // 如果是多选,直接返回 _placeholder, 这里无需担心,因为多选模式下,如果选择了值,就会进入到了上面 shouldShowPlaceholder 的 if 里,会移除 placeholder 容器
    // 如果不是多选,判断 v-model 绑定的值, 如果没有值,展示 placeholder,否则展示选择的 label 值
    return props.multiple || !hasModelValue.value
      ? _placeholder
      : states.selectedLabel
  })
 
  // iOS Safari does not handle click events when a mouseenter event is registered and a DOM-change happens in a child
  // We use a Vue custom event binding to only register the event on non-iOS devices
  // ref.: https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html
  // Github Issue: https://github.com/vuejs/vue/issues/9859
 
  // 在 iOS Safari 中,当一个元素注册了 mouseenter 事件且其子元素发生了 DOM 变化时,可能会导致点击事件无法正常处理
  // 只在非 iOS 设备上注册 mouseenter 事件。
  const mouseEnterEventName = computed(() => (isIOS ? null : 'mouseenter'))
 
  // 监听 v-model的值改变
  watch(
    () => props.modelValue,
    (val, oldVal) => {
      // 如果是多选模式,且是可过滤(filterable)并且没有保留输入值 reserveKeyword
      // 就清空输入框的值,并触发查询变化
      if (props.multiple) {
        if (props.filterable && !props.reserveKeyword) {
          states.inputValue = ''
          handleQueryChange('')
        }
      }
      // 调用 setSelected 更新选中的值
      setSelected()
      // 如果新值与旧值不相同,并且需要验证(validateEvent为true)
      // 调用 formItem 验证函数进行表单验证(比如:触发表单项的验证)
      if (!isEqual(val, oldVal) && props.validateEvent) {
        formItem?.validate('change').catch((err) => debugWarn(err))
      }
    },
    {
      flush: 'post', // 在 DOM 更新后触发
      deep: true, // 深度监听,确保嵌套对象的变化也会被检测到
    }
  )
 
  watch(
    () => expanded.value,
    (val) => {
      if (val) {
        // 触发一遍查询方法
        handleQueryChange(states.inputValue)
      } else {
        // 关闭时候 清除输入的值、上一次的查询的值
        states.inputValue = ''
        states.previousQuery = null
        states.isBeforeHide = true
      }
      // 传递 visible-change 事件, val 也就是当前 tooltip 是否展示
      emit('visible-change', val)
    }
  )
 
  watch(
    // fix `Array.prototype.push/splice/..` cannot trigger non-deep watcher
    // https://github.com/vuejs/vue-next/issues/2116
    () => states.options.entries(),
    () => {
      // 不是客户端环境不执行
      if (!isClient) return 
      // tooltipRef.value?.updatePopper?.()
      // 这里为啥要用querySelectorAll不是很清楚
      const inputs = selectRef.value?.querySelectorAll('input') || []
      // 如果不支持过滤或没有默认选中的值,或者当前活动的输入框不是此下拉选择框的输入框
      if (
        (!props.filterable &&
          !props.defaultFirstOption &&
          !isUndefined(props.modelValue)) ||
        !Array.from(inputs).includes(document.activeElement as HTMLInputElement)
      ) {
        setSelected()
      }
      // 如果启用了默认选择的第一项,并且启用了过滤或者远程搜索,且过滤后的选项存在
      if (
        props.defaultFirstOption &&
        (props.filterable || props.remote) &&
        filteredOptionsCount.value
      ) {
        /** 选中第一条 */
        checkDefaultFirstOption()
      }
    },
    {
      flush: 'post',
    }
  )
 
  // 监听鼠标移入的索引
  watch(
    () => states.hoveringIndex,
    (val) => {
      // 如果值存在,记录当前鼠标移入的项,否则置空
      if (isNumber(val) && val > -1) {
        hoverOption.value = optionsArray.value[val] || {}
      } else {
        hoverOption.value = {}
      }
      //寻找鼠标移入的项,设置 hover 的值,动态添加类名
      optionsArray.value.forEach((option) => {
        option.hover = hoverOption.value === option
      })
    }
  )
 
  watchEffect(() => {
    // Anything could cause options changed, then update options
    // If you want to control it by condition, write here
    
    /** 不清楚为什么要做这个判断 */
    if (states.isBeforeHide) return
    updateOptions()
  })
 
  // 根据不同条件执行不通搜索方法
  const handleQueryChange = (val: string) => {
    // 如果值和上一次值相同  或者 正在组合输入 retrun
    if (states.previousQuery === val || isComposing.value) {
      return
    }
    // 更新上一次输入值
    states.previousQuery = val
    // 如果传递了 filterable && filterMethod 是一个函数 则走 filterMethod 自定义的筛选
    if (props.filterable && isFunction(props.filterMethod)) {
      props.filterMethod(val)
    } else if (
      // 如果是远程搜索,走远程搜索
      props.filterable &&
      props.remote &&
      isFunction(props.remoteMethod)
    ) {
      props.remoteMethod(val)
    }
 
    // 如果 默认选中第一个 && 可搜索 或者 远程搜索 && el-option 目前有显示的值
    if (
      props.defaultFirstOption &&
      (props.filterable || props.remote) &&
      filteredOptionsCount.value
    ) {
      // 默认选中第一个,修改对应的索引
      nextTick(checkDefaultFirstOption)
    } else {
      nextTick(updateHoveringIndex)
    }
  }
  
  /**
   * find and highlight first option as default selected
   * @remark
   * - if the first option in dropdown list is user-created,
   *   it would be at the end of the optionsArray
   *   so find it and set hover.
   *   (NOTE: there must be only one user-created option in dropdown list with query)
   * - if there's no user-created option in list, just find the first one as usual
   *   (NOTE: exclude options that are disabled or in disabled-group)
   */
  // 选中第一条
  const checkDefaultFirstOption = () => {
    //筛选出 第一个不隐藏 && 不禁用的 el-option
    const optionsInDropdown = optionsArray.value.filter(
      (n) => n.visible && !n.disabled && !n.states.groupDisabled
    )
    // 找出自己创建的项
    const userCreatedOption = optionsInDropdown.find((n) => n.created)
    // 应该默认选中的第一项内容
    const firstOriginOption = optionsInDropdown[0]
    // 获取当前 el-option 的 全部 value值
    const valueList = optionsArray.value.map((item) => item.value)
    // 更新鼠标移入的索引,如果有用户创建的 先选中用户创建的,否则选中第一项内容
    states.hoveringIndex = getValueIndex(
      valueList,
      userCreatedOption || firstOriginOption
    )
  }
 
  // 设置选中项
  const setSelected = () => {
    // 如果不是多选的情况下
    if (!props.multiple) {
      // 获取当前 v-model 的值
      const value = isArray(props.modelValue)
        ? props.modelValue[0]
        : props.modelValue
      // 获取 value 值对应的 el-option 数据
      const option = getOption(value)
      // 设置选择的 label 值
      states.selectedLabel = option.currentLabel
      // 保存选择的项
      states.selected = [option]
      return
    } else {
      // 清空选中的值,不然切换 multiple 时会展示 selectedLabel
      states.selectedLabel = ''
    }
    // 多选的情况下,收集每一个选中的 el-option
    const result: any[] = []
    if (!isUndefined(props.modelValue)) {
      ensureArray(props.modelValue).forEach((value) => {
        result.push(getOption(value))
      })
    }
    states.selected = result
  }
 
  // 根据 value 值 获取具体的 el-option
  const getOption = (value) => {
    let option
    // 判断值类型
    const isObjectValue = toRawType(value).toLowerCase() === 'object'
    const isNull = toRawType(value).toLowerCase() === 'null'
    const isUndefined = toRawType(value).toLowerCase() === 'undefined'
 
    // 开始查找匹配项
    for (let i = states.cachedOptions.size - 1; i >= 0; i--) {
      const cachedOption = cachedOptionsArray.value[i]
      // 如果 value 是对象类型,则通过 props.valueKey 的值进行比对;如果不是对象,则直接比较 cachedOption.value 与 value 是否相等
      const isEqualValue = isObjectValue
        ? get(cachedOption.value, props.valueKey) === get(value, props.valueKey)
        : cachedOption.value === value
      if (isEqualValue) {
        // 匹配成功将 value 和 currentLabel 存入 option。
        // 同时定义了 isDisabled 属性,该属性通过 getter 函数动态返回 cachedOption.isDisabled 的状态。
        option = {
          value,
          currentLabel: cachedOption.currentLabel,
          get isDisabled() {
            return cachedOption.isDisabled
          },
        }
        break
      }
    }
    // 如果找到了匹配项,直接返回该 option。
    if (option) return option
    // 如果 value 是对象类型,尝试获取其 label 属性作为选项的显示文本。
    // 如果 value 既不是 null 也不是 undefined,则直接将 value 作为标签。
    // 否则,将标签设为空字符串。
    const label = isObjectValue
      ? value.label
      : !isNull && !isUndefined
      ? value
      : ''
    // 如果没有找到缓存中的匹配项,则创建一个新的选项对象 newOption,包含 value 和计算出的 currentLabel,然后返回它。
    const newOption = {
      value,
      currentLabel: label,
    }
    return newOption
  }
 
  // 更新鼠标移入el-option的索引
  const updateHoveringIndex = () => {
    states.hoveringIndex = optionsArray.value.findIndex((item) =>
      states.selected.some(
        (selected) => getValueKey(selected) === getValueKey(item)
      )
    )
  }
 
  const resetSelectionWidth = () => {
    states.selectionWidth = selectionRef.value.getBoundingClientRect().width
  }
 
  // 重新复制 el-select__input-calculator 元素宽度
  const resetCalculatorWidth = () => {
    states.calculatorWidth = calculatorRef.value.getBoundingClientRect().width
  }
 
  const resetCollapseItemWidth = () => {
    states.collapseItemWidth =
      collapseItemRef.value.getBoundingClientRect().width
  }
 
  // 下拉框tooltip 更新方法
  const updateTooltip = () => {
    tooltipRef.value?.updatePopper?.()
  }
 
 //多选 tag 的 tooltip 更新
  const updateTagTooltip = () => {
    tagTooltipRef.value?.updatePopper?.()
  }
 
  // input输入方法, 当不是 remote 时
  const onInputChange = () => {
    // 如果 input 输入框内有内容 && tooltip 是关闭的状态
    // 此时输入 先展开 tooltip
    if (states.inputValue.length > 0 && !expanded.value) {
      expanded.value = true
    }
    handleQueryChange(states.inputValue)
  }
 
 
  // input 输入框输入事件
  const onInput = (event) => {
    // 保存当前输入框的内容
    states.inputValue = event.target.value
    // 是否为远程加载
    if (props.remote) {
      // 调用远程加载方法
      debouncedOnInputChange()
    } else {
      // 调用input 输入方法
      return onInputChange()
    }
  }
 
  // 远程加载时 输入内容触发
  const debouncedOnInputChange = lodashDebounce(() => {
    onInputChange()
  }, debounce.value)
 
  // emit 的 change 事件
  const emitChange = (val) => {
    // 新旧值对比,新旧值不相同才会传递 change 事件
    if (!isEqual(props.modelValue, val)) {
      emit(CHANGE_EVENT, val)
    }
  }
 
  // 获取最后一个没有被禁用的选项的索引
  const getLastNotDisabledIndex = (value) =>
    findLastIndex(value, (it) => {
      const option = states.cachedOptions.get(it)
      return option && !option.disabled && !option.states.groupDisabled
    })
 
  // 当多选情况下按下删除键,可删除已选择的标签
  const deletePrevTag = (e) => {
    // 不是多选 return
    if (!props.multiple) return
    // 不是删除键 return
    if (e.code === EVENT_CODE.delete) return
    // 当输入框内无输入的内容时
    if (e.target.value.length <= 0) {
      const value = ensureArray(props.modelValue).slice()
      // 当有禁用的时候不移除禁用的项,只移除已选择的未禁用的 tag
      const lastNotDisabledIndex = getLastNotDisabledIndex(value)
      if (lastNotDisabledIndex < 0) return
      const removeTagValue = value[lastNotDisabledIndex]
      value.splice(lastNotDisabledIndex, 1)
      // 传递一系列事件
      emit(UPDATE_MODEL_EVENT, value)
      emitChange(value)
      emit('remove-tag', removeTagValue)
    }
  }
 
  // 手动点击 tag 的删除图标删除 tag 时候触发
  const deleteTag = (event, tag) => {
    // 查找要删除的标签在选中的标签数组中的索引
    // 如果标签在选中的标签数组中,并且组件没有被禁用
    const index = states.selected.indexOf(tag)
    if (index > -1 && !selectDisabled.value) {
      // 删除指定索引处的标签传递事件
      const value = ensureArray(props.modelValue).slice()
      value.splice(index, 1)
      emit(UPDATE_MODEL_EVENT, value)
      emitChange(value)
      emit('remove-tag', tag.value)
    }
    event.stopPropagation()
    // 聚焦输入框
    focus()
  }
 
  // 清除按钮事件中会调用该函数,清除选择的值
  const deleteSelected = (event) => {
    event.stopPropagation()
    // 判断是不是多选,多选重置为 [] 否则 取空值,如果有配置 empty-values ,则就是 empty-values
    const value: string | any[] = props.multiple ? [] : valueOnClear.value
 
    // 这里应该是做了个优化
    // 如果当前是多选状态,并且 el-option 有 禁用的, 并且初始化的时候给 v-model 绑定的值中包含禁用项的 value
    // 那么此时在触发清除事件时候,不会重置为[],而是保留禁用项的value
    if (props.multiple) {
      for (const item of states.selected) {
        if (item.isDisabled) value.push(item.value)
      }
    }
    // 传递 "update:modelValue 事件,更新 v-model 的值
    emit(UPDATE_MODEL_EVENT, value)
    // 传递 change 事件
    emitChange(value)
    // 重置 el-option 的 鼠标移入索引
    states.hoveringIndex = -1
    // 关闭 tooltip
    expanded.value = false
    emit('clear')
    // 重新聚焦 input
    focus()
  }
  // el-option 选择事件,会从 option.vue 中调用,传递的参数为 当前组件实例的代理对象
  const handleOptionSelect = (option) => {
    // 如果是多选,单独处理
    if (props.multiple) {
      // 保证值是一个数组
      const value = ensureArray(props.modelValue ?? []).slice()
      // 获取选择项的索引值
      const optionIndex = getValueIndex(value, option)
      // 判断 当前选中的项是否存在
      if (optionIndex > -1) {
        // 存在移除选中的项
        value.splice(optionIndex, 1)
      } else if (
        // 如果最大选择数小于等于 0,也就是没有最大选择数
        // 或者 已选择的值 少于 最大选择数
        props.multipleLimit <= 0 ||
        value.length < props.multipleLimit
      ) {
        // 添加选中项
        value.push(option.value)
      }
      // 传递出 "update:modelValue 事件,更新 v-model 的值
      emit(UPDATE_MODEL_EVENT, value)
      // 传递出 change 事件
      emitChange(value)
      if (option.created) {
        handleQueryChange('')
      }
      if (props.filterable && !props.reserveKeyword) {
        states.inputValue = ''
      }
    } else {
      // 不是多选的情况
      // 传递出 "update:modelValue 事件,更新 v-model 的值
      emit(UPDATE_MODEL_EVENT, option.value)
      // 传递出 change 事件
      emitChange(option.value)
      // 关闭 tooltip
      expanded.value = false
    }
    // 聚焦输框
    focus()
    if (expanded.value) return
    // 这里为什么要执行 scrollToOption 暂时不清楚,因为当输入框打开时候也会执行 scrollToOption
    nextTick(() => {
      scrollToOption(option)
    })
  }
  // 获取选择的项的索引
  const getValueIndex = (arr: any[] = [], option) => {
    // 如果 el-option 绑定的 value 值不是一个对象的话,直接使用 indexOf 查找数组中第一次出现给定元素的下标
    if (!isObject(option?.value)) return arr.indexOf(option.value)
    // 如果el-option 绑定的 value 值是对象的话
    return arr.findIndex((item) => {
      // 先获取 el-option 的value属性绑定的对象数据中的 valueKey 对应的值,
      // 然后获取当前选择项对应的 value 值
      // 对2个值进行相同比较, 返回相同的值
      // 至于这里为啥使用 isEqual 应该是处理对象嵌套的场景
      return isEqual(get(item, props.valueKey), getValueKey(option))
    })
  }
  // 滚动到指定的el-option 位置
  const scrollToOption = (option) => {
    // 判断是不是数组,是数组取第一个值
    const targetOption = isArray(option) ? option[0] : option
    let target = null
    // 寻找目标,获取元素
    if (targetOption?.value) {
      const options = optionsArray.value.filter(
        (item) => item.value === targetOption.value
      )
      if (options.length > 0) {
        target = options[0].$el
      }
    }
    if (tooltipRef.value && target) {
      const menu = tooltipRef.value?.popperRef?.contentRef?.querySelector?.(
        `.${nsSelect.be('dropdown', 'wrap')}`
      )
      if (menu) {
        // 执行滚动
        scrollIntoView(menu as HTMLElement, target)
      }
    }
    // 刷新滚动条状态
    scrollbarRef.value?.handleScroll()
  }
  // 该函数会在 option.vue 中被调用,传递每一个li组件的代理对象,可以理解为 每一个el-option内的数据
  const onOptionCreate = (vm: SelectOptionProxy) => {
    // 收集所有数据
    states.options.set(vm.value, vm)
    // 同时更新缓存
    states.cachedOptions.set(vm.value, vm)
  }
  // 当el-option销毁前执行
  const onOptionDestroy = (key, vm: SelectOptionProxy) => {
    // 清除对应的数据
    if (states.options.get(key) === vm) {
      states.options.delete(key)
    }
  }
  // el-popper 的实例
  const popperRef = computed(() => {
    return tooltipRef.value?.popperRef?.contentRef
  })
  // tooltip 显示之前会调用该函数,如果存在选择的项,并且tooltip内容存在滚动条,滚动到选择的项的位置
  const handleMenuEnter = () => {
    states.isBeforeHide = false
    nextTick(() => scrollToOption(states.selected))
  }
  // el-select 暴露的 focus 事件
  const focus = () => {
    inputRef.value?.focus()
  }
  // el-select 暴露的 blur 事件, 使选择器的输入框失去焦点,并隐藏下拉框
  const blur = () => {
    if (expanded.value) {
      expanded.value = false
      nextTick(() => inputRef.value?.blur())
      return
    }
    inputRef.value?.blur()
  }
  // 清除按钮点击事件
  const handleClearClick = (event: Event) => {
    deleteSelected(event)
  }
  // 当toolip 被展开时,点击外部区域处理
  const handleClickOutside = (event: Event) => {
    // 关闭tooltip
    expanded.value = false
    // 当下拉框内容展示后,点击内容 在点击外部 关掉下拉,发现 wrapper 元素仍然处于聚焦状态,
    // 这里手动创建一个focus事件传递给handleBlur 来进行失去焦点的操作
    if (isFocused.value) {
      const _event = new FocusEvent('focus', event)
      nextTick(() => handleBlur(_event))
    }
  }
  // 当 input 元素被聚焦时候,按下 esc执行的回调
  // 如果当前input元素能输入,并且输入了值,那么按下 esc 先执行清空值 , 否则执行关闭 tooltip
  const handleEsc = () => {
    if (states.inputValue.length > 0) {
      states.inputValue = ''
    } else {
      expanded.value = false
    }
  }
  // 切换 tooltip显示隐藏
  const toggleMenu = () => {
    // 禁用状态下
    if (selectDisabled.value) return
    // We only set the inputHovering state to true on mouseenter event on iOS devices
    // To keep the state updated we set it here to true
    if (isIOS) states.inputHovering = true
    if (states.menuVisibleOnFocus) {
      // controlled by automaticDropdown
      // 对于不可搜索的输入框,聚集后,点击输入框先把此值 变为 false, 否则后续再点击选择框就无法触发 tooltip 的显示隐藏
      states.menuVisibleOnFocus = false
    } else {
      // 切换隐藏
      expanded.value = !expanded.value
    }
  }
  // input 按下回车事件
  const selectOption = () => {
    // 如果 tooltip 未显示,先显示 tooltip
    if (!expanded.value) {
      toggleMenu()
    } else {
      // 如果菜单已展开,处理当前悬停选项的选择操作,前提是该选项有效且未被禁用
      const option = optionsArray.value[states.hoveringIndex]
      if (option && !option.disabled && !option.states.groupDisabled) {
        handleOptionSelect(option)
      }
    }
  }
  // 获取 el-option 的 value 值
  const getValueKey = (item) => {
    // 判断是 el-option 绑定的 value 值是否为对象, 如果为对象,从对象中取 valueKey 对应的值
    // 这里的 valueKey 文档有说明
    // 否则直接取item 的 value 值
    return isObject(item.value) ? get(item.value, props.valueKey) : item.value
  }
  // 判断当前显示的所有 option 是否都被禁用
  const optionsAllDisabled = computed(() =>
    optionsArray.value
      .filter((option) => option.visible)
      .every((option) => option.disabled)
  )
  // 控制多选模式下 显示 tag 的数量
  const showTagList = computed(() => {
    // 如果不是多选模式,直接返回一个空数组
    if (!props.multiple) {
      return []
    }
    // 如果开启了 collapseTags(收起标签),且已经选中了多个标签
    return props.collapseTags
      ? states.selected.slice(0, props.maxCollapseTags) // 仅显示最多 maxCollapseTags 个标签
      : states.selected // // 否则显示所有选中的标签
  })
  // 折叠标签时候渲染的tag的数量
  const collapseTagList = computed(() => {
    // 如果不是多选模式,返回空数组
    if (!props.multiple) {
      return []
    }
    // 如果启用了 collapseTags,返回从 maxCollapseTags 位置开始的标签(即要折叠的标签)
    return props.collapseTags
      ? states.selected.slice(props.maxCollapseTags) // 从第 maxCollapseTags 个标签开始的所有标签
      : [] // 从第 maxCollapseTags 个标签开始的所有标签
  })
  // 键盘导航
  const navigateOptions = (direction) => {
    // 如果tootip被隐藏,先打开tooltip
    if (!expanded.value) {
      expanded.value = true
      return
    }
    // states 里其实没有 filteredOptionsCount 变量,这里不知道为啥
    if (
      states.options.size === 0 ||
      states.filteredOptionsCount === 0 ||
      isComposing.value
    )
      return
    // 如果下拉选项都没有被禁用的情况下才进行键盘导航
    if (!optionsAllDisabled.value) {
      // 如果是 ↓ 箭头
      if (direction === 'next') {
        // 更新索引
        states.hoveringIndex++
        // 如果再最后一个 el-option 继续向下,回到第一个
        if (states.hoveringIndex === states.options.size) {
          states.hoveringIndex = 0
        }
      } else if (direction === 'prev') {
        // 如果是 ↑ 箭头
        // 索引减一
        states.hoveringIndex--
        // 如果已经位于第一个 el-option,再次按↑ 跳到最后一个 el-option
        if (states.hoveringIndex < 0) {
          states.hoveringIndex = states.options.size - 1
        }
      }
      // 获取当前移入的 el-option
      const option = optionsArray.value[states.hoveringIndex]
      // 如果当前 option 被禁用或者不显示的情况下 继续跳转下一个
      if (
        option.disabled === true ||
        option.states.groupDisabled === true ||
        !option.visible
      ) {
        navigateOptions(direction)
      }
      // 滚动到选择的 option 的位置
      nextTick(() => scrollToOption(hoverOption.value))
    }
  }
  const getGapWidth = () => {
    if (!selectionRef.value) return 0
    const style = window.getComputedStyle(selectionRef.value)
    return Number.parseFloat(style.gap || '6px')
  }
  // computed style
  const tagStyle = computed(() => {
    const gapWidth = getGapWidth()
    const maxWidth =
      collapseItemRef.value && props.maxCollapseTags === 1
        ? states.selectionWidth - states.collapseItemWidth - gapWidth
        : states.selectionWidth
    return { maxWidth: `${maxWidth}px` }
  })
  const collapseTagStyle = computed(() => {
    return { maxWidth: `${states.selectionWidth}px` }
  })
  // 对input的宽度进行计算,同步 el-select__input-calculator 元素的宽度
  const inputStyle = computed(() => ({
    width: `${Math.max(states.calculatorWidth, MINIMUM_INPUT_WIDTH)}px`,
  }))
  useResizeObserver(selectionRef, resetSelectionWidth)
  // 监听 el-select__input-calculator 变化,运行宽度回调
  useResizeObserver(calculatorRef, resetCalculatorWidth)
  useResizeObserver(menuRef, updateTooltip)
  useResizeObserver(wrapperRef, updateTooltip)
  useResizeObserver(tagMenuRef, updateTagTooltip)
  useResizeObserver(collapseItemRef, resetCollapseItemWidth)

useOption.ts代码理解

js 复制代码
export function useOption(props, states) {
  // inject
  const select = inject(selectKey)
  const selectGroup = inject(selectGroupKey, { disabled: false })
 
  // computed
  // 当前el-option 的 value 是否被选中
  const itemSelected = computed(() => {
    return contains(ensureArray(select.props.modelValue), props.value)
  })
 
  // 判断 多选模式下 multiple, 选择的内容是否达到最大选择数量 multiple-limit
  // 如果不是 多选  直接 return
  // 当前选项未选中 && 选中的数量大于了 multipleLimit 的值 && multipleLimit 大于 0
  const limitReached = computed(() => {
    if (select.props.multiple) {
      // 确保 值是一个数组
      const modelValue = ensureArray(select.props.modelValue ?? [])
      return (
        !itemSelected.value &&
        modelValue.length >= select.props.multipleLimit &&
        select.props.multipleLimit > 0
      )
    } else {
      return false
    }
  })
 
  /**
   * 渲染 el-option 的li标签文字,每次el-option 都是一个li
   * 如果传递了label 则展示label
   * 否则 判断 props 的value值 是不是一个对象
   * 如果是对象 展示空字符串
   * 不是对象 展示value值,应该是一个显示的优化,在文档中有 说明:   label:  选项的标签,若不设置则默认与value相同
   * 同时 如果是 allow-create 的项,label 就是 输入的内容, value也是输入的内容
   */
  const currentLabel = computed(() => {
    return props.label || (isObject(props.value) ? '' : props.value)
  })
 
  // 好像没有被用到 这个值
  const currentValue = computed(() => {
    return props.value || props.label || ''
  })
 
  /**
   * el-option 是否禁用
   * 如果 直接给el-option 指定了disabled ,则直接 return true
   * 或者 给 el-option-group 指定了 disabled, 那么  该分组下所有选项置为禁用
   * 或者 当 传递了 multiple-limit 之后,选择的项是否达到最大数量
   */
  const isDisabled = computed(() => {
    return props.disabled || states.groupDisabled || limitReached.value
  })
 
  const instance = getCurrentInstance()
 
  /** 检测arr 中 是否包含 taget */
  const contains = (arr = [], target) => {
    if (!isObject(props.value)) {
      return arr && arr.includes(target)
    } else {
      const valueKey = select.props.valueKey
      return (
        arr &&
        arr.some((item) => {
          return toRaw(get(item, valueKey)) === get(target, valueKey)
        })
      )
    }
  }
 
  // 鼠标移入 el-option 事件
  const hoverItem = () => {
    // 当 el-option 未禁用 或者 el-option-group 未禁用时
    if (!props.disabled && !selectGroup.disabled) {
      select.states.hoveringIndex = select.optionsArray.indexOf(instance.proxy)
    }
  }
 
  /** 更新el-option的显示隐藏 */
  const updateOption = (query: string) => {
    // 创建正则表达式,规则为 根据查询的字符串先转义(escapeStringRegexp),然后忽略大小写
    const regexp = new RegExp(escapeStringRegexp(query), 'i')
    // 如果能匹配到 不隐藏 el-option, 否则判断是不是 allow-create 创建出来的
    states.visible = regexp.test(currentLabel.value) || props.created
  }
 
  watch(
    () => currentLabel.value,
    () => {
      if (!props.created && !select.props.remote) select.setSelected()
    }
  )
 
  /** 监听 el-option 的 value 值改变 */
  watch(
    () => props.value,
    (val, oldVal) => {
      const { remote, valueKey } = select.props
 
      // 新旧值不同 更新数据
      if (val !== oldVal) {
        select.onOptionDestroy(oldVal, instance.proxy)
        select.onOptionCreate(instance.proxy)
      }
 
      // 如果没有启用动态创建选项且没有使用远程加载选项
      if (!props.created && !remote) {
        // 如果传入了 valueKey,则根据它检查 val 和 oldVal 是否是相同的对象。如果它们是对象,并且它们的 valueKey 值相等,直接返回,不做任何处理。
        if (
          valueKey &&
          isObject(val) &&
          isObject(oldVal) &&
          val[valueKey] === oldVal[valueKey]
        ) {
          return
        }
        select.setSelected()
      }
    }
  )
 
  // 监听 el-option-group disabled 变化,同步给 el-option
  watch(
    () => selectGroup.disabled,
    () => {
      states.groupDisabled = selectGroup.disabled
    },
    { immediate: true }
  )
相关推荐
洛小豆4 小时前
她问我::is-logged 是啥?我说:前面加冒号,就是 Vue 在发暗号
前端·vue.js·面试
麦麦大数据5 小时前
F024 CNN+vue+flask电影推荐系统vue+python+mysql+CNN实现
vue.js·python·cnn·flask·推荐算法
博客zhu虎康7 小时前
Element中 el-tree 如何隐藏 Tree 组件中的父节点 Checkbox
javascript·vue.js·elementui
长明7 小时前
Electron 的西天取经
vue.js·electron
九十一7 小时前
Reflect 在 Vue3 响应式中作用
前端·vue.js
岁月宁静8 小时前
前端添加防删除水印技术实现:从需求拆解到功能封装
前端·vue.js·人工智能
华仔啊9 小时前
Vue3 登录页还能这么丝滑?这个 hover 效果太惊艳了
前端·css·vue.js
云和数据.ChenGuang9 小时前
Component template requires a root element, rather than just错误
前端·javascript·vue.js
哲此一生98412 小时前
搭建Vue3工程(去除不必要的文件)
前端·javascript·vue.js