遮遮掩掩的滾動條 -> el-scrollbar

el-scrollbar 是啥?

Element-UI,做爲一套很是出名 Vue 的 UI 組件庫,玩 Vue 人幾乎都認識它。最近在翻看 Element 的源碼時,發現了一個有趣的現象,怎麼 autocomplete 組件的聯想列表組件 -> autocomplete-suggestions 裏面,還包了一個 el-scrollbar 組件,這是用來作什麼的?
通過一番瞭解,原來是 Element 本身寫的一個滾動條組件(但卻沒有公開發布出來),它屏蔽了原生的滾動條,使用了一個統一的樣式來代替,解決了滾動條的兼容性問題。javascript

如何使用?

關於 el-scrollbar 的使用方式,能夠看 Github 上的 issues,這裏也簡單展現一下:在 el-scrollbar 的默認 slot 中填入一個列表,並設定最外層的包裹元素的高度,這樣就能順利產生滾動條了。css

<template>
    // 這裏的 tag 屬性能夠先忽略,它用於控制生成的view元素具體是什麼類型的元素
  <el-scrollbar style="width: 150px; height: 50px" tag="ul">
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
  </el-scrollbar>
</template>

效果以下:
image.pngvue

如何實現?

先來看剛剛的代碼渲染出來的DOM:
image.png
能夠看到,咱們的 li 被包裹在了 .el-scrollbar -> .&__wrap -> .&__view 裏面,而底下還有兩個 DOM:.is-horizontal 和 .is-vertical ,每一個元素都有他本身的做用:java

<div class="el-scrollbar"> //根元素,包裹全部元素
  <div class="el-scrollbar__wrap"> // wrap 元素,是視覺視口元素,它表明着元素最終展現的窗口大小
    <ul class="el-scrollbar__view"> // 佈局視口元素,它表明着整個列表(以及他們的寬高),經過調整 wrap 的scrollTop/left,顯示不一樣的 view 內容
      // 默認插槽裏的內容會被放在這裏
    </ul> 
  </div>
  <div class="el-scrollbar__bar is-horizontal">...</div> //橫向滾動條
  <div class="el-scrollbar__bar is-vertical">...</div> // 豎向滾動條
</div>

隱藏原有滾動條

瞭解了wrap/view/bar這幾個概念以後,咱們直接來看源碼: element/packages/scrollbar/src/main.js 這個文件是 scrollbar 組件的入口文件,它定義了一些/components/data/接受的 props,以及最重要的:render 函數。render 函數在被調用的時候,首先調用了 scrollbarWidth 函數:node

let gutter = scrollbarWidth();

這個 gutter 的意思是當前瀏覽器的滾動條寬度,element 經過 scrollbarWidth 這個方法來獲取到這個寬度,點擊這個方法,能夠看到其實它作了三件事情:git

  1. 建立了一個 outer 元素,設置了寬度,拿到此時的 offsetWidth
  2. 把 outer 元素 overflow 設置爲 visible,再建立一個inner元素,append 到 outer 上(此時會產生滾動條),再拿到 inner 的 offsetWidth。
  3. 二者相減便是滾動條的寬度
/* eslint-disable no-debugger */
import Vue from 'vue';

let scrollBarWidth;

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

  // 建立外層的div,此時是一個普通的dom
  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);

  // 獲取這個dom的實際寬度
  const widthNoScroll = outer.offsetWidth;
  // 修改外層 dom 的css,設置爲 overflow: scroll(默認產生滾動條)
  outer.style.overflow = 'scroll';
    // 建立內層的 div,並 append 到 outer 上
  const inner = document.createElement('div');
  inner.style.width = '100%';
  outer.appendChild(inner);
  // 計算內層 div 的實際寬度
  const widthWithScroll = inner.offsetWidth;
  outer.parentNode.removeChild(outer);
  // 經過「無滾動條時的寬度」減去「有滾動條時的寬度」來算出滾動條的具體寬度
  scrollBarWidth = widthNoScroll - widthWithScroll;

  return scrollBarWidth;
};

拿到了滾動條最主要的目的就是爲了把它隱藏掉,這也是 render 函數接下來作的事情。github

const gutterStyle = `margin-bottom: ${gutterWith}; margin-right: ${gutterWith};`;

// 根據傳入的 wrapStyle 的不一樣類型,把 gutterStyle 加入進去
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;
  }
}

建立 DOM

緊接着就是 DOM 的建立過程,前後建立了 view/wrap(監聽其滾動事件),以及非原生版本/原生版本的根元素。若是你傳入了 native: true,就表明着使用了原生滾動條版本的 scrollbar。segmentfault

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>
      ]);
    }

在 wrap 窗口滾動時,handleScroll 方法會被執行,更新 data 中的 moveY 和 moveX 屬性。這二者會被傳入滾動條組件 Bar ,更新它的 translateY()/translateX() ,Bar 組件咱們後面會講到。api

mount/beforeDestroy 鉤子

在 mounted 的時候還作了一件事,就是給 view 元素添加了 resize 事件的監聽器(beforeDestroy 時取消監聽):數組

!this.noresize && addResizeListener(this.$refs.resize, this.update);

值得注意的是,addResizeListener 並非簡單地設置了 window.resize 回調,而是使用了一個船新的 api 來監聽 DOM 元素的 resize:ResizeObserver API(具體可看這裏的介紹)。總的來講,ResizeObserver 能夠直接給 DOM 綁定事件,專門用來觀察 DOM 元素的尺寸是否發生了變化,減小了 window.resize 帶來的多餘監聽。
爲了給某個元素實現多個 resize 事件的監聽,element 還使用了觀察者模式,給 DOM 元素綁定了一個 __resizeListeners__ 數組,當有 resize 事件被觸發時,執行整個 _ _resizeListeners__ 數組的全部回調。

DOM 元素一旦 resize,就會執行 update 回調。那麼 update 的時候作了什麼事情呢?

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 + '%') : '';
}

update 方法負責更新 Bar 的滑塊長度(多是橫向/豎向滾動條),咱們以豎向滾動條爲例:首先經過 clientHeight * 100/scrollHeight 獲得 resize 後的 wrap 展現高度和總高度的比例,這也是 scrollbar 滑塊長度的比例,再把它傳入給表示滾動條的 Bar 組件,更新滾動條的 height。
這個時候若是比例值大於 100,說明已經不須要滾動條了,則傳一個空字符串給 Bar 。

點擊/拖動滾動條

到了這一步,咱們的滾動條組件已經建立完成了,可是咱們點擊滾動條或者拖動滾動條的時候,這個組件如何處理呢?還得看 element/packages/scrollbar/src/bar.js 這個組件。
Bar 組件負責展現滾動條,咱們直接來看它的 render 函數:

render(h) {
    // move 屬性用於控制滾動條的滾動位置
    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>
    );
  }

咱們能夠看到重點在於 clickTrackHandler/clickThumbHandler 這兩個函數,他們分別用於控制滾動條 container 被點擊時的行爲,以及滾動條自己被點擊的時候產生的行爲。

clickTrackHandler:快速跳到某個區間

clickTrackHandler(e) {
    /**
     * 0. 以垂直滾動條爲例:
     * this.bar.direction = "top"/this.bar.client = "clientY"/this.bar.offset="offsetHeight"/this.bar.scrollSize="scrollHeight"
     * 1. getBoundingClientRect()[this.bar.direction] 返回元素的 top 值(距離瀏覽器視口的高度值)
     * 2. 用 1 的值減去 e.clientY(鼠標當前位置), 再用 Math.abs 得出相對值,這個值就是鼠標在滾動條 container 上的相對偏移量。
     * 3. 計算出滾動條滑塊的一半位置 thumbHalf
     * 4. offset - thumbHalf 獲得具體偏移量,併除以整個 bar 的 offsetHeight,獲得了滑塊新的位置的百分比。
     * 5. 接下來就能夠愉快地更新 wrap 元素的 scrollTop,顯示新的內容啦~
     * 6. wrap 滾動後會觸發 handleScroll 方法,回過頭來更新 Bar 組件的 move 值,從而更新滾動條位置。
     */
    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]);
    //  設置外殼的 scrollHeight 或 scrollWidth 新值。達到滾動內容的效果
    this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
},

clickThumbHandler:拖動滾動條滑塊更新視圖

這裏主要是計算拖動時滑塊的高度與整個滾動條的比例,從而更新 wrap 元素的 scrollTop 值,具體代碼與 clickTrackHandler 較爲類似,因爲篇幅所限,就不贅述了。
這裏有一個小點,咱們是給滑塊元素綁定 onMousedown 事件的,可是 mousemove 和 mouseup 倒是綁定在 document 上的,這是由於鼠標在移動過程當中,會比滑塊的移動要快,此時滑塊元素會失去 onMousemove 事件,因此綁定 mousemove 的時候不能綁定在對應元素上。

總結

咱們從整個滾動條元素的生命週期,看到 element 是如何建立出一個滾動條,如何監聽元素的變化,如何控制滾動條的滑動等等。源碼的閱讀到這裏就所有結束了,若有什麼錯漏,請幫忙指出來;如你有所收穫,是我莫大的榮幸。

感謝:
Element-ui el-scrollbar 源碼解析
ResizeObserver API
相關文章
相關標籤/搜索