追求极致滑动体验,我在JS中做了这个万级节点树,终于让页面不卡顿!
在本文中,将介绍如何使用纯 JS 技术构建一个具有优秀性能的可展开/折叠树形结构,并尽可能避免遍历节点时的性能瓶颈,同时保证代码的易读性和可维护性。
第一步:设计数据结构
在实现高性能树组件之前,我们必须考虑如何处理数据。因此,首先要开始设计数据结构。
这里,我们创建了一个“节点”类——Node:
class Node {
constructor(name, children = []) {
this.name = name;
this.children = children;
}
}
可以看到,每个节点都具有一个名称和子节点列表。其中,子节点列表可以是空数组。通过对这个数据结构进行优化和精简,我们可以更有效地将数据存储到树形结构中。
第二步:实现功能
接下来,我们要实现几个主要功能——展开、折叠和选中节点。
展开和折叠
首先,我们创建了一个树组件类——Tree,它接收一个根节点并具有以下方法:
class Tree {
constructor(root) {
this.root = root;
}
// 展开节点功能
expand(node) {
node.expanded = true;
}
// 折叠节点功能
collapse(node) {
node.expanded = false;
}
}
在这里,我们简单地使用一个 expanded
属性来存储该节点是否已展开或折叠。从而实现相关功能。
选中节点
为了实现选中节点的功能,我们创建了一个名为 SelectableTree
的类,并继承了先前的 Tree
类方法:
class SelectableTree extends Tree {
constructor(root) {
super(root);
this.selectedNode = null;
}
// 选中节点
select(node) {
if (this.selectedNode) {
this.selectedNode.selected = false;
}
node.selected = true;
this.selectedNode = node;
}
}
在这里,我们添加了一个新的 selectedNode
属性,用于跟踪当前选中的节点。当选择其他节点时,我们会将当前选中的节点取消选中,并选择新节点。由此实现选中节点的功能。
第三步:解决性能问题
虽然我们已经成功实现了展开、折叠和选中节点的功能,但是当数据量大到几万个节点时,遍历整个树结构对性能的影响很明显。为了解决这个问题,我们采取了以下方式:
惰性加载
在初始化组件时,我们不会立即生成每个节点的完整子节点列表。相反,我们等到用户实际展开该节点时再加载它的子节点。这些添加到节点的子项可以非常灵活地根据需要动态加载。
缓存
为了在第二次访问时提高性能,我们缓存了节点的所有后代按层次(level)分组。由此实现快速访问和遍历。
虚拟化
即使经过上述优化,如果在一次请求中显示几万条数据,浏览器的行为也将变得愈加缓慢。取而代之的是,我们可能希望只渲染当前可见节点的子项。这意味着在滚动时应该禁止对不可见节点进行更新。
为了实现这一点,我们使用了一个虚拟列表的技巧。虚拟列表会实现仅仅渲染那些可见的节点,同时通过数据预先加载,保持滑动平滑流畅。
在这里,我们为可视区域内的树节点构建一个缓存数组,并动态插入/删除缺失的节点。每次滚动时,我们计算屏幕上首尾节点的索引,并依此设置“部分可见子项”的位置。
class VirtualTree extends SelectableTree {
constructor(root, parentNode) {
super(root);
this.parentNode = parentNode;
this.cache = [];
this.startIndex = 0;
this.endIndex = 0;
this.vHeight = 300;
this.itemHeight = 20;
}
// 缓存所有childen数据到Array中
cacheChildren() {
const cache = [];
let index = 0;
const walk = (node, level) => {
node.level = level;
cache[index] = node;
index++;
if (node.expanded) {
node.children.forEach((n) => {
walk(n, level + 1);
});
}
};
this.root.children.forEach((node) => {
walk(node, 0);
});
return cache;
}
// 视图窗口内可显示的节点数
get visibleCount() {
return Math.ceil(this.vHeight / this.itemHeight);
}
// 更新缓存
updateCache() {
this.endIndex = this.startIndex + this.visibleCount;
const {cache, startIndex, endIndex} = this;
for (let i = startIndex; i < endIndex; i++) {
const node = cache[i];
if (node) {
if (!node.el) {
node.el = document.createElement('div');
}
node.el.style.top = i * this.itemHeight + 'px';
this.parentNode.appendChild(node.el);
this.renderItem(node);
}
}
for (let i = 0; i < startIndex; i++) {
const node = cache[i];
if (node && node.el) {
this.parentNode.removeChild(node.el);
delete node.el;
}
}
for (let i = endIndex, len = cache.length; i < len; i++) {
const node = cache[i];
if (node && node.el) {
this.parentNode.removeChild(node.el);
delete node.el;
}
}
}
// 动态生成节点
renderItem(node) {
if (!node.el) return;
if (node.expanded) {
node.el.innerHTML = `<span class="folder expanded">${node.name}</span>`;
} else if (node.children?.length) {
node.el.innerHTML = `<span class="folder collapsed">${node.name}</span>`;
} else {
node.el.innerHTML = `<span class="file">${node.name}</span>`;
}
node.el.onclick = () => {
this.select(node);
};
}
// 预渲染
async prefetch() {
await new Promise((resolve) => setTimeout(resolve, 100));
// 模拟API请求
const data = Array.from({length: 10000}, (_, index) => new Node(index + ''));
this.parentNode.style.height = data.length * this.itemHeight + 'px';
this.cache = data;
this.updateCache();
}
// 滚动时更新视图
onScroll() {
this.startIndex = Math.floor(this.parentNode.scrollTop / this.itemHeight);
this.updateCache();
}
}
总结
通过本文,我们了解了如何使用原生JS实现一个具有几万个节点的高性能树组件。深入探究了优化的各种方法,包括惰性加载、缓存和虚拟化。
希望同学们可以从中受益,熟悉高效的前端组件开发技巧,提高自己的 Web 程序设计能力。如果你对上述代码和思路感兴趣,欢迎留言分享自己的见解和见闻。
更多题目
转载自:https://juejin.cn/post/7223314301972725820