Sticky Headers

发布于 大漠

在实际业务中经常碰到页头固定在浏览器的顶部,而在移动端上使用position:fixed坑多难搞。记得EFE团队分享过一篇《Web移动端Fixed布局的解决方案》博文,就是介绍如何解决移动端上实现页头固定的技术方案。除了文章中介绍的方案之外,@Brad Frost也推荐了几个JavaScript的解决方案,比如iScroll 4Scrollability。使用fixed是一种固定页头的,但很多时候是希望实现Sticky Header的效果,说到这里大家可能会想起position新增的属性值sticky。虽然这个能实现我们想要的效果,但这个属性的支持性还是需要等待一段时间。

sticky正常的使用方法

position:sticky正常的使用方法,非常的简单:

<div class="header">Sticky Headers</div>

.sticky {
    position: sticky;
    top: 15px;
}

元素sticky距离浏览器顶部15px,该元素就固定在那了。很多时候这个配合JavaScript的scroll事件一起使用。

var header = document.querySelector('.header');
var origOffsetY = header.offsetTop;

function onScroll(e) {
    window.scrollY >= origOffsetY ? header.classList.add('sticky') : header.classList.remove('sticky');
}

document.addEventListener('scroll', onScroll, false);

看上去是不是非常的简单。刚才也说了,sticky的支持度还是需要等待一段时间。可以通过caniuse.com来查阅。

有关于position:sticky的相关资源也可以阅读下面几篇文章:

当然也有对应的Polyfill。比如这个这个。不过@Jeff Wainwright前几天在CSS-Tricks网站上分享了一篇文章使用Stickybits来替换position:sticky的Polyfills方案:

如果对Stickybits感兴趣的同学,可以仔细阅读这篇文章《Stickybits: an alternative to position: sticky polyfills》或其官网查阅相关文档

虽然上面的方案都可以解决Sticky Headers效果,但我更对@Remy Sharp分享的几篇文章更感兴趣:

下面的内容是根据上面三篇文章整理而来的,不过英文好的同学建议直接阅读上面三篇文章。

Sticky Headers

以前实现Sticky Headers效果,很多时候都是借助于JS(更早有很多jQuery插件)。比如像下面这样的代码:

var toggleHeaderFloating = function() {
    // Floating Header
    if ($window.scrollTop() > 80) {
        $('.header-section').addClass('floating');
    } else {
        $('.header-section').removeClass('floating');
    }
}

$window.on('scroll', toggleHeaderFloating);

上面的代码检查每次浏览器垂直滚动条滚动的位置超过80px的时候给元素.header-section添加floating类名,反之则删除floating类名。

其中$window是一个window对象。但事实上,这段代码有很多禁忌。不过这不是一个大问题,问题是我们应该要理解如何避免一些小障碍。

几年前@Paul Irish分享过一篇滚动性能相关的文章,文章虽然介绍的是scroll事件,但也可能适用于wheelmousemove事件。文章中建议使用scroll事件时应该尽量避免接触DOM(Touching DOM),避免引发布局(也称为回流)。@Paul也搜集了一些,怎样才会触发回流

如果我们在scroll事件上什么都不做,我们又能做些什么呢?我们可以使用requestAnimationFrame以防反跳(Debounce)。当用户滚动滚动条时,我们会使用一个函数来检查滚动条的位置,但如果用户快速滚动滚动条,那么我们最好先避免Scroll Jank

// used to only run on raf call
var rafTimer;
$window.on('scroll', function(){
    cancelAnimationFrame(rafTimer);
    rafTimer = requestAnimationFrame(toggleHeaderFloating);
});

上面的jQuery代码有两件事情一直困扰我:

  • 每次scroll事件都会触发运行一个jQuery选择器
  • 查询每一个元素

诚然getElementsByClassName(jQuery或Sizzle选择一个类)已经优化得很好,这不是很大的问题。然而,我们在每一个滚动勾子时不需要构造一个新的jQuery对象。

在Chrome Devtools中运行下面的代码,每次滚动页面将会记录运行的次数:

window.onscroll = () => console.count('scroll')
// or monitorEvents('scroll')

整体代码:

var $headerSection = $('.header-section');
var toggleHeaderFloating = function () {
    // Float Header
    if ($window.scrollTop() > 80) {
        $headerSection.addClass('floating');
    } else {
        $headerSection.removeClass('floating');
    }
}

var rafTimer;
$window.on('scroll', function(){
    cancelAnimationFrame(rafTimer);
    rafTimer = requestAnimationFrame(toggleHeaderFloating);
});

事实上,当滚动条位置超过一定的阈值时,header-section只需要改变一个场景。

另一种选择是使用classList检查这个类是否需要改变。

var rafTimer;
window.onscroll = function (event) {
    cancelAnimationFrame(rafTimer);
    rafTimer = requestAnimationFrame(toggleHeaderFloating);
}

function toggleHeaderFloating() {
    // does cause layout/reflow: https://git.io/vQCMn
    if (window.scrollY > 80) {
        document.body.classList.add('sticky');
    } else {
        document.body.classList.remove('sticky');
    }
}

组件问题

预期的效果是,当我滚动滚动条时,导航会固定在页头。哪果我点击导航菜单项时,将平滑地滚动到对应的位置。

实际效果,你可以点浏览2016.ffconf.org网站查看效果。

要实现此效果,还有些问题有待解决:

Sticky Element

Sticky Element元素是导航部分,它一直固定在页面的顶部会更简单,因为它总是有一个position:fixed样式设置。虽然,导航元素只有超过一个阈值才会粘在顶部。

最初考虑我们是否能使用IntersectionObserver,一种逆向方法,但它不适合。

下面的代码跟踪和应用sticky类名来解决导航元素的位置。请注意,我把sticky类名用在body元素上。至于为什么,稍后再聊。

// 获取Sticky Element,这里指的是`sticky-header`元素
var stickyHeader = document.getElementById('sticky-header');

// 记录当前的位置,当超过这个阈值时添加`sticky`类名,反则删除
var boundary = stickyHeaderRef.offsetHeight;

// 当页面滚动时,尽可能少,在这种情况下注册一个 rAF回调`checkSticky`
window.onscroll = function (event) {
    requestAnimationFrame(checkSticky);
}

function checkSticky() {
    // 收集当前滚动条位置
    var y = window.scrollY + 2;

    // 检测body元素是否包含`sticky`类
    var isSticky = document.body.classList.contains('sticky');

    if (y > boundary) {
        // 当前滚动条位置超过阈值
        // body元素并没有`sticky`类; 如果没有包含,添加该类名
        if(!isSticky) {
            document.body.classList.add('sticky');
        }
    } else if (isSticky) {
        document.body.classList.remove('sticky');
    }
}

只有上面的JavaScript代码还是不够的,还需要配合一些CSS代码:

#sticky-header {
    top: 0;
}
body.sticky {
    padding-top: 100px;
}
body.stick #sticky-header {
    position: fixed;
}

对应的原理就不用做更多的阐述吧。这里有两个点比较重要,当滚动条位置超过预定阈值时,body元素会添加一个sticky类名,并且给body.sticky添加padding-top:100px。同时Sticky元素的position:static变成了position:fixed。给body添加一个padding-top:100px主要是让内容不会被Sticky元素固定在顶部是遮住内容。

链接到锚点位置

这里指的是,当你点击导航栏的菜单项时,到达到页面的指定位置。也就是对应的锚点位置。

现在我们实现了导航粘在浏览器顶部,但我们单击导航中链接时页面会跳转,但导航元素遮盖了标题,这不是我们想要的效果:

为了解决这个问题,给目标元素添加一个height值,来抵消导航元素的高度,在我们的示例中是100px

:target:before {
    content: '';
    display: block;
    height: 100px;
}

这里采用了CSS选择器:target和伪类选择器:before配合。

平滑滚动

另一个有待解决的问题是,当我们点击导航到达指定位置时需要一个平滑滚动效果。这个让事情变得有点复杂。不过我发现一个较好的JavaScript库,但发现的有点晚。

虽然用已有的JavaScript库来解决这个效果,但我还是决定自己来强撸这个功能。部分功能是我所能预料到的,但部分功能是我想得太简单了。比如说,我想用一个简单的Tweening函数,但我自己还是不能独立完成,最后还是选择了@Soledad Penadéstween库

具体代码如下,代码中有一些简单的注释:

// 监听body元素的点击事件
document.body.addEventListener('click', function(event){
    var node = event.target;
    var location = window.location;

    // ignore non-links elements being clicked
    if (node.nodeName !== 'A') {
        return;
    }

    // ignore cmd+click etc
    if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey) {
        return;
    }

    // only hook local URLs to the page
    if (node.origin !== location.origin || node.pathname !== location.pathname){
        return;
    }

    event.preventDefault();

    window.history.pushState(null, null, node.hash);

    var target = document.querySelector(node.hash);

    var fromY = window.scrollY;
    var coords = {
        x: 0,
        y: fromY
    }
    var y = target.offsetTop;

    if (fromY < y) {
        y -= 100; // offset for the padding-top
    }

    var running = true;

    var tween = new TWEEN.Tween(coords)
        .to({x: 0, y: y}, 500)
        .easing(TWEEN.Easing.Quadratic.Out)
        .onUpdate(function(){
            window.scrollTo(this.x, this.y);

            if (this.y === y) {
                running = false;
            }
        })
        .start();

    requestAnimationFrame(animate);

    function animate(time) {
        if (running) {
            requestAnimationFrame(animate);
            TWEEN.update(time)
        }
    }
});

原生的CSS方案

第二部分主要介绍的是使用JavaScript来实现Sticky元素和平滑滚动的效果。但随着CSS发展,可以使用CSS来实现。

html {
    scroll-behavior: smooth;
}

#masthead {
    position: sticky;
    top: calc(-100% + 100px);
    /* make sure stick above images */
    z-index: 1;

    /* tweaks to the ffconf design
    to keep the height right */
    display: flex;
    flex-direction: column;
}

.logo-wrapper {
    flex-basis: 85vh;
}

具体的DEMO可以点击这里查看效果

遗憾的是,到目前为止仅只有Firefox浏览器同时支持position:stickyscroll-behavior。但在其他的浏览器中也能看到较好的效果,比如Chrome浏览器。

回到最初,如果你在项目中想直接使用position:stickyscroll-behavior。你可以在支持的浏览器中直接使用这两个属性,在不支持的浏览器中使用对应的Polyfill。

总结

这篇文章简单的介绍了如何在项目中实现positon:sticky和平滑滚动条效果。除了借助JavaScript之外,我们更期待使用纯CSS来实现。我们的宗旨是:能使用CSS实现的效果绝不使用JavaScript。但碍于浏览器对其兼容性的原因,在实际项目中,可以考虑采用对应的Polyfill。这个时候,你可能会说,这样一来还不是使用JavaScript吗?事实是这样,但很多时候,咱们还可以考虑Houdini来实现。如果你对Houdini从未了解过,建议阅读@Philip Walton在2017 CSS Day分享的主题《Houdini & Polyfilling CSS》。这是一个很有意思的话题。

大漠

常用昵称“大漠”,W3CPlus创始人,目前就职于手淘。对HTML5、CSS3和Sass等前端脚本语言有非常深入的认识和丰富的实践经验,尤其专注对CSS3的研究,是国内最早研究和使用CSS3技术的一批人。CSS3、Sass和Drupal中国布道者。2014年出版《图解CSS3:核心技术与案例实战》。

如需转载,烦请注明出处:https://www.fedev.cn/css/sticky-headers.htmlVans Varsity Pack Sk8-Hi Era