sticky組件,通常應用於導航條或工具列,當網頁在某一區域滾動的時候,將導航條或工具列這類元素固定在頁面頂部或底部,方便用戶快速進行這類元素提供的操作。本文介紹這種組件的實現思路,並提供一個同時支持將sticky元素固定在頂部或底部的具體實現,由於這種組件在網站中非常常見,所以有必要掌握它的實現方式,以便在有需要的時候基於它的思路寫出功能較多的組件出來。
固定在頂部的demo效果(對應sticky-top.html):
固定在底部的demo效果(對應sticky-bottom.html):
1. 實現思路
實現這個元件的關鍵在於找到元素何時被固定以及何時被取消固定的臨界點,要找到這個臨界點,首先要詳細看看前面demo的變化過程。在前面的demo中,有一個導航條元素,也就是我們要控制固定與否的元素,我稱它為sticky元素;還有一個元素,它用來顯示網頁的一塊列表內容,這個列表元素跟sticky元素在功能上是相關的,因為sticky元素要導航的正是這個列表元素提供的內容,本文在開始介紹sticky組件的功能時,就說過sticky組件固定是發生在網頁滾動至某一區域的時候,離開這一區域就會取消固定,這個滾動區域或者說滾動範圍,就是由列表元素來決定的,所以這個列表元素是找到臨界點的關鍵,它表示sticky組件可被固定的網頁滾動範圍,為了後面引用方便,我把這個元素稱為target元素。下面就來詳細了解下前面demo的變化過程,由於固定在底部的情況與固定在頂部的情況實現思路是相通的,如果弄明白了固定在頂部的實現原理,相信你也一定能弄清楚固定在底部的實作原理,所以這裡也是為了減少篇幅,提高效率,只介紹固定在頂部的情況:
一開始sticky元素和target元素的狀態是這樣的:
當捲軸慢慢向下,使得網頁向上滾動的時候,sticky元素和target元素在一段滾動距離內狀態並沒有發生變化,一直到這個狀態(滾動條滾動距離為573px):
在這個狀態只要滾動條再往下滾動1px,sticky元素就會被固定在頂部(滾動條滾動距離為574px):
也就是說當target元素的頂部離瀏覽器頂部的距離小於0的時候(target元素的頂部未超出瀏覽器頂部的時候,距離看作大於0),sticky元素就會被固定,所以這就是我們要找的第一個臨界點。然後捲軸繼續向下捲動,只要target元素還在瀏覽器視覺區域內,sticky元素就會一直被固定:
直到這個狀態(滾動條滾動距離為1861px):
在這個狀態只要捲軸再往下滾動1px,sticky元素就會取消固定在頂部(滾動條滾動距離為1862px):
顯然,這就是我們要找的第2個臨界點,不過它的判斷條件是:當target元素的底部離瀏覽器頂部的距離小於sticky元素的高度時,sticky元素就會被取消固定。這裡為什麼是小於sticky元素的高度,而不是小於0,原因是因為基於小於0這個臨界點開發出來的組件,會出現target元素幾乎快從瀏覽器可視區域消失了,但是sticky元素還固定在那的效果:
sticky還把footer的內容給蓋住了,本來是為了方便用戶操作,結果影響了用戶操作,所以得把取消固定這個臨界點提前,而用sticky元素的高度最合適。
透過前面對demo變化過程的拆解,我們已經得到了滾動條一直向下滾動時,sticky狀態變化的兩個臨界點:
1)當target元素的頂部離瀏覽器頂部的距離小於0的時候,sticky元素就會被固定;
2)當target元素的底部離瀏覽器頂部的距離小於sticky元素的高度時,sticky元素就會被取消固定。
綜合這兩個臨界點,可以得出滾動條向下滾動時,sticky元素被固定的滾動範圍的判斷條件是:target元素的頂部離瀏覽器頂部的距離小於0 並且target元素的底部離瀏覽器頂部的距離大於sticky元素的高度。而這個判斷條件,同樣適用於滾動條向上滾動的情況,因為滾動條一直向上滾動時,sticky狀態變化的臨界點是:
1)當target元素的底部離瀏覽器頂部的距離大於sticky元素的高度時,sticky元素就會被固定;
2)當target元素的頂部離瀏覽器頂部的距離大於0的時候,sticky元素就會被取消固定。
(這兩個臨界點,其實跟滾動條向下滾動時提到的兩個臨界點,是一個意思,只不過是正話反著說而已)
所以只要得到【target元素的頂部離瀏覽器頂部的距離】,【target元素的底部離瀏覽器頂部的距離】,【sticky元素的高度】這三個值基本上就能實現這個組件了。這三個值中sticky元素的高度由設計圖決定,它從網頁一開始製作就是已知的,在定義組件的時候我們可以從外部傳進去,雖然也能從js去獲取它的高度,不過顯然沒有必要增加額外的計算;另外兩個值【target元素的頂部離瀏覽器頂部的距離】,【target元素的底部離瀏覽器頂部的距離】,我們正好可以利用DOM提供的一個方法來獲取,這個方法是:getBoundingClientRect,這是一個相容性很好的方法,它的呼叫方式是:
var target = document.getElementById('main-container'); var rect = target.getBoundingClientRect(); console.log(rect);
傳回一個ClientRect對象,這個物件儲存元素框模型的一些訊息,例如它的寬高度(width and height),以及元素框上下邊距離瀏覽器頂部邊緣的距離(top and bottom),左右邊距離瀏覽器左邊緣的距離(left and right):
top跟bottom正是我們要取得的【target元素的頂部離瀏覽器頂部的距離】,【target元素的底部離瀏覽器頂部的距離】,而且當框的頂部或底部未超出瀏覽器頂部的時候,top跟bottom都是大於0的值,而當框的頂部或底部超出瀏覽器頂部的時候,top跟bottom是小於0的值:
當我們找到了【target元素的頂部離瀏覽器頂部的距離】,【target元素的底部離瀏覽器頂部的距離】,【sticky元素的高度】這三個值,就可以用程式碼來描述前面的判斷條件:
rect.top < 0 && (rect.bottom - stickyHeight) > 0;
(rect表示target元素调用getBoundingClientRect返回的对象,stickyHeight表示sticky元素的高度)
最后为了让实现思路更加完整,虽然不详细介绍固定在底部的情况的变化过程,我还是把这种情况的临界点跟判断方式补充进来,它的临界点是(这里列的是滚动条向下滚动时的临界点):
1)当target元素的顶部离浏览器顶部的距离 + sticky元素的高度 小于浏览器可视区域的高度时,sticky元素被固定;
2)当target元素的底部离浏览器的顶部的距离小于浏览器可视区域的高度时,sticky元素被取消固定。
浏览器可视区域的高度,可用document.documentElement.clientHeight来获取,这个属性也是没有兼容性问题的,判断代码为:
var docClientWidth = document.documentElement.clientHeight; rect.bottom > docClientWidth && (rect.top + stickyHeight) < docClientWidth;
2. 实现细节
1)html结构
固定在顶部的html结构:
<div class="container-fluid sticky-wrapper"> <ul id="sticky" data-target="#main-container" class="sticky nav nav-pills"> <li role="presentation" class="active"><a href="#">Home</a></li> <li role="presentation"><a href="#">Profile</a></li> <li role="presentation"><a href="#">Messages</a></li> </ul> </div> <div id="main-container" class="container-fluid"> <div class="row"> ... </div> ... </div>
固定在底部的html结构:
<div id="main-container" class="container-fluid"> <div class="row"> ... </div> ... </div> <div class="container-fluid sticky-wrapper"> <ul id="sticky" data-target="#main-container" class="sticky nav nav-pills"> <li role="presentation" class="active"><a href="#">Home</a></li> <li role="presentation"><a href="#">Profile</a></li> <li role="presentation"><a href="#">Messages</a></li> </ul> </div>
以上#main-container就是我们的target元素,#sticky就是我们的sticky元素,还需要注意两点:
a. 顺序问题,两种结构中,target元素与sticky的父元素顺序位置是反的;
b. sticky元素外面必须包裹一层元素,而且还得给这一层元素设置height属性:
.sticky-wrapper { margin-bottom: 10px; height: 52px; }
这是因为当sticky元素被固定的时候,它会脱离普通文档流,所以要利用它的父元素把sticky元素的高度在普通文档流中撑起来,以免在固定效果出现的时候,target元素的内容出现跳动的情况。
2)固定效果
让一个元素固定在浏览器的某个位置,当然是通过position: fixed来弄,所以可以用两个css类来实现固定在顶部和固定在底部的效果:
.sticky--in-top,.sticky--in-bottom { position: fixed; z-index: 1000; } .sticky--in-top { top: 0; } .sticky--in-bottom { bottom: 0; }
当我们判断元素需要被固定在顶部的时候,就给它添加.sticky--in-top的css类;当我们判断元素需要被固定在底部的时候,就给它添加.sticky--in-bottom的css类。
3)滚动回调
控制sticy元素固定的逻辑显然要写在window的scroll事件回调中(有了前面对实现思路以及判断条件的说明,相信理解下面这段代码应该会很容易):
固定在顶部的回调逻辑:
$(window).scroll(function() { var rect = $target[0].getBoundingClientRect(); if (rect.top < 0 && (rect.bottom - stickyHeight) > 0) { !$elem.hasClass('sticky--in-top') && $elem.addClass('sticky--in-top').css('width', stickyWidth + 'px'); } else { $elem.hasClass('sticky--in-top') && $elem.removeClass('sticky--in-top').css('width', 'auto'); } });
其中:$target是target元素的jq对象,$elem是sticky元素的jq对象,stickyHeight是sticky元素的高度,stickyWidth是sticky元素的宽度。由于sticky元素固定时,脱离原来的文档流,需要设置宽度才能显示跟固定前一样的宽度。
固定在底部的回调逻辑:
$(window).scroll(function() { var rect = $target[0].getBoundingClientRect(), docClientWidth = document.documentElement.clientHeight; if (rect.bottom > docClientWidth && (rect.top + stickyHeight) < docClientWidth) { !$elem.hasClass('sticky--in-bottom') && $elem.addClass('sticky--in-bottom').css('width', stickyWidth + 'px'); } else { $elem.hasClass('sticky--in-bottom') && $elem.removeClass('sticky--in-bottom').css('width', 'auto'); } });
这里是为了把回调逻辑说的更清楚才把代码分成两份,最后给的实现会把这两个代码合并成一份:)
4)函数节流
函数节流通常应用于window的scroll事件,resize事件以及普通元素的mousemove事件,因为这些事件由于鼠标或滚轮操作很频繁,会导致回调连续触发,如果回调里面含有DOM操作,这种连续调用就会影响页面的性能,所以很有必要控制这类回调的执行次数,函数节流就是做这个的,我这里提供了一个很简单的函数节流实现:
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); } }
这个函数可以控制func所指定的函数,执行的间隔指定为wait指定的毫秒数,利用它,我们可以把前面的滚动回调改动一下,比如固定在顶部的情况改成:
$(window).scroll(throttle(function() { var rect = $target[0].getBoundingClientRect(), docClientWidth = document.documentElement.clientHeight; if (rect.bottom > docClientWidth && (rect.top + stickyHeight) < docClientWidth) { !$elem.hasClass('sticky--in-bottom') && $elem.addClass('sticky--in-bottom').css('width', stickyWidth + 'px'); } else { $elem.hasClass('sticky--in-bottom') && $elem.removeClass('sticky--in-bottom').css('width', 'auto'); } }, 50);
其实真正处理回调的是throttle返回的函数,这个返回的函数逻辑少,而且没有DOM操作,它是会被连续调用的,但是不影响页面性能,而我们真正处理逻辑的那个函数,也就是传入throttle的那个函数因为throttle创建的闭包的作用,不会被连续调用,这样就实现了控制函数执行次数的目的。
5)resize的问题
window resize总是在定义组件的时候带来问题,因为页面可视区域的宽高度发生了变化,sticky元素的父容器宽度也可能发生了变化,而且resize的时候不会触发scroll事件,所以我们需要在resize回调内,刷新sticky元素的宽度以及重新调用固定效果的逻辑,这个相关的代码就不贴出来了,后面直接看整体实现吧,否则我怕放出来会影响理解。总之resize是我们在定义组件的时候肯定要考虑的,不过一般都放到最后来处理,有点算处理BUG之类的工作。
3. 整体实现
代码比较简洁:
/** * @param elem: jquery选择器,用来获取要被固定的元素 * @param opts: * - target: jquery选择器,用来获取表示固定范围的元素 * - type: top|bottom,表示要固定的位置 * - height: 要固定的元素的高度,由于高度在做页面时就是确定的并且几乎不会被DOM操作改变,直接从外部传入可以除去获取元素高度的操作 * - wait: 滚动事件回调的节流时间,控制回调至少隔多长时间才执行一次 * - getStickyWidth:获取要固定元素的宽度,window resize或者DOM操作会导致固定元素的宽度发生变化,需要这个回调来刷新stickyWidth */ var Sticky = function (elem, opts) { var $elem = $(elem), $target = $(opts.target || $elem.data('target')); if (!$elem.length || !$target.length) return; var stickyWidth, $win = $(window), stickyHeight = opts.height || $elem[0].offsetHeight, rules = { top: function (rect) { return rect.top < 0 && (rect.bottom - stickyHeight) > 0; }, bottom: function (rect) { var docClientWidth = document.documentElement.clientHeight; return rect.bottom > docClientWidth && (rect.top + stickyHeight) < docClientWidth; } }, type = (opts.type in rules) && opts.type || 'top', className = 'sticky--in-' + type; refreshStickyWidth(); $win.scroll(throttle(sticky, $.isNumeric(opts.wait) && parseInt(opts.wait) || 100)); $win.resize(throttle(function () { refreshStickyWidth(); sticky(); }, 50)); function refreshStickyWidth() { stickyWidth = typeof opts.getStickyWidth === 'function' && opts.getStickyWidth($elem) || $elem[0].offsetWidth; $elem.hasClass(className) && $elem.css('width', stickyWidth + 'px'); } //效果实现 function sticky() { if (rules[type]($target[0].getBoundingClientRect())) { !$elem.hasClass(className) && $elem.addClass(className).css('width', stickyWidth + 'px'); } else { $elem.hasClass(className) && $elem.removeClass(className).css('width', 'auto'); } } //函数节流 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); } } };
调用方式,固定在顶部的情况(type选项默认为top):
<script> new Sticky('#sticky',{ height: 52, getStickyWidth: function($elem){ return ($elem.parent()[0].offsetWidth - 30); } }); </script>
固定在底部的情况:
<script> new Sticky('#sticky',{ height: 52, type: 'bottom', getStickyWidth: function($elem){ return ($elem.parent()[0].offsetWidth - 30); } }); </script>
还有一个要说明的是,opts的getStickyWidth选项,这个回调用来获取sticky元素的宽度,为什么要把它放出来,通过外部去获取宽度,而不是在组件内部通过offsetWidth获取?是因为当sticky元素的外部容器是自适应的时候,sticky元素固定时的宽度不是由sticky元素自己决定的,而是依赖于外部容器的宽度,所以这个宽度只能在外部去获取,内部获取不准确。比如上面的代码中我减了一个30,如果在组件内部获取的话,我肯定不知道要添加减30这样一个逻辑。
4. 总结
本文提供了一个很常见的sticky组件实现,实现这个组件的关键在于找到控制sticky元素固定与否的关键点,同时在实现的时候函数节流跟window resize的问题需要特别注意。
我一直认为对于一些简单的组件,掌握它的思路,自己去定义比直接从github上去找开源的插件要来的更切实际:
1)代码可控,不用去阅读别人的代码,有问题也能快速修改
2)代码量小,开源的插件会尽可能多做事,而有些工作你的项目并不一定需要它去做;
3)更贴合项目的实际需求,跟第2点差不多的意思,在已有的思路基础上,我们能开发出与项目需求完全契合的功能模块;
4)有助于提高自己的技术水平,增进知识的广度和深度;
所以有能力造轮子的时候,造造也是很有必要的。
本文虽然在最后提供了整体的组件实现,但是并不是建议拿来就用,否则前面大篇幅地去介绍实现思路就没有必要了,我只要放个github地址即可,思路远比实现重要。我最近几篇博客都是在分享思路,而不是分享某个具体的实现,思路这种抽象的东西是通用的,理解前它不是你的,理解后它就存在于脑袋里,任何时候都可以拿来就用,我提供的思路也同样来自于我对其它博客其它插件源码学习之后的思考与总结。
补充于说明:
本文实现有不足,不完美的地方,请在了解本文相关内容后,移步阅读《sticky组件的改进实现》了解更佳的实现。