在后台管理系统中,可编辑表格几乎是刚需。
目前公司项目基于 Element Plus,实现方式也比较直接:在 el-table 的每一个单元格中直接嵌入 el-input,开发起来顺手,改动也非常直观。
但问题也来得很快。
当列数一多(比如 50+ 列),或者数据行稍微大一点,页面直接开始卡顿,输入延迟、滚动掉帧,严重的时候浏览器都直接卡死。
最近正好踩了这个坑,花了一点时间把性能问题系统性地优化了一下,效果还挺明显,这里记录一下思路和实现。
问题本质:组件实例太多了
先说结论:
不是 el-table 慢,而是 el-input 太重。
el-input 本身是一个复杂组件:
- 响应式
- 校验
- 事件监听
- 样式计算
- ……
如果你有:
- 50 列 × 20 行 = 1000 个 el-input
- Vue 需要同时维护 1000 个组件实例
,一个el-input实例不只是一个input,而是多层div等节点嵌套,数量一多,dom节点直接爆炸,CPU 和内存当场起飞,卡顿是必然的。
核心优化思路(两句话版)
我最后的优化方案,其实就两点:
- 读写分离:
平时只展示文本,点谁才渲染谁的el-input - 分批渲染:
列数太多,不一次性渲染完,分几帧慢慢上屏
下面展开说。
一、性能提升最大的一步:点击才编辑(EditCell)
这是最关键、收益也最大的一步。 解决思路很简单:
平时只展示纯文本,点哪儿,哪儿才变成输入框。
我们封装一个 EditCell 组件,它主要负责两个状态的切换:
- 非编辑状态(View Mode) : 渲染一个普通的
div或span。这属于原生的 HTML 标签,渲染成本几乎可以忽略不计。 - 编辑状态(Edit Mode) : 只有当用户点击这个单元格时,才把它替换为
el-input,并自动让它获得焦点。当用户点到别处(失焦 blur)时,立刻销毁 Input,变回文本。
这样一来:
无论表格有多大,页面上同时存在的 el-input,通常只有 1 个。
EditCell 组件实现
Template
<template>
<div v-if="isEditing" class="edit-cell-input">
<el-input
ref="inputRef"
v-model="internalValue"
@blur="handleBlur"
/>
</div>
<div v-else class="edit-cell-display" @click="handleEdit">
<span v-if="internalValue">{{ internalValue }}</span>
<span v-else class="placeholder">请输入</span>
</div>
</template>
Script
核心逻辑代码(EditCell.vue):
const handleCellClick = () => {
// 1. 切换状态,准备渲染 el-input
isEditing.value = true;
// 2. 等待 DOM 更新完成后,立即让 input 获取焦点
nextTick(() => {
// inputRef 是 el-input 组件的引用
inputRef.value?.focus();
});
};
const onBlur = () => {
// 3. 失焦后立即切回文本模式,销毁 input 组件,释放内存
isEditing.value = false;
emit('change', internalValue.value);
};
通过这一招,原本页面上几千个 el-input 瞬间减少到了 0 个(或者是 1 个,如果你正在编辑的话)。内存占用直线下降。
Style(关键在“看起来像 el-input”)
.edit-cell-display {
width: 100%;
height: 32px;
box-sizing: border-box;
text-align: left;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0 10px;
color: #333;
cursor: pointer;
box-shadow: 0 0 0 1px var(--el-input-border-color, #dcdfe6) inset;
border-radius: 4px;
transition: box-shadow 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
box-shadow: 0 0 0 1px var(--el-input-hover-border-color, #c0c4cc) inset;
}
span {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
为了让切换过程不“跳”,我在查看模式下用 CSS 把 div 伪装成 el-input 的样子(高度、padding、圆角都一致)。这样,无论怎么切换,肉眼几乎看不出 DOM 的替换过程
这一点优化完,输入卡顿基本就消失了。而且 EditCell 被封装成独立组件后,后续在其他表格或表单场景中也可以直接复用,一次优化,长期受益。
二、辅助优化:列太多怎么办?分帧渲染
解决了「组件太重」的问题后,还有一个场景:
列真的非常多,比如 80、90 列。
哪怕每个单元格只是普通 DOM,
90 列 × 20 行 = 1800 个 td,一次性让浏览器在 DOM 树中插入 96 列 x 30 行的节点,主线程依然会阻塞,导致页面出现短暂的“白屏”或无法响应。。
解决思路:时间分片(Time Slicing)
不要一次性把所有列都丢给 el-table,而是:
- 每一帧只渲染一小部分列(比如 10 列)
- 用
requestAnimationFrame分几帧完成 - 用户几乎感知不到,但主线程不会被一次性堵死
核心代码
const addColumnsTimeSliced = () => {
let currentIndex = 0;
const batchSize = 10; // 每帧只处理 10 列
const doBatch = () => {
// 1. 计算这一批次的结束点
const endIndex = Math.min(currentIndex + batchSize, totalColumns);
// 2. 截取这一批列配置,加入到当前显示的列列表中
// 使用 markRaw 进一步告诉 Vue:这些列配置不需要深度监听,省点力气
const nextBatch = allColumns.slice(currentIndex, endIndex).map(col => markRaw(col));
internalColumns.value = [...internalColumns.value, ...nextBatch];
currentIndex = endIndex;
// 3. 如果还没渲染完,就预约下一帧继续
if (currentIndex < totalColumns) {
requestAnimationFrame(doBatch);
}
};
// 启动任务
requestAnimationFrame(doBatch);
};
总结一下
这次优化后,表格从「卡到没法用」变成了「基本无卡顿流畅输入」。
核心就两点:
- 减负
用 EditCell,把el-input从“全量渲染”变成“按需出现” - 分流
用requestAnimationFrame把大渲染任务拆开,避免主线程阻塞