最近接到了一个挺有意思的需求,要给一个树形的、懒加载的表格组件添加排序功能。
由于数据是懒加载的,每次请求后端都是返回指定节点下的单层数组,所以如果后端排序的话,需要前端递归请求所有展开的节点,数据重新渲染的时候也可能会出现 UI 闪烁的情况,所以最终决定由前端来实现排序功能。
但是没想到啊,里边的坑还不少。这篇文章就统一梳理下,完整的讲讲这个功能该怎么实现。
基础回顾
首先讲一下会用到的几个 element-ui table 组件相关配置,官方文档在这里:Table 组件 | Element。
树形配置:
tree-props:一个对象,用于配置树形数据,其实就包含俩键:hasChildren:用于懒加载,代表用哪个字段判断这个节点下是否有数据children:代表哪个字段下存放着该节点的子节点数组
row-key:用于配置哪个键存放着该节点的唯一索引expand-row-keys:一个数组,代表哪些节点是展开的
懒加载配置:
lazy:添加后就可以使用懒加载功能。load:一个函数,在点击可以懒加载的节点时触发,用于数据请求。
排序配置:
@sort-change:回调,在用户点击排序按钮后触发sortable:注意,这个是 <el-table-column /> 的属性,代表该列是否可以排序,本文实现会将其值设置为custom,用来触发 sort-change 回调
所以说,一个基础的树形懒加载 table 的组件应该长这样:
<template>
<el-table
:data="tableData"
@sort-change="onSortChange"
lazy
row-key="rowId"
:expand-row-keys="[1, 2, 3]"
:tree-props="{ hasChildren: 'hasChildren', children: 'children' }"
:load="lazyLoad"
>
<el-table-column prop="myValue" sortable="custom" label="字段A" />
</el-table>
</template>
<script>
export default {
data() {
return {
tableData: [],
}
},
methods: {
// 实现前端排序功能
onSortChange({ column, prop, order }) {
},
// 根据 row 懒加载子节点数据
async lazyLoad(row, treeNode, resolve) {
const data = await queryTableData(row)
// 调用 resolve 来将懒加载的数据设置到表格
resolve(data);
}
}
}
</script>
Table 组件的坑
这里先介绍一下遇到的坑:
一般来说,在懒加载的数据 resolve 之后,我们会认为这些新的子节点数据会被追加到绑定在 Table 组件的 data 对应节点的 children 下。
但是事实并不是这样的,table 组件不会对 data 进行修改,而是会将懒加载到的数据缓存起来。并且,数据不是缓存在 Table 组件内部,而是 Root 上的一个全局存储!你可以通过 vue devtool 找到这个 store:
缓存的具体路径是 store.data.states.lazyTreeNodeMap。如下,其值是一个对象,键为展开的节点 row-key,值是该节点的 children:
也就是说,懒加载到的数据并没有更新回绑定的 data 上。所以,我们是不能在 v-bind 到表格的 data 里找到懒加载的数据的。数据都拿不到你怎么排序?
上面的全局缓存还会导致另一个问题:缓存不会被正确释放,也就是说,你通过 v-if 或者 key 来销毁并重新渲染这个 table 组件,缓存依旧是在的。
这就导致了树形懒加载的表格根本没法实现后端排序。毕竟你就算请求到了排序后的表格数据,人家表格依旧优先使用之前没排序的缓存,你请求了也没用。在 data 中懒加载的节点就算存在 children 属性,table 组件依旧会优先使已存在的缓存。
想解决这个问题需要写一些额外的代码才能清除这些缓存,解决方法见文末,这里不再赘述。
可以发现这个设计本质上还是想实现:收起再展开懒节点时可以直接展示而不是重新请求。但是由于功能的不完备导致会出现上述问题,其实也有很多人反馈,你可以在 issue 区里搜索 table lazy 找到很多同类型问题。
官方也有 回复 表示 data 是通过 props 传进来的,所以不会直接改,但是也没提供回调来更新数据或者管理缓存,总之这个问题估计已经不会修复了。
功能实现
下面就来讲一下怎么实现这个问题以及如何避免上面这个坑。
首先是两个辅助函数,一个是递归排序(会修改原数组),另一个是递归查找目标节点,没什么好讲的:
/**
* 数组递归排序
* 注意,这个排序会修改原数组
*
* @param {Array} arr 要进行排序的数组
* @param {String} key 要排序的字段
* @param {String} type 是升序还是降序
*/
const mySort = (arr, prop, order) => {
arr.sort((a, b) => {
const valueA = a[prop];
const valueB = b[prop];
if (valueA > valueB) {
return order === 'ascending' ? 1 : -1;
} else if (valueA < valueB) {
return order === 'ascending' ? -1 : 1;
} else {
return 0;
}
});
arr.forEach(item => {
if (item.children) {
mySort(item.children, prop, order);
}
});
return arr;
};
/**
* 递归找到对应的树节点
*
* @param {Object} treeNode 树的根节点
* @param {String} rowId 要找的子节点的 id
*/
const findTreeNode = (treeNode, targetRowId) => {
if (treeNode.rowId === targetRowId) return treeNode;
if (!treeNode.children) return
treeNode.children.forEach(child => {
const node = findTreeNode(child, targetRowId);
if (node) return node;
})
};
而功能实现的核心思路是:保存一份原始的(未排序)树数据,在触发排序回调时深拷贝原始树,排序完成后绑定到表格,在懒加载时将新数据追加到原始树。
根据这个思路,我们要在 vue 组件里声明下面三个 data:
// 当前排序的字段
currentSortProp: undefined,
// 当前正序还是倒叙
currentSortOrder: undefined,
// 没有经过排序的原始顺序数组
originTableData: [],
这里其实没必要声明 originTableData,因为它只作为存储,并不参与渲染。
然后是排序回调:
import { cloneDeep } from 'lodash';
// methods 内部
onSortChange({ column, prop, order }) {
if (!order) {
this.tableData = cloneDeep(this.originTableData);
this.currentSortProp = null;
this.currentSortOrder = null;
return;
}
const newTableData = cloneDeep(this.tableData);
mySort(newTableData, prop, order);
this.currentSortProp = prop;
this.currentSortOrder = order;
this.tableData = newTableData;
},
这里需要注意的是, element 的 table 组件排序(即上面代码里的 order 字段)是可能为空的,代表当前未进行排序。
所以在未排序时直接深拷贝原始树传递给表格进行渲染,排序时就根据排序和字段将数据排序后再传递给表格组件。
然后是懒加载的数据获取回调:
async lazyLoad(row, treeNode, resolve) {
const listData = await queryTableData(row);
if (!listData) return;
// 查找加载的是哪个节点,然后把数据追加到原始树
const target = findTreeNode({ children: this.originTableData }, row.rowId);
if (!target) return;
target.children = this.refactorTableData(listData);
target.hasChildren = null;
// 根据当前排序规则重新排序并渲染
const newTableData = cloneDeep(this.originTableData);
if (this.currentSortProp) {
mySort(newTableData, this.currentSortProp, this.currentSortOrder);
}
this.tableData = newTableData;
// 注意这里,完全没有使用组件内部的缓存。因为上面已经存了一个完整拷贝了。
resolve([]);
}
这里找到懒加载了哪个节点,然后通过浅拷贝把数据追加到原始树上,数据更新好之后再递归排序一下。
注意最后,一定不能 把数据传递给 resolve,不然表格组件就会把这个数据缓存下来一直使用。导致出现懒加载的节点排序不会变的问题。
最后就是要处理一下初始化表格数据的情况:
created() {
this.fetchTable();
},
methods: {
async fetchTable() {
const tableData = await queryTableData();
this.originTableData = cloneDeep(tableData);
// 如果当前已经有排序的话,就要更新数据
if (this.currentSortProp) {
this.tableData = mySort(cloneDeep(tableData), this.currentSortProp, this.currentSortOrder);
}
else {
this.tableData = cloneDeep(tableData);
}
},
// ...
}
因为页面可能会有一些重置搜索条件等重新渲染的操作,所以这里初始化的时候不需要保留之前的原始树,而是将其覆盖掉,这样再展开懒加载节点的时候就可以重新触发 lazyLoad 拉取数据。
一些问题
使用 sort-method 或者 sort-by 可以么?
可以但不推荐,这两者都是单纯的排序算法回调,而且由于我们是一个树形数据,sort-method 和 sort-by 只会针对树的第一级子节点进行遍历,更深层级的子节点依旧需要手动处理,@sort-change 省不了。
而且如果有特殊需求,比如总计行不参与排序之类的,@sort-change 适应性也更好。
深拷贝这么多,性能怎么样?
经过测试性能满足需求了,所以这里为了代码简洁没有进行优化。
如果有优化需求的话,可以把全量排序改成局部排序,先 findTreeNode 递归找到目标节点,只深拷贝排序这个节点,再递归找到 tableData 上的目标节点,把排好序的节点 $set 进去。注意这里不能直接设置,不然会导致无法重新渲染。
简单说就是通过两次递归定位来对不需要更新的内容进行剪枝。
怎么强制清除已经存在的懒加载缓存?
先给表格绑定 ref,然后通过下面代码来清空缓存,出处在 这里。
this.$refs['table'].store.states.lazyTreeNodeMap = {};