实战解析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。
踩坑提醒:这三点一定注意
如果你也要做类似功能,这几个坑我亲自踩过,一定要避开:
- 别在 overflow: auto/scroll 的容器里用 sticky,直接失效,毫无征兆
- 移动端别频繁读 scrollTop,IOS 上 getBoundingClientRect 性能极差
- fixed 元素记得设 width,不然容器变窄它也会跟着缩
还有个小技巧:可以在 body 上加个 is-sidebar-fixed 的 class,用来控制其他元素的 margin-top,避免内容突然上移。
以上是我的项目经验,希望对你有帮助
这个 Affix 功能看着简单,真做起来全是细节。很多方案看似能跑,一上生产就露馅。我自己也是改了三四版才稳定下来。现在这套代码已经在三个项目里复用了,稳定性还可以。
如果有更优的实现方式,比如怎么彻底解决 Safari 的 fixed 键盘问题,欢迎评论区交流。我也一直在找更好的解法。

暂无评论