列表与表格最佳实践:分页、筛选、排序、批量操作

0 阅读12分钟

同学们好,我是 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,
})

为什么集中管理更好?

  1. 重置方便: 不用一个个清空,用 Object.assign 一次搞定
  2. 传参方便: 直接把整个对象传给 API,不用手动拼
  3. 维护方便: 新增筛选字段只需要加一个属性
// 重置的优雅写法
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-pickerdaterange 类型绑定的值是一个数组 ['2025-01-01', '2025-01-31'],但后端通常要的是两个独立字段 startDateendDate。不要直接把数组传给后端,要拆开。

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>

注意两个必须条件:

  1. el-table 上必须设置 :row-key(而且要用函数或唯一字段名)
  2. 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>

几个容易忘的细节:

  1. 批量删除后要清空选中状态,否则已删除的 ID 还留在 selectedIds
  2. 批量操作按钮禁用状态:没选中时 disabled,别让用户点了报错
  3. 二次确认弹窗:批量删除这种不可逆操作,必须弹确认框,而且要告诉用户「即将删除 X 条」
  4. 操作成功后的反馈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-tablefixed 列是通过复制一份表格来实现的(两份 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,你的电子学友,我们下一篇干货见~