likes
comments
collection
share

追求极致滑动体验,我在JS中做了这个万级节点树,终于让页面不卡顿!

作者站长头像
站长
· 阅读数 38

追求极致滑动体验,我在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
评论
请登录