【ElementPlus源码】Scrollbar 滚动条

看源码时候做的笔记。若有问题,请指出!
路径相关格式请看button的源码阅读!

Scrollbar 滚动条组件从里到外分为三个组件:thumb(滚动条的可拖动部分)、bar(thumb的相关信息)和Scrollbar(滚动条组件)。

本博客将从里到外学习这三个组件。

thumb

路径:src/thumb

thumb定义了滚动条的可拖动部分。下面的模板代码定义了一个带有过渡效果的滚动条,包括滚动条本身(track)和可拖动部分(thumb)。

<template>
  <transition :name="ns.b('fade')">
    <div
      v-show="always || visible"
      ref="instance"
      :class="[ns.e('bar'), ns.is(bar.key)]"
      @mousedown="clickTrackHandler"
    >
      <div
        ref="thumb"
        :class="ns.e('thumb')"
        :style="thumbStyle"
        @mousedown="clickThumbHandler"
      />
    </div>
  </transition>
</template>

transition:是vue的一个内置组件,用于在元素或组件的插入、更新、移除时应用过渡效果。它可以在你的组件中添加 进入/离开 的动画和过渡

v-show、class、ref、style等属性不赘述。

此组件绑定了两个事件,分别是clickTrackHandler,处理鼠标点击滚动条轨道的事件;clickThumbHandler,处理鼠标点击滚动条可拖动部分事件。

clickThumbHandler

此方法处理了鼠标点击滚动条可拖动部分事件,做了如下事情:

  1. 阻止事件冒泡
  2. 按下ctrl键或鼠标中/右键则return
  3. 清空选区,方便拖动
  4. startDrag()
  5. 拖动完毕后,计算鼠标点击位置相对于滚动条起始位置的距离

其中,计算鼠标点击位置相对于滚动条起始位置的距离thumbState.value[bar.value.axis],在startDrag() 中会改变。

const clickThumbHandler = (e: MouseEvent) => {
  // prevent click event of middle and right button
  e.stopPropagation()
  // 按下了ctrl键或鼠标中键/右键
  if (e.ctrlKey || [1, 2].includes(e.button)) return
  // 清除所有选区,防止在拖动滚动条时选中文本
  window.getSelection()?.removeAllRanges()
  //  开始拖动
  startDrag(e)

  const el = e.currentTarget as HTMLDivElement
  if (!el) return

  // 鼠标点击位置相对于滚动条的起始位置的距离
  thumbState.value[bar.value.axis] =
    el[bar.value.offset] -
    (e[bar.value.client] - el.getBoundingClientRect()[bar.value.direction])
}

startDrag

此方法是拖动操作。

const startDrag = (e: MouseEvent) => {
  e.stopImmediatePropagation()
  cursorDown = true
  document.addEventListener('mousemove', mouseMoveDocumentHandler)
  document.addEventListener('mouseup', mouseUpDocumentHandler)
  //   阻止用户在拖动过程中选择文本
  originalOnSelectStart = document.onselectstart
  document.onselectstart = () => false
}

mouseMoveDocumentHandler

在拖动方法中调用,添加为mousemove的监听事件。

鼠标移动时,函数会根据鼠标位置更新滚动条thumb的位置,并调整滚动区域的滚动位置。

通俗的语言就是:点击滚动条然后拖动,滚动条和滚动区域都会更新位置,就做了这样的事情。

const mouseMoveDocumentHandler = (e: MouseEvent) => {
  if (!instance.value || !thumb.value) return
  if (cursorDown === false) return

  //   点击时滚动条的位置
  const prevPage = thumbState.value[bar.value.axis]
  if (!prevPage) return

  //   当前鼠标位置与滚动条 轨道 起始位置的距离
  const offset =
    (instance.value.getBoundingClientRect()[bar.value.direction] -
      e[bar.value.client]) *
    -1
  // 当前鼠标位置相对于滚动条"thumb"的起始位置的距离
  const thumbClickPosition = thumb.value[bar.value.offset] - prevPage
  const thumbPositionPercentage =
    ((offset - thumbClickPosition) * 100 * offsetRatio.value) /
    instance.value[bar.value.offset]
  scrollbar.wrapElement[bar.value.scroll] =
    (thumbPositionPercentage * scrollbar.wrapElement[bar.value.scrollSize]) /
    100
}

mouseUpDocumentHandler

鼠标释放时,结束拖动操作。

  • thumbState.value[bar.value.axis] 表示存储滚动条thumb 可拖动部分 的位置
  • bar.value.axis表示正在操作的轴
  • 将可拖动部分设置为0,清除设置的监听
  • document.onselectstart赋值回去

对于if (cursorLeave) visible.value = false,可以在elementPlus滚动条的文档中试验一下,拖动滚动条结束后,滚动条的显示会消失。

const mouseUpDocumentHandler = () => {
  cursorDown = false
  thumbState.value[bar.value.axis] = 0
  document.removeEventListener('mousemove', mouseMoveDocumentHandler)
  document.removeEventListener('mouseup', mouseUpDocumentHandler)
  // document.onselectstart的原始值,允许用户选择文本
  restoreOnselectstart()
  if (cursorLeave) visible.value = false
}

对于restoreOnselectstart():在前面拖动时startDrag(),为了阻止用户在拖动过程中选择到文本,将document.onselectstart的值存到originalOnSelectStart中,将document.onselectstart赋值为一个只返回false的回调函数,它会阻止用户的选择操作。

现在把它恢复。

const restoreOnselectstart = () => {
  if (document.onselectstart !== originalOnSelectStart)
    document.onselectstart = originalOnSelectStart
}

clickTrackHandler

处理鼠标点击滚动条轨道的事件。此方法做的事情是:将滚动条thumb移动到点击位置,并相应地调整滚动区域的滚动位置。

const clickTrackHandler = (e: MouseEvent) => {
  if (!thumb.value || !instance.value || !scrollbar.wrapElement) return

  //   计算点击位置与起始位置的距离
  const offset = Math.abs(
    (e.target as HTMLElement).getBoundingClientRect()[bar.value.direction] -
      e[bar.value.client]
  )
  const thumbHalf = thumb.value[bar.value.offset] / 2
  const thumbPositionPercentage =
    ((offset - thumbHalf) * 100 * offsetRatio.value) /
    instance.value[bar.value.offset]

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

如点击这里:滚动条就会弹到这里,整个容器也会显示到对应位置。

在这里插入图片描述

其他

BAR_MAP,一个只读的枚举对象。

export 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',
  },
} as const

通过props.vertical选择滚动条是垂直或水平,写进style中:

const bar = computed(() => BAR_MAP[props.vertical ? 'vertical' : 'horizontal'])

const thumbStyle = computed(() =>
  renderThumbStyle({
    size: props.size,
    move: props.move,
    bar: bar.value,
  })
)
<div
  ref="thumb"
  :class="ns.e('thumb')"
  :style="thumbStyle"
  @mousedown="clickThumbHandler"
/>

bar

路径:src/bar

包含两个滚动条,水平滚动和垂直滚动。

导出两个方法,handleScrollupdate。分别是:滚动时更新moveY和moveX,更新滚动条的大小和比率。

<template>
  <thumb :move="moveX" :ratio="ratioX" :size="sizeWidth" :always="always" />
  <thumb
    :move="moveY"
    :ratio="ratioY"
    :size="sizeHeight"
    vertical
    :always="always"
  />
</template>

Scrollbar

导出的方法

查看导出的方法:

defineExpose({
  /** @description scrollbar wrap ref */
  wrapRef,
  /** @description update scrollbar state manually */
  update,
  /** @description scrolls to a particular set of coordinates */
  scrollTo,
  /** @description set distance to scroll top */
  setScrollTop,
  /** @description set distance to scroll left */
  setScrollLeft,
  /** @description handle scroll event */
  handleScroll,
})

在这里插入图片描述
wrapRef是滚动条包裹的ref对象。

<template>
  <div ref="scrollbarRef" :class="ns.b()">
    <div
      ref="wrapRef"
      :class="wrapKls"
      :style="wrapStyle"
      @scroll="handleScroll"
    >
      <component
        :is="tag"
        :id="id"
        ref="resizeRef"
        :class="resizeKls"
        :style="viewStyle"
        :role="role"
        :aria-label="ariaLabel"
        :aria-orientation="ariaOrientation"
      >
        <slot />
      </component>
    </div>
    <!-- 如果不用原生的滚动条,就使用自己封装的 两个滚动条 -->
    <template v-if="!native">
      <bar ref="barRef" :always="always" :min-size="minSize" />
    </template>
  </div>
</template>

update是调用bar中导出的update,更新滚动条的大小和比率。

scrollTo用于滚动到指定位置,重载了,有两种调用方式:传入xy坐标和传入包含滚动选项的对象:

(根据注释,这一段代码之后要被重构)

// TODO: refactor method overrides, due to script setup dts
// @ts-nocheck
function scrollTo(xCord: number, yCord?: number): void
function scrollTo(options: ScrollToOptions): void
function scrollTo(arg1: unknown, arg2?: number) {
  // ScrollToOptions,即第二种调用方式
  if (isObject(arg1)) {
    wrapRef.value!.scrollTo(arg1)
    // 第一种调用方式
  } else if (isNumber(arg1) && isNumber(arg2)) {
    wrapRef.value!.scrollTo(arg1, arg2)
  }
}

setScrollTopsetScrollLeft:设置滚动条到顶部/左边的距离。传入参数赋值即可。

const setScrollTop = (value: number) => {
  if (!isNumber(value)) {
    debugWarn(COMPONENT_NAME, 'value must be a number')
    return
  }
  wrapRef.value!.scrollTop = value
}

const setScrollLeft = (value: number) => {
  if (!isNumber(value)) {
    debugWarn(COMPONENT_NAME, 'value must be a number')
    return
  }
  wrapRef.value!.scrollLeft = value
}

noresize

对于noresize这个属性,不响应容器尺寸变化,如果容器尺寸不会发生变化,最好设置它。可以优化性能。

优化原理:

代码中初始化2个没有参数没有返回值(或返回值为undefined)的变量/方法,用来存储停止某些监听器的方法,初始化为undefined:

let stopResizeObserver: (() => void) | undefined = undefined
let stopResizeListener: (() => void) | undefined = undefined

这两个方法是由useResizeObserver和useEventListener这两个hook解构出的。

watch监听noresize这个属性:如果它是true,表示容器尺寸不会变化,就会调用这两个“停止监听”的方法。

  1. 若noresize初始为true且一直为true,则stopResizeObserver和stopResizeListener一直是undefined,无事发生,不会启动监听;
  2. 若noresize为false,则启动监听useResizeObserver和useEventListener,并解构出停止监听的方法。当noresize为true时,调用它们。停止监听。
watch(
  () => props.noresize,
  (noresize) => {
    if (noresize) {
      stopResizeObserver?.()
      stopResizeListener?.()
    } else {
      ;({ stop: stopResizeObserver } = useResizeObserver(resizeRef, update))
      stopResizeListener = useEventListener('resize', update)
    }
  },
  { immediate: true }
)

因此,设置属性noresize可以优化性能,因为它可以停止监听。

import { useEventListener, useResizeObserver } from '@vueuse/core'
这两个hook来自vueuse:useEventListener | VueUse 中文网 (nodejs.cn)

更新滚动条相关属性

props.maxHeightprops.height中任意一个变量变化时,都会触发下面的回调函数,即在下一个DOM更新周期之后更新滚动条的大小和比率(update()),并且处理一些滚动事件.

watch(
  () => [props.maxHeight, props.height],
  () => {
    if (!props.native)
      nextTick(() => {
        update()
        if (wrapRef.value) {
          barRef.value?.handleScroll(wrapRef.value)
        }
      })
  }
)

utils

dom/style

用于将单位添加到给定的值,默认单位为px。

export function addUnit(value?: string | number, defaultUnit = 'px') {
  if (!value) return ''
  if (isNumber(value) || isStringNumber(value)) {
    return `${value}${defaultUnit}`
  } else if (isString(value)) {
    return value
  }

  //   如果value既不是数字也不是字符串,则发出警告:"绑定值必须是字符串或数字"
  debugWarn(SCOPE, 'binding value must be a string or number')
}

runtime.ts

buildProps

用于创建类型安全的props。 具体没看懂

export const buildProps = <
  Props extends Record<
    string,
    | { [epPropKey]: true }
    | NativePropType
    | EpPropInput<any, any, any, any, any>
  >
>(
  props: Props
): {
  [K in keyof Props]: IfEpProp<
    Props[K],
    Props[K],
    IfNativePropType<Props[K], Props[K], EpPropConvert<Props[K]>>
  >
} =>
  fromPairs(
    Object.entries(props).map(([key, option]) => [
      key,
      buildProp(option as any, key),
    ])
  ) as any

  • 13
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

karshey

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值