element-ui之el-scrollbar源碼解析學習

最近使用vue在做pc端項目,需要處理滾動條樣式外加滾動加載。

使用了betterscroll和perfectscroll發現前者還是更偏向於移動端(也可能我比較着急沒用明白),

後者在ie11上面拖拽滾動條的時候會閃動,會有兼容性問題。

經同事點播,最終使用了element-ui裏面的scrollbar,發現還真是不錯,用着簡單而且兼容性好。

所以,來看看源碼是怎麼實現的,學習學習,進步進步。

先看一下怎麼使用,便於理解組件的設計思路

<el-scrollbar class="content-table" :tag="'div'">
	<div v-for="(info, idx) in tab.humanInfo" :key="idx">
		<div></div>
	</div>
</el-scrollbar>

如上圖代碼,直接把會很長的需要滾動的內容放到el-scrollbar組件裏面就好,其他細節不做贅述。

生成的html代碼如下,這張圖pic-html的內容後面會多次提及。

根據對照頁面上的效果可知,div.el-scrollbar是最外層的容器,可以通過寫自己的class來添加出現滾動條的高度。

.el-scrollbar{
  overflow: hidden;
  position: relative;
}

div.el-scrollbar__wrap是實際上真正用來包裹內容併產生滾動條的容器,高度100%,div.el-scrollbar__view裏面就是我們自己寫的需要滾動的內容。

.el-scrollbar__wrap{
  overflow: scroll;
  height: 100%;
}

div.el-scrollbar__bar是滾動條的軌道,分爲橫向和縱向,橫向爲is-horizontal,縱向爲is-vertical,兩者其實差不多,就只關注縱向了。鼠標劃入劃出滾動區域,滾動條會隨之顯示隱藏。

.el-scrollbar__bar {
    position: absolute;
    right: 2px;
    bottom: 2px;
    z-index: 1;
    border-radius: 4px;
    opacity: 0;
    -webkit-transition: opacity 120ms ease-out;
    transition: opacity 120ms ease-out;
}
.el-scrollbar__bar.is-vertical {
    width: 6px;
    top: 2px;
}

div.el-scrollbar__thumb在div.el-scrollbar__bar內部,作爲滾動條,hover會產生背景色的變化

.el-scrollbar__thumb {
    position: relative;
    display: block;
    width: 0;
    height: 0;
    cursor: pointer;
    border-radius: inherit;
    background-color: rgba(144,147,153,.3);
    -webkit-transition: .3s background-color;
    transition: .3s background-color;
}

ok,到這裏就把html結構大致分析完了,接着來看源碼了,看看到底咋回事兒了

scrollbar/src/main.js就是組件主入口文件了,首先看一下里面寫了什麼

props: {
    native: Boolean,
    wrapStyle: {},
    wrapClass: {},
    viewClass: {},
    viewStyle: {},
    noresize: Boolean, // 如果 container 尺寸不會發生變化,最好設置它可以優化性能
    tag: {
      type: String,
      default: 'div'
    }
  },

props主要傳入內聯樣式和自定義class,都以默認值來分析代碼降低不必要的複雜度

data() {
    return {
      sizeWidth: '0',
      sizeHeight: '0',
      moveX: 0,
      moveY: 0
    };
  },
computed: {
    wrap() {
      return this.$refs.wrap;
    }
},

data中存了4個狀態,是用來存儲滾動條長度和滾動條移動距離,滾動條的長度是要隨着滾動內容變長而變短的

computed中存儲了ref=wrap這個dom節點,其實就是div.el-scrollbar__wrap這個產生滾動條的容器

methods: {
    handleScroll() {
      const wrap = this.wrap;

      this.moveY = ((wrap.scrollTop * 100) / wrap.clientHeight);
      this.moveX = ((wrap.scrollLeft * 100) / wrap.clientWidth);
    },

    update() {
      let heightPercentage, widthPercentage;
      const wrap = this.wrap;
      if (!wrap) return;

      heightPercentage = (wrap.clientHeight * 100 / wrap.scrollHeight);
      widthPercentage = (wrap.clientWidth * 100 / wrap.scrollWidth);

      this.sizeHeight = (heightPercentage < 100) ? (heightPercentage + '%') : '';
      this.sizeWidth = (widthPercentage < 100) ? (widthPercentage + '%') : '';
    }
  },

methods中存儲了2個方法,以下均以縱向滾動條分析

handleScroll顧名思義,就是滾動事件的監聽函數。這個函數用  內容的滾動距離/視口高度  算出滾動條滾動的百分比。之所以用滾動距離和視口高度相比,舉例說明一下,假如滾動內容scrollHeight爲2個視口高度,那麼滾動距離scrollTop爲1個視口高度的時候,滾動條應該是滾到底部,滾動條最上方距離滾動條軌道最上方應該是自身的高度,也就是說moveY爲100%,滾動條高度爲滾動軌道的50%

update,用於隨着滾動內容的改變,來更新滾動條的高度,滾動條高度的計算方法爲,視口高度/滾動區域高度,依舊可以用上面的例子來解釋。

如果所得百分比不小於100%,則滾動條高度爲空,即沒有可滾動空間,滾動條不出現

mounted() {
    if (this.native) return;
    this.$nextTick(this.update);
    !this.noresize && addResizeListener(this.$refs.resize, this.update);
  },

beforeDestroy() {
    if (this.native) return;
    !this.noresize && removeResizeListener(this.$refs.resize, this.update);
}

mouted執行了一次update方法,用於初始化滾動條長度,併爲div.el-scrollbar__view添加resize事件監聽,只要其中的內容改變引起高度變化,則更新滾動條高度

beforeDestroy用於組件銷燬時幹掉resize事件

下面進入main.js的主要實現邏輯部分,採用了渲染函數和jsx混用的方式實現

render(h) {
 ....
}

由於render函數裏面內容很多,這裏拆開來分析

let gutter = scrollbarWidth();
let style = this.wrapStyle;
if (gutter) {
  const gutterWith = `-${gutter}px`;
  const gutterStyle = `margin-bottom: ${gutterWith}; margin-right: ${gutterWith};`;
  if (Array.isArray(this.wrapStyle)) {
     style = toObject(this.wrapStyle);
     style.marginRight = style.marginBottom = gutterWith;
  } else if (typeof this.wrapStyle === 'string') {
     style += gutterStyle;
  } else {
     style = gutterStyle;
  }
}

gutter獲取了瀏覽器的滾動條寬度,並且對props傳入的樣式進行分情況處理,最終style獲得到一個樣式,其中主要是要包含margin-bottom=橫向滾動條高度;margin-right=縱向滾動條寬度

const view = h(this.tag, {
      class: ['el-scrollbar__view', this.viewClass],
      style: this.viewStyle,
      ref: 'resize'
 }, this.$slots.default);
const wrap = (
    <div
        ref="wrap"
        style={ style }
        onScroll={ this.handleScroll }
        class={ [this.wrapClass, 'el-scrollbar__wrap',
                 gutter ? '' : 'el-scrollbar__wrap--hidden-default'] }>
        { [view] }
    </div>
);

使用渲染函數的方式,拼接出div.el-scrollbar__view,裏面包含滾動的內容,ref=‘resize’,前面提到的resize的監聽函數就綁在這個方法上面

並且將滾動事件綁在wrap上,將refs.resize放在wrap容器的內部,style樣式放在了wrap上面

wrap的樣式並沒有width,而是直接進行流體佈局,這樣的話就可以通過添加style樣式中的margin-right的值來改變wrap的外部尺寸特性,使得wrap的長度向右拉伸一個滾動條的寬度那麼大的距離。而wrap的外部容器的overflow爲hidden,所以多出來的距離剛好被外部容器隱藏。那個距離就是原生滾動條產生的位置,這樣就實現了隱藏真正的滾動條。這樣處理的好處是沒有兼容性問題。

let nodes;
if (!this.native) {
   nodes = ([
     wrap,
     <Bar
        move={ this.moveX }
        size={ this.sizeWidth }></Bar>,
     <Bar
        vertical
        move={ this.moveY }
        size={ this.sizeHeight }></Bar> 
    ]);
} else {
    nodes = ([
      <div
         ref="wrap"
         class={ [this.wrapClass, 'el-scrollbar__wrap'] }
         style={ style }>
         { [view] }
      </div>
    ]);
}
return h('div', { class: 'el-scrollbar' }, nodes);

將wrap和bar組件放在容器div.el-scrollbar中

moveY和sizeHeight的數據傳入到bar組件中,bar組件爲滾動條組件,滾動事件通過操作moveY和sizeHeight的值來操作滾動條的位置和長度

下面來看一下bar組件的源碼

props: {
    vertical: Boolean,
    size: String,
    move: Number
  },

傳入的props上面已經說到了,只是還有一個用來區分橫縱滾動條的一個入參

computed: {
    bar() {
      return BAR_MAP[this.vertical ? 'vertical' : 'horizontal'];
    },

    wrap() {
      return this.$parent.wrap;
    }
},

computed返回了bar,這個主要是一些配置,wrap就還是上面說到的外面的那個wrap容器

export const BAR_MAP = {
  vertical: {
    offset: 'offsetHeight',
    scroll: 'scrollTop',
    scrollSize: 'scrollHeight',
    size: 'height',
    key: 'vertical',
    axis: 'Y',
    client: 'clientY',
    direction: 'top'
  },
  horizontal: {
    offset: 'offsetWidth',
    scroll: 'scrollLeft',
    scrollSize: 'scrollWidth',
    size: 'width',
    key: 'horizontal',
    axis: 'X',
    client: 'clientX',
    direction: 'left'
  }
};

下面我們來看一下bar中的主要邏輯

render(h) {
    const { size, move, bar } = this;
    return (
      <div
        class={ ['el-scrollbar__bar', 'is-' + bar.key] }
        onMousedown={ this.clickTrackHandler } >
        <div
          ref="thumb"
          class="el-scrollbar__thumb"
          onMousedown={ this.clickThumbHandler }
          style={ renderThumbStyle({ size, move, bar }) }>
        </div>
      </div>
    );
},

這段代碼主要生成了滾動條的html,包含了點擊滾動條軌道,拖拽滾動條,改變滾動條位置的邏輯

先來看一下renderThumbStyle函數的作用

export function renderThumbStyle({ move, size, bar }) {
  const style = {};
  const translate = `translate${bar.axis}(${ move }%)`;

  style[bar.size] = size;
  style.transform = translate;
  style.msTransform = translate;
  style.webkitTransform = translate;

  return style;
};

這個函數用來生成一段css樣式,根據size、move的值動態控制滾動條位置。實現原理主要是依靠css的translate根據百分比來調整滾動條位置,調整height來動態改變滾動條的長度。

繼續看一下div.el-scrollbar__bar軌道上綁定的點擊事件監聽函數clickTrackHandler

clickTrackHandler(e) {
    const offset = Math.abs(e.target.getBoundingClientRect()[this.bar.direction] -
                      e[this.bar.client]);
    const thumbHalf = (this.$refs.thumb[this.bar.offset] / 2);
    const thumbPositionPercentage = ((offset - thumbHalf) * 100 / this.$el[this.bar.offset]);
    this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
},

這裏面的很多變量都是取自上面介紹的那個配置文件裏面的配置

先獲取點擊的軌道div.el-scrollbar__bar距離整個document視口最上方的距離 減去 鼠標點擊的位置距離視口最上方的距離,得到鼠標點擊位置距離滾動條軌道最上方的距離,取絕對值賦值給offset

獲取滾動條的offsetHeight的一半賦值給thumbHalf

offset 減去 thumbHalf可獲取當滾動條中心在鼠標點擊位置時,滾動條最上方距離滾動條軌道最上方的距離,用這個值比上滾動條滾到的長度,獲取一個相對於滾動條軌道長度的百分比,將這個百分比賦值給thumbPositionPercentage

用thumbPositionPercentage乘以wrap的scroolHeight獲取scrollTop的值,賦值給wrap的scrollTop,實現內容的滾動,內容滾動之後觸發滾動事件,執行監聽函數,改變move和size的值執行renderThumbStyle,從而改變滾動條的位置。

下面是div.el-scrollbar__thumb上的點擊拖拽滾動條事件監聽函數clickThumbHandler

clickThumbHandler(e) {
      this.startDrag(e);
      this[this.bar.axis] = (e.currentTarget[this.bar.offset] - (e[this.bar.client] - 
      e.currentTarget.getBoundingClientRect()[this.bar.direction]));
},

clickThumbHandler中包含一個拖拽函數startDrag,以及一個賦值操作,對於縱向滾動條來講,就是this.Y = 滾動條的高度 減去 鼠標點擊位置距離滾動條最上方位置的距離,即鼠標點擊位置到滾動條最下方位置的距離

startDrag

startDrag(e) {
      e.stopImmediatePropagation();
      this.cursorDown = true;
      on(document, 'mousemove', this.mouseMoveDocumentHandler);
      on(document, 'mouseup', this.mouseUpDocumentHandler);
      document.onselectstart = () => false;
},

阻止事件冒泡,此處的stopImmediatePropagation和stopPropagation不太一樣

-----------------------------------------------------------分割線------------------------------------------------------------------------

1、stopImmediatePropagation方法:stopImmediatePropagation方法作用在當前節點以及事件鏈上的所有後續節點上,目的是在執行完當前事件處理程序之後,停止當前節點以及所有後續節點的事件處理程序的運行

2、stopPropagation方法:stopPropagation方法作用在後續節點上,目的在執行完綁定到當前元素上的所有事件處理程序之後,停止執行所有後續節點的事件處理程序

var div = document.getElementById("div1");
var btn = document.getElementById("button1");
div.addEventListener("click" , function(){alert("第一次執行");} , true);        //1
div.addEventListener("click" , function(){alert("第二次執行");} , true);        //2
btn.addEventListener("click" , function(){alert("button 執行");});            //3

在這裏,給 1 函數alert後加上stopImmediatePropagation, 那麼之後彈出窗口“第一次執行”
但是如果給 1 函數alert後加上stopPropagation , 那麼之後會彈出窗口“第一次執行”,“第二次執行”兩個窗口

----------------------------------------------------------------分割線-----------------------------------------------------------------

拉回來,繼續看這個拖拽函數

阻止冒泡之後,設置鼠標點擊狀態爲true,監聽mousemove和mouseup,一般的拖拽邏輯都是這種寫法

document.onselectstart = () => false;是爲了在點擊頁面時不會選中文本出現藍色選中

mouseMoveDocumentHandler(e) {
      if (this.cursorDown === false) return;
      const prevPage = this[this.bar.axis];
      if (!prevPage) return;
      const offset = ((this.$el.getBoundingClientRect()[this.bar.direction] - e[this.bar.client]) * 
                       -1);
      const thumbClickPosition = (this.$refs.thumb[this.bar.offset] - prevPage);
      const thumbPositionPercentage = ((offset - thumbClickPosition) * 100 / 
                                         this.$el[this.bar.offset]);
      this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
    },
mouseUpDocumentHandler(e) {
      this.cursorDown = false;
      this[this.bar.axis] = 0;
      off(document, 'mousemove', this.mouseMoveDocumentHandler);
      document.onselectstart = null;
}

mouseMoveDocumentHandler爲mousemove的監聽函數,其中做了2個判斷,cursorDown === false,this.Y爲空的時候不會執行函數,也就是說當沒有點擊狀態,沒有識別到鼠標點到滾動條上的時候不執行代碼。如果點到滾動條,獲取鼠標點擊位置距離滾動條軌道最上方的距離賦值給offset,將鼠標點擊位置距離滾動條最上方的距離賦值給thumbClickPosition,兩者相減,得到滾動條距離滾動條軌道最上方的距離,將這個距離比上滾動條軌道的長度,獲得一個百分比,將這個百分比賦值給thumbPositionPercentage。用thumbPositionPercentage乘以scrollHeight獲取滾動距離scrollTop,實現內容滾動,滾動條隨之移動,實現拖拽效果。

mouseUpDocumentHandler爲mouseup監聽函數,當鼠標完成拖拽擡起時,執行此函數。將cursorDown,this.Y重置回默認值,解綁mousemove監聽函數,解綁document.onselectstart監聽函數。

destroyed() {
    off(document, 'mouseup', this.mouseUpDocumentHandler);
}

組件銷燬時解綁mouseup事件

將上面的這一大堆的源碼解讀串起來的整體邏輯是:

鼠標直接滾動時,觸發滾動事件監聽函數,根據scrollTop、clientHeight、scrollHeigth計算滾動條的滾動距離的百分比,即move。

move作爲滾動條組件的props,傳入bar組件,動態計算滾動條的translateY,實現滾動條相對於滾動條軌道的位置改變。

鼠標直接作用在滾動條軌道和滾動條上時,根據鼠標位置,滾動條位置計算出滾動距離的百分比,根據百分比計算出scrollTop並賦值給wrap.scrollTop,觸發滾動事件監聽函數改變move,從而改變滾動條的位置。

初始化或者滾動內容改變時,觸發update函數,根據clientHeight、scrollHeight計算出滾動條高度百分比size,傳入bar組件,計算height,進而改變滾動條的高度。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章