Vue 3 + Element Plus:表格「行内可编辑单元格」组件设计与实战

发布时间:2026/10/30 15:39:14
Vue 3 + Element Plus:表格「行内可编辑单元格」组件设计与实战

列表页里「点一下就能改数据」是很常见但细节很多的需求。很多人第一反应是在`el-table` 的插槽里塞个`el-input` ,功能是能跑通,但一旦遇到下面这些场景就会很痛苦:

  1. 数字列要 限制只能输数字、限制小数位 ;
  2. 点击进入编辑、 回车确认、Esc 取消、失焦提交 ;
  3. 编辑态要 自动聚焦 ;
  4. 配合表格的 分组小计、合并单元格 一起用;
  5. 单元格要 受控 (数据源可控、可回滚)。

本文基于一个真实管理系统的实现,拆解一个可复用的`EditableCell` 组件。文末会重点讲几个新手极易踩的坑(`nextTick` 聚焦时机、数字过滤正则、失焦与输入的冲突)。

一、需求拆解

先明确组件要解决什么:

  1. 非编辑态 :显示格式化后的文本 + 一个悬停才出现的「编辑」小图标;
  2. 编辑态 :变成输入框,并 自动聚焦 ;
  3. 文本列 :直接双向绑定;
  4. 数字列 :只能输数字和小数点,且 小数位受控 ;
  5. 交互 :心跳式三件套 —— 回车提交、Esc 取消、失焦提交;
  6. 受控 :组件自身不持有「落地值」,一切通过`modelValue` 单向流入、`update:modelValue` 流出,父组件决定何时生效、何时回滚。

一句话总结: 这是一个完全受控的、可复用的行内编辑单元 。

二、组件设计(受控 + v-model 语义)

采用 Vue 3 标准的`v-model` 约定:

  1. `props.modelValue` —— 当前值(父组件传入,编辑时输入框跟着它走);
  2. `props.displayValue` —— 非编辑态展示文本(可能和`modelValue` 不同,例如金额要格式化、空值显示`-` );
  3. `props.editing` —— 是否处于编辑态( 由父组件控制 ,而不是组件自己猜);
  4. `props.numeric` —— 是否数字列(决定要不要做输入过滤);
  5. `props.decimalScale` —— 允许的小数位,默认 2。

为什么要让`editing` 由父组件控制?因为「哪些行、哪些列现在处于编辑态」往往关联着表格的草稿态、批量保存、分组小计等 页面级状态 ,组件自己持有会失控。组件只负责「把编辑行为上报」,父组件负责「决定状态」。

三、完整代码实现

<!-- EditableCell.vue --> <template> <div class="editable-cell" :class="{ 'is-editing': editing }" @click="handleEdit"> <!-- 编辑态:输入框 --> <el-input v-if="editing" ref="inputRef" :model-value="modelValue" size="small" @input="handleInput" @blur="$emit('blur')" @keydown.enter="$event.target.blur()" @keydown.esc="$emit('cancel')" /> <!-- 非编辑态:文本 + 悬停图标 --> <template v-else> <span class="editable-cell__text">{{ displayValue }}</span> <el-icon class="editable-cell__icon"><EditPen /></el-icon> </template> </div> </template> <script setup> import { nextTick, ref, watch } from 'vue' import { EditPen } from '@element-plus/icons-vue' const props = defineProps({ modelValue: { type: [String, Number], default: '' }, displayValue: { type: String, default: '-' }, editing: Boolean, numeric: Boolean, decimalScale: { type: Number, default: 2 }, }) const emit = defineEmits(['edit', 'update:modelValue', 'blur', 'cancel']) const inputRef = ref() // 进入编辑态后,等 DOM 渲染完成再聚焦 watch( () => props.editing, async (editing) => { if (!editing) return await nextTick() inputRef.value?.focus() }, ) // 点击非编辑态区域 -> 通知父组件进入编辑 const handleEdit = () => { if (!props.editing) emit('edit') } // 输入处理:文本直接回传;数字列做过滤 const handleInput = (value) => { if (!props.numeric) { emit('update:modelValue', value) return } const cleaned = String(value) .replace(/[^\d.]/g, '') // 去掉数字和小数点以外的字符 .replace(/\.(?=.*\.)/g, '') // 只保留一个小数点 const [integer = '', decimal] = cleaned.split('.') emit( 'update:modelValue', decimal === undefined ? integer : `${integer}.${decimal.slice(0, props.decimalScale)}`, ) } </script> <style scoped> .editable-cell { display: inline-flex; align-items: center; min-width: 70px; min-height: 28px; cursor: text; } .editable-cell.is-editing { width: 100%; } .editable-cell__text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .editable-cell__icon { flex: none; margin-left: 5px; color: #1677ff; opacity: 0; /* 默认隐藏 */ transition: opacity 0.15s ease; } .editable-cell:hover .editable-cell__icon { opacity: 1; /* 悬停时出现 */ } </style>

关键实现点

① 数字输入过滤(核心正则)

const cleaned = String(value) .replace(/[^\d.]/g, '') // 1. 只留数字和点 .replace(/\.(?=.*\.)/g, '') // 2. 多个点只留一个

第一条正则把所有非「数字/小数点」的字符清空。第二条正则是亮点:`\.(?=.*\.)` 表示「后面还跟着一个点的点」,把它替换掉,最终只保留一个小数点。然后`split('.')` 拆整数/小数,`decimal.slice(0, decimalScale)` 截断小数位。

② 自动聚焦 —— 为什么要`await nextTick()`

因为输入框是用`v-if="editing"` 渲染的,`editing` 变`true` 的那一刻,`el-input` 还没挂到 DOM 上。直接在 watch 里`inputRef.value?.focus()` 会拿到`undefined` 。必须`await nextTick()` 等渲染完成后再聚焦。

③ 回车 = 失焦,Esc = 取消

1. `@keydown.enter="$event.target.blur()"` 回车触发 blur,自然走到「失焦提交」逻辑;
2.`@keydown.esc="$emit('cancel')"` Esc 走取消,父组件可以把草稿值回滚。

四、配合 el-table 分组小计使用

光有单元格不够,真正复杂的是把它嵌进一张「带分组小计」的表格里。下面是父页面的大致用法:

<el-table :data="tableRows" :span-method="tableSpanMethod" border> <el-table-column label="资产编号" min-width="135"> <template #default="{ row }"> <!-- 明细行:可编辑 --> <EditableCell v-if="row._rowType === 'detail'" :model-value="row.assetNo" :display-value="displayText(row.assetNo)" :editing="row._isDraft || isEditing(row, 'assetNo')" @edit="beginEdit(row, 'assetNo')" @update:model-value="updateCell(row, 'assetNo', $event)" @blur="finishEdit(row, 'assetNo')" @cancel="cancelEdit(row, 'assetNo')" /> <!-- 小计行:只读 --> <span v-else class="summary-label">小计</span> </template> </el-table-column> </el-table>

父页面需要维护几个状态:

const editingCell = ref({ _key: '', field: '' }) // 当前编辑的单元格 const isEditing = (row, field) => editingCell.value._key === row._key && editingCell.value.field === field const beginEdit = (row, field) => { editingCell.value = { _key: row._key, field } } const updateCell = (row, field, value) => { if (!row._isDraft) row._isDraft = true // 进入草稿态 row[field] = value } const finishEdit = () => { editingCell.value = { _key: '', field: '' } } const cancelEdit = () => { finishEdit() }

几个设计要点:

  1. `row._rowType` 区分「明细行」和「小计行」:小计行不渲染`EditableCell` ,直接显示`小计` ;
  2. `row._isDraft` 标记草稿行:新行进来时天然是草稿态,可直接编辑;已有行点一下才进入编辑;
  3. 编辑态是「页级唯一」的 :用`editingCell` 记录「当前是哪一行哪一列」,保证同一时间只有一个单元格在编辑,避免表格里一堆输入框互相抢焦点。

这样就把「单元格交互」和「页面状态管理」分层了:`EditableCell` 无脑上报,父组件统筹草稿、分组、保存/回滚。

五、踩坑清单

坑表现解法
直接在 watch 里 focus拿不到 input,聚焦失效`await nextTick()` 后再 focus
数字列没做过滤能输字母、多个小数点正则过滤 + 截断小数位
`editing` 由组件自己管理多行多列同时进入编辑、状态混乱交给父组件,页级唯一
失焦提交与 Esc 取消冲突Esc 后仍被 blur 提交Esc 用独立`cancel` 事件,不走 blur
空值展示`null` /`undefined`单元格空白`displayValue` 兜底为`-`
点击输入框冒泡触发外层点击循环进入编辑编辑态下`handleEdit` 直接 return

六、更轻量的备选方案

如果只是个别列需要「随手改」,不想引入完整组件,也可以在表格插槽里直接用`el-input`。

<el-input :model-value="row[item.key]" size="small" @input="(val) => handleInput(val, row, item.key)" @blur="$emit('blur', row, item.key)" @keydown.enter="$event.target.blur()" />

优点是无`editing` 状态、代码更短;缺点是没法做「点击图标才进编辑」「Esc 取消回滚」这类精细交互,数字校验也要自己写。 完整组件适合强交互、高复用场景,轻量写法适合一次性场景 ,按需选择即可。

七、总结

一个合格的行内可编辑单元格,本质上是三件事的分层:

1. 组件层(EditableCell) :只负责渲染 + 上报(`edit` /`update:modelValue` /`blur` /`cancel` ),完全受控、无状态;
2. 页面层 :维护「当前编辑单元格」「草稿态」等页级状态,决定生效与回滚;
3. 表格层(span-method + 行类型) :处理分组小计、合并单元格等展示逻辑。
核心细节记住两个就够了: `nextTick` 才能拿 DOM 聚焦 、 数字列要用正则过滤 + 小数位截断 。把这两点吃透,剩下都是业务拼装。