本人最近在修改 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 系列都不支持:
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.im/post/59de306451882578c52662e9