element-ui 树形懒加载 table 的前端排序实现

1,175 阅读7分钟

最近接到了一个挺有意思的需求,要给一个树形的、懒加载的表格组件添加排序功能。

由于数据是懒加载的,每次请求后端都是返回指定节点下的单层数组,所以如果后端排序的话,需要前端递归请求所有展开的节点,数据重新渲染的时候也可能会出现 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:

image.png

缓存的具体路径是 store.data.states.lazyTreeNodeMap。如下,其值是一个对象,键为展开的节点 row-key,值是该节点的 children:

image.png

也就是说,懒加载到的数据并没有更新回绑定的 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 = {};