Dropdown下拉菜单的实现原理与交互优化实战

皇甫杏花 组件 阅读 1,961
赞 36 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周重构一个老项目,里面有个下拉菜单(Dropdown)组件,点开后加载 300+ 个选项,直接卡到页面白屏。不是“稍微卡一下”那种,是点开后整个浏览器标签页失去响应,鼠标转圈 3 秒以上,连滚动条都拖不动。用户反馈说“每次点下拉都像在等加载”,我本地开发也烦得不行——改一行代码保存,热更新完再点下拉,又得等半天。

Dropdown下拉菜单的实现原理与交互优化实战

一开始我以为是数据量太大渲染慢,但后来发现,哪怕只有 50 个选项,只要反复开关几次,性能也会越来越差。明显是内存泄漏或者重复渲染的问题。这玩意儿不能再拖了,得动刀。

找到病根了!

先打开 Chrome DevTools,Performance 面板录了一次操作:点开下拉 → 等待 → 关闭。结果吓一跳——主线程被 JS 占满,光是 rendering 就花了 4.8 秒,其中 layout 和 paint 各占 1.5 秒以上。更离谱的是,每次开关,DOM 节点数量都在增加,关掉后那些选项节点居然没被销毁!

再切到 Memory 面板,拍了个快照,发现大量重复的 <li> 元素和绑定的事件监听器。原来问题出在这:

  • 每次打开下拉都重新创建所有 DOM 节点
  • 关闭时只隐藏,没移除,导致下次打开又新建一批
  • 每个选项都绑了 click 事件,没做事件委托

典型的“一次性渲染 + 无清理”模式,数据量一大就崩。

核心优化:虚拟滚动 + 事件委托

折腾了半天,最后决定上虚拟滚动(Virtual Scrolling)。虽然 Dropdown 通常选项不多,但这个业务场景就是硬要塞几百个,不优化没法用。

关键思路就两点:

  1. 只渲染可视区域内的选项(比如 10 个),滚动时动态替换
  2. 用一个父容器监听 click,通过 event.target 判断点的是哪个选项

先看优化前的代码(简化版):

// 优化前:暴力渲染全部
function renderDropdown(options) {
  const container = document.getElementById('dropdown');
  container.innerHTML = ''; // 每次清空重绘
  options.forEach(option => {
    const li = document.createElement('li');
    li.textContent = option.label;
    li.addEventListener('click', () => handleSelect(option));
    container.appendChild(li);
  });
}

这段代码的问题很明显:innerHTML = '' 触发强制重排,forEach 创建几百个元素,每个都绑事件,内存和性能双杀。

优化后,我用了一个轻量级的虚拟滚动方案,核心逻辑如下:

// 优化后:虚拟滚动 + 事件委托
class VirtualDropdown {
  constructor(container, options) {
    this.container = container;
    this.options = options;
    this.visibleCount = 10; // 只显示10个
    this.itemHeight = 36; // 每个选项高度
    this.scrollTop = 0;

    this.render();
    this.bindEvents();
  }

  render() {
    // 设置容器高度和内部占位
    this.container.style.height = ${this.visibleCount * this.itemHeight}px;
    this.container.style.overflow = 'auto';
    
    // 创建一个大 div 占位,撑起滚动条
    const totalHeight = this.options.length * this.itemHeight;
    let placeholder = this.container.querySelector('.placeholder');
    if (!placeholder) {
      placeholder = document.createElement('div');
      placeholder.className = 'placeholder';
      placeholder.style.height = ${totalHeight}px;
      placeholder.style.position = 'relative';
      this.container.appendChild(placeholder);
    } else {
      placeholder.style.height = ${totalHeight}px;
    }

    this.renderVisibleItems();
  }

  renderVisibleItems() {
    const start = Math.floor(this.scrollTop / this.itemHeight);
    const end = Math.min(start + this.visibleCount, this.options.length);

    // 清空旧的 visible items
    const existingItems = this.container.querySelectorAll('.dropdown-item');
    existingItems.forEach(el => el.remove());

    // 只渲染可见区域
    for (let i = start; i < end; i++) {
      const item = document.createElement('div');
      item.className = 'dropdown-item';
      item.textContent = this.options[i].label;
      item.style.position = 'absolute';
      item.style.top = ${i * this.itemHeight}px;
      item.style.width = '100%';
      item.dataset.index = i;
      this.container.querySelector('.placeholder').appendChild(item);
    }
  }

  bindEvents() {
    // 事件委托:只监听容器
    this.container.addEventListener('click', (e) => {
      if (e.target.classList.contains('dropdown-item')) {
        const index = e.target.dataset.index;
        this.handleSelect(this.options[index]);
      }
    });

    // 滚动时更新可见项
    this.container.addEventListener('scroll', () => {
      this.scrollTop = this.container.scrollTop;
      this.renderVisibleItems();
    });
  }

  handleSelect(option) {
    console.log('Selected:', option);
    // 你的选择逻辑
  }
}

这里注意我踩过好几次坑:

  • 绝对定位的 .dropdown-item 必须放在一个相对定位的占位容器里,否则 top 计算错位
  • 滚动事件要防抖,但实测发现 10 行以内滚动太快,干脆不加防抖,直接调用(因为 renderVisibleItems 本身很快)
  • 别忘了在关闭下拉时 destroy 实例,否则 placeholder 会残留

其他小优化:能省则省

除了虚拟滚动,还顺手做了几处微优化:

  • 懒加载数据:如果选项来自 API,先展示骨架屏,数据回来再初始化 VirtualDropdown。避免主线程阻塞。
  • CSS 层级优化:给下拉容器加 transform: translateZ(0) 提升为合成层,避免滚动时重绘整个页面。
  • 避免频繁 setState:如果是 React/Vue 项目,确保下拉状态变更不触发父组件重渲染。我们用的是原生,所以没这个问题。

这些改动不大,但积少成多。尤其是 CSS 那条,亲测在低端机上滚动流畅度提升明显。

性能数据对比

优化前后实测数据(MacBook Pro M1,Chrome 120):

指标 优化前 优化后
首次打开耗时 4.9s 780ms
内存占用(打开后) 120MB 28MB
连续开关 5 次后 FPS 8-12 FPS 58-60 FPS

最明显的改善是内存——从 120MB 降到 28MB,说明 DOM 节点和事件监听器不再堆积。FPS 也基本稳在 60,滚动丝滑。

当然,这个方案也不是完美的。比如选项高度不固定时,虚拟滚动计算会复杂很多。我们业务里选项都是单行文本,高度固定,所以简单处理就行。如果你遇到高度不一的情况,可能需要 ResizeObserver 或预计算高度数组,那就另说了。

结尾:就这么干

这次优化下来,最大的体会是:别小看一个 Dropdown,数据量上来照样能拖垮页面。虚拟滚动听起来高大上,其实核心就几十行代码,关键是想清楚“只渲染可见区域”这个原则。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的实现方式——比如用 Intersection Observer 做懒加载、或者用 Web Worker 处理数据过滤——欢迎评论区交流。这个技巧的拓展用法还有很多,后续会继续分享这类实战博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论