position:sticky 的 polyfill——stickyfill 源码浅析

3,007 阅读8分钟

本人最近在修改 blogsue 中的样式时,使用到了 position: sticky。话不多说,开始主要内容。

定义

position: sticky 是 CSS position 属性的一个新值。正如它的名字那样,它会“黏在”你的浏览器窗口中。这个展示方式有很多的应用场景。例如知乎的右侧就是这样一个场景:当用户一直往下翻的时候右侧的专栏(广告)固定住,不会消失在用户界面。又例如手机端的美团,上面的筛选框也需要保持左边固定。

正如之前的瀑布流与 colum-count 一样,这类应用广泛的排版格式最终都会有原生的实现。 具体使用方式此处就不展开了,可以参照MDN:https://developer.mozilla.org/zh-CN/docs/Web/CSS/position

Polyfill——stickyfill

position: sticky 作为新特性,兼容问题一直是一个迈不过去的坎。可以看到整个 IE 系列都不支持:

image
此处,如果我们希望兼容旧版本的浏览器,我们就需要借助 polyfill 的力量了。这就是 stickyfill(https://github.com/wilddeer/stickyfill)。 在我们进行接下来的探索前,要说明的是**stickyfill 并不是 position: sticky 的完全实现。**他们的最终效果有些许差异:

  • stickyfill 不支持x轴
  • stickyfill 会将元素限制在父元素内,即父元素离开屏幕后该元素也会离开(贴着父元素的边)

stickyfill 用法介绍

在 stickyfill repo 中,作者介绍了该 polyfill 的使用方式:

<div class="sticky">
    ...
</div>
.sticky {
    position: -webkit-sticky;
    position: sticky;
    top: 0;
}

Then apply the polyfill:

var elements = document.querySelectorAll('.sticky');
Stickyfill.add(elements);

pollyfill 作为“补丁”,最理想的状态下是只需要将其代码引入到项目中,之后不需要做任何事情。例如 Promise 的 polyfill,就是直接在 global 下创建了 promise 类,我们只需引入,其会自动帮我们做好准备工作。但 stickyfill能否这样做呢? 理论上是可以的。因为 stickyfill 只需要遍历 DOM 树找出所有 position attribute 为 sticky 的 DOM 节点,然后对其添加规则即可。但在实际中,由于遍历 DOM 树性能消耗太高,stickyfill 退而求其次,让我们来选择需要遍历的节点。

源码简析

刚刚我们知道了 stickyfill 的用法,可以知道,stickyfill 是将我们所需要处理的元素进行了托管,利用 javascript 的能力来模拟实现 position: sticky 的功能。 接下来我们一起去看一下 stickyfill 是如何管理、处理元素的。基于文章长度限制,本文只讲解核心的几个方法。下面的源码为了条理清晰,经过精简:

包内预设变量 && 托管元素自定义类

stickyfill 模块内预设了一些类以及变量:

// 此处 stickies 是该库存放所有托管节点的数组
const stickies = [];

// 用来存放最新状态的top和left值
const scroll = {
    top: null,
    left: null
};

// Sticky类
// 所有确认需要维护的节点都会被这个类wrap
class Sticky {
    constructor (node) {
        // 差错检测
        if (!(node instanceof HTMLElement))
            throw new Error('First argument must be HTMLElement');
        // 防止重复出现相同的DOM节点
        if (stickies.some(sticky => sticky._node === node))
            throw new Error('Stickyfill is already applied to this node');
        
        // wrap的DOM节点
        this._node = node;
        // 存放DOM节点当前的状态,有三个值:
        // start: 该节点在界面上正常显示
        // middle: 该节点处于fixed状态
        // end: 该节点滑动到了父节点底部,将会贴着父节点底部边缘
        this._stickyMode = null;
        // 该节点是否生效。
        this._active = false;
        // 放到实例队列中管理
        stickies.push(this);
        // refresh函数会对节点做初始处理,并激活
        this.refresh();
    }
    // .....
}

全局初始化函数

这里 Stickyfill 在全局初始化阶段做好了滚动事件监听、运行环境检测等工作:

function init () {
    // 避免重复初始化
    if (isInitialized) {
        return;
    }
    isInitialized = true;

    // 定义onScroll事件所需要的处理逻辑,可以看到是基于pageXOffset/pageYOffset来确定滚动距离
    function checkScroll () {
        if (window.pageXOffset != scroll.left) {
            scroll.top = window.pageYOffset;
            scroll.left = window.pageXOffset;
            // 如果当前left值有遍的话,我们要刷新所有元素
            // 为什么要刷新?因为stickyfill只支持上下的sticky
            // 如果当前是处于fixed的情况,right/left值是基于浏览器窗口定位的,与效果不一致
            // 所以此处就要重新刷新托管的节点
            // 具体可以参见下面的「Sticky 类中DOM节点的三种状态(核心)」
            Stickyfill.refreshAll();
        }
        else if (window.pageYOffset != scroll.top) {
            scroll.top = window.pageYOffset;
            scroll.left = window.pageXOffset;

            // 如果是高度变化,就执行状态刷新函数
            stickies.forEach(sticky => sticky._recalcPosition());
        }
    }

    checkScroll();
    window.addEventListener('scroll', checkScroll);

    // 当界面大小发生改变,或者是手机端屏幕方向发生改变,就重新刷新节点
    window.addEventListener('resize', Stickyfill.refreshAll);
    window.addEventListener('orientationchange', Stickyfill.refreshAll);

    // 定义一个循环器,其中的sticky._fastCheck()函数的主要作用
    // 是检测其元素本身以及父元素是否发生了位置变化,变化了就执行刷新节点
    // 主要作用是在你使用js操作元素的时候可以及时跟进你的刷新
    // 此处定时500ms,个人观点是出于性能考虑
    let fastCheckTimer;
    function startFastCheckTimer () {
        fastCheckTimer = setInterval(function () {
            stickies.forEach(sticky => sticky._fastCheck());
        }, 500);
    }
    function stopFastCheckTimer () {
        clearInterval(fastCheckTimer);
    }
    // 查看页面的隐藏情况
    // window.hidden 这个值可以标示页面的隐藏情况
    // 处于性能考虑,stickyfill会在页面隐藏时取消fastCheckTimer
    let docHiddenKey;
    let visibilityChangeEventName;
    // 兼容是否有前缀的两种格式
    if ('hidden' in document) {
        docHiddenKey = 'hidden';
        visibilityChangeEventName = 'visibilitychange';
    }
    else if ('webkitHidden' in document) {
        docHiddenKey = 'webkitHidden';
        visibilityChangeEventName = 'webkitvisibilitychange';
    }
    if (visibilityChangeEventName) {
        if (!document[docHiddenKey]) startFastCheckTimer();
        document.addEventListener(visibilityChangeEventName, () => {
            if (document[docHiddenKey]) {
                stopFastCheckTimer();
            }
            else {
                startFastCheckTimer();
            }
        });
    }
    else startFastCheckTimer();
}

元素管理

我们从 API 中知道给 stickyfill 添加元素的方式是 Stickyfill.addOne(element)Stickyfill.add(elementList)

addOne (node) {
    // 检测是否是 Node 节点
    if (!(node instanceof HTMLElement)) {
        if (node.length && node[0]) node = node[0];
        else return;
    }
    // 此处是为了去重,避免托管多次
    for (var i = 0; i < stickies.length; i++) {
        if (stickies[i]._node === node) return stickies[i];
    }
    // 返回实例
    return new Sticky(node);
},
// 传数组方法
// 和 addOne 类似
add (nodeList) {
    // ...
},

元素状态转换

那接下来 stickyfill 是如何判断当前节点是什么状态的呢?

Sticky 类中DOM节点的三种状态

我们知道在 stcikyfill 库中(注意,和当前规范不一样):

  • position: sticky 当元素原本的定位处于界面中时,就像 position: absolute 一样。
  • 当元素移动到本该隐藏的情况下,就像 position: fixed 一样。
  • 当元素到达父元素底部,则贴着父元素底部,直至消失。就像 position: absolute; bottom: 0 一样。
转换方法详解

我们从上述方法看到了,stickyfill 将我们需要托管的元素经过筛选并 wrap 上 Sricky 类后,存入了 stickies 数组。同时,我们也知道了 Sticky 中对元素展示形式的三种表示方式。 由此,我们引出关于 Sticky 类中DOM节点的三种状态及各个状态对应的样式定义以及转换方式。具体逻辑在 Sticky 类中的一个私有方法 _recalcPosition

    _recalcPosition () {
        // 如果元素无效就退出
        if (!this._active || this._removed) return;
        // 获取当前元素应该的状态
        const stickyMode = scroll.top <= this._limits.start
            ? 'start'
            : scroll.top >= this._limits.end? 'end': 'middle';
        // 状态相同就退出,避免重复操作
        if (this._stickyMode == stickyMode) return;

        switch (stickyMode) {
            // start状态,可以看到这个就是采用了absolute
            // 然后定义top/right/left值
            case 'start':
                extend(this._node.style, {
                    position: 'absolute',
                    left: this._offsetToParent.left + 'px',
                    right: this._offsetToParent.right + 'px',
                    top: this._offsetToParent.top + 'px',
                    bottom: 'auto',
                    width: 'auto',
                    marginLeft: 0,
                    marginRight: 0,
                    marginTop: 0
                });
                break;
            // 元素真正”黏在“界面上的状态,使用fixed
            // 然后定义top/right/left值
            case 'middle':
                extend(this._node.style, {
                    position: 'fixed',
                    left: this._offsetToWindow.left + 'px',
                    right: this._offsetToWindow.right + 'px',
                    top: this._styles.top,
                    bottom: 'auto',
                    width: 'auto',
                    marginLeft: 0,
                    marginRight: 0,
                    marginTop: 0
                });
                break;
            // 元素贴着父元素底部的状态,使用absolute
            // 同时将bottom设置为0
            case 'end':
                extend(this._node.style, {
                    position: 'absolute',
                    left: this._offsetToParent.left + 'px',
                    right: this._offsetToParent.right + 'px',
                    top: 'auto',
                    bottom: 0,
                    width: 'auto',
                    marginLeft: 0,
                    marginRight: 0
                });
                break;
        }
        // 保存当前状态
        this._stickyMode = stickyMode;
    }

其它小技巧

stickyfill 内部有一些很有意思的小技巧来进行代码优化:

检测是否原生支持sticky

在 stickyfill 中,我们通过一个变量 seppuku 来判断系统是否支持 position: sticky

let seppuku = false;
const isWindowDefined = typeof window !== 'undefined';

// 没 `window` 或者没 `window.getComputedStyle` 这个模块都是不可以用的
if (!isWindowDefined || !window.getComputedStyle) seppuku = true;
// 检测是否支持原生 `position: sticky`
// 大概方法就是:创建一个测试用DOM节点,然后给它的style.potision赋sticky所有可能的值(即带各类前缀)
// 然后再次去取style.position,看DOM元素是否能识别该值
// 这里涉及到了DOM中的部分知识,我们给node.style下面的属性set值时,会自动对输入值进行一次检测,若无误才会真正存入其中
// 这也就是 node.xxx 和 node.setAttribute 之间的区别
else {
    const testNode = document.createElement('div');

    if (
        ['', '-webkit-', '-moz-', '-ms-'].some(prefix => {
            try {
                testNode.style.position = prefix + 'sticky';
            }
            catch(e) {}

            return testNode.style.position != '';
        })
    ) seppuku = true;
}
通过clone节点来避免对真正DOM节点的反复操作

在真实情况下,我们想被托管的node节点可能非常复杂以及庞大。那么我们在对其获取style属性的时候计算量可能会变得很大。在此 stickyfill 通过新建了一个无content的简易div,然后将原node节点的形状样式复制给它,实现了性能的优化:

// 创建clone节点
const clone = this._clone = {};
clone.node = document.createElement('div');

// 将原节点的样式复制一份给clone节点
extend(clone.node.style, {
    width: nodeWinOffset.right - nodeWinOffset.left + 'px',
    height: nodeWinOffset.bottom - nodeWinOffset.top + 'px',
    marginTop: nodeComputedProps.marginTop,
    marginBottom: nodeComputedProps.marginBottom,
    marginLeft: nodeComputedProps.marginLeft,
    marginRight: nodeComputedProps.marginRight,
    cssFloat: nodeComputedProps.cssFloat,
    padding: 0,
    border: 0,
    borderSpacing: 0,
    fontSize: '1em',
    position: 'static'
});
// 插入到界面中
// 因为node节点的定位都是absolute,所以此处直接插在该节点之前,然后被其覆盖掉
// 给用户的展示效果就不会因此发生变化
referenceNode.insertBefore(clone.node, node);
clone.docOffsetTop = getDocOffsetTop(clone.node);

总结

总的来说,stickyfill 的原理是针对元素的三种可能状态,通过监听 window.onscroll 事件来进行状态转换。

参考链接

  • https://github.com/wilddeer/stickyfill
  • https://developer.mozilla.org/zh-CN/docs/Web/CSS/position
  • https://css-tricks.com/position-sticky-2/
  • https://juejin.cn/post/6844903503001829390