本人最近在修改 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
事件來進行狀態轉換。