1. 场景描述
- 你有没有碰到这种情况,当你使用element-ui的el-tree组件时,你的树的节点量很大很大(10000+),每个节点下面的子节点也有可能多达10000+,使用el-tree首屏加载非常慢
- 点击某个节点,由于子节点非常多,导致要等很久节点才能够展开
- 页面滚动很卡顿,点击某个节点也很卡
- 后端不支持点击某个节点返回对应的子节点信息,而是一次性把所有节点的信息返回给你了,你自己去拼成tree
为什么会出现上面的情况呢?
- 其实js处理数据是很快的,原因是数据多渲染到页面,dom节点多导致的卡顿
如何解决呢?
- 解决的思路,减少渲染的dom节点的数量,我们就要用到虚拟滚动,但是展现的数据是有层级递归的所以我们实现虚拟树。咱一起来手写一个虚拟树组件。(Vue2版本,Vue3思路应该大差不差)
gitee代码地址:gitee.com/Lizhihang12…
2. 组件描述
- 组件用于生成组织树
- 组件需要支持任何时候,都只渲染固定数量的数据
- 组件滚动的时候需要更新数据
- 点击某个节点,展开子节点,此时也要能够更新数据
- 滚动事件不能触发太多次,使用防抖还是节流?白屏情况怎么办
- 如果你需要支持搜索数据,那么这个组件也要能够支持
最终效果:
3. 组件开始
假设是在vue2的环境中实现下面的代码,快去搭建一个基础的环境吧
3.1 数据结构
数据结构如下,整体上是数组,每一项数据可能包含children数组代表子节点
let treeData = [
{
name: '1',
path: '/1',
showChildren: false,
children: [
{
name: '11',
path: '/1/11',
showChildren: false,
children: [
{
name: '111',
path: '/1/11/111',
showChildren: false,
children: []
}
]
}
]
},
{
name: '2',
path: '/2',
showChildren: false,
children: [
{
name: '22',
path: '/2/22',
showChildren: false,
children: [
{
name: '222',
path: '/2/22/222',
showChildren: false,
children: []
}
]
}
]
},
{
name: '3',
path: '/3',
showChildren: false,
children: [
{
name: '33',
path: '/3/33',
showChildren: false,
children: [
{
name: '333',
path: '/3/33/333',
showChildren: false,
children: [
{
name: '3333',
path: '/3/33/333/3333',
showChildren: false,
children: []
}
]
}
]
}
]
},
]
name一般是用于树的显示path是完整的路径showChildren用于控制是否展开子节点children是子节点的内容- 其实还应该有
level表示每一层的深度,这个我们在下面的函数中再添加
3.2 拍平数据
这是很关键的一步。为什么要拍平这个数据呢?只要把原本的嵌套数据转化为一维的数组,然后用start和end索引去截取里面的数据,就能实现每次只展示一部分数据的效果。
如何拍平呢?就是按照顺序递归遍历数据及其children,依次放到数组中
function flatten = (
list = [], // 要排名的数据
level = 1, // 树的深度
parent = null, // 父节点的数据
defaultExpand = false // 控制每一层是展开还是关闭
) => {
let arr = []
for (let i = 0; i < list.length; i++) {
let item = list[i]
// 设置深度
this.$set(item, 'level', level)
// 默认所有的节点都不展开,不显示子节点
this.$set(item, 'showChildren', defaultExpand)
if (item.visible === undefined && item.level > 1) {
// visible变量用于控制是否显示,
// 我们的原理就是每次只展示固定数量的节点
// 有的节点隐藏就是通过这个变量判断
// 我们让层级大于1的都隐藏
this.$set(item, 'visible', false)
} else if (item.visible === undefined) {
// 层级是1的显示
this.$set(item, 'visible', true)
}
// 设置parent的值
this.$set(item, 'parent', parent)
// 把当前的item的值放到arr中
arr.push(item)
// 递归遍历
if (item['children'] && item['children'].length > 0) {
arr.push(
...this.flatten(
item['children'],
level + 1, // 树的深度递归就 + 1
item, // 当前的item用作父亲
defaultExpand // 控制每一层是展开还是关闭
)
)
}
}
return arr
}
- 声明一个arr数组,最终拍平的结果都放在这个里面
- 我们依次设置每个节点的
level,showChildren,visible,parent,children
- level: 每个节点所在的层级
- showChildren:是否展示子节点
- visible:当前节点是否可见,如果父节点showChildren是true,子节点的visible就是true,否则是false,假设所有的一级节点的visible是true,所有的二级节点是false
- parent:当前节点的父节点是谁
- children:当前节点的子节点是谁
- 需要递归遍历子节点
3.3 visible为true的数据
拍平后数据长啥样呢?
const flattenTree = this.flatten(this.treeData)
// flattenTree应该是长这样的
/*
[
{name: '1', path: '/1', level: 1, children: [{name: '11', path: '/1/11', level: 2, children: []}], showChildren: true},
{name: '11', path: '/1/11', level: 2, children: [……], showChildren: false},
{name: '111', path: '/1/11/111', level: 3, children: [……], showChildren: false},
{name: '1111', path: '/1/11/111/1111', level: 4, children: [……], showChildren: false},
{name: '11111', path: '/1/11/111/1111/11111', level: 5, children: [……], showChildren: false},
]
*/
上面是所有的数据拍平后的结果,这个flattenTree可能有10000条+,也就是所有的节点的数量包括子节点的数量加起来的和,我们最终的是allVisibleData中visible属性为true的节点数据,visible为true的节点。
this.allVisibleData = (this.flattenTree || []).filter(item => item.visible)
这个是什么意思呢?比如节点1下面有节点1/1和节点1/1/1,由于节点1是折叠的,所以他的子节点的visible的数据是false。我们去截取数组的时候,肯定从visible是true里面的去截取
3.4 start和end的截取
start和end是哪里来?是滚动时判断出来的,监听滚动条滚动的距离,判断此时从哪里开始显示
下面是给指定的dom元素绑定滚动事件,
<div
class="viewport"
ref="viewport"
@scroll="handleScroll"
>
</div>
handleScroll () {
if (this.$refs.viewport) {
this.top= this.$refs.viewport.scrollTop // 拿到滚动距离
this.updateVisibleData(this.top) // 处理可视数据
}
},
在updateVisibleData函数里面处理最终的数据
// 更新可视区域的数据
updateVisibleData (scrollValue = 0) {
// 开始索引,防止白屏,多减去一部分的距离
let numMore = Math.floor(scrollValue / this.option.itemHeight) - Math.floor(this.option.count)
let num = Math.floor(scrollValue / this.option.itemHeight)
this.start = numMore < 0 ? num : numMore
// 结束索引,防止白屏,多加一部分距离
this.end = this.start + this.option.count * 2
// 所有显示数组
this.allVisibleData = (this.flattenTree || []).filter(item => item.visible)
// 最终显示数据
this.visibleData = this.allVisibleData.slice(this.start, this.end)
// ul滚动的绝对定位滚动值
this.offset = this.start * this.option.itemHeight
},
上面的代码是什么意思呢?
先理解第一幅图,
上图中,我们为什么需要展位容器呢?占位容器帮助我们撑开了盒子,我们就可以开始滚动了。
再理解第二幅图

当发生滚动时,

- 这幅图里面有ul,这个是数据的容器,用于展示数据,我们后续通过控制
transform: translateY(10px);(比直接设置top的情况要好,transform不会导致重排),注意,ul容器的移动的距离和placeholder占位容器的滚动一致时,数据才能被看到!!! - 你还能够注意到,为了避免白屏的情况出现,我们还会在首尾处多添加一些值,结尾索引的this.end = this.start +
this.option.count * 2,开始索引的Math.floor(scrollValue / this.option.itemHeight) -Math.floor(this.option.count / 2)
3.5 设置样式
如何实现,树样式的递归效果呢?
如下,设置动态stylepaddingLeft,值是某个数字 * (level - 1)
<ul class="sb-tree"
:style="{transform: `translateY(${offset}px)`}"
>
<li
v-for="(item) in visibleData"
:key="item.id + item.path + item.uuid"
>
<span
v-if="item.ip"
@click.prevent.stop="handleNodeClick(item)"
class="tree-item"
:style="{
display: 'block',
background: (item.path + item.uuid) === ($store.state.selectCatalog.path + $store.state.selectCatalog.id) ? '#F1F4FF' : 'none',
color: (item.path + item.uuid) === ($store.state.selectCatalog.path + $store.state.selectCatalog.id) ? '#3C7EFF' : '#2C3E50',
+ paddingLeft: 12.5 * (item.level - 1) + 'px',
}"
>
3.8 handleNodeClick
点击某个节点的时候,我们要做什么操作呢?
- 假设这个节点有子节点,此时处于折叠状态,我们点击后就要展开子节点;假设此时处于展开状态,点击就要折叠
handleNodeClick(data, type) {
this.$set(data, "showChildren", !data.showChildren);
this.recursionVisible(data, data.showChildren)
}
- recursionVisible函数,来控制子节点的展开和关闭
recursionVisible (data, status) {
if (status) {
data.children.forEach(node => {
// 只展开第一层 ==》通过visible属性来控制显示和隐藏
node.visible = status
// 如果下面解开注释,就是把所有的children都展开了,应该只操作一层
// if (node.children) {
// this.recursionVisible(node.children, status)
// }
})
} else {
// 关闭,应该把所有子节点都关闭
data.children.forEach(node => {
node.visible = status
node.showChildren = status // 展开节点都给它收起来
if (node.children) {
this.recursionVisible(node, status)
}
})
}
}
- 记录当前点击的节点的path或者id
handleNodeClick(data, type) {
this.$set(data, "showChildren", !data.showChildren);
this.recursionVisible(data, data.showChildren)
this.selectedNode = data.id;
this.selectedNode = data.uuid;
this.handleScroll() // 触发一次滚动事件更新视图
}
3.7 防抖还是节流?
建议用防抖,为什么?
因为节流是规定时间只能执行一次,防抖是规定时间你又执行了会重新开始计时。如果是节流,那你handleScroll事件如果连续触发了好多次了呢?top的值改变了,但是数据的start和end没有更新,那么页面就看不到数据了。
handleScroll () {
if (this.scrollTimer) {
clearTimeout(this.scrollTimer)
}
this.scrollTimer = setTimeout(() => {
if (this.$refs.viewport) {
if (isSearch === true) this.top = 0
else this.top = this.$refs.viewport.scrollTop // 滚动的距离
this.updateVisibleData(this.top)
}
}, this.option.timeout) // this.option.timeout假设是30
},
这里有一个点,防抖,时间设置为多少比较合适。我个人测试过,如果你在滚动的时候,内存没什么变化,页面效果也还不错,可以设置的短一点好一些。我是出现了内存涨的很快,设置为30依然涨的很快。设置为100,则会出现白屏情况,至于原因,就是scroll触发dom移动了,最新的数据还没更新好。
3.8 多选按钮如何添加
模仿element-ui里面的tree组件的多选,在li标签里面添加checkbox。
实现点击勾选成功,在input的change事件里面做勾选操作;注意在最外层的label里面标上@click.stop,否则会把节点展开来,
<li
v-for="item in visibleData"
:key="item.path + item.name"
@click="handleNodeClick(item)"
:style="{paddingLeft: `${item.level * 5}px`}"
>
+ <label
class="checkbox"
v-if="option.showCheckbox"
@click.stop
>
<span class="checkbox__input"
:class="
[
item.isChecked ? 'is-checked' : '',
item.isHalfChecked ? 'is-indeterminate' : '',
]
"
:for="item.path + item.name"
>
<span class="checkbox__inner"></span>
<input
class="checkbox__original"
type="checkbox"
:checked="item.isChecked"
:indeterminate="item.isHalfChecked"
@change.stop="handleCheckChange(item)"
:id="item.path + item.name"
>
</span>
+ </label>
<i v-if="item.showChildren" class="el-icon-caret-bottom"></i>
<i v-else class="el-icon-caret-right"></i>
<span>{{ item.name }}</span>
</li>
待添加的样式
.checkbox__original {
width: 0;
height: 0;
}
.checkbox__inner {
display: inline-block;
box-sizing: border-box;
position: relative;
border-radius: 2px;
width: 14px;
height: 14px;
border: 1px solid #dcdfe6;
}
.checkbox__input.is-checked {
.checkbox__inner {
background-color: #409eff;
border-color: #409eff;
&::after {
content: '';
position: absolute;
box-sizing: content-box;
top: 1px;
left: 4px;
width: 3px;
height: 7px;
font-size: 12px;
color: #fff;
border: 1px solid #fff;
border-left: 0;
border-top: 0;
transition: transform .15s ease-in .05s;
transform-origin: center;
transform: rotate(44deg) scaleY(1);
}
}
}
.checkbox__input.is-indeterminate {
.checkbox__inner {
background-color: #409eff;
border-color: #409eff;
&::after {
content: '';
position: absolute;
box-sizing: content-box;
left: 0;
right: 0;
top: 5px;
height: 2px;
transform: scale(.5);
background-color: #fff;
}
}
}
注意!handleCheckChange方法需要跟input的change事件绑定,如果写在label上面,可能会导致勾选情况和点击情况没对应上。(尤其注意这个,刚开始我就是下面那样写的,直到去看element-ui里面的checkbox组件,发现他是这样封装的)
<label
class="checkbox"
v-if="showCheckbox"
+@click.prevent.stop="handleCheckChange(item)"
>
<span class="checkbox__input"
:class="
[item.isChecked ? 'is-checked' : '', item.isHalfChecked ? 'is-halfChecked' : '']
"
>
<span class="checkbox__inner"></span>
<input
class="checkbox__original"
type="checkbox"
:checked="item.isChecked"
:indeterminate="item.isHalfChecked"
>
</span>
</label>
拍平数组里面做的操作
flatten (
list = [],
childKey = 'children',
level = 1,
parent = null,
defaultExpand = true,
isSearch
) {
if (!Array.isArray(list)) {
return
}
let arr = []
// 无ipc
list.forEach((item) => {
this.$set(item, 'level', level)
if (item.showChildren === undefined) {
item.showChildren === defaultExpand
this.$set(item, 'showChildren', defaultExpand)
}
if (item.visible === undefined && level > 2) {
this.$set(item, 'visible', false)
} else if (item.visible === undefined) {
this.$set(item, 'visible', true)
}
this.$set(item, 'parent', parent)
+ // 设置勾选框 增加isChecked属性和isHalfChecked属性,
if (this.option.showCheckbox) {
this.$set(item, 'isChecked', false)
this.$set(item, 'isHalfChecked', false)
}
// 当前目录
arr.push(item)
// 当前目录下的子目录
if (item[childKey] && item[childKey].length > 0) {
arr.push(...this.flatten(
item[childKey],
childKey,
level + 1,
item,
defaultExpand,
isSearch
))
}
})
return arr
},
处理节点的勾选
handleCheckChange (node) {
try {
// 如果是半选,修改为全选
if (node.isHalfChecked) {
this.$set(node, 'isChecked', true)
} else if (node.isChecked) {
// 如果是全选,修改为非全选
this.$set(node, 'isChecked', false)
} else {
// 如果是非全选,修改为全选
this.$set(node, 'isChecked', true)
}
// 只会有孩子选中时(handleCheckChildren)触发isHalfChecked的修改,其他都改为false
this.$set(node, 'isHalfChecked', false)
// 处理子目录和子ipc
this.handleCheckChildren(node)
// 处理父目录
this.handleCheckParent(node.parent)
} catch (error) {
console.log(error, 'error');
}
},
勾选当前节点影响子节点:
- 父节点点击后只会改为全选或者全不选,子节点也是
- 子节点半选统一改为false
- 递归遍历当前函数
handleCheckChildren (node) {
node.children && node.children.forEach(child => {
// 点击当前目录,修改子目录,只会影响子目录的全选或者全不选,半选统一为false
this.$set(child, 'isChecked', node.isChecked)
this.$set(child, 'isHalfChecked', false)
if (child.children) {
// 递归遍历子目录
this.handleCheckChildren(child)
}
})
},
点击当前节点影响父节点,逻辑:
- 如果子节点全部勾选,父节点就是勾选
- 如果子节点有部分勾选,父节点就是半选状态
- 如果子节点全部没选中,父节点就是没有勾选的状态
- 递归影响父亲的父亲
handleCheckParent (node) {
// 初始值,假设所有子节点是checked,在后面的遍历中修改这个值
let isAll = true
// 初始值,假设没有子节点是半选,在后面的遍历中修改这个值
let isHalf = false
// 无name属性的目录不递归
if (!node || !node.name) {
return
}
// 根据所有的子目录判断是否全选
if (node && node.children && node.children.length > 0) {
// 如果子目录没有全部勾选,item.isChecked是false
isAll = node.children.every(item => item.isChecked)
// 如果子目录有一个是全选或者半选状态
isHalf = node.children.some(item => item.isHalfChecked || item.isChecked)
// 修改父目录选中
node.isChecked = isAll
// 全选和半选不能同时存在
node.isHalfChecked = !isAll && isHalf
}
// 递归影响父节点
this.handleCheckParent(node.parent)
},