Subnav子导航组件的实现思路与交互优化实践

欣佑~ 组件 阅读 2,992
赞 17 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月接手一个内容密集型的后台管理项目,页面左侧是主菜单,点击某项后右侧会动态加载一组子功能模块。产品经理说:“这个子导航得能收起展开,还能高亮当前页,最好带点过渡动画。”我一听,这不就是典型的 Subnav(子导航)需求嘛。

Subnav子导航组件的实现思路与交互优化实践

一开始想直接用现成的 UI 库,比如 Ant Design 的 Menu 组件,但项目要求轻量、无依赖,而且设计稿里的交互有点“野”——比如滚动时自动吸附顶部、子项过多时横向滚动、甚至要支持键盘方向键切换。算了,还是自己撸吧。

核心代码就这几行

先搭个基础结构,HTML 很简单:

<nav class="subnav" id="subnav">
  <div class="subnav-inner">
    <a href="/page1" class="subnav-item">数据概览</a>
    <a href="/page2" class="subnav-item active">用户分析</a>
    <a href="/page3" class="subnav-item">行为追踪</a>
    <!-- 更多项... -->
  </div>
</nav>

CSS 用 Flex 布局搞定横向排列,加个 overflow-x: auto 应对子项太多的情况:

.subnav {
  position: sticky;
  top: 60px;
  background: #fff;
  z-index: 10;
  border-bottom: 1px solid #eee;
}
.subnav-inner {
  display: flex;
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}
.subnav-item {
  padding: 12px 16px;
  text-decoration: none;
  color: #333;
  white-space: nowrap;
}
.subnav-item.active {
  color: #1890ff;
  font-weight: bold;
}

JavaScript 部分主要是高亮当前路由。我偷懒直接用 window.location.pathname 匹配:

function setActiveSubnav() {
  const currentPath = window.location.pathname;
  document.querySelectorAll('.subnav-item').forEach(item => {
    if (item.getAttribute('href') === currentPath) {
      item.classList.add('active');
    } else {
      item.classList.remove('active');
    }
  });
}
document.addEventListener('DOMContentLoaded', setActiveSubnav);

到这一步,基本功能跑起来了。但问题也来了。

又踩坑了,滚动吸附失效

设计师要求:当用户向下滚动页面时,子导航要“吸顶”在顶部;向上滚动时,如果回到顶部区域,就恢复原位。我第一反应是用 position: sticky,但测试发现——在某些安卓机上,sticky 完全不生效,尤其当父容器有 transform 或 overflow 时。

折腾了半天,最后还是得靠 JS 监听 scroll。但直接监听 scroll 事件性能太差,尤其在低端机上卡得飞起。后来改用 IntersectionObserver + requestAnimationFrame 组合:

let ticking = false;
const subnav = document.getElementById('subnav');
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (!ticking) {
      requestAnimationFrame(() => {
        if (entry.isIntersecting) {
          subnav.classList.remove('stuck');
        } else {
          subnav.classList.add('stuck');
        }
        ticking = false;
      });
      ticking = true;
    }
  });
}, { threshold: [0] });

observer.observe(document.querySelector('#page-header'));

这里注意我踩过好几次坑:IntersectionObserver 的 rootMargin 要设对,否则判断时机不准;还有,别忘了在组件销毁时 disconnect,不然内存泄漏。

最大的坑:横向滚动与键盘导航冲突

产品提了个“小需求”:用户按左右方向键,能在子导航项之间切换焦点。听起来简单,但和横向滚动条撞车了。

问题在于:当子导航项超出容器宽度,出现横向滚动条时,按右方向键应该既移动焦点,又自动滚动容器,让新焦点项可见。但浏览器默认不会自动滚动 overflow:auto 的容器。

我试了两种方案:

  • 方案一:用 element.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) —— 但在 iOS Safari 上动画卡顿严重,而且会触发整个页面的滚动。
  • 方案二:手动计算偏移量,用 scrollLeft 调整 —— 这个更可控,但得处理各种边界情况。

最后选了方案二,核心逻辑如下:

function handleKeyDown(e) {
  const items = Array.from(document.querySelectorAll('.subnav-item'));
  const currentIndex = items.findIndex(item => item === document.activeElement);
  
  if (e.key === 'ArrowRight' && currentIndex < items.length - 1) {
    const next = items[currentIndex + 1];
    next.focus();
    // 滚动容器,确保 next 可见
    const container = document.querySelector('.subnav-inner');
    const containerRect = container.getBoundingClientRect();
    const nextRect = next.getBoundingClientRect();
    
    if (nextRect.right > containerRect.right) {
      container.scrollLeft += nextRect.right - containerRect.right + 10;
    } else if (nextRect.left < containerRect.left) {
      container.scrollLeft += nextRect.left - containerRect.left - 10;
    }
  }
  // ArrowLeft 类似,略
}
document.querySelector('.subnav-inner').addEventListener('keydown', handleKeyDown);

亲测有效,但有个小问题没完全解决:如果用户快速连按方向键,scrollLeft 更新会有延迟,导致焦点项短暂不可见。后来加了个防抖,但体验还是不够丝滑。不过产品经理说“能用就行”,我就没再深究。

回顾与反思

整体来看,这个 Subnav 组件在项目中表现还算稳定。优点很明显:轻量(不到 50 行 JS)、无外部依赖、兼容性覆盖到 IE11(虽然 sticky 不支持,但降级为固定定位也能用)。

但有几个地方还能优化:

  • 横向滚动的平滑度不够,尤其是快速切换时。或许可以试试 CSS 的 scroll-behavior: smooth,但得测兼容性。
  • 高亮逻辑写死了 pathname,如果项目用 hash 路由或前端路由(如 Vue Router),就得重写。下次应该抽象成可配置的匹配函数。
  • 无障碍(a11y)支持不足,比如没加 aria-current=”page”,这对屏幕阅读器用户很重要。这次时间紧,先欠着了。

另外,一开始没想到子项数量会动态变化(比如根据权限显示不同菜单),导致初始化时没监听 DOM 变化。后来临时加了 MutationObserver,但总觉得有点重。其实用 ResizeObserver 监听容器尺寸变化可能更合适。

这个方案不是最优的,但最简单。在工期压力下,能跑起来比完美更重要。

结尾

以上是我个人在项目中实现 Subnav 子导航的完整踩坑过程。核心难点集中在滚动吸附、键盘导航和性能优化上,其他都是常规操作。如果你也在做类似需求,希望这些细节对你有帮助。

有更优的实现方式?比如用纯 CSS 解决横向滚动焦点问题,或者更好的吸顶方案?欢迎评论区交流。这个技巧的拓展用法还有很多(比如结合 Tab 切换),后续会继续分享这类实战博客。

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

暂无评论