做后端管理系统的人,十有八九都用过 vxe-table 这类重度表格组件。实话说,它比普通 el-table 能扛的场景多太多了:大数据量虚拟滚动、单元格编辑、树形表格、固定列、自定义渲染……但用起来门槛也高,尤其是"数据到底怎么喂进去、怎么改"这个问题,官方文档散落在 API 里,新手很容易把loadData、reloadData、setData、直接改:data绑定数组这几种方式混在一起用,结果表格不刷新、行状态丢光、loading 卡死,各种玄学问题全冒出来。
这篇文章我就围绕 vxe-table 加载数据的几种方式,以及更新数据的常见用法,把数据流这件事彻底讲透。内容适合正在用 vxe-table 做中后台项目、或者在 el-table 和 vxe-table 之间纠结要不要切换的兄弟参考,我会直接给代码、给选择建议、给排坑经验,不绕弯子。
1. 先把数据来源理清楚:vxe-table 的数据体系
1.1 外部响应式数据与表格内部数据并存
vxe-table 有一个很容易被忽略的设计:它允许你完全不声明data属性,也能通过实例方法管理数据;同时它又支持v-model:data双向绑定外部数组。这就意味着表格内部有一套自己的数据维护逻辑,外部数组只是它的一个"受控镜像"。
这句话怎么理解?我举个例子。你在模板里写了:
<vxe-table :data="tableData"></vxe-table>此时表格在渲染时会读取tableData的值,并把这份数据作为当前表格数据。你通过ref调用loadData或insert时,表格内部的记录会变化,同时会触发update:data事件,把最新的数组同步回tableData。反过来,你直接给tableData.value赋一个全新的数组,表格感知到data变化后也会重新渲染。
所以这里存在两条操作路径:
- 外部驱动:修改
tableData变量本身,表格被动刷新,通常用于查询、筛选、重置这种整包换数据的场景。 - 内部驱动:调用表格实例方法,比如
loadData、insert、remove、setRow,表格自己维护数据,并通过事件把结果回写到外部变量。
很多人的困惑就出在这两条路径交叉使用上。你在外部改了数组,回头又用reloadData加载同一份数据,行状态、展开状态、选中状态全被重置了,然后你开始怀疑是 vxe-table 的 bug——其实这属于没有理清"你的数据入口到底是谁"。
1.2 声明式绑定和命令式调用怎么选
我的项目经验是,先定一个数据入口策略,不要一会儿走外部数组、一会儿走实例方法。如果你的表格比较简单,没有大量内部操作,走声明式绑定就够了,即只维护tableData,增删改都靠数组的push、splice、filter完成。但如果表格开启了行编辑、树形结构、勾选联动,内部方法往往比你自己操作数组更靠谱,因为 vxe-table 的方法内部会处理很多状态同步细节。
拿一个很典型的场景来说:默认分页查询,接口返回{ rows, total },查询按钮点击后重新拉数据,这个时候用外部驱动非常舒服:
const tableData = ref([]) const loading = ref(false) const loadPage = async () => { loading.value = true try { const { rows } = await fetchUserList({ pageNo: 1, pageSize: 20 }) tableData.value = rows } finally { loading.value = false } }但如果你要在这个表格里做行内编辑,保存时只更新某一行:
const saveRow = async ({ row }) => { await updateUser(row) xTable.value.updateData({ row, field: 'status', value: 'saved' }) }这种情况下你用实例方法会更精准,不用重建整个数组。
不过这不是说两条路不能混用,而是要明白每条路各自的重新渲染范围和状态影响。接下来我展开讲加载数据的几种具体方式。
2. 加载数据的几种方式:从直观到进阶
2.1 声明式绑定:直接给 :data 赋值
最直观的加载方式当然是直接给表格的data属性绑定一个数组,这也是大多数人刚上手 vxe-table 时最先接触的。它最大的优点就是符合 Vue 的响应式直觉:你把数据交给组件,组件负责渲染,后续只要tableData.value = newList,表格就会刷新。
这种方式的完整代码大概长这样:
<template> <vxe-table :data="tableData" :loading="loading"> <vxe-column field="id" title="ID" width="80"></vxe-column> <vxe-column field="name" title="姓名" width="150"></vxe-column> <vxe-column field="age" title="年龄" width="120"></vxe-column> </vxe-table> </template> <script setup> import { ref, onMounted } from 'vue' const tableData = ref([]) const loading = ref(false) const queryList = async () => { loading.value = true try { const data = await fetch('/api/user/list').then(res => res.json()) tableData.value = data.rows || [] } finally { loading.value = false } } onMounted(queryList) </script>这里有一个容易被忽略的点:vxe-table的data支持v-model双向绑定,在 4.x 版本里你可以直接写v-model="tableData",效果等同于:data=""加@update:data。但实战中我更倾向于只写:data,因为双向绑定虽然在数据回写时方便,却容易让外部数组被表格内部操作"悄悄修改",排查问题时多一层不确定。
声明式绑定适合以下场景:
- 表格数据量不大,基本在一页内展示完。
- 数据流简单,查询结果直接覆盖旧数据。
- 没有复杂的行状态维护需求,刷新后即使选中态丢失也能接受。
缺点也很明显:每次全量替换数组,虚拟滚动场景下卡顿感较弱,但如果你对行状态有强依赖,比如翻页后要保留之前的勾选,直接替换tableData无法保留这些状态,需要自己存储并回填,这活儿就麻烦了。
另外注意一点,直接替换数组时,如果新的数组引用被多个地方共享,要小心后续的push、splice操作污染了另一个页面的数据。我习惯在接口返回后做一次浅拷贝再赋值:
tableData.value = rows.map(item => ({ ...item }))这样至少能避免同一对象引用被多处改动。
2.2 命令式加载:通过 ref 调用 loadData 方法
loadData是 vxe-table 表格实例上的方法,官方原意是"加载数据到表格"。它和直接改:data的最大区别在于:loadData会尽可能保留表格的现有状态。
先说用法。给表格加ref,然后在异步请求回来后调用:
<template> <vxe-table ref="xTable" :data="tableData"></vxe-table> </template> <script setup> import { ref } from 'vue' const xTable = ref() const tableData = ref([]) const fetchData = async () => { const { rows } = await getData() xTable.value.loadData(rows) } </script>如果你用<script setup>,记得在模板里给组件加上ref="xTable",然后在onMounted之后调用,否则xTable.value可能是null。异步请求通常在onMounted时发起,等响应回来时实例已经挂载,所以问题不大;但如果你在组件创建后立即同步调用,那就很可能会拿不到实例。
loadData做了什么事?它会把传入的数据替换为表格当前的数据源,同时尝试保留:
- 展开行状态
- 勾选状态(如果有
checkbox-config) - 当前高亮行
- 排序状态
原理大概是这样:表格记录内部会对比新旧数据,把能匹配的行的状态迁移过来,匹配不上的行状态就丢弃。正因如此,它非常适合"刷新数据但不想丢掉用户操作痕迹"的场景。
不过要注意:loadData对"状态保留"不是绝对的。如果你的主键列没有配置row-key,或者新旧数据在比对时找不到对应行,状态一样会丢。所以想在翻页后保留勾选,正确做法是给vxe-table配置row-key和checkbox-config的preserve属性:
<vxe-table ref="xTable" row-key="id" :data="tableData" :checkbox-config="{ highlight: true, checkMethod: () => true }" >没有row-key之前,我吃过一两次亏,翻页回来勾选全没了,排查半天才发现是这个配置没写。
2.3 重载数据:reloadData 与 loadData 的区别
reloadData在命名上就和loadData很像,官方定位于"重载数据"。区别在于它会重新初始化表格的很多内部状态:排序、筛选、展开行、选中行、高亮行,全部重置为初始值,相当于你第一次拿到这个数据源时看到的干净表格。
换个说法:loadData是"尽量保持现场刷新数据",reloadData是"清空现场重新来过"。
它们在代码层面只是换一个方法名:
// 保留状态 xTable.value.loadData(rows) // 重置所有状态 xTable.value.reloadData(rows)我实际项目里的取舍习惯是这样:查询按钮点击后用reloadData,因为用户主动查询时通常希望看到一个全新的、符合筛选条件的结果;而定时轮询刷新数据、或者静默同步最新记录时用loadData,避免把用户正在操作的表格状态打断。
另外有一点非常实用:reloadData在某些版本里能顺带重置表格内部的排序和筛选条件,但如果你想把外部绑定的排序参数也重置,记得同时清空自己的查询参数。
2.4 全局加载:full-data 与 VXETable.setConfig
这一节要讲的可能是大多数人没留意过的隐藏玩法:vxe-table 还支持通过全局配置加载数据。怎么做?先用VXETable.setConfig全局设置一份数据,然后表格上声明full-data:
import VXETable from 'vxe-table' VXETable.setConfig({ table: { data: [ { id: 1, name: '张三', age: 20 }, { id: 2, name: '李四', age: 26 } ] } })模板里:
<vxe-table full-data> <vxe-column field="id" title="ID"></vxe-column> <vxe-column field="name" title="姓名"></vxe-column> </vxe-table>加了full-data之后,表格会忽略局部的data属性,直接读取全局配置里的数据源。这种方式的实用场景非常有限,我推测官方设计它主要是为了满足一些特殊项目里"全局统一数据源、多个表格共享同一份数据"的需求,比如多个表格同时展示一份配置数据,统一在某个入口刷新后所有表格自动联动。
但说实话,普通业务项目我不太推荐用。全局数据意味着任何一处调用setConfig都会影响所有声明了full-data的表格,调试时很难定位是哪段代码改了全局数据。而且它与团队协作时的数据流设计相悖,别人看你的模板时很难一眼看出数据从哪里来。
如果你只是想要一个"多表格共享数据"的效果,更常规的做法是抽一个公共 store,比如 Pinia,维护一份commonData,每个表格的:data都从 store 里取。这样数据来源明确,也方便在各处手动刷新。
3. 更新数据的完整用法:方法选型与语义
3.1 setData / loadData / reloadData 三兄弟的区别
更新数据这一块,我经常在社区看到这种问题:"我把数据从接口拿回来后,该用setData还是loadData?"这说明官方文档虽然 API 写得很清楚,但缺少场景化说明。
简单梳理一下:
setData(records):直接设置表格数据,不带有明显状态保留语义,适合全量覆盖。loadData(records):加载数据,尽量保留表格状态。reloadData(records):重载数据,清空表格状态。
理论上看,setData和loadData在大部分场景下最终效果差不多,都是把新数据替换旧数据。但从 vxe-table 内部实现和实测行为来看,loadData更侧重于"保留现场",setData更侧重于"直接覆盖"。
我自己的使用规则是:
| 方法 | 适用场景 | 行状态影响 |
|---|---|---|
setData | 初始化表格、全量覆盖数据 | 不做额外状态保留 |
loadData | 刷新数据但想保留展开、勾选等状态 | 尽量保留,依赖row-key |
reloadData | 查询、筛选、重置后展示全新结果 | 重置所有状态 |
拿真实需求举例。我的一个后台项目里有张订单表格,用户会勾选几行订单,点"批量退款"按钮,提交接口成功后我需要刷新表格。这时候如果直接reloadData,用户勾选的订单虽然在刷新后已经从列表移除,但由于状态重置,其他未勾选订单的勾选状态、当前展开的明细行也没了,体验很差。改成loadData后,未勾选订单的勾选状态能尽量保留,展开行也还在。
不过注意,loadData保留状态依赖row-key能稳定匹配新旧数据。如果接口返回的数据里没有稳定唯一键,建议在接口层就补齐,比如把数据库主键或业务唯一单号返回来。
另外,setData、loadData、reloadData传入的参数都是普通数组,不需要传响应式代理对象。如果你从 Pinia 或 Vuex 里直接取数组传进去,vxe-table 内部会做自己的数据接管,外部 store 数组不会因为表格内部编辑而自动同步。想要同步的话,要么监听update:data事件手动更新 store,要么保持外部数组是唯一数据源,不走这些方法。
3.2 单行更新:setRow 与 updateData
很多新手对"更新某一行"的第一反应是:重新查一遍列表,然后tableData.value = rows。这样做的确能刷新,但代价很大:接口请求一次、列表闪烁一下、可能在表格组件里丢状态。如果是编辑弹窗保存后只想更新那几行,完全没必要重查整表。
vxe-table 提供了两组单行粒度更新方法:setRow和updateData。
先说setRow,它的签名大致是:
setRow(row, field, value)表示把某一行对象的field字段更新为value。这里的row是表格里行的引用,也就是你可以从@edit-closed事件回调里拿到的row,也可以从表格数据数组里找出来。
const updateAge = ({ row }) => { xTable.value.setRow(row, 'age', 30) }updateData则更灵活,官方文档里支持两种入参形式。一种是更新整个行的数据字段,另一种是更新单个单元格:
// 更新整行多个字段 xTable.value.updateData({ row, fields: ['name', 'age', 'status'] }) // 更新单元格 xTable.value.updateData({ row, field: 'status', value: 'success' })注意updateData传入的是一个对象,不是直接传值。这个细节很容易踩坑,我看到过有人写updateData(row, 'name', '张三'),结果报错也没反应,就是因为把方法签名和setRow混了。
另外,setRow和updateData都只是把新值更新到表格内部数据,不会自动触发接口提交。真正要保存到后端,你需要在编辑事件里自己调接口,接口成功后可以再用这些方法把最新状态同步回表格。
3.3 新增、删除与清空:insert / remove / clearData
除了修改已有数据,表格更新数据的高频操作还有新增行、删除行、清空数据。
insert方法的典型用法是把新记录插到表格数据末尾:
const addRow = () => { const newRow = { id: Date.now(), name: '', age: null } xTable.value.insert(newRow) }如果你想插入到指定行后面,可以传第二个参数,表示插在哪一行附近:
xTable.value.insert(newRow, currentRow)删除行用remove,传入行对象或者行对象数组:
const deleteRows = (rows) => { xTable.value.remove(rows) }如果你用了复选框,并且通过checkbox-config开启了勾选功能,可以用removeCheckboxRow快速删除所有勾选行:
xTable.value.removeCheckboxRow()这个方法的语义比你自己遍历勾选行再filter干净得多,尤其在大数据量勾选场景下,它能省掉大量手动维护选中集合的代码。
清空数据用clearData,它会清掉表格内部所有记录,同时重置行状态。注意clearData和把外部数组赋空数组tableData.value = []并不完全等价,前者会连表格内部的一些状态一起重置,后者只改变绑定数据,某些内部状态可能残留。如果你在切换业务模块时需要完全重置表格,优先用clearData。
4. 真实项目里的加载与更新实践(含避坑)
4.1 异步加载的 loading 与竞态处理
异步加载数据是后台管理系统的基本操作,但有两件事经常被忽略:loading 状态的收尾,和请求竞态。
loading 状态我见过很多人只写开头不写结尾,结果接口报错后表格一直转圈。正确姿势是无论成功失败都要把 loading 关掉,用try...finally最稳:
const queryList = async () => { loading.value = true try { const { rows } = await fetchList(params) xTable.value.loadData(rows) } finally { loading.value = false } }竞态问题更隐蔽。用户快速切换分页页码、或者连续点击两次查询按钮时,较晚发出的请求可能先返回,较早发出的请求后返回,最终表格展示的是过期数据。我早期在分页表格上遇到过几次:点第 3 页,页面先加载出来了,再点第 2 页,结果表格数据突然变回第 1 页的内容,排查了一会儿才发现是竞态。
解决方案也不复杂,用请求序号做标记即可:
let querySeq = 0 const queryList = async () => { const currentSeq = ++querySeq loading.value = true try { const { rows } = await fetchList(params) if (currentSeq === querySeq) { xTable.value.loadData(rows) } } finally { if (currentSeq === querySeq) { loading.value = false } } }做了这个保护后,哪怕旧请求后返回,也不会覆盖更新后的数据。
4.2 分页场景下的数据加载顺序
分页表格里,通常表格和分页组件是一起使用的,vxe-table 官方有配套的vxe-pager。经典的加载流程是:
- 分页参数变化。
- 根据
currentPage和pageSize请求接口。 - 拿到
total后设置分页组件总条数。 - 表格加载当前页数据。
针对这个流程,我建议把"设置总条数"和"给表格喂数据"放在同一次数据更新里做,避免分页组件和数据源不一致导致用户翻页时看到空的表格。
const pageParams = reactive({ pageNo: 1, pageSize: 20, total: 0 }) const queryList = async () => { loading.value = true try { const { rows, total } = await fetchList({ pageNo: pageParams.pageNo, pageSize: pageParams.pageSize }) pageParams.total = total pagerRef.value.setTotal(total) xTable.value.reloadData(rows) } finally { loading.value = false } }这里我用的是reloadData而不是loadData,因为用户主动翻页、筛选后,通常希望看到一个全新的数据结果,旧的行状态在语义上不合时宜。不过如果你们产品要求翻页保留勾选,那就得改用loadData,并且加好row-key和checkbox-config的保留配置。
4.3 直接操作外部数组可能踩的雷
声明式绑定的确方便,但有一个常见误区:你以为:data绑定的数组和表格内部数据是同一个引用。其实不完全是。当你通过表格内置功能修改数据(比如编辑单元格、勾选删除)时,vxe-table 会把结果通过update:data事件同步到外部数组;但如果你直接给tableData.value赋一个新的数组,表格内部的行状态会基于上一次的数据结构重新计算,可能导致展开、选中等状态丢失。
更隐蔽的问题是,如果你不写@update:data,而外部数组又被 fork 了一份传给别的地方,表格内部的变化不会自动更新到其他引用。举个我踩过的例子:
const rawList = await fetchList() const copyList = rawList.rows.map(item => ({ ...item })) tableData.value = copyList表格渲染用copyList,但我另一条业务逻辑操作的却是rawList.rows,结果表格里改完数据,rawList里的对象还是旧值。后来我统一了数据流转,明确"接口返回后只保留一份数据源,所有操作都基于这一份",这类问题才消失。
所以我的建议是:如果表格要参与编辑、删除这类内部操作,尽量用 ref + 实例方法完成,外部数组只负责初始渲染和查询结果的展示。不要在多个地方持有数据引用,否则"改不动""不同步"的问题迟早找上门。
4.4 大数据量下的加载优化建议
vxe-table 能扛大数据量,但这不代表你可以无脑往data里塞几万条记录。数据量大了以后,加载和更新的差距会变得明显。举个例子,接口一次性返回 2 万行,你直接用tableData.value = rows赋值,表格整体渲染一次的成本并不算低;如果这时候再频繁调用reloadData,会造成明显的卡顿和闪烁。
我处理大数据量的经验是:
- 优先开启虚拟滚动。
height或max-height加上scroll-y的虚拟滚动配置,让表格只渲染可视区域的数据,而不是一次性把所有行渲染进 DOM。
<vxe-table :data="tableData" height="600" :scroll-y="{ enabled: true }" ></vxe-table>如果做大范围的单元格更新,尽量用
loadData一次性替换,而不是循环调setRow改几百行。虽然setRow是局部更新,但循环触发大量更新同样会让表格频繁重绘。数据源尽量是纯对象数组,不要在数组里挂函数、复杂嵌套对象,否则 vxe-table 内部做状态记录和对比时的损耗会明显上升。
后端分页永远比前端一次性加载更稳。哪怕是 vxe-table 这种性能不错的表格,数据量到十万行级别时,建议不要硬扛前端渲染,该分页还是分页,或者用懒加载加上滚动到底自动请求下一页的方案。
我在一个报表项目里把所有明细数据一次性灌到前端再做列统计,结果浏览器直接卡到无响应。后来改成部分明细 + 后端聚合统计,体验和性能立刻正常了。所以数据加载策略一定要跟着业务量级走,不要迷信组件性能。
4.5 配合 vxe-grid 与 proxy-config 的进阶方案
最后提一个很多人会忽略的进阶玩法:vxe-table 的完整版还提供了vxe-grid组件,它能配合proxy-config实现代理模式的数据加载。什么意思?就是你不必手写"请求接口 → 赋值给 data → 手动管理 loading"这一整套流程,只需要给 grid 配置好查询接口和参数,组件自己会处理加载、分页、排序、筛选。
简单示例:
<template> <vxe-grid v-bind="gridOptions"></vxe-grid> </template> <script setup> import { reactive } from 'vue' const gridOptions = reactive({ proxyConfig: { query: async ({ page, sorts, filters }) => { const { rows, total } = await fetchList({ pageNo: page.currentPage, pageSize: page.pageSize, sorts, filters }) return { result: rows, total } } }, columns: [ { field: 'id', title: 'ID' }, { field: 'name', title: '姓名' } ] }) </script>配置了proxyConfig.query后,vxe-grid会在初始化、翻页、排序、筛选时自动调用你的查询函数,并把返回值塞进表格。你不需要手动维护 loading,组件内部会处理。这是数据加载方式的"终极形态",适合中大型项目里数据流高度统一的场景。当然它也有学习成本,如果你刚接触 vxe-table,建议还是先从简单的:data和loadData上手,等熟悉了再往 proxy 模式迁。
写到这里,我在实际项目里最常见的感受是:vxe-table 不是不好用,而是它的数据管理方式比普通表格组件多了一层"内部数据与外部数据"的抽象,一旦你适应了"能用声明式就用声明式,需要精细控制时果断切实例方法"这个思路,大部分加载和更新问题都不是问题。另外,不管用哪种方式,一定先明确 row-key 的配置,它是状态保留和数据匹配的基石,也是我排障时最先检查的地方。