設計一個基於vue.js 2.x的虛擬滾動條

wangy7099發表於2018-01-23

前言

記得以前偶然有一次瀏覽過一個開源的cms專案,發現這個專案的左邊的選單已經超出了windows的寬度,我就好奇為什麼沒出滾動條呢?然後我仔細一看,發現它左側有一個小的div,然後我嘗試著拖動它,發現竟能和原生的滾動條一樣!可以通過檢視它的原始碼,發現了這款滾動條的叫做slimScroll,然後我去它的github倉庫看了下,研究了一下原始碼,給我的感覺是我也能做出來一樣的滾動條!通過vue實現!

設計

好, 現在開始我們的設計滾動條的步驟:

設計滾動條dom

首先要思考的是:如果要使你需要滾動的內容滾動的話,首先一點是它的父dom必須為固定長寬,即超出部分要隱藏掉,即加了個樣式:overflow: hidden, 所以,我們給所要滾動的內容加個包裝,使它的長寬和父dom相等,然後有一個樣式叫:overflow: hidden ,這個包裝的元素就叫 scrollPanel

其次:我們知道,我們要做到與原生滾動條一樣強大!就必須設計水平滾動條和垂直滾動條,滾動條和scrollPanel屬於兄弟節點之間的關係,因為滾動條的存在不能使原本的樣式排版錯誤,並且支援topleft來控制其位置,所以滾動條的position必須是absolute,好了,我們叫水平滾動條為:hBar,垂直滾動條為:vBar

最後:我們設計了scrollPanelvBarhBar, 我們需要一個父div來把他們包裝起來,然後加個樣式:position: relative

實踐

設計元件結構

首先,我們的外掛一共是4個元件,其中3個是子元件,1個是父元件,分別是:vueScroll(父元件)、scrollPanel(包裹需要滾動內容的子元件)、vBar(垂直滾動條)、hBar(水平滾動條)

其次,讓我們設計一下各元件所分管的功能。這裡的元件分為控制層元件和展示元件(熟悉react的同學應該有所瞭解),展示層元件只完成展示的功能:vBarhBarscrollPanel,控制層元件有點類似於cpu,可以控制子元件的各個狀態,比如寬、高、顏色、透明度、位置等等。控制層元件就是:vueScroll

具體實現

hBar/vBar

hBar/vBar這兩個分別為水平滾動條和垂直滾動條,所實現的功能大體是一樣的,所以舊放在一起說了,這裡以vBar為例。

  1. props 接收父元件傳過來的屬性,具體為:
 {
    height: vm.state.height + 'px',  //滾動條的高度
    width: vm.ops.width, // 滾動條的寬度
    position: 'absolute', 
    background: vm.ops.background, // 滾動條背景色
    top: vm.state.top + 'px', // 滾動條的高度
    transition: 'opacity .5s', // 消失/顯示 所用的時間
    cursor: 'pointer', //
    opacity: vm.state.opacity, // 透明度
    userSelect: 'none' 
 }
複製程式碼

2 事件,主要是當滑鼠移動的時候,顯示滾動條。

...
render(_c){
    return _c(
       // ...
        {
            mouseenter: function(e) {
                vm.$emit('showVBar'); // 觸發父元件事件,顯示滾動條
            }
        }
       // ...
    )
}
複製程式碼

其中state表示狀態,是在執行時可發生改變的,而 ops 則是配置引數,是使用者傳過來的。

scrollPanel

包裹滾動內容的元件,樣式需設定為:overflow: hidden

  1. 樣式
 var style = vm.scrollContentStyle;
 style.overflow = 'hidden';
 // ...
  {
      style: style
  }
 // ...
複製程式碼
  1. 事件
 // ...
    render(_c) {
        // ...
            on: {
                mouseenter: function() {
                    vm.$emit('showBar');
                },
                mouseleave: function() {
                    vm.$emit('hideBar');
                }
            }
        // ...
    }
 // ...
複製程式碼

vuescroll

控制元件。控制子元件顯示的狀態,新增各種監聽事件等。

  1. 取得子元件的dom元素,用來取得dom的實時資訊。
    // ...
     initEl() {
        this.scrollPanel.el = this.$refs['vueScrollPanel'] && this.$refs['vueScrollPanel'].$el;
        this.vScrollBar.el = this.$refs['vScrollBar'] && this.$refs['vScrollBar'].$el;
        this.hScrollBar.el = this.$refs['hScrollBar'] && this.$refs['hScrollBar'].$el;
    }
    // ...
複製程式碼
  1. 顯示滾動條

顯示滾動條,包括顯示水平滾動條和顯示垂直滾動條,這裡以顯示垂直滾動條為例:

    // ...
        var temp;
        var deltaY = {
            deltaY: this.vScrollBar.ops.deltaY // 獲取使用者配置的deltaY
        };
        if(!this.isMouseLeavePanel || this.vScrollBar.ops.keepShow){
            if ((this.vScrollBar.state.height = temp = this.getVBarHeight(deltaY))) { // 判斷條件
                // 重新設定滾動條的狀態
                this.vScrollBar.state.top = this.resizeVBarTop(temp);
                this.vScrollBar.state.height = temp.height;
                this.vScrollBar.state.opacity = this.vScrollBar.ops.opacity;
            }
        }
    // ...
複製程式碼
  1. 獲取滾動條的高度

因為dom元素的高度不是固定的,所以你要實時地獲取dom真實的高度,滾動條的高度計算公式如下:

var height = Math.max(
            scrollPanelHeight / 
            (scrollPanelScrollHeight / scrollPanelHeight), 
            this.vScrollBar.minBarHeight
            );
複製程式碼

即:滾動條的高度:scrollPanel的高度 == scrollPanel的高度:dom元素高度

  1. resizeVBarTop,為了防止誤差,並且可以求出滾動條距離父元素的高度。
 resizeVBarTop({height, scrollPanelHeight, scrollPanelScrollHeight, deltaY}) {
    // cacl the last height first
    var lastHeight = scrollPanelScrollHeight - scrollPanelHeight - this.scrollPanel.el.scrollTop;
    if(lastHeight < this.accuracy) {
        lastHeight = 0;
    }
    var time = Math.abs(Math.ceil(lastHeight / deltaY));
    var top = scrollPanelHeight - (height + (time * this.vScrollBar.innerDeltaY));
    return top;
}
複製程式碼
  1. 監聽滾輪滾動的事件。
    // ...
    on: {
        wheel: vm.wheel
    }
    // ...
     wheel(e) {
        var vm = this;
        vm.showVBar();
        vm.scrollVBar(e.deltaY > 0 ? 1 : -1, 1);
        e.stopPropagation();
    }
    // ...
複製程式碼
  1. 監聽滾動條拖拽事件
    listenVBarDrag: function() {
        var vm = this;
        var y;
        var _y;
        function move(e) {
            _y = e.pageY;
            var _delta = _y - y;
            vm.scrollVBar(_delta > 0 ? 1 : -1, Math.abs(_delta / vm.vScrollBar.innerDeltaY));
            y = _y;
        }
        function t(e) {
            var deltaY = {
                deltaY: vm.vScrollBar.ops.deltaY
            };
            if(!vm.getVBarHeight(deltaY)) {
                return;
            }
            vm.mousedown = true;
            y = e.pageY; // 記錄初始的Y的位置
            vm.showVBar();
            document.addEventListener('mousemove', move);
            document.addEventListener('mouseup', function(e) {
                vm.mousedown = false;
                vm.hideVBar();
                document.removeEventListener('mousemove', move);
            });
        }
        this.listeners.push({
            dom: vm.vScrollBar.el,
            event: t,
            type: "mousedown"
        });
        vm.vScrollBar.el.addEventListener('mousedown', t); // 把事件放到陣列裡面,等銷燬之前移除掉註冊的時間。
    }
複製程式碼
  1. 適配移動端,監聽touch事件。原理跟拖拽事件差不多,無非就是多了個判斷,來判斷當前方向是x還是y。
    listenPanelTouch: function() {
        var vm = this;
        var pannel = this.scrollPanel.el;
        var x, y;
        var _x, _y;
        function move(e) {
            if(e.touches.length) {
                var touch = e.touches[0];
                _x = touch.pageX;
                _y = touch.pageY;
                var _delta = void 0;
                var _deltaX = _x - x;
                var _deltaY = _y - y;
                if(Math.abs(_deltaX) > Math.abs(_deltaY)) {
                    _delta = _deltaX;
                    vm.scrollHBar(_delta > 0 ? -1 : 1, Math.abs(_delta / vm.hScrollBar.innerDeltaX));
                } else if(Math.abs(_deltaX) < Math.abs(_deltaY)){
                    _delta = _deltaY;
                    vm.scrollVBar(_delta > 0 ? -1 : 1, Math.abs(_delta / vm.vScrollBar.innerDeltaY));
                }
                x = _x;
                y = _y;
            }
        }
        function t(e) {
            var deltaY = {
                deltaY: vm.vScrollBar.ops.deltaY
            };
            var deltaX = {
                deltaX: vm.hScrollBar.ops.deltaX
            };
            if(!vm.getHBarWidth(deltaX) && !vm.getVBarHeight(deltaY)) {
                return;
            }
            if(e.touches.length) {
                e.stopPropagation();
                var touch = e.touches[0];
                vm.mousedown = true;
                x = touch.pageX;
                y = touch.pageY;
                vm.showBar();
                pannel.addEventListener('touchmove', move);
                pannel.addEventListener('touchend', function(e) {
                    vm.mousedown = false;
                    vm.hideBar();
                    pannel.removeEventListener('touchmove', move);
                });
            }
        }
        pannel.addEventListener('touchstart', t);
        this.listeners.push({
            dom: pannel,
            event: t,
            type: "touchstart"
        });
    }
複製程式碼
  1. 滾動內容

滾動內容的原理無非就是改變scrollPanelscrollTop/scrollLeft來達到控制內容上下左右移動的目的。

    scrollVBar: function(pos, time) {
        // >0 scroll to down  <0 scroll to up
         
        var top = this.vScrollBar.state.top; 
        var scrollPanelHeight = getComputed(this.scrollPanel.el, 'height').replace('px', "");
        var scrollPanelScrollHeight = this.scrollPanel.el.scrollHeight;
        var scrollPanelScrollTop = this.scrollPanel.el.scrollTop;
        var height = this.vScrollBar.state.height;
        var innerdeltaY = this.vScrollBar.innerDeltaY;
        var deltaY = this.vScrollBar.ops.deltaY;
        if (!((pos < 0 && top <= 0) || (scrollPanelHeight <= top + height && pos > 0) || (Math.abs(scrollPanelScrollHeight - scrollPanelHeight) < this.accuracy))) {
            var Top = top + pos * innerdeltaY * time;
            var ScrollTop = scrollPanelScrollTop + pos * deltaY * time;
            if (pos < 0) {
                // scroll ip
                this.vScrollBar.state.top = Math.max(0, Top);
                this.scrollPanel.el.scrollTop = Math.max(0, ScrollTop);
            } else if (pos > 0) {
                // scroll down
                this.vScrollBar.state.top = Math.min(scrollPanelHeight - height, Top);
                this.scrollPanel.el.scrollTop = Math.min(scrollPanelScrollHeight - scrollPanelHeight, ScrollTop);
            }
        }
        // 這些是傳遞給父元件的監聽滾動的函式的。
        var content = {};
        var bar = {};
        var process = "";
        content.residual = (scrollPanelScrollHeight - scrollPanelScrollTop - scrollPanelHeight);
        content.scrolled = scrollPanelScrollTop;
        bar.scrolled = this.vScrollBar.state.top;
        bar.residual = (scrollPanelHeight - this.vScrollBar.state.top - this.vScrollBar.state.height);
        bar.height = this.vScrollBar.state.height;
        process = bar.scrolled/(scrollPanelHeight - bar.height);
        bar.name = "vBar";
        content.name = "content";
        this.$emit('vscroll', bar, content, process);
    },
複製程式碼
  1. 銷燬註冊的事件。

剛才我們已經把註冊事件放到listeners陣列裡面了,我們可以在beforedestroy鉤子裡將他們進行銷燬。

    // remove the registryed event.
    this.listeners.forEach(function(item) {
        item.dom.removeEventListener(item.event, item.type);
    });
複製程式碼

以上部分就是這個元件的核心原始碼了。

執行截圖

PC端執行截圖如下圖所示:

設計一個基於vue.js 2.x的虛擬滾動條

註冊監聽事件以後如下圖所示:

設計一個基於vue.js 2.x的虛擬滾動條

在手機上執行截圖:

設計一個基於vue.js 2.x的虛擬滾動條

可以看出,跟原生滾動條表現效果一致。

結語&感悟

以上就基本把我設計的滾動條設計完了,首先很感激掘金給了我這麼一個分享平臺,然後感謝slimScroll的作者給了我這麼一個思路。做完這個外掛, 我對dom元素的scrollWidth、scrollHeigh、scrollTop、scrollLeft的瞭解更多了,最後,附上github專案地址,如果本文對你有所幫助,請點個star,非常感謝~

相關文章