#24826·element-plus

[Component] [date-picker] 日期选择器清空时触发两次 `change` 的完整机制分析

Author: fhasklaCreated Sep 5, 2026Updated Sep 15, 2026

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

Element Plus Playground

Steps to reproduce

日期选择器清空时触发两次 change 的完整机制分析

一、问题现象

在使用 Element Plus 的 el-date-picker 时,如果父组件在更新绑定值时将空值 null 转换为空字符串 ''

xml
<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

javascript
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 事件,同步调用 handleFocusafterFocus

javascript
function afterFocus() {
  if (!props.automaticDropdown) return
  pickerVisible.value = true   // 面板打开
}

此时面板被打开。这一步是 bug 的起点:聚焦输入框是正常行为,但打开面板是不应该的。

注意:如果输入框已经聚焦,handleClick 会因焦点已在内部而直接 return,面板不会打开,后续不会出现第二次 change。这解释了为什么触发条件是“输入框未聚焦”。


步骤 2:微任务检查点,watch 触发打开分支

捕获监听器执行完毕,浏览器执行微任务检查点

Vue 的 watch(pickerVisible) 打开分支被触发:

javascript
watch(pickerVisible, (val) => {
  if (!val) {
    // 关闭分支,稍后分析
  } else {
    nextTick(() => {
      if (val) {
        valueOnOpen.value = props.modelValue  // 记录旧日期
      }
    })
  }
})

valueOnOpen 被记录为清空前的旧日期。


步骤 3:目标/冒泡阶段,清空按钮执行清空逻辑

事件继续传播到清空图标,触发 onClearIconClick

javascript
const onClearIconClick = (event) => {
  event.stopPropagation()
  emitInput(null)                    // 发出 update:modelValue
  emitChange(null, true)             // 第一次 change
  onHide()                           // 关闭面板
}

依次发生三件事:

3.1 emitInput(null) → 父组件更新绑定值

父组件 handleUpdate 收到 null,将其转换为 ''

javascript
function handleUpdate(val) {
  date.value = val ?? ''   // null 被转成 ''
}

props.modelValue 变为 ''(Vue 响应式更新异步完成,但会在当前微任务阶段处理)。

3.2 emitChange(null, true) → 第一次 change

emitChange 源码:

javascript
const emitChange = (val, isClear) => {
  if (isClear || !valueEquals(val, valueOnOpen.value)) {
    emit(CHANGE_EVENT, val);
    isClear && (valueOnOpen.value = val);   // 清空时同步更新 valueOnOpen
  }
};

此时 emitChange 接收到的参数:

  • val = null
  • isClear = true
  • valueOnOpen.value = 旧日期(面板打开时记录的)

因为 isCleartrue,条件直接成立:

  1. 触发第一次 change,值为 null
  2. 执行 valueOnOpen.value = null,将 valueOnOpen 从旧日期更新为 null

3.3 onHide() → 关闭面板

javascript
pickerVisible.value = false

面板关闭。


步骤 4:微任务检查点,watch 触发关闭分支

目标/冒泡监听器执行完毕,浏览器再次执行微任务检查点

此时状态:

  • props.modelValue = ''(父组件已更新)
  • valueOnOpen = null(清空逻辑已同步设置)
  • pickerVisibletrue 变为 false,触发 watch 关闭分支:
javascript
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(清空时已设置)

内部比较:

javascript
if (isClear || !valueEquals(val, valueOnOpen.value)) {
  emit(CHANGE_EVENT, val)
}

valueEquals('', null) 比较不等,条件成立,触发第二次 change,值为 ''


四、关键机制:为什么面板的打开和关闭没有被合并?

一个自然会产生的疑问:pickerVisible 在同一个 click 事件传播过程中先被设为 true 又被设为 false,最终值等于初始值。按道理 Vue 的 watch 不应该触发,但实际触发了。为什么?

实验验证

最小化 demo:

xml
<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 中检查:

javascript
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 中转换空值类型

xml
<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 保持 nullvalueOnOpen 也是 null,面板关闭时比较相等,不会触发第二次 change


七、总结

  1. 第一次 change:清空按钮自身触发,值为 null,同时将 valueOnOpen 同步设置为 null
  2. 第二次 change:仅当输入框未聚焦时发生。聚焦后 afterFocus 错误地打开了面板,随后清空逻辑关闭面板。由于浏览器在每个事件监听器之间执行微任务检查点,Vue 无法合并 pickerVisible 的两次变化。关闭分支中的额外检查发现 props.modelValue(父组件转换为 '')与 valueOnOpennull)不相等,触发第二次 change
  3. 根本缺陷:清空按钮点击导致的聚焦不应打开面板。
  4. 规避方法:保持绑定值为 null,格式化延后到提交阶段。

What is Expected?

只触发一次 change,值为 null。

What is actually happening?

触发两次 change: 第一次值为 null(由清空按钮自身触发) 第二次值为 ''(由面板关闭后的额外检查触发)

Additional comments

(empty)

Source: element-plus/element-plus