element ScrollBar滾動組件源碼深刻分析

element-ui ScrollBar組件源碼深刻分析

scrollbar組件根目錄下包括index.js文件和src文件夾,index.js是用來註冊Vue插件的地方,沒什麼好說的,不瞭解的童鞋能夠看一下Vue官方文檔中的 插件,src目錄下的內容纔是scrollbar組件的核心代碼,其入口文件是main.js。javascript

在開始分析源碼以前,咱們先來講一下自定義滾動條的原理,方便你們更好的理解。css

scrollbar.png
如圖,黑色wrap爲滾動的可顯示區域,咱們的滾動內容就是在這個區域中滾動,view是實際的滾動內容,超出wrap可顯示區域的內容都將被隱藏。右側track是滾動條的滾動滑塊thumb上下滾動的軌跡

當wrap中的內容溢出的時候,就會產生各瀏覽器的原生滾動條,要實現自定義滾動條,咱們必須將原生滾動條消滅掉。假設咱們給wrap外面再包一層div,而且把這個div的樣式設爲overflow:hidden,同時咱們給wrap的marginRight,marginBottom設置一個負值,值得大小正好等於原生滾動條的寬度,那麼這個時候因爲父容器的overflow:hidden屬性,正好就能夠將原生滾動條隱藏掉。而後咱們再將自定義的滾動條絕對定位到wrap容器的右側和下側,並加上滾動、拖拽事件等滾動邏輯,就能夠實現自定義滾動條了。html

接下來咱們從main.js入口開始,詳細分析一下element是如何實現這些邏輯的。vue

main.js文件中直接導出一個對象,這個對象採用render函數的方式渲染scrollbar組件,組件對外暴漏的接口以下:java

props: {
  native: Boolean,  // 是否採用原生滾動(即只是隱藏掉了原生滾動條,但並無使用自定義的滾動條)
  wrapStyle: {},  // 內聯方式 自定義wrap容器的樣式
  wrapClass: {},  // 類名方式 自定義wrap容器的樣式
  viewClass: {},  // 內聯方式 自定義view容器的樣式
  viewStyle: {},  // 類名方式 自定義view容器的樣式
  noresize: Boolean, // 若是 container 尺寸不會發生變化,最好設置它能夠優化性能
  tag: {  				// view容器用那種標籤渲染,默認爲div
    type: String,
    default: 'div'
  }
}
複製代碼

能夠看到,這就是整個ScrollBar組件對外暴露的接口,主要包括了自定義wrap,view樣式的接口,以及用來優化性能的noresize接口。node

而後咱們再來分析一下render函數:element-ui

render(){
	let gutter = scrollbarWidth();  // 經過scrollbarWidth()方法 獲取瀏覽器原生滾動條的寬度
  let style = this.wrapStyle;

  if (gutter) {
    const gutterWith = `-${gutter}px`;
    
    // 定義即將應用到wrap容器上的marginBottom和marginRight,值爲上面求出的瀏覽器滾動條寬度的負值
    const gutterStyle = `margin-bottom: ${gutterWith}; margin-right: ${gutterWith};`;

    // 這一部分主要是根據接口wrapStyle傳入樣式的數據類型來處理style,最終獲得的style多是對象或者字符串
    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;
    }
  }
  
  ...
}

複製代碼

這一塊代碼中最重要的知識點就是獲取瀏覽器原生滾動條寬度的方式了,爲此element專門定義了一個方法scrllbarWidth,這個方法是從外部導入進來的 import scrollbarWidth from 'element-ui/src/utils/scrollbar-width';,咱們一塊兒來看一下這個函數:瀏覽器

import Vue from 'vue';

let scrollBarWidth;

export default function() {
  if (Vue.prototype.$isServer) return 0;
  if (scrollBarWidth !== undefined) return scrollBarWidth;

  const outer = document.createElement('div');
  outer.className = 'el-scrollbar__wrap';
  outer.style.visibility = 'hidden';
  outer.style.width = '100px';
  outer.style.position = 'absolute';
  outer.style.top = '-9999px';
  document.body.appendChild(outer);

  const widthNoScroll = outer.offsetWidth;
  outer.style.overflow = 'scroll';

  const inner = document.createElement('div');
  inner.style.width = '100%';
  outer.appendChild(inner);

  const widthWithScroll = inner.offsetWidth;
  outer.parentNode.removeChild(outer);
  scrollBarWidth = widthNoScroll - widthWithScroll;

  return scrollBarWidth;
};
複製代碼

其實也很簡單,就是動態建立一個body的子元素outer,給固定寬度100px,而且將overflow設置爲scroll,這樣wrap就產生滾動條了,這個時候再動態建立一個outer的子元素inner,將其寬度設置爲100%。因爲outer有滾動條存在,inner的寬度必然不可能等於outer的寬度,此時用outer的寬度減去inner的寬度,得出的就是瀏覽器滾動條的寬度了。是否是也很簡單啊,最後記得從body中銷燬動態建立outer元素哦。微信

回過頭來咱們接着看render函數,在根據瀏覽器滾動條寬度及wrapStyle動態生成樣式變量style以後,接下來就是在render函數中生成ScrollBar組件的 HTML了。app

// 生成view節點,而且將默認slots內容插入到view節點下
const view = h(this.tag, {
  class: ['el-scrollbar__view', this.viewClass],
  style: this.viewStyle,
  ref: 'resize'
}, this.$slots.default);

// 生成wrap節點,而且給wrap綁定scroll事件
const wrap = (
  <div ref="wrap" style={ style } onScroll={ this.handleScroll } class={ [this.wrapClass, 'el-scrollbar__wrap', gutter ? '' : 'el-scrollbar__wrap--hidden-default'] }> { [view] } </div>
);
複製代碼

接着是根據native來組裝wrap,view生成整個HTML節點樹了。

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);
複製代碼

能夠看到若是native爲false,則使用自定義的滾動條,若是爲true,則不使用自定義滾動條。簡化上面的render函數生成的HTML以下:

<div class="el-scrollbar">
  <div class="el-scrollbar__wrap">
    <div class="el-scrollbar__view">
    	this.$slots.default
    </div>
  </div>
  <Bar vertical move={ this.moveY } size={ this.sizeHeight } />
  <Bar move={ this.moveX } size={ this.sizeWidth } />
</div>
複製代碼

最外層的el-scrollbar設置了overflow:hidden,用來隱藏wrap中產生的瀏覽器原生滾動條。使用ScrollBar組建時,寫在ScrollBar組件中的內容都將經過slot分發到view內部。另外這裏使用move,size和vertical三個接口調用了Bar組件,這個組件就是原理圖上的Track和Thumb了。下面咱們來看一下Bar組件:

props: {
  vertical: Boolean,  // 當前Bar組件是否爲垂直滾動條
  size: String,  // 百分數,當前Bar組件的thumb長度 / track長度的百分比 
  move: Number   // 滾動條向下/向右發生transform: translate的值
},
複製代碼

Bar組件的行爲都是由這三個接口來進行控制的,在前面的分析中,咱們能夠看到,在scrollbar中調用Bar組件時,分別傳入了這三個props。那麼父組件是如何初始化以及更新這三個參數的值,從而達到更新Bar組件的呢。首先在mounted鉤子中調用update方法對size進行初始化:

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 + '%') : '';
}
複製代碼

能夠看到,這裏核心的內容就是計算thumb的長度heightPercentage/widthPercentage。這裏使用wrap.clientHeight / wrap.scrollHeight得出了thumb長度的百分比。這是爲何呢

分析前面咱們畫的那張scrollbar的原理圖,thumb在track中上下滾動,可滾動區域view在可視區域wrap中上下滾動,能夠將thumb和track的這種相對關係看做是wrap和view相對關係的一個微縮模型(微縮反應),而滾動條的意義就是用來反映view和wrap的這種相對運動關係的。從另外一個角度,咱們能夠將view在wrap中的滾動反過來當作是wrap在view中的上下滾動,這不就是一個放大版的滾動條嗎?

根據這種類似性,咱們能夠得出一個比例關係: wrap.clientHeight / wrap.scrollHeight = thumb.clientHeight / track.clientHeight。在這裏,咱們並不須要求出具體的thumb.clientHeight的值,只須要根據thumb.clientHeight / track.clientHeight的比值,來設置thumb 的css高度的百分比就能夠了。

另外還有一個須要注意的地方,就是當這個比值大於等於100%的時候,也就是wrap.clientHeight(容器高度)大於等於 wrap.scrollHeight(滾動高度)的時候,此時就不須要滾動條了,所以將size置爲空字符串。

接下來咱們再來看一下move,也就是滾動條滾動位置的更新。

handleScroll() {
  const wrap = this.wrap;

  this.moveY = ((wrap.scrollTop * 100) / wrap.clientHeight);
  this.moveX = ((wrap.scrollLeft * 100) / wrap.clientWidth);
}
複製代碼

moveX/moveY用來控制滾動條的滾動位置,當這個值傳給Bar組件時,Bar組件render函數中會調用renderThumbStyle方法將它轉化爲trumb的樣式transform: translateX(${moveX}%) / transform: translateY(${moveY}%)。由以前分析的類似關係可知,當wrap.scrollTop正好等於wrap.clientHeight的時候,此時thumb應該向下滾動它自身長度的距離,也就是transform: translateY(100%)。因此,當wrap滾動的時候,thumb應該向下滾動的距離正好是 transform: translateY(wrap.scrollTop / wrap.clientHeight )。這就是wrap滾動函數handleScroll中的邏輯所在。

如今咱們已經徹底弄清楚了scrollbar組件中的全部邏輯,接下來咱們再看看Bar組件在接收到props以後是如何處理的。

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>
  );
}
複製代碼

render函數獲取父組件傳遞的size,move以後,經過renderThumbStyle來生成thumb,而且給track和thumb分別綁定了onMousedown事件。

clickThumbHandler(e) {
  this.startDrag(e);
  // 記錄this.y , this.y = 鼠標按下點到thumb底部的距離
  // 記錄this.x , this.x = 鼠標按下點到thumb左側的距離
  this[this.bar.axis] = (e.currentTarget[this.bar.offset] - (e[this.bar.client] - e.currentTarget.getBoundingClientRect()[this.bar.direction]));
},
 
// 開始拖拽函數
startDrag(e) {
  e.stopImmediatePropagation();
  // 標識位, 標識當前開始拖拽
  this.cursorDown = true;

  // 綁定mousemove和mouseup事件
  on(document, 'mousemove', this.mouseMoveDocumentHandler);
  on(document, 'mouseup', this.mouseUpDocumentHandler);
  
  // 解決拖動過程當中頁面內容選中的bug
  document.onselectstart = () => false;
},
  
mouseMoveDocumentHandler(e) {
  // 判斷是否在拖拽過程當中,
  if (this.cursorDown === false) return;
  // 剛剛記錄的this.y(this.x) 的值
  const prevPage = this[this.bar.axis];

  if (!prevPage) return;

  // 鼠標按下的位置在track中的偏移量,即鼠標按下點到track頂部(左側)的距離
  const offset = ((this.$el.getBoundingClientRect()[this.bar.direction] - e[this.bar.client]) * -1);
  // 鼠標按下點到thumb頂部(左側)的距離
  const thumbClickPosition = (this.$refs.thumb[this.bar.offset] - prevPage);
  // 當前thumb頂部(左側)到track頂部(左側)的距離,即thumb向下(向右)偏移的距離 佔track高度(寬度)的百分比
  const thumbPositionPercentage = ((offset - thumbClickPosition) * 100 / this.$el[this.bar.offset]);
	// wrap.scrollHeight / wrap.scrollLeft * thumbPositionPercentage獲得wrap.scrollTop / wrap.scrollLeft
  // 當wrap.scrollTop(wrap.scrollLeft)發生變化的時候,會觸發父組件wrap上綁定的onScroll事件,
  // 從而從新計算moveX/moveY的值,這樣thumb的滾動位置就會從新渲染
  this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
},

mouseUpDocumentHandler(e) {
  // 當拖動結束,將標識位設爲false
  this.cursorDown = false;
  // 將上一次拖動記錄的this.y(this.x)的值清空
  this[this.bar.axis] = 0;
  // 取消頁面綁定的mousemove事件
  off(document, 'mousemove', this.mouseMoveDocumentHandler);
  // 清空onselectstart事件綁定的函數
  document.onselectstart = null;
}
複製代碼

上面的代碼就是thumb滾動條拖拽的全部處理邏輯,總體思路就是在拖拽thumb的過程當中,動態的計算thumb頂部(左側)到track頂部(左側)的距離佔track自己高度(寬度)的百分比,而後利用這個百分比動態改變wrap.scrollTop的值,從而觸發頁面滾動以及滾動條位置的從新計算,實現滾動效果。

微信圖片_20190121160845.jpg

上一個圖方便你們理解吧( ̄▽ ̄)"

track的onMousedown和trumb的邏輯也差很少,有兩點須要注意:

  1. track的onMousedown事件回調中不會給頁面綁定mousemove和mouseup事件,由於track至關於click事件
  2. 在track的onmousedown事件中,咱們計算thumb頂部到track頂部的方法是,用鼠標點擊點到track頂部的距離減去thumb的二分之一高度,這是由於點擊track以後,thumb的中點恰好要在鼠標點擊點的位置。

至此,整個scrollbar源碼就分析結束了,回過頭來看看,其實scrollbar的實現並不難,主要仍是要理清各類滾動關係、thumb的長度以及滾動位置怎麼經過wrap,view之間的關係來肯定。這一部分可能比較繞,沒搞懂的同窗建議本身手動畫畫圖研究一下,只要搞懂這個滾動原理,實現起來就很簡單了。

相關文章
相關標籤/搜索