在表单交互设计中,我们经常面临这样的场景:多种输入方式互斥。例如,在评分系统中,用户可以选择通过填写明细表格进行逐项评分,也可以直接给出总评分。这两种输入方式不应同时生效,否则可能导致数据不一致。
传统解决方案的痛点:
为解决上述问题,我们设计了一套基于Vue3 Composition API的解决方案,将互斥输入模式的管理抽象为可复用的组合式函数。设计原则如下:
我们实现了两个核心组合式API:
useMutuallyExclusiveInputs
:通用的互斥输入模式管理useScoreInputModes
:基于通用API的评分场景特化版本首先,我们实现了基础的互斥输入模式管理API:
// useMutuallyExclusiveInputs.ts
import { ref, computed, Ref } from 'vue';
/**
* 互斥输入模式的组合式API
*/
export function useMutuallyExclusiveInputs<T extends string, D = any>(
modes: readonly T[],
initialData: Record<T, D>,
options: {
initialMode?: T;
disabled?: Ref<boolean>;
onModeChange?: (newMode: T, oldMode: T | null) => void;
confirmClear?: () => Promise<boolean> | boolean;
} = {}
) {
// 提取配置项
const {
initialMode = modes[0],
disabled = ref(false),
onModeChange,
confirmClear = async () => true
} = options;
// 当前活动的输入模式
const activeMode = ref<T | null>(initialMode) as Ref<T | null>;
// 为每个模式创建响应式数据存储
const modeData: Record<T, Ref<D>> = {} as Record<T, Ref<D>>;
// 初始化每个模式的数据
modes.forEach((mode) => {
modeData[mode] = ref(
Array.isArray(initialData[mode]) ? [...initialData[mode]] : initialData[mode]
) as Ref<D>;
});
// 检查特定模式是否应该禁用
function shouldDisable(mode: T) {
return computed(() => {
// 如果全局禁用,则禁用所有模式
if (disabled.value) return true;
// 如果当前没有激活的模式,则不禁用任何模式
if (activeMode.value === null) return false;
// 如果请求检查的模式就是当前激活模式,则不禁用
if (activeMode.value === mode) return false;
// 如果当前激活的是其他模式,检查其他模式是否有实际数据
const activeData = modeData[activeMode.value].value;
// 检查激活模式的数据是否有效(有实际值)
if (Array.isArray(activeData)) {
// 对于数组类型,检查是否有有效数据项
return activeData.length > 0 && activeData.some(item => {
if (typeof item === 'object' && item !== null) {
return Object.values(item).some(val =>
val !== undefined && val !== null && val !== ''
);
}
return item !== undefined && item !== null && item !== '';
});
} else if (typeof activeData === 'object' && activeData !== null) {
// 对于对象类型,检查是否有非空属性
return Object.values(activeData).some(val =>
val !== undefined && val !== null && val !== '' && val !== 0
);
} else {
// 对于原始类型,检查是否有值
return activeData !== undefined && activeData !== null &&
activeData !== '' && activeData !== 0;
}
});
}
// 清除特定模式的数据
async function clearData(mode: T): Promise<void> {
const shouldClear = await confirmClear();
if (!shouldClear) return;
const currentData = modeData[mode].value;
// 根据数据类型进行智能清空
if (Array.isArray(currentData)) {
// 检查数组是否为空,如果不为空才清空
if (currentData.length > 0) {
(modeData[mode].value as any) = [];
}
} else if (typeof currentData === 'object' && currentData !== null) {
// 检查对象是否有有效属性,只清空有值的属性
const hasValidData = Object.values(currentData).some(val =>
val !== undefined && val !== null && val !== '' && val !== 0
);
if (hasValidData) {
// 创建新对象,保留原有结构但清空有值的属性
const clearedObj = { ...currentData };
// 遍历对象属性,只清空有值的属性
Object.keys(clearedObj).forEach(key => {
const value = (clearedObj as any)[key];
if (value !== undefined && value !== null && value !== '' && value !== 0) {
(clearedObj as any)[key] = undefined;
}
});
modeData[mode].value = clearedObj as D;
}
} else {
// 对于原始类型,检查是否有值
if (currentData !== undefined && currentData !== null &&
currentData !== '' && currentData !== 0) {
modeData[mode].value = undefined as unknown as D;
}
}
}
// 设置当前活动模式
async function setMode(mode: T): Promise<void> {
if (disabled.value) return;
const oldMode = activeMode.value;
// 只有当模式发生变化时才进行处理
if (oldMode !== mode) {
// 检查旧模式是否有实际数据
let shouldClearOldData = false;
if (oldMode !== null) {
const oldData = modeData[oldMode].value;
// 根据数据类型判断是否需要清空
if (Array.isArray(oldData)) {
// 对于数组,检查是否有有效项
shouldClearOldData = oldData.length > 0 && oldData.some(item => {
if (typeof item === 'object' && item !== null) {
return Object.values(item).some(val =>
val !== undefined && val !== null && val !== ''
);
}
return item !== undefined && item !== null && item !== '';
});
} else if (typeof oldData === 'object' && oldData !== null) {
// 对于对象,检查是否有非空属性
shouldClearOldData = Object.values(oldData).some(val =>
val !== undefined && val !== null && val !== '' && val !== 0
);
} else {
// 对于原始类型,检查是否有值
shouldClearOldData = oldData !== undefined && oldData !== null &&
oldData !== '' && oldData !== 0;
}
// 只有在有实际数据需要清除时才清空
if (shouldClearOldData) {
await clearData(oldMode);
}
}
// 更新活动模式
activeMode.value = mode;
// 触发模式变更回调
if (onModeChange) {
onModeChange(mode, oldMode);
}
}
}
// 获取当前活动模式的数据
const activeData = computed(() =>
activeMode.value !== null ? modeData[activeMode.value].value : undefined
);
// 返回API
return {
activeMode,
modeData,
setMode,
clearData,
shouldDisable,
activeData,
// 其他API...
};
}
然后,我们基于通用API实现了评分场景的特化版本:
// useScoreInputModes.ts
import { ref, computed, Ref } from 'vue';
import useMutuallyExclusiveInputs from './useMutuallyExclusiveInputs';
/**
* 评分项接口
*/
interface ScoreItem {
id: string | number;
score: number;
scoreResult?: number;
[key: string]: any;
}
/**
* 评分输入模式组合式API
*/
export function useScoreInputModes(options = {}) {
const {
enabled = ref(true),
totalScore = ref(100),
scoreRules = {
thresholds: [0, 60, 70, 80, 90, 101],
levels: ['E', 'D', 'C', 'B', 'A']
},
minScore = 0,
maxScore = 100,
onScoreChange
} = options;
// 使用通用互斥输入模式API
const { activeMode, modeData, setMode, clearData, reset } =
useMutuallyExclusiveInputs(
['table', 'final'] as const,
{
table: [] as ScoreItem[],
final: undefined as number | undefined
},
{
initialMode: 'table',
disabled: computed(() => !enabled.value)
}
);
// 表格评分数据
const tableScores = modeData.table as Ref<ScoreItem[]>;
// 最终评分数据
const finalScore = modeData.final as Ref<number | undefined>;
// 评分等级
const scoreLevel = ref<string | number>();
// 表格评分总和
const tableScoreTotal = computed(() => {
return tableScores.value.reduce((sum, item) => {
return sum + (item.scoreResult || 0);
}, 0);
});
// 表格评分百分比
const tableScorePercentage = computed(() => {
if (totalScore.value <= 0) return 0;
return (tableScoreTotal.value / totalScore.value) * 100;
});
// 表格评分是否禁用
const isTableDisabled = computed(() => {
// 如果全局禁用,则禁用表格评分
if (!enabled.value) return true;
// 只在最终评分模式且有实际的最终评分值时才禁用表格评分
if (activeMode.value === 'final') {
return finalScore.value !== undefined &&
finalScore.value !== null &&
finalScore.value !== 0;
}
return false;
});
// 最终评分是否禁用
const isFinalDisabled = computed(() => {
// 如果全局禁用,则禁用最终评分
if (!enabled.value) return true;
// 只在表格评分模式且表格中有实际评分项时才禁用最终评分
if (activeMode.value === 'table') {
return tableScores.value.some(
(item) =>
item.scoreResult !== undefined &&
item.scoreResult !== null &&
item.scoreResult !== 0
);
}
return false;
});
// 切换到表格评分模式
function useTableMode() {
// 只有当启用且不处于表格模式时才切换
if (enabled.value && activeMode.value !== 'table') {
setMode('table');
}
}
// 切换到最终评分模式
function useFinalMode() {
// 只有当启用且不处于最终评分模式时才切换
if (enabled.value && activeMode.value !== 'final') {
setMode('final');
}
}
// 更新评分项目
function updateScoreItem(id: string | number, result: number) {
// 检查是否启用且在表格评分模式
if (!enabled.value || activeMode.value !== 'table') return;
const item = tableScores.value.find((item) => item.id === id);
if (item) {
// 只有当分数发生变化时才更新
if (item.scoreResult !== result) {
item.scoreResult = result;
// 在表格评分模式下,自动计算最终评分
if (tableScorePercentage.value > 0) {
const calculatedScore = Math.min(
maxScore,
Math.max(minScore, parseFloat(tableScorePercentage.value.toFixed(2)))
);
finalScore.value = calculatedScore;
scoreLevel.value = calculateLevel(calculatedScore);
if (onScoreChange) {
onScoreChange(calculatedScore, scoreLevel.value);
}
}
}
}
}
// 更新最终评分
function updateFinalScore(score: number) {
// 检查是否启用
if (!enabled.value) return;
// 如果不在最终评分模式,切换模式
if (activeMode.value !== 'final') {
// 只有当没有实际的表格评分数据时才自动切换
const hasTableScoreData = tableScores.value.some(
(item) =>
item.scoreResult !== undefined && item.scoreResult !== null && item.scoreResult !== 0
);
if (!hasTableScoreData) {
setMode('final');
} else {
// 如果有数据,不自动切换,避免清空现有数据
return;
}
}
// 只有当最终评分实际改变时才更新
if (finalScore.value !== score) {
const validScore = Math.min(maxScore, Math.max(minScore, score));
finalScore.value = validScore;
scoreLevel.value = calculateLevel(validScore);
if (onScoreChange) {
onScoreChange(validScore, scoreLevel.value);
}
}
}
// 根据分数计算等级
function calculateLevel(score: number): string | number {
if (score === undefined || score === null) return scoreRules.levels[0];
const { thresholds, levels } = scoreRules;
for (let i = 0; i < thresholds.length - 1; i++) {
if (score >= thresholds[i] && score < thresholds[i + 1]) {
return levels[i];
}
}
return levels[0];
}
// 返回API
return {
scoreMode: activeMode,
tableScores,
finalScore,
scoreLevel,
useTableMode,
useFinalMode,
isTableDisabled,
isFinalDisabled,
tableScoreTotal,
tableScorePercentage,
updateScoreItem,
updateFinalScore,
// 其他API...
};
}
该组合式API适用于多种互斥输入场景,以下是几个典型应用:
在我们的评分系统中,用户可以通过表格逐项评分,也可以直接给出最终评分:
评分表
{{ item.name }}
最终评分
除了评分系统,这一组合式API还适用于:
在实现过程中,我们遇到了以下几个典型问题:
问题描述:初始版本中,只要用户点击或聚焦到某一输入模式,就会立即禁用另一个输入模式,用户体验不好。
解决方案:
tableScoreInputDisabled
和finalScoreInputDisabled
计算属性,只有当另一个模式有实际数据时才禁用// 改进前 - 仅模式切换就禁用
const tableScoreInputDisabled = computed(() =>
isManualScoreInput.value
);
// 改进后 - 只有当最终评分有值时才禁用
const tableScoreInputDisabled = computed(() =>
isManualScoreInput.value &&
formData.value.finalEvaluationScore !== undefined &&
formData.value.finalEvaluationScore !== null &&
formData.value.finalEvaluationScore !== 0
);
问题描述:早期版本中,切换输入模式会自动清空另一个模式的数据,导致用户信息丢失。
解决方案:
setMode
函数,只有在另一模式有实际数据且确认清空时才清除数据shouldClearOldData
逻辑,智能判断是否需要清空// 改进前
if (activeMode.value !== null && activeMode.value !== mode) {
await clearData(activeMode.value);
}
// 改进后
if (oldMode !== mode && oldMode !== null) {
let shouldClearOldData = false;
const oldData = modeData[oldMode].value;
// 检查是否有实际数据需要清除
// ...判断逻辑...
if (shouldClearOldData) {
await clearData(oldMode);
}
}
问题描述:判断一个输入模式是否有"有效数据"并不简单,特别是当数据类型多样时。
解决方案:
在使用这套组合式API时,我们总结出以下最佳实践:
// ✅ 好的做法:为每种模式设置合适的初始数据类型
useMutuallyExclusiveInputs(
['table', 'final'],
{
table: [], // 数组类型
final: undefined // 原始类型
}
)
// ❌ 不好的做法:随意设置,不符合实际数据类型
useMutuallyExclusiveInputs(
['table', 'final'],
{
table: {},
final: []
}
)
// ✅ 好的做法:只有在有实际数据时才禁用
const inputDisabled = computed(() =>
isOtherMode.value && hasActualData.value
);
// ❌ 不好的做法:仅切换模式就禁用
const inputDisabled = computed(() =>
isOtherMode.value
);
当处理特定场景时,优先使用针对该场景优化的特化API,比如评分场景使用useScoreInputModes
而不是直接使用useMutuallyExclusiveInputs
。
通过抽象互斥输入模式逻辑为可复用的组合式API,我们成功解决了表单互斥输入的痛点问题。这一解决方案具有以下优势:
随着项目的发展,我们将继续优化这套API,添加更多功能并支持更多场景,为表单开发提供更强大的工具支持。
useMutuallyExclusiveInputs
通用的互斥输入模式管理API。
参数 | 类型 | 必填 | 默认值 | 说明 |
---|---|---|---|---|
modes | readonly string[] |
是 | - | 互斥模式的枚举列表 |
initialData | Record |
是 | - | 各模式的初始数据 |
options | object |
否 | {} |
配置选项 |
options.initialMode | string |
否 | modes[0] |
初始激活的模式 |
options.disabled | Ref |
否 | ref(false) |
是否全局禁用所有模式 |
options.onModeChange | (newMode, oldMode) => void |
否 | - | 模式变更时的回调 |
options.confirmClear | () => Promise |
否 | async () => true |
清空数据前的确认函数 |
属性 | 类型 | 说明 |
---|---|---|
activeMode | Ref |
当前激活的模式 |
modeData | Record |
各模式的数据 |
setMode | (mode: string) => Promise |
设置当前激活模式 |
clearData | (mode: string) => Promise |
清空指定模式的数据 |
shouldDisable | (mode: string) => Ref |
获取指定模式是否应该禁用 |
activeData | Ref |
当前激活模式的数据 |
reset | () => void |
重置所有数据和模式 |
const { activeMode, modeData, setMode, shouldDisable } = useMutuallyExclusiveInputs(
['form', 'template'],
{
form: {},
template: null
},
{
initialMode: 'form',
disabled: computed(() => !isEditable.value)
}
);
// 检查模板模式是否应该禁用
const isTemplateDisabled = shouldDisable('template');
// 切换到表单模式
function switchToForm() {
setMode('form');
}
// 获取表单数据
const formData = modeData.form;
useScoreInputModes
评分场景的专用互斥输入模式管理API。
参数 | 类型 | 必填 | 默认值 | 说明 |
---|---|---|---|---|
options | object |
否 | {} |
配置选项 |
options.enabled | Ref |
否 | ref(true) |
是否启用评分功能 |
options.totalScore | Ref |
否 | ref(100) |
总分基准值 |
options.scoreRules | object |
否 | {thresholds: [0, 60, 70, 80, 90, 101], levels: ['E', 'D', 'C', 'B', 'A']} |
评分规则 |
options.minScore | number |
否 | 0 |
最小评分值 |
options.maxScore | number |
否 | 100 |
最大评分值 |
options.onScoreChange | (score, level) => void |
否 | - | 评分变更时的回调 |
属性 | 类型 | 说明 |
---|---|---|
scoreMode | Ref<'table' | 'final' | null> |
当前评分模式 |
tableScores | Ref |
表格评分数据 |
finalScore | Ref |
最终评分数据 |
scoreLevel | Ref |
评分等级 |
useTableMode | () => void |
切换到表格评分模式 |
useFinalMode | () => void |
切换到最终评分模式 |
isTableDisabled | Ref |
表格评分是否禁用 |
isFinalDisabled | Ref |
最终评分是否禁用 |
tableScoreTotal | Ref |
表格评分总和 |
tableScorePercentage | Ref |
表格评分百分比 |
updateScoreItem | (id, result) => void |
更新评分项目 |
updateFinalScore | (score) => void |
更新最终评分 |
clearTableScores | () => Promise |
清空表格评分 |
resetScores | () => void |
重置所有评分数据 |
const {
scoreMode,
useTableMode,
useFinalMode,
isTableDisabled,
isFinalDisabled,
updateScoreItem,
updateFinalScore,
clearTableScores
} = useScoreInputModes({
enabled: computed(() => formData.value.complianceEvaluation === '1'),
totalScore: computed(() => totalScore.value),
scoreRules: {
thresholds: [0, 70, 85, 101],
levels: ['1', '2', '3', '4']
},
maxScore: 100,
onScoreChange: (score, level) => {
if (score !== null) {
formData.value.finalEvaluationScore = score;
formData.value.finalEvaluationLevel = level?.toString();
}
}
});
在将互斥输入模式API集成到组件中时,需要注意以下几点:
正确的做法是将"模式切换"与"输入禁用"分离,不要仅仅因为切换了模式就立即禁用另一输入方式:
当需要清空用户输入的数据时,应提供明确的确认机制,并只在必要时清空:
// 设置清空前确认
const {
/* ... */
} = useMutuallyExclusiveInputs(
['table', 'final'],
initialData,
{
confirmClear: async () => {
return await ElMessageBox.confirm(
'切换输入模式将清空已输入的数据,是否继续?',
'提示',
{ type: 'warning' }
).then(() => true)
.catch(() => false);
}
}
);
在评分场景中,表格评分与最终评分往往需要进行数据关联计算:
// 表格评分变化时自动计算最终评分
function handleTableScoreChange() {
tableData.value.forEach(item => {
if (item.scoreResult !== undefined && item.scoreResult !== null) {
updateScoreItem(item.id, Number(item.scoreResult));
}
});
// 自动计算最终评分逻辑会在updateScoreItem内部处理
}
在实际应用中,为了提高互斥输入组件的性能,我们采取了以下措施:
// 使用计算属性的惰性求值特性
const isDisabled = computed(() => {
// 先检查简单条件
if (disabled.value) return true;
// 复杂条件判断放在后面,避免不必要的计算
if (someCondition.value) {
return someLongComputation();
}
return false;
});
对于大型数据结构,可以使用shallowRef
或shallowReactive
,只在顶层进行响应式追踪:
import { shallowRef } from 'vue';
// 对于大型表格数据,使用shallowRef避免深层响应
const tableData = shallowRef([/* 大量评分项 */]);
// 手动触发更新
function updateTable() {
tableData.value = [...tableData.value];
}
对于频繁变化的输入,使用防抖或节流技术减少更新频率:
import { useDebounceFn } from '@vueuse/core';
// 使用防抖函数处理频繁的评分更新
const debouncedUpdateScore = useDebounceFn((id, value) => {
updateScoreItem(id, value);
}, 300);
function handleScoreChange(id, value) {
debouncedUpdateScore(id, value);
}
我们计划对互斥输入模式API进行以下扩展:
增强API对更多数据类型的支持,例如Map、Set、特殊对象等。
添加状态持久化功能,在页面刷新或会话结束后恢复用户输入:
// 未来计划示例:支持本地存储持久化
const { /* ... */ } = useMutuallyExclusiveInputs(
['table', 'final'],
initialData,
{
persistence: {
enabled: true,
storageKey: 'user-score-data',
storage: localStorage // 或sessionStorage
}
}
);
与常见表单校验库(如Vee-Validate、FormKit等)进行更紧密的集成。
根据业务需求,计划开发更多特化场景的API,如:
usePaymentModes
:支付方式选择useDeliveryModes
:配送方式选择useSearchModes
:搜索方式选择A: useMutuallyExclusiveInputs
设计上支持任意数量的互斥模式:
const { activeMode, setMode } = useMutuallyExclusiveInputs(
['simple', 'advanced', 'expert', 'custom'],
{
simple: { /* ... */ },
advanced: { /* ... */ },
expert: { /* ... */ },
custom: { /* ... */ }
}
);
A: 可以通过自定义clearData
逻辑来实现:
// 在组件中处理
const clearCustomData = async (mode) => {
// 保留某些字段
if (mode === 'advanced') {
const commonFields = ['name', 'email'];
const currentData = { ...advancedData.value };
// 清空除了通用字段外的所有数据
Object.keys(currentData).forEach(key => {
if (!commonFields.includes(key)) {
currentData[key] = undefined;
}
});
advancedData.value = currentData;
return true; // 阻止默认清除逻辑
}
return false; // 使用默认清除逻辑
};
const { /* ... */ } = useMutuallyExclusiveInputs(
['simple', 'advanced'],
initialData,
{
onClearData: clearCustomData
}
);
A: 完全可以,Vue的组合式API设计理念就是可组合性。例如:
// 结合useForm和useScoreInputModes
const { form, validate } = useForm();
const { scoreMode, updateScoreItem } = useScoreInputModes();
// 结合useVModel处理双向绑定
const props = defineProps(['modelValue']);
const emit = defineEmits(['update:modelValue']);
const value = useVModel(props, 'modelValue', emit);
// 在表单提交前进行分数验证
async function submitForm() {
// 首先验证表单
const isValid = await validate();
if (!isValid) return;
// 然后检查评分数据
if (scoreMode.value === 'table' && tableScoreTotal.value === 0) {
ElMessage.warning('请至少评分一项');
return;
}
// 提交数据
// ...
}
通过这套基于Vue3 Composition API的互斥输入模式解决方案,我们成功解决了传统方法中的痛点问题,提供了一种优雅、高效且可复用的实现方式。它不仅简化了开发流程,也提升了用户体验,避免了互斥输入场景中常见的困扰。
希望这篇文章能帮助你理解互斥输入模式的设计思路和实现方法,更好地应用到自己的项目中。如有任何问题或建议,欢迎在评论区留言讨论。
参考资料: