实战解析Affix固钉组件的实现原理与优化技巧

Code°素平 组件 阅读 2,335
赞 8 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

这项目是个内部用的内容管理后台,左侧是菜单栏,右侧是内容区。产品提了个需求:菜单要能吸顶,用户滚动页面的时候它得一直贴在顶部。听起来挺简单对吧?但实际做起来才发现一堆坑。

实战解析Affix固钉组件的实现原理与优化技巧

一开始我直接上了 CSS 的 position: sticky,毕竟原生支持,代码少还轻量。写了几行就跑起来了:

.sidebar {
  position: sticky;
  top: 0;
}

本地测试看着没问题,一上测试环境就崩了——页面结构复杂,外层有个 overflow: auto 的容器,直接导致 sticky 失效。查了一圈文档才想起来,sticky 在有 overflow 的父级下会失效,这问题我之前居然没注意过。

后来换方案,用了 Ant Design 的 Affix 组件,想着大厂封装的总该稳了吧?结果发现移动端滑动卡顿严重,尤其是低端安卓机,滑一下卡半秒,用户体验直接拉胯。折腾了半天发现是 Affix 内部监听 scroll 事件没做节流,高频触发重绘,页面直接扛不住。

最大的坑:性能问题

我开始以为只是组件的问题,后来自己手撸了一个基于监听 window.onscroll 的版本,结果一样卡。这才意识到根本问题是事件监听太暴力了。

最开始的代码长这样:

window.addEventListener('scroll', () => {
  const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
  const sidebar = document.getElementById('sidebar');
  if (scrollTop > 100) {
    sidebar.style.position = 'fixed';
    sidebar.style.top = '0';
  } else {
    sidebar.style.position = '';
    sidebar.style.top = '';
  }
});

看起来没啥毛病,但问题就在这个回调里直接操作 DOM 样式。每次滚动都触发,浏览器根本来不及渲染,页面就开始抽搐。

后来加了节流,情况好转了一些:

function throttle(fn, delay) {
  let timer = null;
  return function () {
    if (timer) return;
    timer = setTimeout(() => {
      fn.apply(this, arguments);
      timer = null;
    }, delay);
  };
}

const handleScroll = throttle(() => {
  // 同上逻辑
}, 100);

但还是不够顺滑。iOS 上偶尔还会闪一下,Android 上输入框弹起键盘后布局错乱。查了一圈发现,fixed 定位在移动端和键盘交互时容易出问题,特别是 Safari,fixed 元素可能被“钉”在错误的位置。

最离谱的一次,用户点输入框,键盘弹出来,sidebar 被顶到中间,收起键盘后也没恢复。最后发现是 Safari 的 fixed 定位 bug,它不会及时重计算位置。这个问题到现在都没完美解决,只能靠监听页面高度变化来 hack 一下。

最终的解决方案

最后我改成了 hybrid 方案:PC 端用 Affix + 节流 + requestAnimationFrame 控制重绘;移动端改用 IntersectionObserver 来判断是否进入视口,避免频繁读取 scrollTop。

核心代码其实也不多:

class AffixManager {
  constructor(element, offsetTop) {
    this.element = element;
    this.offsetTop = offsetTop;
    this.placeholder = null;
    this.isFixed = false;
    this.init();
  }

  init() {
    this.placeholder = document.createElement('div');
    this.placeholder.style.display = 'none';
    this.element.parentNode.insertBefore(this.placeholder, this.element);

    this.observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting && entry.boundingClientRect.top < this.offsetTop) {
          this.fix();
        } else {
          this.unfix();
        }
      },
      { rootMargin: -${this.offsetTop}px 0px 0px 0px }
    );

    this.observer.observe(this.placeholder);
  }

  fix() {
    if (this.isFixed) return;
    this.isFixed = true;
    this.placeholder.style.height = this.element.offsetHeight + 'px';
    this.placeholder.style.display = '';
    this.element.style.position = 'fixed';
    this.element.style.top = this.offsetTop + 'px';
    this.element.style.width = this.element.offsetWidth + 'px'; // 防止宽度塌陷
  }

  unfix() {
    if (!this.isFixed) return;
    this.isFixed = false;
    this.placeholder.style.display = 'none';
    this.element.style.position = '';
    this.element.style.top = '';
    this.element.style.width = '';
  }

  destroy() {
    this.observer && this.observer.unobserve(this.placeholder);
    this.placeholder.remove();
  }
}

然后调用:

new AffixManager(document.getElementById('sidebar'), 0);

这里有几个细节要注意:

  • 必须创建一个占位元素,不然 unfix 的时候页面会跳
  • width 要手动设置,不然 fixed 后可能因为容器宽度变化导致错位
  • rootMargin 设置负值是为了提前触发 fixed 状态

这套方案上线后,移动端滑动流畅多了,CPU 占用从平均 60% 降到了 20% 左右。虽然 Safari 的键盘问题还是偶尔出现,但频率低了很多,产品也接受了这个“小瑕疵”。

回顾与反思

现在回头看,其实最开始就不该图省事直接用现成组件。Ant Design 的 Affix 虽然方便,但在复杂场景下太重,而且定制性差。自己实现虽然多花了两天,但可控性强,后期优化也方便。

做得好的地方:

  • 用 IntersectionObserver 替代 scroll 监听,大幅降低性能消耗
  • 占位元素的设计让布局切换更平滑
  • 代码轻量,不到 100 行,维护成本低

还能优化的点:

  • Safari 下键盘弹出后的 fixed 错位问题还没根治,目前是监听页面 resize 做 fallback
  • 横向滚动时也可能触发 fixed,需要加判断
  • 没有考虑 transform 缩放场景,如果有 zoom 功能可能会有问题

还有一个没解决的小问题:当页面加载时就在滚动位置,Affix 状态初始化不准确。试过 onload 和 DOMContentLoaded 里触发一次 check,但偶尔还是会错过。后来干脆加了个 timeout 强刷一次,dirty but works。

踩坑提醒:这三点一定注意

如果你也要做类似功能,这几个坑我亲自踩过,一定要避开:

  1. 别在 overflow: auto/scroll 的容器里用 sticky,直接失效,毫无征兆
  2. 移动端别频繁读 scrollTop,IOS 上 getBoundingClientRect 性能极差
  3. fixed 元素记得设 width,不然容器变窄它也会跟着缩

还有个小技巧:可以在 body 上加个 is-sidebar-fixed 的 class,用来控制其他元素的 margin-top,避免内容突然上移。

以上是我的项目经验,希望对你有帮助

这个 Affix 功能看着简单,真做起来全是细节。很多方案看似能跑,一上生产就露馅。我自己也是改了三四版才稳定下来。现在这套代码已经在三个项目里复用了,稳定性还可以。

如果有更优的实现方式,比如怎么彻底解决 Safari 的 fixed 键盘问题,欢迎评论区交流。我也一直在找更好的解法。

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

暂无评论