同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~
(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)
你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?
你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?
就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。
一天只有24小时,时间永远不够用,常常感到力不从心。
技术行业,本就是逆水行舟,不进则退。
如果你也有同样的困扰,别慌。
从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲。
这一次,我们一起慢慢来,扎扎实实变强。
不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,
咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。
一、先搞清楚:列表和表格,到底该用哪个?
很多人上来就 <el-table>,但其实不是所有场景都需要表格。
1.1 什么时候用列表(List)
特征: 数据结构简单、展示维度少(1~3个字段)、侧重浏览而非对比。
典型场景:
- 消息通知列表
- 文章/博客列表
- 评论列表
- 移动端几乎所有数据展示
<!-- 一个典型的列表场景 -->
<template>
<div class="message-list">
<div
v-for="item in messages"
:key="item.id"
class="message-item"
>
<div class="message-header">
<span class="author">{{ item.author }}</span>
<span class="time">{{ item.createTime }}</span>
</div>
<div class="message-content">{{ item.content }}</div>
</div>
</div>
</template>
1.2 什么时候用表格(Table)
特征: 数据字段多(≥4列)、需要逐行对比、有排序/筛选/批量操作需求。
典型场景:
- 后台管理系统的数据管理页
- 订单管理、用户管理、商品管理
- 财务报表、数据分析面板
1.3 选型决策速查表
| 维度 | 用列表 List | 用表格 Table |
|---|---|---|
| 字段数量 | 1~3 个 | ≥ 4 个 |
| 是否需要排序 | 不需要 | 需要 |
| 是否需要批量操作 | 不需要 | 需要 |
| 是否需要逐行对比 | 不需要 | 需要 |
| 移动端适配 | 优先列表 | 尽量避免表格 |
| 数据量 | 无限滚动友好 | 分页友好 |
踩坑提醒: 很多后台系统,不管什么场景上来就用
el-table,结果移动端打开一看全乱了。如果你的系统有响应式需求,列表在小屏上的表现远好于表格。
二、表格组件选型:Element UI / Ant Design / vxeTable 怎么选?
这是很多 Vue 开发者的第一个岔路口。
2.1 三大主流方案对比
| 维度 | el-table (Element Plus) | a-table (Ant Design Vue) | vxe-table |
|---|---|---|---|
| 上手难度 | ⭐⭐(最简单) | ⭐⭐⭐ | ⭐⭐⭐⭐ |
| 功能丰富度 | 基础够用 | 中等偏上 | 非常全面 |
| 大数据性能 | 差(>500行卡顿) | 中等 | 优秀(虚拟滚动) |
| 可编辑表格 | 需自己封装 | 需自己封装 | 原生支持 |
| 树形表格 | 支持但有限 | 支持 | 深度支持 |
| 导入导出 | 无 | 无 | 内置支持 |
| 社区生态 | 最大 | 大 | 中等偏小 |
2.2 我的选型建议
简单后台、快速交付 → el-table
团队用 Ant Design 体系 → a-table
数据量大 / 可编辑 / 复杂表格 → vxe-table
个人经验: 我在实际项目中,一般的 CRUD 页面用
el-table就够了,但一旦遇到「可编辑表格」「万级数据量」「复杂树形结构」这些场景,vxe-table的优势就非常明显了。后续我会专门写一篇 vxeTable 实战文章,这里先埋个伏笔。
三、分页:前端分页 vs 后端分页,别选错了
分页看起来简单,但选错方案后果很严重。
3.1 两种分页模式
后端分页(Server-side Pagination): 每次请求只拿当前页的数据。
// 请求参数
const params = {
pageNum: 1, // 当前页码
pageSize: 10, // 每页条数
// ...其他筛选条件
}
// 后端返回
{
"list": [...], // 当前页数据(10条)
"total": 1000, // 总条数
"pageNum": 1,
"pageSize": 10
}
前端分页(Client-side Pagination): 一次性拿全部数据,前端自己切片展示。
// 一次性拿到全部数据
const allData = await fetchAllData() // 返回全部 200 条
// 前端自己做分页
const currentPageData = computed(() => {
const start = (pageNum.value - 1) * pageSize.value
return allData.value.slice(start, start + pageSize.value)
})
3.2 怎么选?
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 数据量 > 200 条 | 后端分页 | 前端一次加载太多数据会卡 |
| 数据量 < 200 条 | 前端分页 | 减少请求次数,体验更好 |
| 需要全文搜索 | 后端分页 | 前端只有当前页数据,搜不全 |
| 数据实时性要求高 | 后端分页 | 每次翻页都拿最新数据 |
| 纯静态展示、数据不变 | 前端分页 | 一次加载,翻页秒切 |
踩坑重灾区 ①:前端分页 + 后端筛选 = 翻车
我见过这种写法:前端拿了全部数据做分页,但筛选条件传给了后端。结果用户先筛选(后端返回 50 条),再翻页(前端按 allData 总量算页码),页码对不上,数据全乱了。
原则:分页和筛选必须在同一端处理。要么全走后端,要么全走前端,别混搭。
3.3 后端分页的标准实现(Vue 3 + Composition API)
这是我在实际项目中沉淀下来的一套标准写法:
<template>
<div class="table-page">
<!-- 筛选区域 -->
<div class="filter-bar">
<el-input
v-model="queryParams.keyword"
placeholder="请输入关键字"
clearable
@clear="handleSearch"
/>
<el-select
v-model="queryParams.status"
placeholder="状态"
clearable
@change="handleSearch"
>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 表格 -->
<el-table :data="tableData" v-loading="loading">
<el-table-column prop="name" label="名称" />
<el-table-column prop="status" label="状态" />
<el-table-column prop="createTime" label="创建时间" />
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { getListApi } from '@/api/example'
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const queryParams = reactive({
pageNum: 1,
pageSize: 10,
keyword: '',
status: undefined,
})
// 核心方法:拉取数据
async function fetchData() {
loading.value = true
try {
const res = await getListApi(queryParams)
tableData.value = res.data.list
total.value = res.data.total
} finally {
loading.value = false
}
}
// 搜索:重置到第一页
function handleSearch() {
queryParams.pageNum = 1
fetchData()
}
// 重置:清空筛选条件 + 回到第一页
function handleReset() {
queryParams.keyword = ''
queryParams.status = undefined
queryParams.pageNum = 1
queryParams.pageSize = 10
fetchData()
}
onMounted(() => {
fetchData()
})
</script>
为什么 handleSearch 要把 pageNum 重置为 1?
这是一个非常经典的坑:用户在第 5 页改了筛选条件点搜索,如果不重置页码,请求的还是第 5 页,而新的筛选结果可能总共就 2 页,后端返回空数据,用户看到的是空白表格。
记住:只要筛选条件变了,页码必须重置为 1。
四、筛选:看似简单,细节全是坑
4.1 筛选参数的管理规范
推荐做法: 把所有查询参数集中在一个 reactive 对象里,而不是一堆零散的 ref。
// ❌ 不推荐:零散的 ref
const keyword = ref('')
const status = ref(undefined)
const dateRange = ref([])
const pageNum = ref(1)
const pageSize = ref(10)
// ✅ 推荐:集中管理
const queryParams = reactive({
keyword: '',
status: undefined,
dateRange: [],
pageNum: 1,
pageSize: 10,
})
为什么集中管理更好?
- 重置方便: 不用一个个清空,用
Object.assign一次搞定 - 传参方便: 直接把整个对象传给 API,不用手动拼
- 维护方便: 新增筛选字段只需要加一个属性
// 重置的优雅写法
const defaultParams = {
keyword: '',
status: undefined,
dateRange: [],
pageNum: 1,
pageSize: 10,
}
function handleReset() {
Object.assign(queryParams, defaultParams)
fetchData()
}
4.2 筛选值的清理:传给后端之前一定要洗数据
这是一个很多人不注意但非常重要的点。
// 用户清空了输入框,此时 keyword 可能是 '' 空字符串
// 直接传给后端:?keyword=&status=
// 有的后端会把空字符串当作有效筛选条件,导致查不出数据
// ✅ 推荐:提交前清理空值
function cleanParams(params) {
const cleaned = {}
for (const [key, value] of Object.entries(params)) {
// 过滤掉 undefined、null、空字符串
if (value !== undefined && value !== null && value !== '') {
cleaned[key] = value
}
}
return cleaned
}
async function fetchData() {
loading.value = true
try {
const params = cleanParams(queryParams)
const res = await getListApi(params)
tableData.value = res.data.list
total.value = res.data.total
} finally {
loading.value = false
}
}
4.3 日期范围筛选的处理
日期范围筛选几乎每个后台系统都有,但处理方式五花八门,这里给一个标准写法:
<template>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
@change="handleDateChange"
/>
</template>
<script setup>
import { ref } from 'vue'
const dateRange = ref([])
function handleDateChange(val) {
if (val && val.length === 2) {
queryParams.startDate = val[0]
queryParams.endDate = val[1]
} else {
queryParams.startDate = undefined
queryParams.endDate = undefined
}
handleSearch()
}
</script>
踩坑提醒:
el-date-picker的daterange类型绑定的值是一个数组['2025-01-01', '2025-01-31'],但后端通常要的是两个独立字段startDate和endDate。不要直接把数组传给后端,要拆开。
4.4 防抖:输入框筛选必须加
如果用户在输入框里打字,每按一个键就发一次请求,体验极差还浪费性能。
import { useDebounceFn } from '@vueuse/core'
// 输入框实时搜索时,加 300ms 防抖
const debouncedSearch = useDebounceFn(() => {
handleSearch()
}, 300)
<el-input
v-model="queryParams.keyword"
placeholder="请输入关键字"
@input="debouncedSearch"
/>
什么时候用防抖,什么时候不用?
| 交互方式 | 是否防抖 | 原因 |
|---|---|---|
| 输入框实时搜索(@input) | ✅ 要 | 用户打字频率高 |
| 点击"搜索"按钮 | ❌ 不要 | 用户主动触发,防抖反而让人觉得卡 |
| 下拉框 @change | ❌ 不要 | 一次性选择,不会连续触发 |
| 输入框回车搜索(@keyup.enter) | ❌ 不要 | 用户主动触发 |
五、排序:前端排序和后端排序的分界线
5.1 两种排序方式
前端排序: 只对当前页面已有的数据进行排序。
// el-table 自带的排序就是前端排序
<el-table-column prop="createTime" label="创建时间" sortable />
后端排序: 把排序字段和方向传给后端,后端返回排序后的分页数据。
// el-table 后端排序
<el-table-column
prop="createTime"
label="创建时间"
sortable="custom" // 注意:是 "custom",不是 true
/>
// 监听排序变化
<el-table @sort-change="handleSortChange">
function handleSortChange({ prop, order }) {
// order 的值:'ascending' | 'descending' | null
queryParams.sortField = prop
queryParams.sortOrder = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : undefined
handleSearch()
}
5.2 怎么选?
一句话原则:用了后端分页,排序就必须走后端。
为什么?因为后端分页时,前端只有当前页的数据(比如 10 条)。你对这 10 条排序,排出来的结果是「这 10 条里的排序」,而不是「全部 1000 条数据里的排序」。用户看到的排序结果是错的。
全部数据:1, 3, 5, 7, 9, 2, 4, 6, 8, 10, 11, 13, 15...
当前页(后端分页第1页):1, 3, 5, 7, 9, 2, 4, 6, 8, 10
前端排序结果:1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ← 看起来没问题?
但翻到第2页:11, 13, 15... ← 这些比第1页的10还大,但排序只管了第1页
正确做法:后端排序后再分页
全部数据排序后:1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15...
第1页:1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ✅
第2页:11, 13, 15... ✅
踩坑重灾区 ②:sortable 和 sortable="custom" 搞混
el-table-column上写sortable(不带值或sortable="true"),是前端排序,表格组件自己处理。写
sortable="custom"才是后端排序,会触发@sort-change事件让你自己处理。写错了不会报错,但排序行为完全不同。这是一个非常隐蔽的坑。
5.3 多字段排序
有些业务需要「先按状态排,状态相同的按时间排」。
// 后端通常支持传数组
const queryParams = reactive({
// ...
sorts: [
{ field: 'status', order: 'asc' },
{ field: 'createTime', order: 'desc' },
]
})
如果你的表格场景复杂到需要多字段排序 + 拖拽列 + 自定义排序规则,
vxe-table在这块的支持比el-table强得多,开箱即用。这也是我后面会单独写 vxeTable 实战的原因之一。
六、批量操作:选中管理是个技术活
6.1 基础多选
<template>
<el-table
ref="tableRef"
:data="tableData"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="status" label="状态" />
</el-table>
<div class="batch-bar">
<span>已选中 {{ selectedRows.length }} 项</span>
<el-button
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
批量删除
</el-button>
<el-button
:disabled="selectedRows.length === 0"
@click="handleBatchExport"
>
批量导出
</el-button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const tableRef = ref(null)
const selectedRows = ref([])
function handleSelectionChange(selection) {
selectedRows.value = selection
}
function handleBatchDelete() {
const ids = selectedRows.value.map(row => row.id)
ElMessageBox.confirm(
`确定要删除选中的 ${ids.length} 条数据吗?`,
'提示',
{ type: 'warning' }
).then(async () => {
await batchDeleteApi({ ids })
ElMessage.success('删除成功')
fetchData()
}).catch(() => {})
}
</script>
6.2 跨页选中:最常见的需求,也是最容易做错的
场景: 用户在第 1 页选了 3 条,翻到第 2 页又选了 2 条,点批量删除,应该删除 5 条。
问题: el-table 翻页后,之前页面的选中状态会丢失,因为 DOM 被重新渲染了。
解决方案:维护一个独立的选中 ID 集合
<template>
<el-table
:data="tableData"
:row-key="row => row.id"
@selection-change="handleSelectionChange"
>
<el-table-column
type="selection"
width="55"
:reserve-selection="true" <!-- 关键:开启跨页保留选中 -->
/>
<!-- 其他列 -->
</el-table>
</template>
注意两个必须条件:
el-table上必须设置:row-key(而且要用函数或唯一字段名)el-table-column上必须设置:reserve-selection="true"
两个缺一个都不行,而且不会报错,只是选中状态悄悄丢失。
踩坑重灾区 ③:row-key 不唯一导致的诡异 bug
如果你的
row-key不是真正唯一的(比如用了index,或者后端数据里有重复 ID),会出现:
- 选中一行,另一行也被选中
- 取消选中不生效
- 翻页回来发现选中状态全乱了
永远用后端返回的唯一标识(通常是
id)作为row-key,不要用数组下标index。
6.3 如果不用 reserve-selection,自己怎么实现跨页选中?
有时候你可能用的不是 el-table,或者 reserve-selection 满足不了需求(比如需要回显已选数据列表),那就需要手动管理:
const selectedIds = ref(new Set())
function handleSelectionChange(selection) {
// 获取当前页所有 ID
const currentPageIds = tableData.value.map(row => row.id)
// 先把当前页的全移除(因为可能有取消选中的)
currentPageIds.forEach(id => selectedIds.value.delete(id))
// 再把当前选中的加进去
selection.forEach(row => selectedIds.value.add(row.id))
}
// 翻页后回显选中状态
function restoreSelection() {
nextTick(() => {
tableData.value.forEach(row => {
if (selectedIds.value.has(row.id)) {
tableRef.value.toggleRowSelection(row, true)
}
})
})
}
// 在 fetchData 成功后调用 restoreSelection
async function fetchData() {
loading.value = true
try {
const res = await getListApi(cleanParams(queryParams))
tableData.value = res.data.list
total.value = res.data.total
restoreSelection() // 回显选中
} finally {
loading.value = false
}
}
6.4 批量操作的 UX 规范
几个提升用户体验的细节:
<template>
<!-- ① 批量操作栏:有选中才显示,带过渡动画 -->
<transition name="fade">
<div v-if="selectedRows.length > 0" class="batch-action-bar">
<span>已选中 <strong>{{ selectedRows.length }}</strong> 项</span>
<el-button size="small" @click="clearSelection">取消选择</el-button>
<el-button size="small" type="danger" @click="handleBatchDelete">
批量删除
</el-button>
</div>
</transition>
</template>
<script setup>
// ② 清空选中
function clearSelection() {
tableRef.value.clearSelection()
// 如果用了手动管理的方式
selectedIds.value.clear()
}
// ③ 批量操作后清空选中 + 刷新列表
async function handleBatchDelete() {
await batchDeleteApi({ ids: [...selectedIds.value] })
ElMessage.success('删除成功')
clearSelection() // 先清选中
fetchData() // 再刷列表
}
</script>
几个容易忘的细节:
- 批量删除后要清空选中状态,否则已删除的 ID 还留在
selectedIds里 - 批量操作按钮禁用状态:没选中时
disabled,别让用户点了报错 - 二次确认弹窗:批量删除这种不可逆操作,必须弹确认框,而且要告诉用户「即将删除 X 条」
- 操作成功后的反馈:
ElMessage.success不能省
七、进阶:把上面这些封装成 Hook
写了这么多,你会发现每个表格页面都在重复这些逻辑。好的工程师应该把重复的模式抽象出来。
7.1 封装 useTable Hook
// hooks/useTable.js
import { ref, reactive } from 'vue'
/**
* 表格通用逻辑封装
* @param {Function} api - 列表请求接口
* @param {Object} options - 配置项
* @param {Object} options.defaultParams - 默认查询参数
* @param {boolean} options.immediate - 是否立即执行,默认 true
* @param {Function} options.formatParams - 请求前参数格式化
* @param {Function} options.formatResult - 响应后数据格式化
*/
export function useTable(api, options = {}) {
const {
defaultParams = {},
immediate = true,
formatParams,
formatResult,
} = options
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const queryParams = reactive({
pageNum: 1,
pageSize: 10,
...defaultParams,
})
// 清理空值
function cleanParams(params) {
const cleaned = {}
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
cleaned[key] = value
}
}
return cleaned
}
// 拉取数据
async function fetchData() {
loading.value = true
try {
let params = cleanParams(queryParams)
if (formatParams) params = formatParams(params)
const res = await api(params)
if (formatResult) {
const result = formatResult(res)
tableData.value = result.list
total.value = result.total
} else {
tableData.value = res.data.list
total.value = res.data.total
}
} finally {
loading.value = false
}
}
// 搜索(重置页码)
function handleSearch() {
queryParams.pageNum = 1
fetchData()
}
// 重置
function handleReset() {
Object.assign(queryParams, {
pageNum: 1,
pageSize: 10,
...defaultParams,
})
// 把非默认的字段清空
Object.keys(queryParams).forEach(key => {
if (!(key in defaultParams) && key !== 'pageNum' && key !== 'pageSize') {
queryParams[key] = undefined
}
})
fetchData()
}
// 排序
function handleSortChange({ prop, order }) {
queryParams.sortField = prop
queryParams.sortOrder = order === 'ascending' ? 'asc'
: order === 'descending' ? 'desc'
: undefined
handleSearch()
}
if (immediate) fetchData()
return {
loading,
tableData,
total,
queryParams,
fetchData,
handleSearch,
handleReset,
handleSortChange,
}
}
7.2 使用 Hook 后,页面代码变成这样
<template>
<div class="table-page">
<div class="filter-bar">
<el-input v-model="queryParams.keyword" placeholder="关键字" clearable />
<el-select v-model="queryParams.status" placeholder="状态" clearable>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<el-table
:data="tableData"
v-loading="loading"
@sort-change="handleSortChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="status" label="状态" />
<el-table-column prop="createTime" label="创建时间" sortable="custom" />
</el-table>
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</template>
<script setup>
import { useTable } from '@/hooks/useTable'
import { getUserListApi } from '@/api/user'
const {
loading,
tableData,
total,
queryParams,
fetchData,
handleSearch,
handleReset,
handleSortChange,
} = useTable(getUserListApi)
</script>
对比一下: 没有 Hook 前,每个页面都要写 40~60 行重复的逻辑。有了 Hook 后,核心逻辑只需要几行,而且所有表格页面的行为是一致的——不会出现「这个页面搜索重置了页码,那个页面忘了」这种问题。
八、性能优化:大数据量表格怎么办?
8.1 问题场景
当表格数据量超过一定规模时,DOM 节点过多会导致页面卡顿:
| 数据量 | el-table 表现 | 用户体感 |
|---|---|---|
| < 200 行 | 流畅 | 没问题 |
| 200~500 行 | 轻微卡顿 | 能接受 |
| 500~1000 行 | 明显卡顿 | 不能接受 |
| > 1000 行 | 几乎无法使用 | 灾难 |
8.2 解决方案
方案一:控制每页条数(最简单有效)
// 每页最多 50 条,别给用户提供 500、1000 的选项
:page-sizes="[10, 20, 50]"
很多人为了"方便"给用户一个
pageSize=500的选项,结果用户一点页面就卡死了。限制每页最大条数是成本最低的优化。
方案二:虚拟滚动
当业务确实需要展示大量数据(比如不分页的长列表),虚拟滚动是标准方案。原理是:只渲染可视区域内的 DOM 节点,滚动时动态替换。
可视区域(假设高度能显示20行):
┌──────────────────┐
│ 第 51 行 │ ← 实际只渲染这 20 行
│ 第 52 行 │
│ ... │
│ 第 70 行 │
└──────────────────┘
上方 50 行:不渲染,用空白 div 撑高度
下方 930 行:不渲染,用空白 div 撑高度
el-table 不支持虚拟滚动(Element Plus 新版的 el-table-v2 支持但 API 完全不同)。vxe-table 对虚拟滚动的支持是开箱即用的:
<!-- vxe-table 虚拟滚动,只需要加一个属性 -->
<vxe-table
:data="bigData"
:scroll-y="{ enabled: true, gt: 60 }"
>
<!-- 数据量超过60行自动启用虚拟滚动 -->
</vxe-table>
这也是我推荐在复杂场景使用
vxe-table的重要原因之一。后面的 vxeTable 专题文章会详细展开虚拟滚动、可编辑单元格、树形表格等高级用法。
方案三:固定列优化
当表格列很多需要横向滚动时,固定关键列可以避免不必要的重渲染:
<el-table-column prop="name" label="名称" fixed="left" width="200" />
<el-table-column prop="actions" label="操作" fixed="right" width="200" />
但要注意:el-table 的 fixed 列是通过复制一份表格来实现的(两份 DOM),所以固定列越多,性能开销越大。不要所有列都 fixed,只固定最关键的 1~2 列。
九、完整实战:一个标准的后台管理表格页
把前面所有知识点整合在一起,这是一个可以直接用在生产项目中的模板:
<template>
<div class="page-container">
<!-- 筛选区 -->
<el-card shadow="never" class="filter-card">
<el-form :model="queryParams" inline>
<el-form-item label="用户名">
<el-input
v-model="queryParams.username"
placeholder="请输入用户名"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="全部" clearable>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
@change="handleDateChange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="action-bar">
<el-button type="primary" @click="handleAdd">新增用户</el-button>
<el-button
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
批量删除 {{ selectedRows.length > 0 ? `(${selectedRows.length})` : '' }}
</el-button>
</div>
<!-- 表格 -->
<el-table
ref="tableRef"
v-loading="loading"
:data="tableData"
:row-key="row => row.id"
@selection-change="handleSelectionChange"
@sort-change="handleSortChange"
>
<el-table-column type="selection" width="55" :reserve-selection="true" />
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="email" label="邮箱" min-width="180" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="createTime"
label="创建时间"
width="180"
sortable="custom"
/>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useTable } from '@/hooks/useTable'
import { getUserListApi, deleteUserApi, batchDeleteUserApi } from '@/api/user'
const tableRef = ref(null)
const selectedRows = ref([])
const dateRange = ref([])
const {
loading,
tableData,
total,
queryParams,
fetchData,
handleSearch,
handleReset: resetTable,
handleSortChange,
} = useTable(getUserListApi)
function handleSelectionChange(selection) {
selectedRows.value = selection
}
function handleDateChange(val) {
if (val && val.length === 2) {
queryParams.startDate = val[0]
queryParams.endDate = val[1]
} else {
queryParams.startDate = undefined
queryParams.endDate = undefined
}
handleSearch()
}
function handleReset() {
dateRange.value = []
resetTable()
}
function handleAdd() {
// 打开新增弹窗
}
function handleEdit(row) {
// 打开编辑弹窗
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定要删除用户「${row.username}」吗?`, '提示', {
type: 'warning',
})
await deleteUserApi(row.id)
ElMessage.success('删除成功')
fetchData()
}
async function handleBatchDelete() {
const ids = selectedRows.value.map(row => row.id)
await ElMessageBox.confirm(
`确定要删除选中的 ${ids.length} 个用户吗?此操作不可恢复。`,
'警告',
{ type: 'warning' }
)
await batchDeleteUserApi({ ids })
ElMessage.success('删除成功')
tableRef.value.clearSelection()
fetchData()
}
</script>
<style scoped>
.page-container {
padding: 20px;
}
.filter-card {
margin-bottom: 16px;
}
.action-bar {
margin-bottom: 16px;
display: flex;
gap: 12px;
}
.pagination-wrapper {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>
十、总结:一张清单检查你的表格页
每次写完一个表格页面,对照这张清单检查:
基础功能
- 分页和筛选是否在同一端处理?
- 搜索/筛选时是否重置页码为 1?
- 重置按钮是否清空了所有筛选条件?
- 日期范围是否拆成了 startDate / endDate 传给后端?
- 空字符串是否在请求前被过滤掉?
排序
- 后端分页是否用了
sortable="custom"而不是sortable? - 排序变化时是否重新请求了后端数据?
批量操作
-
row-key是否设置为唯一标识字段? - 跨页选中是否正常工作?
- 批量操作是否有二次确认?
- 操作完成后是否清空了选中状态?
用户体验
- 表格是否有
v-loading加载状态? - 输入框实时搜索是否加了防抖?
- 每页最大条数是否做了限制(建议 ≤ 50)?
- 操作列是否
fixed="right"? - 数据为空时是否展示了空状态提示?
性能
- 数据量大时是否考虑了虚拟滚动?
- 固定列是否只固定了必要的列?
学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。
后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。
关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。
如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。
我是 Eugene,你的电子学友,我们下一篇干货见~