1.虚拟滚动树:使用@femessage element-ui。这个库是由某大神在element-ui的基础上又丰富了一些功能,我们需要的el-tree虚拟滚动树就在里面。
地址:https://femessage.github.io/element/#/zh-CN
安装过程和vue2.0的一样,树的api也基本一致,但是多了一个“height” 属性。
给树设置了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属性进行设置,从而达到过滤的目的。
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相关代码