引言
再对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 }
)