本人最近在修改 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