基于 element,阅读 ScrollBar 滚动组件源码

scrollbar

首先 scrollbar 组件对外暴漏的接口如下:

	props: {
      // 是否采用原生滚动(即只是隐藏掉了原生滚动条,但并没有使用自定义的滚动条)
      native: {
        type: Boolean,
        // 文件配置信息
        default: scrollbar?.native ?? false,
      },
      // 自定义 wrap 容器的样式
      wrapStyle: {
        type: [String, Array],
        default: '',
      },
      wrapClass: {
        type: [String, Array],
        default: '',
      },
      // 自定义 view 容器的样式
      viewClass: {
        type: [String, Array],
        default: '',
      },
      viewStyle: {
        type: [String, Array],
        default: '',
      },
      // 如果 container 尺寸不会发生变化,最好设置它可以优化性能
      noresize: Boolean, 
      // view 容器用那种标签渲染,默认为div
      tag: {
        type: String,
        default: 'div',
      },
    },

最外层的 scrollbar 设置了 overflow:hidden,用来隐藏 wrap 中产生的浏览器原生滚动条。在 scrollbar 组件中的内容都将通过 slot 分发到 view 内部。

<!-- src/components/Scrollbar/src/Scrollbar.vue -->
<template>
  <div class="scrollbar">
    <!-- 生成 wrap 节点,并且给 wrap 绑定 scroll 事件 -->
    <div
      ref="wrap"
      :class="[wrapClass, 'scrollbar__wrap', native ? '' : 'scrollbar__wrap--hidden-default']"
      :style="style"
      @scroll="handleScroll"
    >
      <!-- 生成 view 节点,并且将默认 slots 内容插入到 view 节点下 -->
      <component :is="tag" ref="resize" :class="['scrollbar__view', viewClass]" :style="viewStyle">
        <slot></slot>
      </component>
    </div>
    <!-- 使用自定义的滚动条 -->
    <template v-if="!native">
      <bar :move="moveX" :size="sizeWidth" />
      <bar vertical :move="moveY" :size="sizeHeight" />
    </template>
  </div>
</template>
<script lang="ts">
  export default defineComponent({
    name: 'Scrollbar',
    setup (props) {
      const sizeWidth = ref('0');
      const sizeHeight = ref('0');
      const moveX = ref(0);
      const moveY = ref(0);
      const wrap = ref();
      const resize = ref();

      // wrapStyle 传入样式的数据类型来处理 style
      const style = computed(() => {
        if (Array.isArray(props.wrapStyle)) {
          return toObject(props.wrapStyle);
        }
        return props.wrapStyle;
      });
    },
  })
</script>

在 onMounted 钩子中调用 update 方法对 sizeHeight、sizeWidth 进行初始化:


const update = () => {
    if (!unref(wrap)) return;

    // 计算 thumb 长度的百分比
    const heightPercentage = (unref(wrap).clientHeight * 100) / unref(wrap).scrollHeight;
    const widthPercentage = (unref(wrap).clientWidth * 100) / unref(wrap).scrollWidth;

    // 设置 thumb 的 css 高度的百分比
    // 当这个比值大于等于100,wrap.clientheight(容器高度)大于等于 wrap.scrollheight(滚动高度)时,不需要滚动条了,将 size 置为空字符串。
    sizeHeight.value = heightPercentage < 100 ? heightPercentage + '%' : '';
    sizeWidth.value = widthPercentage < 100 ? widthPercentage + '%' : '';
}

update 方法:thumb 在 track 中上下滚动,可滚动区域 view 在可视区域 wrap 中上下滚动,可以将 thumb 和 track 的这种相对关系看作是 wrap 和 view 相对关系的一个 微缩模型 (微缩反应),而滚动条的意义就是用来反映 view 和 wrap 的这种相对运动关系的。即:wrap.clientheight / wrap.scrollheight = thumb.clientheight / track.clientheight。

// 滚动条滚动位置的更新
const handleScroll = () => {
    if (!props.native) {
        moveY.value = (unref(wrap).scrollTop * 100) / unref(wrap).clientHeight;
        moveX.value = (unref(wrap).scrollLeft * 100) / unref(wrap).clientWidth;
    }
}

handleScroll 函数逻辑所在:wrap.scrolltop = wrap.clientheight 时,thumb 应该向下滚动它自身长度的距离,也就是transform: translateY(100%)。即当 wrap 滚动的时候,thumb 应该向下滚动的距离正好是 transform: translateY(wrap.scrolltop / wrap.clientheight )。

import { onMounted, onBeforeUnmount } from 'vue';
onMounted(() => {
    if (props.native) return;
    nextTick(update);
    if (!props.noresize) {
        addResizeListener(unref(resize), update);
        addResizeListener(unref(wrap), update);
        addEventListener('resize', update);
    }
})

onBeforeUnmount(() => {
    if (props.native) return;
    if (!props.noresize) {
        removeResizeListener(unref(resize), update);
        removeResizeListener(unref(wrap), update);
        removeEventListener('resize', update);
    }
})

对于使用自定义的滚动条,若容器尺寸有变化的,我们分别在 onMounted 和 onBeforeUnmount 生命周期内,添加监听 resize 事件和移出事件等。

bar

bar 组件:右侧 track 是滚动条的滚动滑块、thumb 上下滚动的轨迹吧,并分别绑定了onmousedown事件。

bar 组件对外暴漏的接口如下:

  props: {
    vertical: Boolean, // 当前bar组件是否为垂直滚动条
    size: String, // 百分数,当前bar组件的thumb长度 / track长度的百分比
    move: Number, // 滚动条向下/向右发生transform: translate的值
  },

自定义滚动条渲染 dom 信息。

import { defineComponent, h, computed, ref, getCurrentInstance, onUnmounted, inject, Ref } from 'vue'
import { on, off } from '/@/utils/domUtils'

export default defineComponent({
    name: 'Bar',
    setup(props) {
        const instance = getCurrentInstance();
        const thumb = ref();
        const barStore = ref<Recordable>({});
        const cursorDown = ref();
        const wrap = inject('scroll-bar-wrap', {} as Ref<Nullable<HTMLElement>>) as any;


        return () =>
            h(
                'div',
                {
                    class: ['scrollbar__bar', 'is-' + bar.value.key],
                    onMousedown: clickTrackHandler,
                },
                h('div', {
                    ref: thumb,
                    class: 'scrollbar__thumb',
                    onMousedown: clickThumbHandler,
                    style: renderThumbStyle({
                        size: props.size,
                        move: props.move,
                        bar: bar.value,
                    }),
                }),
            );
    }
})

通过 renderthumbstyle 转化为 trumb 的样式 transform: translatex( m o v e X {moveX}%) / transform: translatey( moveX{moveY}%) ,来生成 thumb,并且给 track 和 thumb 分别绑定了 onmousedown 事件。

const clickThumbHandler = () => {
    if (e.ctrlKey || e.button === 2) {
        return;
    }
    window.getSelection() ?.removeAllRanges();
    startDrag(e);
    // 记录this.y , this.y = 鼠标按下点到thumb底部的距离
    // 记录this.x , this.x = 鼠标按下点到thumb左侧的距离
    barStore.value[bar.value.axis] =
        e.currentTarget[bar.value.offset] -
        (e[bar.value.client] - e.currentTarget.getBoundingClientRect()[bar.value.direction]);
}

// 开始拖拽
const startDrag = () => {
    e.stopImmediatePropagation();
    // 标识位, 标识当前开始拖拽
    cursorDown.value = true;
    // 绑定 mousemove 和 mouseup 事件
    on(document, 'mousemove', mouseMoveDocumentHandler);
    on(document, 'mouseup', mouseUpDocumentHandler);
    // 解决拖动过程中页面内容选中的bug
    document.onselectstart = () => false;
}

const mouseMoveDocumentHandler = () => {
    // 判断是否在拖拽过程中
    if (cursorDown.value === false) return;
    // 刚刚记录的this.y(this.x) 的值
    const prevPage = barStore.value[bar.value.axis];

    if (!prevPage) return;

    // 鼠标按下的位置在 track 中的偏移量,即鼠标按下点到 track 顶部(左侧)的距离
    const offset = (instance ?.vnode.el ?.getBoundingClientRect()[bar.value.direction] - e[bar.value.client]) * -1;
    // 鼠标按下点到 thumb 顶部(左侧)的距离
    const thumbClickPosition = thumb.value[bar.value.offset] - prevPage;
    // 当前thumb顶部(左侧)到track顶部(左侧)的距离,即thumb向下(向右)偏移的距离 占track高度(宽度)的百分比
    const thumbPositionPercentage = ((offset - thumbClickPosition) * 100) / instance ?.vnode.el ?.[bar.value.offset];

    // wrap.scrollheight / wrap.scrollleft * thumbpositionpercentage 得到 wrap.scrolltop / wrap.scrollleft

    // 当 wrap.scrolltop(wrap.scrollleft) 发生变化时,会触发父组件 wrap 上绑定的 onscroll 事件,

    // 从而重新计算movex/movey的值,这样 thumb 的滚动位置就会重新渲染
    wrap.value[bar.value.scroll] = (thumbPositionPercentage * wrap.value[bar.value.scrollSize]) / 100;
}

function mouseUpDocumentHandler() {
    // 当拖动结束,将标识位设为false
    cursorDown.value = false;
    // 将上一次拖动记录的this.y(this.x)的值清空
    barStore.value[bar.value.axis] = 0;
    // 取消页面绑定的 mousemove 事件
    off(document, 'mousemove', mouseMoveDocumentHandler);
    // 清空 onselectstart 事件绑定的函数
    document.onselectstart = null;
}

thumb 滚动条拖拽处理逻辑:在拖拽 thumb 的过程中,动态的计算 thumb 顶部(左侧)到 track 顶部(左侧)的距离占 track 本身高度(宽度)的百分比,然后利用这个百分比动态改变 wrap.scrolltop 的值,从而触发页面滚动以及滚动条位置的重新计算,实现滚动效果。

const clickTrackHandler = () => {
    const offset = Math.abs(
        e.target.getBoundingClientRect()[bar.value.direction] - e[bar.value.client],
    );
    const thumbHalf = thumb.value[bar.value.offset] / 2;
    const thumbPositionPercentage =
        ((offset - thumbHalf) * 100) / instance ?.vnode.el ?.[bar.value.offset];

    wrap.value[bar.value.scroll] =
        (thumbPositionPercentage * wrap.value[bar.value.scrollSize]) / 100
}

需要注意一下两点:

  • track 的 onmousedown 事件回调中不会给页面绑定 mousemove 和 mouseup 事件,因为 track 相当于 click 事件。
  • 计算 thumb 顶部到 track 顶部的方法是:用鼠标点击点到 track 顶部的距离减去 thumb 的二分之一高度,因为点击 track 之后,thumb 的中点刚好要在鼠标点击点的位置。

完整代码如下:

// src/components/Scrollbar/src/bar.ts
import { defineComponent, h, computed, ref, getCurrentInstance, onUnmounted, inject, Ref } from 'vue'
import { on, off } from '/@/utils/domUtils'

export default defineComponent({
  name: 'Bar',

  setup(props) {
    const instance = getCurrentInstance();
    const thumb = ref();
    const barStore = ref<Recordable>({});
    const cursorDown = ref();
    const wrap = inject('scroll-bar-wrap', {} as Ref<Nullable<HTMLElement>>) as any;


    // 滚动条信息
    const bar = computed(() => {
      return BAR_MAP[props.vertical ? 'vertical' : 'horizontal'];
    });

    const clickThumbHandler = () => {
      if (e.ctrlKey || e.button === 2) {
        return;
      }
      window.getSelection()?.removeAllRanges();
      startDrag(e);
      // 记录this.y , this.y = 鼠标按下点到thumb底部的距离
      // 记录this.x , this.x = 鼠标按下点到thumb左侧的距离
      barStore.value[bar.value.axis] =
        e.currentTarget[bar.value.offset] -
        (e[bar.value.client] - e.currentTarget.getBoundingClientRect()[bar.value.direction]);
    }

    // track 的 onmousedown 事件回调中不会给页面绑定 mousemove 和 mouseup 事件,因为 track 相当于 click 事件。
    const clickTrackHandler = () => {
      const offset = Math.abs(
        e.target.getBoundingClientRect()[bar.value.direction] - e[bar.value.client],
      );
      const thumbHalf = thumb.value[bar.value.offset] / 2;
      // 用鼠标点击点到 track 顶部的距离减去thumb的二分之一高度,点击 track 之后,thumb 的中点刚好要在鼠标点击点的位置。
      const thumbPositionPercentage =
        ((offset - thumbHalf) * 100) / instance?.vnode.el?.[bar.value.offset];

      wrap.value[bar.value.scroll] =
        (thumbPositionPercentage * wrap.value[bar.value.scrollSize]) / 100
    }

    // 开始拖拽
    const startDrag = () => {
      e.stopImmediatePropagation();
      // 标识位, 标识当前开始拖拽
      cursorDown.value = true;
      // 绑定 mousemove 和 mouseup 事件
      on(document, 'mousemove', mouseMoveDocumentHandler);
      on(document, 'mouseup', mouseUpDocumentHandler);
      // 解决拖动过程中页面内容选中的bug
      document.onselectstart = () => false;
    }

    const mouseMoveDocumentHandler = () => {
      // 判断是否在拖拽过程中
      if (cursorDown.value === false) return;
      // 刚刚记录的this.y(this.x) 的值
      const prevPage = barStore.value[bar.value.axis];

      if (!prevPage) return;

      // 鼠标按下的位置在 track 中的偏移量,即鼠标按下点到 track 顶部(左侧)的距离
      const offset = (instance?.vnode.el?.getBoundingClientRect()[bar.value.direction] - e[bar.value.client]) * -1;
      // 鼠标按下点到 thumb 顶部(左侧)的距离
      const thumbClickPosition = thumb.value[bar.value.offset] - prevPage;
      // 当前thumb顶部(左侧)到track顶部(左侧)的距离,即thumb向下(向右)偏移的距离 占track高度(宽度)的百分比
      const thumbPositionPercentage = ((offset - thumbClickPosition) * 100) / instance?.vnode.el?.[bar.value.offset];

      // wrap.scrollheight / wrap.scrollleft * thumbpositionpercentage 得到 wrap.scrolltop / wrap.scrollleft

      // 当 wrap.scrolltop(wrap.scrollleft) 发生变化时,会触发父组件 wrap 上绑定的 onscroll 事件,

      // 从而重新计算movex/movey的值,这样 thumb 的滚动位置就会重新渲染
      wrap.value[bar.value.scroll] = (thumbPositionPercentage * wrap.value[bar.value.scrollSize]) / 100;
    }

    function mouseUpDocumentHandler() {
      // 当拖动结束,将标识位设为false
      cursorDown.value = false;
      // 将上一次拖动记录的this.y(this.x)的值清空
      barStore.value[bar.value.axis] = 0;
      // 取消页面绑定的 mousemove 事件
      off(document, 'mousemove', mouseMoveDocumentHandler);
      // 清空 onselectstart 事件绑定的函数
      document.onselectstart = null;
    }

    onUnmounted(() => {
      // 取消页面绑定的 mouseup 事件
      off(document, 'mouseup', mouseUpDocumentHandler)
    })
    
    return () =>
      h(
        'div',
        {
          class: ['scrollbar__bar', 'is-' + bar.value.key],
          onMousedown: clickTrackHandler,
        },
        h('div', {
          ref: thumb,
          class: 'scrollbar__thumb',
          onMousedown: clickThumbHandler,
          // 将它转化为trumb 的样式 transform: translatex(${moveX}%) / transform: translatey(${moveY}%) 
          style: renderThumbStyle({
            size: props.size,
            move: props.move,
            bar: bar.value,
          }),
        }),
      );
  }
})

滚动容器 ScrollContainer

ScrollContainer 组件对外提供 scrollTo、scrollBottom 方法,通过该方法可以指定滚动条滚动的位置。

<!-- src/components/Container/src/ScrollContainer.vue -->
<template>
  <Scrollbar ref="scrollbarRef" class="scroll-container" v-bind="$attrs">
    <slot></slot>
  </Scrollbar>
</template>

<script lang="ts">
  import { defineComponent, ref, unref, nextTick } from 'vue'
  import { Scrollbar } from '/@/components/Scrollbar';
  import { useScrollTo } from '/@/hooks/event/useScrollTo';
  

  export default defineComponent({
    name: 'ScrollContainer',
    components: { Scrollbar },

    setup() {
      const scrollbarRef = ref(null)

      function getScrollWrap () {
        const scrollbar = unref(scrollbarRef)
        if (!scrollbar) {
          return null
        }
        return scrollbar.wrap
      }

      // 滚动到指定位置
      function scrollTo (to: Number, duration = 500) {
        const scrollbar = unref(scrollbarRef)
        if (!scrollbar) {
          return
        }
        nextTick(() => {
          const wrap = unref(scrollbar.wrap)
          if (!wrap) {
            return
          }
          const { start } = useScrollTo({ el: wrap, to, duration })
          start()
        })
      },
      
      // 滚动到底部
      function scrollBottom () {
        const scrollbar = unref(scrollbarRef)
        if (!scrollbar) {
          return
        }

        nextTick(() => {
          const wrap = unref(scrollbar.wrap)
          if (!wrap) {
            return
          }
          const scrollHeight = wrap.scrollHeight
          const { start } = useScrollTo({ el: wrap, to: scrollHeight })
          start()
        })
      }

      return {
        scrollbarRef,
        scrollTo,
        scrollBottom,
        getScrollWrap,
      }
    },
  })
</script>
useScrollTo 实现

useScrollTo Hook 添加滚动动画效果。

// src/hooks/event/useScrollTo.ts

// 当前位置
const position = (el: HTMLElement) => {
  return el.scrollTop
}

// 滚动至
const move = (el: HTMLElement, amount: number) => {
  el.scrollTop = amount
}

// 缓入缓出
const easeInOutQuad = (t: number, b: number, c: number, d: number) => {
  t /= d / 2;
  if (t < 1) {
    return (c / 2) * t * t + b;
  }
  t--;
  return (-c / 2) * (t * (t - 2) - 1) + b;
}

export function useScrollTo({ el, to, duration = 500, callback}) {
  // 滚动标识
  const isActiveRef = ref(false)
  const start = position(el)
  const change = to - start
  const increment = 20
  let currentTime = 0
  duration = isUnDef(duration) ? 500 : duration

  // 滚动动画
  const animateScroll = function () {
    if (!unref(isActiveRef)) {
      return
    }
    currentTime += increment
    const val = easeInOutQuad(currentTime, start, change, duration)
    move(el, val)

    if (currentTime < duration && unref(isActiveRef)) {
      // 节流: 每个刷新间隔内,函数只被执行一次,这样既能保证流畅性,也能更好的节省函数执行的开销。
      requestAnimationFrame(animateScroll)
    } else {
      if (callback && isFunction(callback)) {
        callback({ currentTime, start, change, duration })
      }
    }
  }

  const run = () => {
    isActiveRef.value = true
    animateScroll()
  }

  const stop = () => {
    isActiveRef.value = false
  }

  return { start: run, stop }
}

使用案例

<template>
	<ScrollContainer class="mt-4" ref="scrollRef">
		<template v-for="index in 100" :key="index">
			<p class="p-2" :style="{ border: '1px solid #eee' }"> {{ index }} </p>
		</template>
	</ScrollContainer>
</template>
<script lang="ts">
  import { defineComponent, ref, unref } from 'vue';

  export default defineComponent({
    setup () {
      const scrollRef = ref(null)

      const getScroll = () => {
        const scroll = unref(scrollRef)
        if (!scroll) {
          throw new Error('scroll is Null')
        }
        return scroll
      }

      function scrollTo(top: Number) {
        getScroll().scrollTo(top)
      }
      function scrollBottom () {
        getScroll().scrollBottom()
      }

      return { scrollRef, scrollTo, scrollBottom };
    }
  })
</script>

参考地址:

  1. 深入分析element ScrollBar滚动组件源码
  2. vue 是实现简易滚动效果
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值