返回

解决表单blur事件触发数据更新导致焦点丢失

vue.js

表单输入流中断:blur 事件触发数据更新引发的问题

当组件使用 blur 事件触发数据更新时,可能会中断表单元素的输入流。这个现象尤其容易出现在使用了响应式数据或在父子组件之间传递复杂数据结构的情形下。常见的一个表象就是用户在表单控件之间按 Tab 键切换时,焦点会丢失,无法连续输入。这是一种典型的由于组件重新渲染引发的副作用。

问题根源分析

问题的核心在于 blur 事件触发后,会触发组件的数据更新。这个数据更新可能会影响组件的 props 或者其它响应式状态。这些更改都会触发 Vue 的组件重新渲染机制。 当组件重新渲染时,它可能失去当前获得焦点的 DOM 元素的信息。 如果在重新渲染过程中改变了 DOM 元素的结构(即使是细微的修改,比如属性变化),或者因为数据更新导致当前控件重新渲染(销毁再重新渲染)从而失去焦点状态。于是就会观察到在输入框之间按Tab切换时,焦点会突然丢失的现象。

上述场景在 Vue 3 的 setup 语法糖下可能发生得更加频繁,因为使用 ref 定义的变量通常在模板中以 v-model 绑定的方式直接影响着组件状态,任何细微的变化都可能导致组件重新渲染。

在我们的例子中:

  • Child 组件的 input 元素通过 v-model 绑定到 ref,如 abc
  • 每个 input 元素的 blur 事件都调用 update 函数。
  • update 函数触发 emit 事件向 Parent 组件传递最新的表单数据。
  • Parent 组件接收到数据后更新自身的 connection,并将其传递给 Child 作为 prop。
  • Child 的 prop 更新触发组件重新渲染。

可以明显看出问题是 Parent 组件接收更新并反过来修改了子组件 Child 的props导致组件重新渲染,并在过程中失去了焦点,因为在执行过程中重新创建和渲染了Child组件。

解决方案

解决这个问题,核心原则是避免因组件不必要地重新渲染而导致焦点丢失。 这里有多种方案可以选择,并且应根据具体场景选择适合的方案。

方案一:仅在父组件中使用状态更新

这个方案需要调整的是将子组件的所有状态操作和更新都提到父组件完成。这意味着,父组件会负责管理所有的 connection 对象数据,Child 组件只是被动接收数据,并通过回调向父组件提交更新的数据。这样做就可以保证更新总是先发生于父组件,这样就避免了因Child 组件属性改变引发不必要的重新渲染。

Parent.vue 代码示例如下:

<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

const connection = ref({ a: 'a', b: 'b', c: 'c'})

const updateChild = (updated) => {
    connection.value = { ...connection.value, ...updated }
};
</script>

<template>
  <Child :connection="connection" @update="updateChild" />
    <div>Connection State: {{ connection }}</div>
</template>

Child.vue 代码示例如下:

<script setup>
const props = defineProps({
  connection: Object,
})
const emit = defineEmits(['update'])

const inputAChange = (e) => emit('update', { a: e.target.value });
const inputBChange = (e) => emit('update', { b: e.target.value });
const inputCChange = (e) => emit('update', { c: e.target.value });


</script>

<template>
  <input :value="props.connection?.a" @input="inputAChange" />
    <input :value="props.connection?.b"  @input="inputBChange"/>
    <input :value="props.connection?.c" @input="inputCChange" />
</template>

这种方案利用 input 事件,实时地更新父组件的属性,并利用 spread 语法确保其余数据不受影响。 此方案不涉及到在子组件中声明ref。此方案优势是更加可控并且保证组件最小化更新,但是略微修改了原组件的代码结构。

方案二:使用 watch 监视 props 变更

此方案可以在 Child 组件中使用 watch 函数,监视 props 中的 connection 对象变化,并仅在数据发生实质性改变时更新 ref 值。这样就可以减少因为prop不变而导致的重渲染。此方案的副作用也比较少,相对来说是较为合适的方案,建议采用此方案作为默认选项。

Child.vue 代码示例:

<script setup>
import { ref, watch } from 'vue';

const props = defineProps({
    connection: Object,
})

const emit = defineEmits(['update']);

const a = ref(props.connection?.a);
const b = ref(props.connection?.b);
const c = ref(props.connection?.c);

watch(() => props.connection, (newVal) => {
    if (newVal) {
    a.value = newVal.a;
    b.value = newVal.b;
    c.value = newVal.c;
    }
  },{deep:true} // 这里添加了deep 深度监听可以避免浅拷贝的问题

);

const update = () => {
    emit('update', {
    a: a.value,
    b: b.value,
    c: c.value,
    })
}
</script>

<template>
    <input v-model="a" @blur="update" />
    <input v-model="b" @blur="update" />
    <input v-model="c" @blur="update" />
</template>

Parent.vue 组件不需要任何调整,仍然可以使用之前的代码:

<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const connection = ref(null)

const change = (updated) => {
    connection.value = updated
}
</script>
<template>
    <Child :connection="connection" @update="change" />
</template>

需要注意的是watch的深度监听deep:true选项,是为了防止因为connection属性内的值改变,watch不响应的副作用。如果父组件在传递connection时重新创建,那么监听 props.connection也是可行的。

方案三:使用防抖或节流函数

如果更新逻辑过于频繁,比如在input事件上更新,可以考虑采用防抖 (debounce) 或节流 (throttle) 技术,在更新前等待一段延迟时间。这样可以减少不必要的更新次数。 防抖与节流的具体逻辑可以在很多工具库找到,不再赘述。需要考虑的一点是如何保证blur事件可以准确地执行更新,因为有时候用户的鼠标点击或者tab操作不一定能被debounce到。通常在blur上不采用这类优化方法。

总结与建议

组件中的状态更新与渲染流程对应用性能至关重要,选择合适的更新方式对于提高用户体验尤为重要。在表单输入中出现类似焦点丢失的问题通常都是因为更新数据触发了不必要的组件重新渲染所致,避免组件频繁的重渲染能够解决这类问题。合理运用上述这些方案可以提升开发效率和应用程序性能。在实际应用时需要结合实际场景灵活运用这些策略,找到平衡性能和开发便利的最佳实践。