在上一篇文章使用getBoundingClientRect方法實現簡潔的sticky組件的方法介紹了一個sticky組件的簡潔實現,經過這兩天的思考,發現上次提供的實現還有較多不足的地方,另外跟別的網站上實現的效果在取消固定的時候也有一些不同,上次提供的取消固定的處理方式不好,本文在上文的基礎上,提供一個改進版的sticky組件,功能更加完善,希望您有興趣閱讀。
1. 舊版本的問題
上一個sticky組件的實現中,有多個問題存在:
第一,從sticky的效果上來說,sticky元素在固定前後,不會變化的是相對浏覽器左邊的位置以及sticky元素的整體寬度,可能會變化的是相對浏覽器頂部或底部的位置和sticky元素的高度,而上文提供的實現中把後面兩個會變化的值都當成了不變的值。為什麼在固定的時候top值或bottom值就一定是0?當然可以不是0阿,比如top: 20px,bottom: 15px,在某些場景裡,加上一些這樣的偏移,sticky的效果會更好看,比如bootstrap官方文檔中用到的affix組件實例(這個組件的功能跟本文實現的sticky組件是差不多的):
它就把固定的時候,相對浏覽器頂部的位置設置成了top: 20px。sticky元素的高度也是,為了在固定的時候顯示更好看的效果,調整原來的Line-height或者padding-top等更高度有關的屬性,也是非常常見的需求,比如天貓花呗的這個頁面,這塊內容就用到了sticky組件:
固定前,sticky元素的高度是:
固定後,sticky元素的高度是:
第二,在取消固定的時候,以sticky元素固定在頂部為例,上文提供的實現是在target元素跟浏覽器頂部的距離小於stickyHeight的時候,就直接取消sticky元素的position: fixed屬性,sticky元素立馬被還原到普通文檔流中,效果是:
它是在臨界點的時候立馬就消失的,而天貓花呗的那個效果就不是這樣:
它在臨界點的時候並不是立即消失,而是重新去調整sticky元素的top值,讓它配合著滾動條一起跟隨網頁主體內容一起向上滾動:
從體驗上來說,顯然天貓花呗的這個效果更好一點,從功能上來說,上文提供的實現有一個致命的缺點:就是當sticky元素的高度非常大,超出了浏覽器可視區域的高度的時候,會出現不管你怎麼滾動,都無法浏覽全sticky元素所有內容的BUG,有興趣的可以拿上次實現的代碼在自己博客的側邊欄上試一試。我試過發現了這個問題,所以才想要改進sticky組件:(
第三,上次的實現還有幾處不足的地方:
1)documentElement.clientHeight沒有做緩存,導致每次判斷臨界點時都要去重新獲取:
2)滾動回調間隔的默認值太大,應該再設置小一點,這次用的是5,bootstrap用的是1,只有這樣才能保證效果流暢;
3)有的場景可能不需要resize的時候重新設置sticky元素的寬度,應該加個選項來控制;
4)在sticky元素固定和取消固定的時候,應該提供回調函數,以便其它組件依賴這個組件的時候可以在關鍵點做些事情。
2. 如何改進
組件的選項重新定義了一下:
var DEFAULTS = { target: '', //target元素的jq選擇器 type: 'top', //固定的位置,top | bottom,默認為top,表示固定在頂部 wait: 5, //scroll事件回調的間隔 stickyOffset: 0, //固定時距離浏覽器可視區頂部或底部的偏移,用來設置top跟bottom屬性的值,默認為0 isFixedWidth: true, //sticky元素寬度是否固定,默認為true,如果是自適應的寬度,需設置為false getStickyWidth: undefined, //用來獲取sticky元素寬度的回調,在不傳該參數的情況下,stickyWidth將設置為sticky元素的offsetWidth unStickyDistance: undefined, //該參數決定sticky元素何時進入dynamicSticky狀態 onSticky: undefined, ///sticky元素固定時的回調 onUnSticky: undefined ///sticky元素取消固定時的回調 };
加粗的幾個是新增或有修改的,去掉了原來的height,用unStickyDistance來替代。固定時候相對浏覽器頂部或底部的位置,用stickyOffset來指定,這樣在.sticky--in-top或.sticky--in-bottom的css裡就不用再寫top或bottom屬性值了。isFixedWidth如果為false,才會去添加resize時刷新sticky元素寬度的回調:
!opts.isFixedWidth && $win.resize(throttle(function () { setStickyWidth(); $elem.hasClass(className) && $elem.css('width', stickyWidth); sticky(); }, opts.wait));
本次實現相比上次,麻煩的是取消固定時的邏輯處理,上次sticky元素只有2種狀態,sticky或者unsticky,這次不一樣,sticky狀態裡面又分成了staticSticky和dynamicSticky,前者表示top或bottom值不變的sticky狀態,後者表示top或bottom值會變化的sticky狀態,其實後者對應的就是快要取消固定的時候那段范圍,為了更清晰地解決這個問題,將原來判斷臨界點以及在不同臨界點做不同處理的代碼重構成下面這個樣子:
setSticky = function () { !$elem.hasClass(className) && $elem.addClass(className).css('width', stickyWidth) && (typeof opts.onSticky == 'function' && opts.onSticky($elem, $target)); return true; }, states = { staticSticky: function () { setSticky() && $elem.css(opts.type, opts.stickyOffset); }, dynamicSticky: function (rect) { setSticky() && $elem.css(opts.type, rules[opts.type].getDynamicOffset(rect)); }, unSticky: function () { $elem.hasClass(className) && $elem.removeClass(className).css('width', '').css(opts.type, '') && (typeof opts.onUnSticky == 'function' && opts.onUnSticky($elem, $target)); } }, rules = { top: { getState: function (rect) { if (rect.top < 0 && (rect.bottom - unStickyDistance) > 0) return 'staticSticky'; else if ((rect.bottom - unStickyDistance) <= 0 && rect.bottom > 0) return 'dynamicSticky'; else return 'unSticky'; }, getDynamicOffset: function (rect) { return -(unStickyDistance - rect.bottom); } }, bottom: { getState: function (rect) { if (rect.bottom > docClientHeight && (rect.top + unStickyDistance) < docClientHeight) return 'staticSticky'; else if ((rect.top + unStickyDistance) >= docClientHeight && rect.top < docClientHeight) return 'dynamicSticky'; else return 'unSticky'; }, getDynamicOffset: function (rect) { return -(unStickyDistance + rect.top - docClientHeight); } } } $win.scroll(throttle(sticky, opts.wait)); function sticky() { var rect = $target[0].getBoundingClientRect(), curState = rules[opts.type].getState(rect); states[curState](rect); }
有點狀態模式的思想在裡面,不過更簡潔。當我寫出這個代碼的時候,其實是很想用之前了解的狀態機來寫的,我想過用狀態機來寫肯定是可以實現的,不過為了少引用一個類庫就算了,等哪天想實踐狀態機的時候再來嘗試一把。
整體實現如下:
var Sticky = (function ($) { function throttle(func, wait) { var timer = null; return function () { var self = this, args = arguments; if (timer) clearTimeout(timer); timer = setTimeout(function () { return typeof func === 'function' && func.apply(self, args); }, wait); } } var DEFAULTS = { target: '', //target元素的jq選擇器 type: 'top', //固定的位置,top | bottom,默認為top,表示固定在頂部 wait: 5, //scroll事件回調的間隔 stickyOffset: 0, //固定時距離浏覽器可視區頂部或底部的偏移,用來設置top跟bottom屬性的值,默認為0 isFixedWidth: true, //sticky元素寬度是否固定,默認為true,如果是自適應的寬度,需設置為false getStickyWidth: undefined, //用來獲取sticky元素寬度的回調,在不傳該參數的情況下,stickyWidth將設置為sticky元素的offsetWidth unStickyDistance: undefined, //該參數決定sticky元素何時進入dynamicSticky狀態 onSticky: undefined, ///sticky元素固定時的回調 onUnSticky: undefined ///sticky元素取消固定時的回調 }; return function (elem, opts) { var $elem = $(elem); opts = $.extend({}, DEFAULTS, opts || {}, $elem.data() || {}); var $target = $(opts.target); if (!$elem.length || !$target.length) return; var stickyWidth, setStickyWidth = function () { stickyWidth = typeof opts.getStickyWidth === 'function' && opts.getStickyWidth($elem) || $elem[0].offsetWidth; }, docClientHeight = document.documentElement.clientHeight, unStickyDistance = opts.unStickyDistance || $elem[0].offsetHeight, setSticky = function () { !$elem.hasClass(className) && $elem.addClass(className).css('width', stickyWidth) && (typeof opts.onSticky == 'function' && opts.onSticky($elem, $target)); return true; }, states = { staticSticky: function () { setSticky() && $elem.css(opts.type, opts.stickyOffset); }, dynamicSticky: function (rect) { setSticky() && $elem.css(opts.type, rules[opts.type].getDynamicOffset(rect)); }, unSticky: function () { $elem.hasClass(className) && $elem.removeClass(className).css('width', '').css(opts.type, '') && (typeof opts.onUnSticky == 'function' && opts.onUnSticky($elem, $target)); } }, rules = { top: { getState: function (rect) { if (rect.top < 0 && (rect.bottom - unStickyDistance) > 0) return 'staticSticky'; else if ((rect.bottom - unStickyDistance) <= 0 && rect.bottom > 0) return 'dynamicSticky'; else return 'unSticky'; }, getDynamicOffset: function (rect) { return -(unStickyDistance - rect.bottom); } }, bottom: { getState: function (rect) { if (rect.bottom > docClientHeight && (rect.top + unStickyDistance) < docClientHeight) return 'staticSticky'; else if ((rect.top + unStickyDistance) >= docClientHeight && rect.top < docClientHeight) return 'dynamicSticky'; else return 'unSticky'; }, getDynamicOffset: function (rect) { return -(unStickyDistance + rect.top - docClientHeight); } } }, className = 'sticky--in-' + opts.type, $win = $(window); setStickyWidth(); $win.scroll(throttle(sticky, opts.wait)); !opts.isFixedWidth && $win.resize(throttle(function () { setStickyWidth(); $elem.hasClass(className) && $elem.css('width', stickyWidth); sticky(); }, opts.wait)); $win.resize(throttle(function () { docClientHeight = document.documentElement.clientHeight; }, opts.wait)); function sticky() { var rect = $target[0].getBoundingClientRect(), curState = rules[opts.type].getState(rect); states[curState](rect); } } })(jQuery);
難理解的可能是getState的那個方法的邏輯,這部分的一些思路在上上篇博客有比較詳細的說明。
3. 博客側邊欄應用說明
首先得把本次的實現粘貼到博客設置頁腳html文本域裡面去,然後加入下面的代碼來初始化:
var timer = setInterval(function(){ if($('#blogCalendar').length && $('#profile_block').length && $('#sidebar_search').length) { new Sticky('#sideBar', { target: '#main', onSticky: function($elem, $target){ $target.css('min-height',$elem.outerHeight()); $elem.css('left', '65px'); }, onUnSticky: function($elem, $target){ $target.css('min-height',''); $elem.css('left', ''); } }); } },100);
使用timer是因為側邊欄的內容都是ajax加載,又不可能在這些ajax請求時候添加回調,只能通過它們返回的內容來判斷側邊欄是否加載完畢。
4. 總結
這周末琢磨了下如何改進sticky組件,加上寫這篇文章,花了大半天的時間,好歹現在這個sticky組件的功能跟實現能讓自己有點滿意的感覺了,上次寫完總覺得怪怪的,好像缺點什麼,原來是因為還差這麼多東西。現在這個組件還只是能實現固定和取消固定的效果,對於實際工作而言,這個層級的效果可能還不夠,網上常見的那種在固定的同時支持導航滾動或者tab導航的功能也很常見,下篇文章會介紹基於本文的sticky組件,如何實現navScrollSticky以及tabSticky組件,敬請關注。
感謝您的閱讀:)
補充說明:
IE跟火狐裡面,在刷新頁面的時候,如果刷新前頁面有滾動,刷新的操作雖然還會把頁面的滾動位置設置成刷新的位置,但是不會觸發scroll事件,所以必須在組件初始化之後立即調用一次sticky函數: