在上一篇文章使用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函數: