el-tree渲染大量数据的解决方案(不通过懒加载)

1.虚拟滚动树:使用@femessage element-ui。这个库是由某大神在element-ui的基础上又丰富了一些功能,我们需要的el-tree虚拟滚动树就在里面。

地址:https://femessage.github.io/element/#/zh-CN

安装过程和vue2.0的一样,树的api也基本一致,但是多了一个“height” 属性。

image.png

给树设置了height就可以开启虚拟滚动。

2.搜索功能:实际业务中,海量树往往需要结合搜索功能,但是很遗憾,虚拟滚动树自带的搜索方法会出现各种各样的问题,查看源码

<virtual-list v-if="height" :style="{ height: height + 'px', 'overflow-y': 'auto' }"
      :data-key="getNodeKey"
      :data-sources="visibleList"
      :data-component="itemComponent"
      :keeps="Math.ceil(height / 22) + extraLine"
      :extra-props="{
        renderAfterExpand,
        showCheckbox,
        renderContent,
        onNodeExpand: handleNodeExpand
      }"
    />

我们发现虚拟滚动树在el-tree里引入了vue-virtual-scroll-list(一个实现虚拟滚动列表的方案),作者把两者进行了一个结合,当设置了height的时候,启用 virtual-list,然后将数据和tree模板放进去。

再看element-ui 实现过滤功能的方法

`

created() {
    this.isTree = true;
    this.store = new TreeStore({
        key: this.nodeKey,
        data: this.data,
        lazy: this.lazy,
        props: this.props,
        load: this.load,
        currentNodeKey: this.currentNodeKey,
        checkStrictly: this.checkStrictly,
        checkDescendants: this.checkDescendants,
        defaultCheckedKeys: this.defaultCheckedKeys,
        defaultExpandedKeys: this.defaultExpandedKeys,
        autoExpandParent: this.autoExpandParent,
        defaultExpandAll: this.defaultExpandAll,
        filterNodeMethod: this.filterNodeMethod
    });
    this.root = this.store.root;
    let dragState = this.dragState;

` 树在初始化的时候先是生成了一个treeStore的对象用来托管数据,并且对每个数据生成对应的一个节点对象来进行托管。在store对象里就有filter方法。

tree-store.js

    filter(value) {
    const filterNodeMethod = this.filterNodeMethod;
    const lazy = this.lazy;
    const traverse = function(node) {
      const childNodes = node.root ? node.root.childNodes : node.childNodes;

      childNodes.forEach((child) => {
        child.visible = filterNodeMethod.call(child, value, child.data, child);

        traverse(child);
      });

      if (!node.visible && childNodes.length) {
        let allHidden = true;
        allHidden = !childNodes.some(child => child.visible);

        if (node.root) {
          node.root.visible = allHidden === false;
        } else {
          node.visible = allHidden === false;
        }
      }
      if (!value) return;

      if (node.visible && !node.isLeaf && !lazy) node.expand();
    };

    traverse(this);
  }



我们可以看到tree在调用filter方法的时候其实是对node节点的visible属性进行设置,从而达到过滤的目的。

扫描二维码关注公众号,回复: 14331721 查看本文章

tree-virtual-node.vue

<div
    class="el-tree-node"
    @click.stop="handleClick"
    @contextmenu="($event) => this.handleContextMenu($event)"
    v-show="source.visible"
    :class="{
	'is-expanded': expanded,
	'is-current': source.isCurrent,
	'is-hidden': !source.visible,
	'is-focusable': !source.disabled,
	'is-checked': !source.disabled && source.checked,
    }"
    role="treeitem"
    tabindex="-1"
    :aria-expanded="expanded"
    :aria-disabled="source.disabled"
    :aria-checked="source.checked"
    ref="node"
>
        .....

从v-show也能看出,后面再查看了vue-virtual-scroll-list源码,

index.js

     'dataSources.length' () {
      this.virtual.updateParam('uniqueIds', this.getUniqueIdFromDataSources())
      this.virtual.handleDataSourcesChange()
    },

发现虚拟列表的刷新监听的是外部传入数组的长度。而我们el-tree里的筛选方法仅仅只是通过设置节点的vis属性为false,来达到隐藏节点的目的。虚拟滚动树并没有重新加载,这应该是自带的filter方法出现bug的原因(可能不严谨)。

3。解决:找到了原因,下面就是寻找解决方案了。其实也简单,只需要不用el-tree自带的搜索方法就可以了,这就需要我们自定义一个搜索方法,根据用户输入的节点信息,去把相关节点的数据过滤出来,放到el-tree的data里,类似的方法网上也很多,比如我们项目在用的是这个

    findRecord(treeData, key, value, eq = false, notIncluded = true) {
            const arr = [];
            for (const node of treeData) {
                let flag = false;
                const val = node[key];
                if (Array.isArray(value)) {
                    if (eq) {
                        if (val && value.includes(val)) {
                            flag = true;
                        }
                        if (!notIncluded && val && !value.includes(val)) {
                            flag = true;
                        } else if (!notIncluded) {
                            flag = false;
                        }
                    } else {
                        if (val && value.findIndex(item => item.indexOf(val) > -1) > 0) {
                            flag = true;
                        }
                        if (!notIncluded && val && value.findIndex(item => item.indexOf(val) === -1) > 0) {
                            flag = true;
                        } else if (!notIncluded) {
                            flag = false;
                        }
                    }
                } else {
                    if (eq) {
                        if (val && val === value) {
                            flag = true;
                        }
                        if (!notIncluded && val && val !== value) {
                            flag = true;
                        } else if (!notIncluded) {
                            flag = false;
                        }
                    } else {
                        if (val && val.indexOf(value) > -1) {
                            flag = true;
                        }
                        if (!notIncluded && val && val.indexOf(value) === -1) {
                            flag = true;
                        } else if (!notIncluded) {
                            flag = false;
                        }
                    }
                }
                if (flag) {
                    arr.push(node);
                } else if (node.children && node.children.length) {
                    const subArr = this.findRecord(node.children, key, value, eq, notIncluded);
                    if (subArr && subArr.length > 0) {
                        node.children = subArr;
                        arr.push(node);
                    }
                }
            }
            return arr;
        },

this.findRecord(this.allTreeData, "label", text)

根据实际需求修改一下就行。

至此,如果你的项目用的是虚拟滚动+单选+搜索,上述修改之后就可以满足需要了,但是如果还要支持多选的话,头疼的问题又来了——搜索时候树的选中状态怎么处理?

因为我们的搜索其实是手动替换了el-tree的显示数据,整个虚拟滚动组件会重新刷新,选中状态也是一样。经过思考,我们大致确定了两种方案:

1、手动去记录搜索过程中用户勾选数据,并且模仿el-tree选中逻辑去进行处理(一开始同事是按照这种方案来的,但是最后感觉用户体验其实不好,也不符合树的正常勾选逻辑,代码逻辑还十分复杂,后面放弃。)

2、在源码上做文章。因为el-tree的选中状态其实是内置的store、node对象来托管的,我们可以在固定源数据的同时,将上面我们手动筛选的节点数据,将多余的node对象过滤掉。(这边需要看一下源码)

    <div
        class="el-tree"
        ref="elTree"
        :class="{
            'el-tree--highlight-current': highlightCurrent,
            'is-dragging': !!dragState.draggingNode,
            'is-drop-not-allow': !dragState.allowDrop,
            'is-drop-inner': dragState.dropType === 'inner'
        }"
        role="tree"
    >
        <virtual-list
            v-if="showData === null || showDataList.length !== 0"
            :style="{  'overflow-y': 'auto' }"
            :data-key="getNodeKey"
            :data-sources="showDataList"
            :data-component="itemComponent"
            :keeps="Math.ceil((height || $refs.elTree.offsetHeight) / 22) + extraLine"
            :extra-props="{
                renderAfterExpand,
                showCheckbox,
                renderContent,
                onNodeExpand: handleNodeExpand
            }"
        />
        <!-- <el-tree-node
			v-else
			v-for="child in root.childNodes"
			:node="child"
			:props="props"
			:render-after-expand="renderAfterExpand"
			:show-checkbox="showCheckbox"
			:key="getNodeKey(child)"
			:render-content="renderContent"
			@node-expand="handleNodeExpand"
		>
		</el-tree-node> -->
        <div class="el-tree__empty-block" v-else>
            <span class="el-tree__empty-text">{{ emptyText }}</span>
        </div>
        <div v-show="dragState.showDropIndicator" class="el-tree__drop-indicator" ref="dropIndicator"></div>
    </div>
</template>

<script>
import TreeStore from "./model/tree-store";
import VirtualList from "vue-virtual-scroll-list";
import { getNodeKey, findNearestComponent } from "./model/util";
import ElTreeNode from "./tree-node.vue";
import ElVirtualNode from "./tree-virtual-node.vue";
import emitter from "./mixins/emitter";
import { addClass, removeClass } from "./utils/dom";

export default {
    name: "ElTree",

    mixins: [emitter],

    components: {
        VirtualList,
        ElTreeNode
    },

    data() {
        return {
            store: null,
            root: null,
            currentNode: null,
            treeItems: null,
            checkboxItems: [],
            dragState: {
                showDropIndicator: false,
                draggingNode: null,
                dropNode: null,
                allowDrop: true
            },
            itemComponent: ElVirtualNode
        };
    },

    props: {
        showData: {
            type: Array | null,
            default: () => {
                return null;
            }
        },
        data: {
            type: Array
        },
        emptyText: {
            type: String,
            default() {
                return "暂无数据";
            }
        },
        renderAfterExpand: {
            type: Boolean,
            default: true
        },
        nodeKey: String,
        checkStrictly: Boolean,
        defaultExpandAll: Boolean,
        expandOnClickNode: {
            type: Boolean,
            default: true
        },
        checkOnClickNode: Boolean,
        checkDescendants: {
            type: Boolean,
            default: false
        },
        autoExpandParent: {
            type: Boolean,
            default: true
        },
        defaultCheckedKeys: Array,
        defaultExpandedKeys: Array,
        currentNodeKey: [String, Number],
        renderContent: Function,
        showCheckbox: {
            type: Boolean,
            default: false
        },
        draggable: {
            type: Boolean,
            default: false
        },
        allowDrag: Function,
        allowDrop: Function,
        props: {
            default() {
                return {
                    children: "children",
                    label: "label",
                    disabled: "disabled"
                };
            }
        },
        lazy: {
            type: Boolean,
            default: false
        },
        highlightCurrent: Boolean,
        load: Function,
        filterNodeMethod: Function,
        accordion: Boolean,
        indent: {
            type: Number,
            default: 18
        },
        iconClass: String,
        height: {
            type: Number,
            default: 0
        },
        extraLine: {
            type: Number,
            default: 8
        }
    },

    computed: {
        children: {
            set(value) {
                this.data = value;
            },
            get() {
                return this.data;
            }
        },
        treeItemArray() {
            return Array.prototype.slice.call(this.treeItems);
        },
        showDataList() {
            let treeToArray = (nodeKeyList, node) => {
                nodeKeyList.push(node.nodeKey);
                if (node.children && node.children.length > 0) {
                    node.children.forEach(item => {
                        treeToArray(nodeKeyList, item);
                    });
                }
            };
            if (this.showData !== null) {
                let nodeKeyList = [];
                if (this.showData.length !== 0) {
                    treeToArray(nodeKeyList, this.showData[0]);
                }
                return this.flattenTree(this.root.childNodes).filter(({ data }) => {
                    return nodeKeyList.includes(data.nodeKey);
                });
            } else {
                return this.flattenTree(this.root.childNodes);
            }
        }
    },

    watch: {
        defaultCheckedKeys(newVal) {
            this.store.setDefaultCheckedKey(newVal);
        },
        defaultExpandedKeys(newVal) {
            this.store.defaultExpandedKeys = newVal;
            this.store.setDefaultExpandedKeys(newVal);
        },

        data(newVal) {
            this.store.setData(newVal);
        },

        checkboxItems(val) {
            Array.prototype.forEach.call(val, checkbox => {
                checkbox.setAttribute("tabindex", -1);
            });
        },

        checkStrictly(newVal) {
            this.store.checkStrictly = newVal;
        }
    },

    methods: {
        flattenTree(datas) {
            return datas.reduce((conn, data) => {
                conn.push(data);
                if (data.expanded && data.childNodes.length) {
                    conn.push(...this.flattenTree(data.childNodes));
                }

                return conn;
            }, []);
        },
        filter(value) {
            if (!this.filterNodeMethod) throw new Error("[Tree] filterNodeMethod is required when filter");
            this.store.filter(value);
        },
        getNodeKey(node) {
            return getNodeKey(this.nodeKey, node.data);
        },
        getNodePath(data) {
            if (!this.nodeKey) throw new Error("[Tree] nodeKey is required in getNodePath");
            const node = this.store.getNode(data);
            if (!node) return [];
            const path = [node.data];
            let parent = node.parent;
            while (parent && parent !== this.root) {
                path.push(parent.data);
                parent = parent.parent;
            }
            return path.reverse();
        },

        getCheckedNodes(leafOnly, includeHalfChecked) {
            return this.store.getCheckedNodes(leafOnly, includeHalfChecked);
        },

        getCheckedKeys(leafOnly) {
            return this.store.getCheckedKeys(leafOnly);
        },

        getCurrentNode() {
            const currentNode = this.store.getCurrentNode();
            return currentNode ? currentNode.data : null;
        },

        getCurrentKey() {
            if (!this.nodeKey) throw new Error("[Tree] nodeKey is required in getCurrentKey");
            const currentNode = this.getCurrentNode();
            return currentNode ? currentNode[this.nodeKey] : null;
        },

        setCheckedNodes(nodes, leafOnly) {
            if (!this.nodeKey) throw new Error("[Tree] nodeKey is required in setCheckedNodes");
            this.store.setCheckedNodes(nodes, leafOnly);
        },

        setCheckedKeys(keys, leafOnly) {
            if (!this.nodeKey) throw new Error("[Tree] nodeKey is required in setCheckedKeys");
            this.store.setCheckedKeys(keys, leafOnly);
        },

        setChecked(data, checked, deep) {
            this.store.setChecked(data, checked, deep);
        },

        getHalfCheckedNodes() {
            return this.store.getHalfCheckedNodes();
        },

        getHalfCheckedKeys() {
            return this.store.getHalfCheckedKeys();
        },

        setCurrentNode(node) {
            if (!this.nodeKey) throw new Error("[Tree] nodeKey is required in setCurrentNode");
            this.store.setUserCurrentNode(node);
        },

        setCurrentKey(key) {
            if (!this.nodeKey) throw new Error("[Tree] nodeKey is required in setCurrentKey");
            this.store.setCurrentNodeKey(key);
        },

        getNode(data) {
            return this.store.getNode(data);
        },

        remove(data) {
            this.store.remove(data);
        },

        append(data, parentNode) {
            this.store.append(data, parentNode);
        },

        insertBefore(data, refNode) {
            this.store.insertBefore(data, refNode);
        },

        insertAfter(data, refNode) {
            this.store.insertAfter(data, refNode);
        },

        handleNodeExpand(nodeData, node, instance) {
            this.broadcast("ElTreeNode", "tree-node-expand", node);
            this.$emit("node-expand", nodeData, node, instance);
        },

        updateKeyChildren(key, data) {
            if (!this.nodeKey) throw new Error("[Tree] nodeKey is required in updateKeyChild");
            this.store.updateChildren(key, data);
        },

        initTabIndex() {
            this.treeItems = this.$el.querySelectorAll(".is-focusable[role=treeitem]");
            this.checkboxItems = this.$el.querySelectorAll("input[type=checkbox]");
            const checkedItem = this.$el.querySelectorAll(".is-checked[role=treeitem]");
            if (checkedItem.length) {
                checkedItem[0].setAttribute("tabindex", 0);
                return;
            }
            this.treeItems[0] && this.treeItems[0].setAttribute("tabindex", 0);
        },

        handleKeydown(ev) {
            const currentItem = ev.target;
            if (currentItem.className.indexOf("el-tree-node") === -1) return;
            const keyCode = ev.keyCode;
            this.treeItems = this.$el.querySelectorAll(".is-focusable[role=treeitem]");
            const currentIndex = this.treeItemArray.indexOf(currentItem);
            let nextIndex;
            if ([38, 40].indexOf(keyCode) > -1) {
                // up、down
                ev.preventDefault();
                if (keyCode === 38) {
                    // up
                    nextIndex = currentIndex !== 0 ? currentIndex - 1 : 0;
                } else {
                    nextIndex = currentIndex < this.treeItemArray.length - 1 ? currentIndex + 1 : 0;
                }
                this.treeItemArray[nextIndex].focus(); // 选中
            }
            if ([37, 39].indexOf(keyCode) > -1) {
                // left、right 展开
                ev.preventDefault();
                currentItem.click(); // 选中
            }
            const hasInput = currentItem.querySelector('[type="checkbox"]');
            if ([13, 32].indexOf(keyCode) > -1 && hasInput) {
                // space enter选中checkbox
                ev.preventDefault();
                hasInput.click();
            }
        }
    },

    created() {
        this.isTree = true;
        this.store = new TreeStore({
            key: this.nodeKey,
            data: this.data,
            lazy: this.lazy,
            props: this.props,
            load: this.load,
            currentNodeKey: this.currentNodeKey,
            checkStrictly: this.checkStrictly,
            checkDescendants: this.checkDescendants,
            defaultCheckedKeys: this.defaultCheckedKeys,
            defaultExpandedKeys: this.defaultExpandedKeys,
            autoExpandParent: this.autoExpandParent,
            defaultExpandAll: this.defaultExpandAll,
            filterNodeMethod: this.filterNodeMethod
        });
        this.root = this.store.root;
        let dragState = this.dragState;
        this.$on("tree-node-drag-start", (event, treeNode) => {
            if (typeof this.allowDrag === "function" && !this.allowDrag(treeNode.node)) {
                event.preventDefault();
                return false;
            }
            event.dataTransfer.effectAllowed = "move";

            // wrap in try catch to address IE's error when first param is 'text/plain'
            try {
                // setData is required for draggable to work in FireFox
                // the content has to be '' so dragging a node out of the tree won't open a new tab in FireFox
                event.dataTransfer.setData("text/plain", "");
            } catch (e) {}
            dragState.draggingNode = treeNode;
            this.$emit("node-drag-start", treeNode.node, event);
        });

        this.$on("tree-node-drag-over", (event, treeNode) => {
            const dropNode = findNearestComponent(event.target, "ElTreeNode");
            const oldDropNode = dragState.dropNode;
            if (oldDropNode && oldDropNode !== dropNode) {
                removeClass(oldDropNode.$el, "is-drop-inner");
            }
            const draggingNode = dragState.draggingNode;
            if (!draggingNode || !dropNode) return;

            let dropPrev = true;
            let dropInner = true;
            let dropNext = true;
            let userAllowDropInner = true;
            if (typeof this.allowDrop === "function") {
                dropPrev = this.allowDrop(draggingNode.node, dropNode.node, "prev");
                userAllowDropInner = dropInner = this.allowDrop(draggingNode.node, dropNode.node, "inner");
                dropNext = this.allowDrop(draggingNode.node, dropNode.node, "next");
            }
            event.dataTransfer.dropEffect = dropInner ? "move" : "none";
            if ((dropPrev || dropInner || dropNext) && oldDropNode !== dropNode) {
                if (oldDropNode) {
                    this.$emit("node-drag-leave", draggingNode.node, oldDropNode.node, event);
                }
                this.$emit("node-drag-enter", draggingNode.node, dropNode.node, event);
            }

            if (dropPrev || dropInner || dropNext) {
                dragState.dropNode = dropNode;
            }

            if (dropNode.node.nextSibling === draggingNode.node) {
                dropNext = false;
            }
            if (dropNode.node.previousSibling === draggingNode.node) {
                dropPrev = false;
            }
            if (dropNode.node.contains(draggingNode.node, false)) {
                dropInner = false;
            }
            if (draggingNode.node === dropNode.node || draggingNode.node.contains(dropNode.node)) {
                dropPrev = false;
                dropInner = false;
                dropNext = false;
            }

            const targetPosition = dropNode.$el.getBoundingClientRect();
            const treePosition = this.$el.getBoundingClientRect();

            let dropType;
            const prevPercent = dropPrev ? (dropInner ? 0.25 : dropNext ? 0.45 : 1) : -1;
            const nextPercent = dropNext ? (dropInner ? 0.75 : dropPrev ? 0.55 : 0) : 1;

            let indicatorTop = -9999;
            const distance = event.clientY - targetPosition.top;
            if (distance < targetPosition.height * prevPercent) {
                dropType = "before";
            } else if (distance > targetPosition.height * nextPercent) {
                dropType = "after";
            } else if (dropInner) {
                dropType = "inner";
            } else {
                dropType = "none";
            }

            const iconPosition = dropNode.$el.querySelector(".el-tree-node__expand-icon").getBoundingClientRect();
            const dropIndicator = this.$refs.dropIndicator;
            if (dropType === "before") {
                indicatorTop = iconPosition.top - treePosition.top;
            } else if (dropType === "after") {
                indicatorTop = iconPosition.bottom - treePosition.top;
            }
            dropIndicator.style.top = indicatorTop + "px";
            dropIndicator.style.left = iconPosition.right - treePosition.left + "px";

            if (dropType === "inner") {
                addClass(dropNode.$el, "is-drop-inner");
            } else {
                removeClass(dropNode.$el, "is-drop-inner");
            }

            dragState.showDropIndicator = dropType === "before" || dropType === "after";
            dragState.allowDrop = dragState.showDropIndicator || userAllowDropInner;
            dragState.dropType = dropType;
            this.$emit("node-drag-over", draggingNode.node, dropNode.node, event);
        });

        this.$on("tree-node-drag-end", event => {
            const { draggingNode, dropType, dropNode } = dragState;
            event.preventDefault();
            event.dataTransfer.dropEffect = "move";

            if (draggingNode && dropNode) {
                const draggingNodeCopy = { data: draggingNode.node.data };
                if (dropType !== "none") {
                    draggingNode.node.remove();
                }
                if (dropType === "before") {
                    dropNode.node.parent.insertBefore(draggingNodeCopy, dropNode.node);
                } else if (dropType === "after") {
                    dropNode.node.parent.insertAfter(draggingNodeCopy, dropNode.node);
                } else if (dropType === "inner") {
                    dropNode.node.insertChild(draggingNodeCopy);
                }
                if (dropType !== "none") {
                    this.store.registerNode(draggingNodeCopy);
                }

                removeClass(dropNode.$el, "is-drop-inner");

                this.$emit("node-drag-end", draggingNode.node, dropNode.node, dropType, event);
                if (dropType !== "none") {
                    this.$emit("node-drop", draggingNode.node, dropNode.node, dropType, event);
                }
            }
            if (draggingNode && !dropNode) {
                this.$emit("node-drag-end", draggingNode.node, null, dropType, event);
            }

            dragState.showDropIndicator = false;
            dragState.draggingNode = null;
            dragState.dropNode = null;
            dragState.allowDrop = true;
        });
    },

    mounted() {
        this.initTabIndex();
        this.$el.addEventListener("keydown", this.handleKeydown);
    },

    updated() {
        this.treeItems = this.$el.querySelectorAll("[role=treeitem]");
        this.checkboxItems = this.$el.querySelectorAll("input[type=checkbox]");
    }
};
</script>
<style lang="less" scoped>
.el-tree {
		height: 100%;
		& > div {
			height: 100%;
			overflow-y: auto;
		}
	}
</style>

主要新增了showDataList prop,可以看一下showDataList相关代码

猜你喜欢

转载自juejin.im/post/7113534011347042334