Element-ui el-scrollbar 原始碼解析

onaug6th發表於2019-05-01

Element-ui el-scrollbar 原始碼解析

前幾天美化部落格時發現滾動條在window下實在太難看,所以在基於vue的技術上尋找美化滾動條的方法。記得Element-ui原始碼中有名為 el-scrollbar 的滾動元件,雖然文件上沒有提到,但使用的人還是不少。今天記錄下原始碼的閱讀心得。

在這之前

在看苦澀的程式碼前,先大概描述一下滾動條元件的用處和行為,方便理解程式碼邏輯。

因為作業系統和瀏覽器的不同,滾動條外觀是不一樣的。需要做風格統一時,就需要做自定義滾動條。當然也可以直接修改CSS3中的 ::-webkit-scrollbar 相關屬性來達到修改原生滾動條外觀,但這個屬性部分瀏覽器上沒有能夠完美相容。

在一個固定高度的元素中,如內部內容超出了父級元素的固定高。為了讓使用者瀏覽其餘的內容,通常都會設定父級元素overflow-y: scroll 出現滾動條。允許使用者以滾動的形式來瀏覽剩下的內容。

而自定義滾動條,是先通過偏移檢視元素,達到隱藏原生滾動條的效果。同時在檢視元素的右側和下方,增加用標籤寫出的模擬滾動條。監聽模擬滾動條的事件(按下滑塊或點選軌道),來動態更新檢視視窗的scrollTopscrollLeft值。同樣的,也會監聽檢視視窗的事件(滾動事件或檢視視窗的尺寸縮放事件),來同步更新自定義滾動條的狀態(滑塊所處的位置或滑塊長度)。

滾動條其實是當前已瀏覽內容的一個直觀展示,在固定元素中,如果scrollTop發生改變往下滾動。滾動條中的滑塊也會向下移動。此時能夠通過滾動條來得知內容的已滾動程度和剩餘程度。

我們將頁面想象成一個很長的畫布,而我們能看到的是一個移動的視窗。當頁面往下滾動時,視窗在畫布中也就往下移動,來檢視被遮擋的內容。同樣的,滾動塊裡的滑塊也往下移動同樣比例的距離。所以滾動條就是一個等比例的縮小模型。

也就是說,固定元素的高度clientHeight 除以 固定元素包括溢位的總高度scrollHeight。同等於 滑塊的高度 除以 滾動條的高度。他們的比例是一樣的。

未滾動前的滾動條
滾動後的滾動條

在大概瞭解滾動條的工作內容和計算公式後,看看原始碼中是如何處理他們之間的計算關係的。

檔案

scrollbar元件在 package/scrollbar/index.js 中被匯出,其中 package/scrollbar/src 是程式碼的核心部分,入口檔案是 main.js

結構

<el-scrollbar>
  <div style="height: 300px;">
    <div style="height: 600px;"></div>
  </div>
</el-scrollbar>
複製程式碼

使用自定義標籤 el-scrollbar 裹住使用的區域,scrollbar 元件會生成 viewwrap 兩個父級元素包裹插槽中的內容,還有兩種型別的自定義滾動條 horizontalvertical

生成後的結構

main.js

main.js預設匯出一個物件,接收一系列配置。

name: 'ElScrollbar',

components: { 
  //  滾動條元件,擁有水平與垂直兩種形態
  Bar 
},

props: {
  native: Boolean,    //  是否使用原生滾動條,即不附加自定義滾動條
  wrapStyle: {},      //  wrap的內聯樣式
  wrapClass: {},      //  wrap的樣式名
  viewClass: {},      //  view的樣式名
  viewStyle: {},      //  view的內聯樣式
  noresize: Boolean,  //  當container尺寸發生變化時,自動更新滾動條元件的狀態
  tag: {              //  元件最外層的標籤屬性,預設為 div
    type: String,
    default: 'div'
  }
},

data() {
  return {
    sizeWidth: '0',   //  水平滾動條的寬度
    sizeHeight: '0',  //  垂直滾動條的高度
    moveX: 0,         //  垂直滾動條的移動比例
    moveY: 0          //  水平滾動條的移動比例
  };
},
複製程式碼

元件在render函式中生成結構。

tips:如果在.vue檔案中同時存在 templaterender 函式,元件例項會先取 template 模板來渲染元件模板,而不採用 render函式

render函式一開始會通過 scrollbarWidth 方法來計算當前瀏覽器的滾動條寬度。

render(h) {
    //  獲取瀏覽器的滾動條寬度
    let gutter = scrollbarWidth();
    //  wrap內聯樣式
    let style = this.wrapStyle;
    
    ...
複製程式碼

scrollbarWidth 方法在 scrollbar-width.js 中被預設匯出。

import Vue from 'vue';

//  閉包變數,用於記錄滾動條寬度
let scrollBarWidth;

export default function() {
  //  如果在服務端執行,返回 0
  if (Vue.prototype.$isServer) return 0;
  //  如存在滾動條寬度,直接返回
  if (scrollBarWidth !== undefined) return scrollBarWidth;

  //  建立outer標籤並隱藏
  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;
  //  設定外層div滾動屬性
  outer.style.overflow = 'scroll';
  //  建立inner標籤,並追加到outer標籤中
  const inner = document.createElement('div');
  inner.style.width = '100%';
  outer.appendChild(inner);
  //  此時outer已經可以滾動,記錄下inner元素的寬度
  const widthWithScroll = inner.offsetWidth;
  //  銷燬outer元素
  outer.parentNode.removeChild(outer);
  //  滾動條寬度 = 沒有滾動條時的outer寬度 減去 有滾動條的outer中的inner寬度
  scrollBarWidth = widthNoScroll - widthWithScroll;
  //  返回滾動條寬度
  return scrollBarWidth;
};
複製程式碼

獲取滾動條方法會進行以下步驟

  1. 建立outer容器,並記錄outer容器的offsetwidth
  2. 設定outer容器overflow: scroll,並新建inner容器,追加到outer容器下
  3. 此時outer容器會帶有滾動條,記錄inner容器的offsetwitdh寬度
  4. 計算滾動條寬度,並返回

用於計算滾動條寬度的臨時標籤結構
outer寬
inner寬
從而得出此時的瀏覽器滾動條寬度為 100 - 83 = 17 畫素

如果存在滾動條寬度,會將wrap設定偏移,達到隱藏原生滾動條的效果。

//  如果存在滾動條寬度
if (gutter) {
  //  設定偏移寬度,隱藏原生滾動條
  const gutterWith = `-${gutter}px`;
  const gutterStyle = `margin-bottom: ${gutterWith}; margin-right: ${gutterWith};`;
  
  //  根據配置型別,生成樣式
  /**
   * 如是物件陣列屬性 Array<Object> [{"background": "red"}, {"color": "red"}]
   * 則會被轉為物件  {background: "red", color: "red"}
   */
  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;
  }
}
複製程式碼

接著生成view結構,設定配置的樣式名和內聯樣式,插槽中的預設內容會放入view下,同時給view增加ref索引,用於後續的事件繫結。

//  生成view
const view = h(
  //  view的標籤型別
  this.tag,
  //  view的屬性
  {
    class: ["el-scrollbar__view", this.viewClass],
    style: this.viewStyle,
    ref: "resize"
  },
  //  接收的插槽內容
  this.$slots.default
);
複製程式碼

接著生成wrap結構,設定配置的樣式名和內聯樣式,同時監聽滾動事件

//  生成wrap,並監聽滾動事件
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 配置,拼接元件的最終結構。

//  如果不使用原生滾動條,則新增自定義滾動條
if (!this.native) {
  /**
   * 使用自定義滾動條
   * <div class="el-scrollbar__wrap">
   *  <div class="el-scrollbar__view"></div>
   * </div>
   * <bar>
   * <bar>
   */
  nodes = [
    wrap,
    <Bar move={this.moveX} size={this.sizeWidth} />,
    <Bar vertical move={this.moveY} size={this.sizeHeight} />
  ];
} else {
  /**
   * 否則使用原生滾動條
   * 
   * <div class="el-scrollbar__wrap"> wrap並無監聽滾動事件
   *  <div class="el-scrollbar__view"></div>
   * </div>
   */
  nodes = [
    <div
      ref="wrap"
      class={[this.wrapClass, "el-scrollbar__wrap"]}
      style={style}
    >
      {[view]}
    </div>
  ];
}

//  返回最終結構
return h("div", { class: "el-scrollbar" }, nodes);
//  render函式結束
複製程式碼

在元件 mountedbeforeDestroy 時,根據配置進行事件監聽。

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

addResizeListener 方法在 resize-event.js 中被匯出,方法接收兩個引數。監聽的DOM節點和回撥事件。

/**
 * 視窗縮放執行回撥
 */
function resizeHandler(entries) {
  //  entry是ResizeObserver建構函式執行時傳入的參
  for (let entry of entries) {
    //  取出之前宣告的回撥函式陣列
    const listeners = entry.target.__resizeListeners__ || [];
    //  遍歷執行回撥
    if (listeners.length) {
      listeners.forEach(fn => {
        fn();
      });
    }
  }
}

/**
 * 新增尺寸改變時事件監聽
 * @param {HTMLDivElement} element 元素
 * @param {Function} fn 回撥
 */
const addResizeListener = function(element, fn) {
  if (!element.__resizeListeners__) {
    //  設定當前元素的事件回撥陣列
    element.__resizeListeners__ = [];
    //  例項化Resize觀察者物件
    element.__ro__ = new ResizeObserver(resizeHandler);
    //  開始觀察指定的目標元素,當元素尺寸改變時,會執行resizeHandler方法
    element.__ro__.observe(element);
    window.ro = element.__ro__;
  }
  //  往回撥陣列中新增本次監聽事件
  element.__resizeListeners__.push(fn);
};

/**
 * 移除尺寸改變時事件監聽
 * @param {HTMLDivElement} element 元素
 * @param {Function} fn 回撥
 */
const removeResizeListener = function(element, fn) {
  if (!element || !element.__resizeListeners__) return;
  //  陣列中移除
  element.__resizeListeners__.splice(
    element.__resizeListeners__.indexOf(fn),
    1
  );
  //  取消目標物件上所有對element的觀察
  if (!element.__resizeListeners__.length) {
    element.__ro__.disconnect();
  }
};
複製程式碼

這樣,main.js的例項化過程就結束了。接著我們看wrap繫結的滾動回撥handleScroll方法,和生命週期鉤子中見到的update方法。

在wrap視窗滾動時,會執行method中的handleScroll方法,更新data中的moveYmoveX屬性。 moveYmoveX會作為配置屬性傳給Bar滾動條元件,實時更新BartranslateY(moveY%)translateX(moveX%)作為滑塊的滾動位置。

handleScroll() {
  const wrap = this.wrap;

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

moveYmodeX的計算邏輯,一開始看著有點迷糊。 但是調轉一下計算順序,就恍然大悟了。

handleScroll() {
  const wrap = this.wrap;

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

這裡是在求滾動高度與可見高度的比例。 上面我們已經知道,固定元素的高度clientHeight除以 固定元素包括溢位的總高度scrollHeight。同等於 滑塊的高度 除以 滾動條的高度。 所以當scrollTop發生改變時,我們能夠計算出比例關係來更新滑塊的正確位置。

假設我們的wrap高度為300px,當前的滾動高 scrollTop 為0,滾動塊的位置是貼緊頂部的,此時Bar元件的 translateY是 0%。 注意,圖中右邊的滾動條和左側的檢視內容,並不真正同高。僅僅是比例尺關係。

當scrollTop為0時

當向下滾動時,scrollTop剛好為300px(一個Wrap的高度),側邊的滾動塊也應該往下移動剛好一個身位。也就是滾動塊的自身的高度。

當scrollTop為300px時

當wrap區域往下滾動剛好一整個wrap的高度時,側邊的滾動塊也會往下移動一整個滾動塊的長度。此時Bar元件的 translateY應該是 100%。

計算公式成立:scrollTop(300px)/ scrollHeight(300px)* 100 = 100。

這裡乘100是因為Bar元件中 translateY 是以百分比為單位設定屬性。 繼續滾動到底部時,此時的scrollTop已經為550px,根據公式計算,550 / 300 * 100 滾動塊的位置為 translateY(183.333%)。約要偏移1.8個滾動塊自身的長度,Bar才能反映出wrapcontainer的當前展示位置。

當scrollTop為550px時,滾動塊已經到了底部
update 方法負責更新 Bar 的滑塊長度,在 mounted 生命週期鉤子中,會根據 noresize 配置對view模板進行選擇性監聽視窗大小改變事件,當內容視窗大小發生改變時,會執行 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方法中,會計算出滾動塊的百分比高度,然後賦值給sizeHeightsizeWidth。更新Bar的滾動塊寬度或高度。 heightPercentage是由 可見區域高度 / 總滾動高度,計算出的佔比。和滑塊在滾動條軌道中的佔比是一樣的。

Element-ui el-scrollbar 原始碼解析

this.sizeHeight = heightPercentage < 100 ? heightPercentage + "%" : "";
複製程式碼

在計算sizeHeight時做了大於100判斷,當尺寸改變後的內容大於滾動高度,說明就不需要滾動塊了。 至此,main.js 中的所有邏輯都已經過完了。簡單總結一下 main.js 所做的事情。

  1. 接收配置引數。
  2. 根據配置生成wrap與view結構包裹使用的區域,根據配置新增自定義滾動條Bar。
  3. 對wrap進行滾動事件監聽,對view進行視窗內容改變事件監聽。
  4. 在滾動或視窗改變時,更新Bar元件的滑塊位置或滑塊長度。

然後來到Bar.js,在點選滑塊和軌道時,如何處理檢視視窗的更新。

Bar.js

Bar元件接收三個屬性verticalsizemove,並在計算屬性中新增了當前滾動塊型別的屬性集合bar,與父元件的wrap索引。

export default {
    name: 'Bar',

    props: {
        //  是否垂直滾動條
        vertical: Boolean,
        //  size 對應的是 水平滾動條的 width 或 垂直滾動條的height
        size: String,
        //  move 用於 translateX 或 translateY 屬性中
        move: Number
    },

    computed: {
        /**
         * 從BAR_MAP中返回一個的新物件,垂直滾動條屬性集合 或 水平滾動條屬性集合
         */
        bar() {
            return BAR_MAP[this.vertical ? 'vertical' : 'horizontal'];
        },
        //  父元件的wrap,用於滑鼠拖動滑塊後更新 wrap 的 scrollTop 值
        wrap() {
            return this.$parent.wrap;
        }
    },
    ...
}
複製程式碼

bar會返回當前滾動條型別的滾動條屬性集合,並在後續的操作中取對應的值作為更新。

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'
    }
};
複製程式碼

render函式中,會對軌道區域和滑塊進行滑鼠按下事件進行監聽,並對滑塊進行內聯樣式繫結,在 size, move, bar 等屬性發生改變時,動態的改變滑塊的位置或長度。

render(h) {
    //  size: 'width' || 'height'
    //  move: 滾動塊的位置,單位為百分比
    //  bar: 垂直滾動條屬性集合 或 水平滾動條屬性集合
    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>
    );
}
複製程式碼

我們以垂直型別的Bar元件為例,首先看繫結在軌道區域的滑鼠點選事件回撥 clickTrackHandler 方法。 在點選軌道區域時,滑塊會快速定位到該位置,並且更新檢視的scrollTop。這就是 clickTrackHandler 處理的事情。

//  對按下 滾動條區域 的某一個位置進行快速定位
clickTrackHandler(e) {
    /**
     * getBoundingClientRect() 方法返回元素的大小及其相對於瀏覽器頁面的位置。
     * this.bar.direction = "top"
     * this.bar.client = "clientY"
     * e.clientY 是事件觸發時,滑鼠指標相對於瀏覽器視窗頂部的距離。
     */
    //  偏移量            絕對值 (當前元素距離瀏覽器視窗的 頂部/左側 距離     減去    當前點選的位置距離瀏覽器視窗的 頂部/左側 距離)
    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);
},
複製程式碼

方法中比較多的公式計算,一時之間比較難理解。下圖是各變數的圖示,接著我們一個一個拆解。

變數圖示
在方法中,第一步會計算滑塊的偏移量(offset)。程式碼中的偏移量計算公式是: 點選元素距離瀏覽器視窗頂部的距離 減去 滑鼠點選位置距離瀏覽器視窗頂部的距離,再求結果的絕對值。

點選元素 實則就是軌道區域,其實公式可以換成這樣看,會更加容易理解。

滑鼠點選位置距離瀏覽器視窗頂部的距離 減去 滾動條區域距離瀏覽器視窗頂部的距離

因為根據scrollBar元件的使用位置不同(有的包裹整個頁面視窗,有的包裹一小塊選單區域),滾動條區域也不一定完全貼緊瀏覽器視窗的頂部。所以這邊需要用 滑鼠點選位置距離瀏覽器視窗頂部的距離e[this.bar.client]滾動條區域距離瀏覽器視窗頂部的距離e.target.getBoundingClientRect()[this.bar.direction] 減去,才能得出準確的 偏移量offset

/**
 * getBoundingClientRect() 方法返回元素的大小及其相對於瀏覽器頁面的位置。
 * this.bar.direction = "top"
 * this.bar.client = "clientY"
 * e.clientY 是事件觸發時,滑鼠指標相對於瀏覽器視窗頂部的距離。
 */

//  偏移量            絕對值 (當前元素距離瀏覽器視窗的 垂直/水平 座標     減去    當前點選的位置距離瀏覽器視窗的 垂直/水平 座標)
const offset = Math.abs(e.target.getBoundingClientRect()[this.bar.direction] - e[this.bar.client]);

複製程式碼

offset的計算
接下來計算的是滑動塊一半的高度,用於後續邏輯處理。


//  滑動塊一半高度
const thumbHalf = (this.$refs.thumb[this.bar.offset] / 2);

複製程式碼

滾動塊一半高度計算
根據瀏覽器滾動條操作行為,一般我們點選軌道某個點時,滑塊的中心總會在我們的落點位置。 在用偏移量 offset 減去滾動塊的一半高度 thumbHalf 後得出 滑塊總移動的長度。再用 滑塊總移動的長度滾動區域的總高度,得出 滾動比例thumbPositionPercentage。 得出 滾動比例 後,因為滾動條和檢視是一個縮放的比例尺關係。此時用 滾動比例wrap的 scrollHeight 得出滾動距離,再對 wrapscrollTop 進行賦值,檢視便滾動到需要更新展示的內容中。


//  計算點選後,根據 偏移量 計算在 滾動條區域的總高度 中的佔比,也就是 滾動塊 所處的位置
const thumbPositionPercentage = ((offset - thumbHalf) * 100 / this.$el[this.bar.offset]);

//  設定外殼的 scrollTop 或 scrollLeft 新值。達到滾動內容的效果
this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);

複製程式碼

計算wrap需要滾動的距離

接下來是滑塊監聽的滑鼠按下事件,clickThumbHandler

clickThumbHandler 方法會在滑鼠按下滑塊時,監聽滑鼠移動事件和滑鼠按鍵釋放事件,更新滑塊位置的同時,也更新檢視視窗的滾動位置。

//  按下滑動塊
clickThumbHandler(e) {
    /**
     * 防止右鍵單擊滑動塊
     * e.ctrlKey: 檢測事件發生時Ctrl鍵是否被按住了
     * e.button: 指示當事件被觸發時哪個滑鼠按鍵被點選 0,滑鼠左鍵;1,滑鼠中鍵;2,滑鼠右鍵
     */
    if (e.ctrlKey || e.button === 2) {
        return;
    }
    //  開始記錄拖拽
    this.startDrag(e);
    //  記錄點選滑塊時的位置距滾動塊底部的距離
    this[this.bar.axis] = (
        //  滑塊的高度
        e.currentTarget[this.bar.offset] - 
        //  點選滑塊距離頂部的位置 減去 滑塊元素距離頂部的位置
        (e[this.bar.client] - e.currentTarget.getBoundingClientRect()[this.bar.direction])
    );
},
複製程式碼

開始先判斷是否滑鼠右鍵觸發的事件,如真返回。接著執行 startDrag 方法。 最後會計算點選滑塊時的位置距滾動塊底部的距離。然後賦值給this[this.bar.axis],因為當前滾動條型別是垂直滾動條,所以this.bar.axis從計算屬性中獲取為字串 Ythis['Y']會用於後續的計算。 this['Y'] 的計算公式為:滑塊的高度 減去 (點選滑塊的位置距離頁面視窗頂部的距離 clientY 減去 滑塊元素距離頁面視窗頂部的距離Rect.top

變數圖示

this.bar.axis 從計算屬性中獲取,返回的是字串,X 或 Y。但在Bar元件的 data 中,並沒有對 this['X']this['Y'] 這兩個屬性進行宣告。 原因是因為Bar元件有兩種型別,垂直或水平。所以作者沒有選擇一開始就宣告,而是通過後續的操作再動態掛上 XY 屬性。

需要注意的是,這樣動態新增的屬性,並不是一個響應式的屬性。即未被vue進行getter/setter重寫,在資料發生改變後檢視是不會同步更新的。 但是這裡僅僅用於資料層面上的使用,並不在檢視上使用。問題不大。 具體可以查閱文件, 深入響應式原理

startDrag方法中,會記錄按下狀態,並監聽滑鼠移動和滑鼠按鈕鬆開事件。

//  開始拖拽
startDrag(e) {
    //  停止後續的相同事件函式執行
    e.stopImmediatePropagation();
    //  記錄按下狀態
    this.cursorDown = true;
    //  監聽滑鼠移動事件
    on(document, 'mousemove', this.mouseMoveDocumentHandler);
    //  監聽滑鼠按鍵鬆開事件
    on(document, 'mouseup', this.mouseUpDocumentHandler);
    //  拖拽滾動塊時,此時禁止滑鼠長按劃過文字選中。
    document.onselectstart = () => false;
},
複製程式碼

on方法和off方法在 utils/dom 中被匯出,在匯出時會對環境進行相容處理,匯出對應的事件監聽處理函式。

/* istanbul ignore next */
export const on = (function() {
  //  查詢例項是否在服務端執行,與是否支援 addEventListener,返回對應處理監聽函式
  if (!isServer && document.addEventListener) {
    return function(element, event, handler) {
      if (element && event && handler) {
        //  適用於現代瀏覽器的監聽事件 addEventListener
        element.addEventListener(event, handler, false);
      }
    };
  } else {
    return function(element, event, handler) {
      if (element && event && handler) {
        //  用於 ie 部分版本瀏覽器的監聽事件 attachEvent
        element.attachEvent('on' + event, handler);
      }
    };
  }
})();

/* istanbul ignore next */
export const off = (function() {
  //  查詢例項是否在服務端執行,與是否支援 removeEventListener,返回對應處理監聽函式
  if (!isServer && document.removeEventListener) {
    return function(element, event, handler) {
      if (element && event) {
        //  適用於現代瀏覽器的移除事件監聽 removeEventListener
        element.removeEventListener(event, handler, false);
      }
    };
  } else {
    return function(element, event, handler) {
      if (element && event) {
        //  用於 ie 部分版本瀏覽器的移除事件監聽 detachEvent
        element.detachEvent('on' + event, handler);
      }
    };
  }
})();
複製程式碼

在滑鼠移動時,會執行mouseMoveDocumentHandler事件。 方法進入會判斷cursorDownthis.['Y']是否存在,如果為假。說明方法並不是正常操作觸發,結束返回。 在滑鼠的不斷移動中,計算按住滑塊移動時的位置距離軌道頂部的實際距離offset,同時用之前記錄下來的this['Y']計算出按下滑塊時距離滑塊頂部的距離thumbClickPosition。 此時offset減去thumbClickPosition,就是滑塊在軌道中實際移動的距離。再用此值除以軌道長度。便是滾動比例thumbPositionPercentage。 最後用thumbPositionPercentage乘檢視視窗的滾動高度,便是檢視視窗需要更新滾動的距離。

//  按下滾動條,並且滑鼠移動時
mouseMoveDocumentHandler(e) {
   //  如果按下狀態為假,返回
   if (this.cursorDown === false) return;
   //  點選位置時距滾動塊底部的距離
   const prevPage = this[this.bar.axis];
   
   if (!prevPage) return;

   //              (滑塊距離頁面頂部的距離                            減  滑鼠移動時距離頂部的距離) * -1
   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);
},

複製程式碼

mouseMoveDocumentHandler中的變數圖示
在滑鼠鬆開時,重置各記錄的狀態,並取消監聽的滑鼠移動事件。

//  按下滾動條,並且滑鼠鬆開
mouseUpDocumentHandler(e) {
    //  重置按下狀態
    this.cursorDown = false;
    //  重置當前點選在滾動塊的位置
    this[this.bar.axis] = 0;
    //  移除監聽滑鼠移動事件
    off(document, 'mousemove', this.mouseMoveDocumentHandler);
    //  拖拽結束,此時允許滑鼠長按劃過文字選中。
    document.onselectstart = null;
}
複製程式碼

原始碼到這裡已經全部解讀結束,因個人水平有限,難免會有不準確或者存在歧義的地方,希望能夠不吝賜教,共同交流進步。 祝你有個愉快的勞動節假期。:)

Have a nice day.

參考資料

  1. ResizeObserver
  2. 深入響應式原理

相關文章