本人最近在修改 blogsue 中的样式时,使用到了 position: sticky
。话很少说,开始主要内容。javascript
position: sticky
是 CSS position
属性的一个新值。正如它的名字那样,它会“黏在”你的浏览器窗口中。这个展现方式有不少的应用场景。例如知乎的右侧就是这样一个场景:当用户一直往下翻的时候右侧的专栏(广告)固定住,不会消失在用户界面。又例如手机端的美团,上面的筛选框也须要保持左边固定。css
正如以前的瀑布流与 colum-count
同样,这类应用普遍的排版格式最终都会有原生的实现。 具体使用方式此处就不展开了,能够参照MDN:https://developer.mozilla.org/zh-CN/docs/Web/CSS/positionhtml
position: sticky
做为新特性,兼容问题一直是一个迈不过去的坎。能够看到整个 IE 系列都不支持: java
position: sticky
的彻底实现。**他们的最终效果有些许差别:
在 stickyfill repo 中,做者介绍了该 polyfill 的使用方式:node
<div class="sticky">
...
</div>
复制代码
.sticky {
position: -webkit-sticky;
position: sticky;
top: 0;
}
复制代码
Then apply the polyfill:git
var elements = document.querySelectorAll('.sticky');
Stickyfill.add(elements);
复制代码
pollyfill 做为“补丁”,最理想的状态下是只须要将其代码引入到项目中,以后不须要作任何事情。例如 Promise 的 polyfill,就是直接在 global 下建立了 promise 类,咱们只需引入,其会自动帮咱们作好准备工做。但 stickyfill可否这样作呢? 理论上是能够的。由于 stickyfill 只须要遍历 DOM 树找出全部 position
attribute 为 sticky
的 DOM 节点,而后对其添加规则便可。但在实际中,因为遍历 DOM 树性能消耗过高,stickyfill 退而求其次,让咱们来选择须要遍历的节点。github
刚刚咱们知道了 stickyfill 的用法,能够知道,stickyfill 是将咱们所须要处理的元素进行了托管,利用 javascript 的能力来模拟实现 position: sticky
的功能。 接下来咱们一块儿去看一下 stickyfill 是如何管理、处理元素的。基于文章长度限制,本文只讲解核心的几个方法。下面的源码为了条理清晰,通过精简:web
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 在全局初始化阶段作好了滚动事件监听、运行环境检测等工做:promise
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 是如何判断当前节点是什么状态的呢?
咱们知道在 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 内部有一些颇有意思的小技巧来进行代码优化:
在 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;
}
复制代码
在真实状况下,咱们想被托管的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
事件来进行状态转换。