1. 1. 主题:element-plus 2.8.7 select options 为[]导致的 bug

主题:element-plus 2.8.7 select options 为[]导致的 bug

先看复现代码(请使用 v2.8.7),你可用在线 playground来操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<script lang="ts" setup>
import { ref } from "vue";
const value = ref<string[]>([]);
const options = [];
</script>

<template>
<el-select
v-model="value"
multiple
filterable
allow-create
default-first-option
:reserve-keyword="false"
style="width: 240px"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>

复现步骤:切换到 2.8.7,打开 console,点击 select,输入一个值,然后选中该值,此时控制台会报错:

1
aria.ts:71 Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'value')

接下来我们开始定位问题:

  1. 我们是在点击某个 option 时触发的这个报错,因此和 click 事件相关,所以我们应该去源码的<el-option>组件看看。

  2. 从这个报错信息中,最重要的信息就是.value 报错,说明某个变量是 undefined,不小心执行了 undefined.value 导致的异常,所以要去找 xxx.value;

综上,我们来翻看源码,找到 el-option 源码的位置:https://github1s.com/element-plus/element-plus/blob/2.8.7/packages/components/select/src/option.vue:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<li
v-show="visible"
:id="id"
:class="containerKls"
role="option"
:aria-disabled="isDisabled || undefined"
:aria-selected="itemSelected"
@mouseenter="hoverItem"
@click.stop="selectOptionClick"
>
<slot>
<span>{{ currentLabel }}</span>
</slot>
</li>
</template>

很好!原来每个 option 就是一个 li 标签实现的,并且我们发现,li 标签确实有一个@click 事件!

接着来看该函数的定义:

1
2
3
4
5
6
7
8
9
import { getCurrentInstance } from 'vue'

const vm = getCurrentInstance().proxy as unknown as SelectOptionProxy

function selectOptionClick() {
if (!isDisabled.value) {
select.handleOptionSelect(vm);
}
}

这里调用 handleOptionSelect 传入了 vm,需要解释一下,getCurrentInstance会获取当前组件实例,.proxy就能拿到当前组件定义的data,还有props。比如一个子组件props有 name age两个字段,那么就能用vm.name和vm.age获取到这两个props的值。所以这里vm就会携带label和value等。

接着看 handleOptionSelect 的定义,我们可以发现,handleOptionSelect 定义在 useSelect.ts 中,参数option就是上面的vm:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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 (props.multipleLimit <= 0 || value.length < props.multipleLimit) {
value.push(option.value);
}
emit(UPDATE_MODEL_EVENT, value);
emitChange(value);
if (option.created) {
handleQueryChange("");
}
if (props.filterable && !props.reserveKeyword) {
states.inputValue = "";
}
} else {
emit(UPDATE_MODEL_EVENT, option.value);
emitChange(option.value);
expanded.value = false;
}
focus();
if (expanded.value) return;
nextTick(() => {
scrollToOption(option);
});
};

这段代码里,我们来找触发option.value的地方。可以看到getValueIndex函数第二个参数传入了option,那就来看getValueIndex函数:

1
2
3
4
5
6
7
8
const getValueIndex = (arr: any[] = [], option) => {
if (!isObject(option?.value)) return arr.indexOf(option.value);

return arr.findIndex((item) => {
return isEqual(get(item, props.valueKey), getValueKey(option));
});
};

isObject的定义可以在点击这里

1
2
export const isObject = (val: unknown): val is Record<any, any> =>
val !== null && typeof val === 'object'

所以我们翻译一下getValueIndex的if判断和执行:如果option?.value不是一个对象,我们就去获取option.value。
仔细想想有没有问题?

undefined是对象吗?不是,所以该if判断为true,就会执行undefined.value,所以我们看handleOptionSelect函数使用到的第一个option就找到了问题~

这里的修复方式很简单:如果option是undefined,则不执行下面的代码。而getValueIndex这个函数的功能是获取当前选中项的index,这里既然没有数据,自然应该返回<0的结果 ,所以我们可以返回-1作为函数返回值:

1
2
3
4
5
6
7
8
9
const getValueIndex = (arr: any[] = [], option) => {
if(option === undefined) return -1; // fix bug line
if (!isObject(option?.value)) return arr.indexOf(option.value);

return arr.findIndex((item) => {
return isEqual(get(item, props.valueKey), getValueKey(option));
});
};