Vue3可编辑字段组件的演进之路:从繁琐到优雅

前言

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

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>

这次优化确实减少了一些代码量,但仍然存在问题:

  1. 模板结构依然复杂
  2. 编辑组件的处理不够灵活
  3. 这些东西都是放在这个组件里面,如果其他组件有这个需求的话,就需要写很多重复代码了。

4. 灵光乍现:组件化重构

经过上述优化,我们发现每个可编辑字段其实都遵循相同的模式:

  1. 显示态与编辑态的切换
  2. 值的展示与编辑
  3. 保存操作
  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 组件优化

基础功能有了,接下来我们不断完善这个组件:

  1. 支持不同的编辑组件类型
  2. 添加值格式化功能,比如像权重的话显示格式就是val%,这种的。
  3. 优化交互(改为双击编辑)
  4. 添加保存回调和错误处理
  5. 使用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 函数进去。业务逻辑有变化?保存时的回调里随便写 ------ 既没让组件变得复杂,又给后续扩展留了余地。

回头看整个过程,最大的感触是:别一开始就想着搞个 "完美设计"。刚上手时对需求的理解其实很浅层,只有真正在代码里踩过坑,遇到过重复劳动的痛苦,才能知道哪些是真正需要抽象的核心逻辑,哪些只是表面差异。组件化不是为了炫技,本质是让代码能 "可持续发展"------ 以后再遇到类似需求,不用从零开始,而是像搭积木一样快速组合,这才是真正的复用价值。

参考资料

相关推荐
若初&4 分钟前
文件上传Ⅲ
前端·web安全
若愚67924 分钟前
前端取经路——前端安全:构建坚不可摧的Web应用防线
前端·安全
邪恶的贝利亚8 分钟前
定时器设计
java·linux·前端
工业互联网专业10 分钟前
基于springboot+vue的机场乘客服务系统
java·vue.js·spring boot·毕业设计·源码·课程设计·机场乘客服务系统
inksci29 分钟前
Vue 3 打开 el-dialog 时使 el-input 获取焦点
前端·javascript·vue.js
若愚67921 小时前
前端取经路——量子UI:响应式交互新范式
前端·ui·交互
PHASELESS4111 小时前
HTML常用标签用法全解析:构建语义化网页的核心指南
前端·html
粉末的沉淀1 小时前
css:倒影倾斜效果
前端·css
zandy10112 小时前
如何快速入门-衡石科技分析平台
服务器·前端·科技·数据库管理员