element ScrollBar滾動元件原始碼深入分析

luichooy發表於2019-01-21

element-ui ScrollBar元件原始碼深入分析

scrollbar元件根目錄下包括index.js檔案和src資料夾,index.js是用來註冊Vue外掛的地方,沒什麼好說的,不瞭解的童鞋可以看一下Vue官方文件中的 外掛,src目錄下的內容才是scrollbar元件的核心程式碼,其入口檔案是main.js。

在開始分析原始碼之前,我們先來說一下自定義滾動條的原理,方便大家更好的理解。

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

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

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

main.js檔案中直接匯出一個物件,這個物件採用render函式的方式渲染scrollbar元件,元件對外暴漏的介面如下:

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介面。

然後我們再來分析一下render函式:

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了。

// 生成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之間的關係來確定。這一部分可能比較繞,沒搞懂的同學建議自己手動畫畫圖研究一下,只要搞懂這個滾動原理,實現起來就很簡單了。

相關文章