position:sticky 的 polyfill——stickyfill 原始碼淺析

CoderMing發表於2019-03-12

本人最近在修改 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 系列都不支援:

image
此處,如果我們希望相容舊版本的瀏覽器,我們就需要藉助 polyfill 的力量了。這就是 stickyfill(https://github.com/wilddeer/stickyfill)。 在我們進行接下來的探索前,要說明的是**stickyfill 並不是 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

相關文章