[Component] [date-picker] 日期选择器清空时触发两次 `change` 的完整机制分析
Bug Type: Component
Environment
- Vue Version:
3.3.4 - Element Plus Version:
2.14.5 - Browser / OS:
Google Chrome 152.0.7977.66 / windows - Build Tool:
Vite
Reproduction
Related Component
el-date-picker
Reproduction Link
Steps to reproduce
日期选择器清空时触发两次 change 的完整机制分析
一、问题现象
在使用 Element Plus 的 el-date-picker 时,如果父组件在更新绑定值时将空值 null 转换为空字符串 '':
<template>
<el-date-picker
:model-value="toPickerValue(date)"
type="date"
clearable
@update:modelValue="handleUpdate"
@change="handleChange"
/>
</template>
<script setup>
import { ref } from 'vue'
const date = ref('')
function toPickerValue(value) {
if (!value) return ''
return value
}
function handleUpdate(val) {
date.value = val ?? ''
}
function handleChange(val) {
console.log('[change]', val)
}
</script>操作:先选择一个日期,然后点击清空按钮(×)。
控制台输出:
[change] null
[change] ''触发条件:此现象仅在点击清空按钮时输入框未聚焦才会出现;若输入框已聚焦,则只会触发一次 change,值为 null。
二、事件流全貌
点击清空按钮时,如果输入框未聚焦,整个事件流如下:
click 事件捕获阶段
↓
ElInput 的 handleClick(捕获监听器)
→ 输入框未聚焦,执行 input.focus()
→ focus 事件触发 afterFocus
→ pickerVisible = true(面板打开)
↓
浏览器微任务检查点(watch 打开分支执行)
↓
click 事件目标/冒泡阶段
↓
清空按钮的 onClearIconClick
→ emitInput(null)(父组件更新绑定值)
→ emitChange(null, true)(第一次 change)
→ onHide() → pickerVisible = false(面板关闭)
↓
浏览器微任务检查点(watch 关闭分支执行)
→ nextTick 中 emitChange(props.modelValue)
→ 比较发现值不相等
→ 第二次 change核心问题:点击清空按钮时,组件错误地在聚焦输入框后打开了面板。 聚焦输入框本身是合理的,但打开面板是不应该发生的,这个多余的面板打开与随后关闭,最终引发了第二次 change。
下面逐步拆解每一个环节。
三、逐步拆解事件流
步骤 1:捕获阶段,ElInput 的聚焦与面板误开
click 事件从 window 向下传播。经过 ElInput 容器时,触发捕获阶段绑定的 handleClick:
const handleClick = (event) => {
if (
disabled.value ||
isFocusable(event.target) ||
(wrapperRef.value?.contains(document.activeElement) &&
wrapperRef.value !== document.activeElement)
)
return
target.value?.focus()
}输入框未聚焦时,条件不满足,执行 input.focus()。focus() 触发 focus 事件,同步调用 handleFocus → afterFocus:
function afterFocus() {
if (!props.automaticDropdown) return
pickerVisible.value = true // 面板打开
}此时面板被打开。这一步是 bug 的起点:聚焦输入框是正常行为,但打开面板是不应该的。
注意:如果输入框已经聚焦,
handleClick会因焦点已在内部而直接return,面板不会打开,后续不会出现第二次change。这解释了为什么触发条件是“输入框未聚焦”。
步骤 2:微任务检查点,watch 触发打开分支
捕获监听器执行完毕,浏览器执行微任务检查点。
Vue 的 watch(pickerVisible) 打开分支被触发:
watch(pickerVisible, (val) => {
if (!val) {
// 关闭分支,稍后分析
} else {
nextTick(() => {
if (val) {
valueOnOpen.value = props.modelValue // 记录旧日期
}
})
}
})valueOnOpen 被记录为清空前的旧日期。
步骤 3:目标/冒泡阶段,清空按钮执行清空逻辑
事件继续传播到清空图标,触发 onClearIconClick:
const onClearIconClick = (event) => {
event.stopPropagation()
emitInput(null) // 发出 update:modelValue
emitChange(null, true) // 第一次 change
onHide() // 关闭面板
}依次发生三件事:
3.1 emitInput(null) → 父组件更新绑定值
父组件 handleUpdate 收到 null,将其转换为 '':
function handleUpdate(val) {
date.value = val ?? '' // null 被转成 ''
}props.modelValue 变为 ''(Vue 响应式更新异步完成,但会在当前微任务阶段处理)。
3.2 emitChange(null, true) → 第一次 change
emitChange 源码:
const emitChange = (val, isClear) => {
if (isClear || !valueEquals(val, valueOnOpen.value)) {
emit(CHANGE_EVENT, val);
isClear && (valueOnOpen.value = val); // 清空时同步更新 valueOnOpen
}
};此时 emitChange 接收到的参数:
val=nullisClear=truevalueOnOpen.value= 旧日期(面板打开时记录的)
因为 isClear 为 true,条件直接成立:
- 触发第一次
change,值为null。 - 执行
valueOnOpen.value = null,将valueOnOpen从旧日期更新为null。
3.3 onHide() → 关闭面板
pickerVisible.value = false面板关闭。
步骤 4:微任务检查点,watch 触发关闭分支
目标/冒泡监听器执行完毕,浏览器再次执行微任务检查点。
此时状态:
props.modelValue=''(父组件已更新)valueOnOpen=null(清空逻辑已同步设置)pickerVisible从true变为false,触发watch关闭分支:
watch(pickerVisible, (val) => {
if (!val) {
userInput.value = null;
nextTick(() => {
emitChange(props.modelValue); // 第二次 change 的源头
});
}
})在 nextTick 中执行 emitChange(props.modelValue)。
此时 emitChange 接收到的参数:
val=''(父组件转换后的props.modelValue)isClear=undefined(未传)valueOnOpen.value=null(清空时已设置)
内部比较:
if (isClear || !valueEquals(val, valueOnOpen.value)) {
emit(CHANGE_EVENT, val)
}valueEquals('', null) 比较不等,条件成立,触发第二次 change,值为 ''。
四、关键机制:为什么面板的打开和关闭没有被合并?
一个自然会产生的疑问:pickerVisible 在同一个 click 事件传播过程中先被设为 true 又被设为 false,最终值等于初始值。按道理 Vue 的 watch 不应该触发,但实际触发了。为什么?
实验验证
最小化 demo:
<div id="outer"><button id="btn">点我测试</button></div>
<script>
let _value = false;
let dirty = false;
let lastWritten = false;
const state = {
get value() { return _value; },
set value(v) {
_value = v;
lastWritten = v;
if (!dirty) {
dirty = true;
Promise.resolve().then(() => {
console.log('watch 触发,最终值:', lastWritten);
dirty = false;
});
}
}
};
const outer = document.getElementById('outer');
outer.addEventListener('click', () => {
console.log('listener 1: set true');
state.value = true;
});
outer.addEventListener('click', () => {
console.log('listener 2: set false');
state.value = false;
});
</script>输出:
listener 1: set true
watch 触发,最终值: true
listener 2: set false
watch 触发,最终值: false原因:浏览器在每个事件监听器之间执行微任务检查点
浏览器事件传播过程中,每执行完一个事件监听器,就会清空微任务队列。
因此:
handleClick(捕获监听器)执行完毕 → 微任务检查点 →watch打开分支被触发。- 然后才执行
onClearIconClick(目标监听器)→ 微任务检查点 →watch关闭分支被触发。 - 两次
pickerVisible的修改发生在不同的微任务周期中,Vue 无法将它们合并。
这就是为什么面板的“打开→关闭”会导致 watch 分别触发两次,而不是被合并为一次。
五、组件 Bug 的本质与修复方向
本质
点击清空按钮时,输入框获得焦点是合理行为,但组件不应因此打开面板。
当前实现中,afterFocus 无条件地在聚焦后打开面板,导致清空按钮的点击意外打开了面板。这个多余的面板打开与随后清空逻辑中的关闭,使 pickerVisible 经历了 false → true → false。由于浏览器在每个事件监听器之间执行微任务检查点,Vue 无法合并这些修改,watch 分别触发了打开和关闭分支。关闭分支的额外检查最终导致第二次 change。
修复方向
聚焦输入框本身不应被禁止,修复的关键是在 afterFocus 中判断聚焦来源,如果聚焦是由清空按钮点击引起的,则不打开面板。
例如,可以在 onClearIconClick 中设置一个标志位,并在 afterFocus 中检查:
let isClearing = false;
const onClearIconClick = (event) => {
isClearing = true;
// ... 清空逻辑
onHide();
isClearing = false;
};
function afterFocus() {
if (!props.automaticDropdown) return;
if (isClearing) return; // 清空引发的聚焦不打开面板
pickerVisible.value = true;
}六、业务侧规避方案(临时)
由于组件修复需要等待官方更新,业务上应遵循组件空值语义,不要在 update:modelValue 中转换空值类型:
<template>
<el-date-picker v-model="date" type="date" clearable @change="handleChange" />
</template>
<script setup>
import { ref } from 'vue'
const date = ref(null) // 初始为 null
function handleChange(val) {
console.log('[change]', val) // 清空时只有一次 change,值为 null
}
function submit() {
const payload = {
date: date.value ?? '' // 提交时再转成空字符串
}
// 发送请求
}
</script>这样清空后 props.modelValue 保持 null,valueOnOpen 也是 null,面板关闭时比较相等,不会触发第二次 change。
七、总结
- 第一次
change:清空按钮自身触发,值为null,同时将valueOnOpen同步设置为null。 - 第二次
change:仅当输入框未聚焦时发生。聚焦后afterFocus错误地打开了面板,随后清空逻辑关闭面板。由于浏览器在每个事件监听器之间执行微任务检查点,Vue 无法合并pickerVisible的两次变化。关闭分支中的额外检查发现props.modelValue(父组件转换为'')与valueOnOpen(null)不相等,触发第二次change。 - 根本缺陷:清空按钮点击导致的聚焦不应打开面板。
- 规避方法:保持绑定值为
null,格式化延后到提交阶段。
What is Expected?
只触发一次 change,值为 null。
What is actually happening?
触发两次 change: 第一次值为 null(由清空按钮自身触发) 第二次值为 ''(由面板关闭后的额外检查触发)
Additional comments
(empty)
Source: element-plus/element-plus