前言
在开发管理系统时,我们经常会遇到需要原地编辑的场景:用户点击某个字段,该字段变成可编辑状态,编辑完成后保存。这看似是个简单的需求,但要做好却不容易。本文将分享一个真实的开发案例,看看如何从最初的繁琐实现,一步步优化到最终的优雅解决方案。

1. 最初的实现
作为开发者,我们往往会选择最直接的实现方式:为每个可编辑字段添加独立的编辑状态和处理逻辑。点击时修改编辑状态,让文本变成可编辑状态,可能是input,也可能是多行文本,还可能是下拉选择。
html
<template>
<div class="indicator-detail">
<!-- 指标名称 -->
<div class="detail-item">
<span class="label">指标名称:</span>
<template v-if="isEditingName">
<input
v-model="indicator.name"
@blur="handleNameSave"
ref="nameInput"
/>
</template>
<span v-else @click="startEditName">
{{ indicator.name }}
</span>
</div>
<!-- 指标类型 -->
<div class="detail-item">
<span class="label">指标类型:</span>
<template v-if="isEditingType">
<select
v-model="indicator.type"
@change="handleTypeSave"
ref="typeSelect"
>
<option value="1">定量指标</option>
<option value="2">定性指标</option>
</select>
</template>
<span v-else @click="startEditType">
{{ getTypeName(indicator.type) }}
</span>
</div>
<!-- 指标权重 -->
<div class="detail-item">
<span class="label">指标权重:</span>
<template v-if="isEditingWeight">
<input
type="number"
v-model="indicator.weight"
@blur="handleWeightSave"
ref="weightInput"
/>
</template>
<span v-else @click="startEditWeight">
{{ indicator.weight }}%
</span>
</div>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue';
import { MessagePlugin } from 'tdesign-vue-next';
// 编辑状态
const isEditingName = ref(false);
const isEditingType = ref(false);
const isEditingWeight = ref(false);
// 输入框引用
const nameInput = ref(null);
const typeSelect = ref(null);
const weightInput = ref(null);
// 开启编辑
const startEditName = async () => {
isEditingName.value = true;
await nextTick();
nameInput.value?.focus();
};
const startEditType = async () => {
isEditingType.value = true;
await nextTick();
typeSelect.value?.focus();
};
const startEditWeight = async () => {
isEditingWeight.value = true;
await nextTick();
weightInput.value?.focus();
};
// 保存处理
const handleNameSave = async () => {
try {
await saveIndicatorName(indicator.name);
isEditingName.value = false;
//这个是t-design的提示
MessagePlugin.success('保存成功');
} catch (error) {
MessagePlugin.error('保存失败');
}
};
const handleTypeSave = async () => {
try {
await saveIndicatorType(indicator.type);
isEditingType.value = false;
MessagePlugin.success('保存成功');
} catch (error) {
MessagePlugin.error('保存失败');
}
};
const handleWeightSave = async () => {
try {
await saveIndicatorWeight(indicator.weight);
isEditingWeight.value = false;
MessagePlugin.success('保存成功');
} catch (error) {
MessagePlugin.error('保存失败');
}
};
</script>
<style scoped>
.detail-item {
margin-bottom: 16px;
}
.label {
font-weight: bold;
margin-right: 8px;
}
</style>
2.1 存在的问题
刚开始的时候就想着多个字段就多复制几次代码,后面越复制越不对劲,重复的东西太多了。:
代码重复
- 每个字段都需要独立的编辑状态变量
- 每个字段都需要独立的开启编辑方法
- 每个字段都需要独立的保存方法
- 每个字段都需要独立的模板结构
维护困难
- 如果要修改编辑交互(比如从点击改为双击),需要修改多处代码
- 样式修改需要确保所有地方都同步更新
- 错误处理逻辑分散,难以统一管理
扩展性差
- 添加新的可编辑字段需要复制大量代码
- 难以统一添加新功能(如校验、格式化等)
- 代码越写越长,越来越难维护
3. 第一次优化:状态集中管理
为了减少重复代码,我使用对象来统一管理编辑状态,核心设计原理: 统一字段配置
- 使用
fields
数组统一管理所有字段的配置信息 - 每个字段包含:key(字段名)、label(显示标签)、editComponent(编辑组件类型)等
编辑状态管理
- 使用 reactive 对象
editingStatus
统一管理所有字段的编辑状态 - 每个字段对应一个布尔值,表示是否处于编辑状态
动态组件渲染
- 使用 component 动态组件根据配置渲染不同的编辑组件
- 支持 input、select 等不同类型的编辑组件
统一的事件处理
- startEdit: 开启编辑状态
- handleSave: 统一的保存处理逻辑,包含成功/失败提示
- formatValue: 统一的值格式化处理
vue
<template>
<div class="indicator-detail">
<div v-for="field in fields" :key="field.key" class="detail-item">
<span class="label">{{ field.label }}:</span>
<template v-if="editingStatus[field.key]">
<component
:is="field.editComponent"
v-model="indicator[field.key]"
@blur="handleSave(field.key)"
ref="inputs"
/>
</template>
<span v-else @click="startEdit(field.key)">
{{ formatValue(field.key, indicator[field.key]) }}
</span>
</div>
</div>
</template>
<script setup>
import { reactive } from 'vue';
// 字段配置
const fields = [
{
key: 'name',
label: '指标名称',
editComponent: 'input'
},
{
key: 'type',
label: '指标类型',
editComponent: 'select',
options: [
{ value: '1', label: '定量指标' },
{ value: '2', label: '定性指标' }
]
},
{
key: 'weight',
label: '指标权重',
editComponent: 'input',
type: 'number'
}
];
// 统一管理编辑状态
const editingStatus = reactive({
name: false,
type: false,
weight: false
});
// 统一的编辑和保存处理
const startEdit = (field) => {
editingStatus[field] = true;
};
const handleSave = async (field) => {
try {
await saveIndicator(field, indicator[field]);
editingStatus[field] = false;
MessagePlugin.success('保存成功');
} catch (error) {
MessagePlugin.error('保存失败');
}
};
// 值格式化
const formatValue = (field, value) => {
if (field === 'type') {
return getTypeName(value);
}
if (field === 'weight') {
return `${value}%`;
}
return value;
};
</script>
这次优化确实减少了一些代码量,但仍然存在问题:
- 模板结构依然复杂
- 编辑组件的处理不够灵活
- 这些东西都是放在这个组件里面,如果其他组件有这个需求的话,就需要写很多重复代码了。
4. 灵光乍现:组件化重构
经过上述优化,我们发现每个可编辑字段其实都遵循相同的模式:
- 显示态与编辑态的切换
- 值的展示与编辑
- 保存操作
- 错误处理
这不正是组件化的最佳场景吗?
4.1 第一版组件
让我们尝试将这些共同的逻辑抽取成一个组件:
vue
<template>
<div class="editable-field" @click="startEdit">
<span v-if="label" class="label">{{ label }}:</span>
<template v-if="isEditing">
<input
v-model="editValue"
@blur="handleSave"
ref="input"
/>
</template>
<span v-else class="value">
{{ value }}
</span>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue';
const props = defineProps({
label: String,
value: [String, Number],
});
const emit = defineEmits(['update']);
const isEditing = ref(false);
const editValue = ref(props.value);
const input = ref(null);
const startEdit = async () => {
isEditing.value = true;
await nextTick();
input.value?.focus();
};
const handleSave = () => {
if (editValue.value !== props.value) {
emit('update', editValue.value);
}
isEditing.value = false;
};
</script>
4.2 组件优化
基础功能有了,接下来我们不断完善这个组件:
- 支持不同的编辑组件类型
- 添加值格式化功能,比如像权重的话显示格式就是val%,这种的。
- 优化交互(改为双击编辑)
- 添加保存回调和错误处理
- 使用t-design(腾讯的)的组件库
最终,我们得到了一个功能完整的可编辑字段组件:
html
<template>
<div class="detail-item" @dblclick="startEdit">
<span v-if="label" class="label" style="width: fit-content; white-space: nowrap;">{{ label }}:</span>
<div class="value-container" style="width: 100%; cursor: pointer;">
<template v-if="isEditing">
<t-select
v-if="type === 'select'"
ref="inputRef"
v-model="editValue"
:style="{ width: '120px' }"
@change="handleSave"
@blur="handleSave"
>
<t-option
v-for="option in options"
:key="option.value"
:value="option.value"
:label="option.label"
/>
</t-select>
<t-textarea
v-else
ref="inputRef"
v-model="editValue"
:style="{ flex: '1',width: '100%' }"
:autosize="{ minRows: 1, maxRows: 5 }"
@blur="handleSave"
/>
</template>
<span v-else class="value" :class="valueClass">
{{ displayValue }}
</span>
<slot name="suffix"></slot>
</div>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue';
import { MessagePlugin } from 'tdesign-vue-next';
const props = defineProps({
label: String,
value: [String, Number],
type: {
type: String,
default: 'text' // 类型可自己加,文本,下拉框,日期
},
options: {
type: Array,
default: () => []
},
valueClass: String,
displayFormatter: {
type: Function,
default: val => val
},
saveHandler: {
type: Function,
required: true
}
});
const isEditing = ref(false);
const editValue = ref(props.value);
const inputRef = ref(null);
const displayValue = computed(() => {
return props.displayFormatter(props.value);
});
const startEdit = () => {
editValue.value = props.value;
isEditing.value = true;
nextTick(() => {
inputRef.value?.focus();
});
};
const handleSave = async () => {
if (editValue.value !== props.value) {
try {
await props.saveHandler(editValue.value);
MessagePlugin.success('修改成功');
} catch (error) {
MessagePlugin.error('修改失败');
editValue.value = props.value;
}
}
isEditing.value = false;
};
</script>
5. 使用示例
有了这个组件后,我们的业务代码变得非常简洁:
vue
<template>
<div class="indicator-detail">
<editable-field
label="指标名称"
:value="indicator.name"
:save-handler="handleNameSave"
/>
<editable-field
label="指标类型"
:value="indicator.type"
type="select"
:options="typeOptions"
:save-handler="handleTypeSave"
:display-formatter="formatType"
/>
<editable-field
label="指标权重"
:value="indicator.weight"
:save-handler="handleWeightSave"
:display-formatter="value => `${value}%`"
/>
</div>
</template>
<script setup>
const handleNameSave = async (value) => {
await saveIndicatorName(value);
};
const handleTypeSave = async (value) => {
await saveIndicatorType(value);
};
const handleWeightSave = async (value) => {
await saveIndicatorWeight(value);
};
</script>
6. 总结与思考
开发这个可编辑字段组件时,我真切感受到代码是需要慢慢 "长" 出来的。一开始没多想,直接给每个字段单独写编辑逻辑,功能倒是实现了,可代码越堆越多,复制粘贴的重复操作越来越频繁 ------ 比如不同字段的编辑状态切换、输入校验这些逻辑,几乎每个都得重写一遍。直到看着满屏相似的代码块,才意识到必须得做点什么改变。
后面边写边琢磨,发现很多字段的差异其实只在表现层:有的是输入框,有的是下拉选,有的需要格式化显示。但核心的编辑流程 ------ 进入编辑态、处理用户输入、校验数据、保存回调 ------ 其实都是一套逻辑。于是试着把这些共性抽出来,把不同的部分做成可配置的参数,比如用一个 type 属性区分编辑类型,用 format 函数处理显示格式,把保存逻辑留给外部通过回调处理。这么一拆,原本零散的代码块就拼成了一个通用组件。
这个过程让我特别有感触:好的组件从来不是一开始就设计出来的,而是在解决具体问题时慢慢 "磨" 出来的。就像手里的工具,刚开始能把眼前的活干完就行,用着用着发现哪里不顺手,就打磨哪里 ------ 加个扩展接口,留个配置项,慢慢就变成了一把 "万能工具"。现在这个组件里,用户想换编辑类型?改改 type 就行。显示格式要调整?传个 format 函数进去。业务逻辑有变化?保存时的回调里随便写 ------ 既没让组件变得复杂,又给后续扩展留了余地。
回头看整个过程,最大的感触是:别一开始就想着搞个 "完美设计"。刚上手时对需求的理解其实很浅层,只有真正在代码里踩过坑,遇到过重复劳动的痛苦,才能知道哪些是真正需要抽象的核心逻辑,哪些只是表面差异。组件化不是为了炫技,本质是让代码能 "可持续发展"------ 以后再遇到类似需求,不用从零开始,而是像搭积木一样快速组合,这才是真正的复用价值。