position:sticky 的 polyfill——stickyfill 源碼淺析

本人最近在修改 blogsue 中的樣式時,使用到了 position: sticky。話很少說,開始主要內容。javascript

定義

position: sticky 是 CSS position 屬性的一個新值。正如它的名字那樣,它會「黏在」你的瀏覽器窗口中。這個展現方式有不少的應用場景。例如知乎的右側就是這樣一個場景:當用戶一直往下翻的時候右側的專欄(廣告)固定住,不會消失在用戶界面。又例如手機端的美團,上面的篩選框也須要保持左邊固定。css

正如以前的瀑布流與 colum-count 同樣,這類應用普遍的排版格式最終都會有原生的實現。 具體使用方式此處就不展開了,能夠參照MDN:https://developer.mozilla.org/zh-CN/docs/Web/CSS/positionhtml

Polyfill——stickyfill

position: sticky 做爲新特性,兼容問題一直是一個邁不過去的坎。能夠看到整個 IE 系列都不支持: java

image
此處,若是咱們但願兼容舊版本的瀏覽器,咱們就須要藉助 polyfill 的力量了。這就是 stickyfill(https://github.com/wilddeer/stickyfill)。 在咱們進行接下來的探索前,要說明的是**stickyfill 並非 position: sticky 的徹底實現。**他們的最終效果有些許差別:

  • stickyfill 不支持x軸
  • stickyfill 會將元素限制在父元素內,即父元素離開屏幕後該元素也會離開(貼着父元素的邊)

stickyfill 用法介紹

在 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 是如何判斷當前節點是什麼狀態的呢?

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
相關文章
相關標籤/搜索