Element Plus 源码阅读 scrollbar 组件(基础组件)

646 阅读6分钟

前言

element-puls 中的 Scrollbar 组件是一个自定义滚动条组件,主要用于替换浏览器默认的滚动条,以提供更美观和可定制的滚动条效果。它通常用于那些需要自定义滚动行为或样式的场景,如自定义的弹窗、侧边栏或内容较多的区域。当然有同学会有疑问,为什么组件库要自己定义一个自己的基础组件scrollbar,没错,浏览器确实有默认的滚动条,它可以满足大部分基本的滚动需求。然而,在一些特定的场景中,开发者可能希望自定义滚动条的外观和行为,以达到更好的用户体验或符合设计要求。

源代码

Element Plus 基础组件scrollbar源码地址

Element Plus 基础组件scrollbar文档地址

组件的入口文件

packages/components/scrollbar/index.ts

import { withInstall } from '@element-plus/utils'

import Scrollbar from './src/scrollbar.vue'
import type { SFCWithInstall } from '@element-plus/utils'

export const ElScrollbar: SFCWithInstall<typeof Scrollbar> =
  withInstall(Scrollbar)
export default ElScrollbar

export * from './src/util'
export * from './src/scrollbar'
export * from './src/thumb'
export * from './src/constants'

withInstall用来将一个 Vue 组件进行包装,使其具备安装插件的能力,并且还能附带一些额外的组件或属性。通过这个函数为Vue组件添加 install 方法,使其能够通过 app.use() 方法安装。

// packages/utils/vue/typescript.ts
export type SFCWithInstall<T> = T & Plugin

import type { SFCWithInstall } from '@element-plus/utils'

引入SFCWithInstall,SFCWithInstall是一个交叉类型,T 是一个组件的类型,Plugin 包含一些额外的属性或方法

scrollbar.vue文件

<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>
<script lang="ts" setup>
import {
  computed,
  nextTick,
  onActivated,
  onMounted,
  onUpdated,
  provide,
  reactive,
  ref,
  watch,
} from 'vue'
import { useEventListener, useResizeObserver } from '@vueuse/core'
import { addUnit, debugWarn, isNumber, isObject } from '@element-plus/utils'
import { useNamespace } from '@element-plus/hooks'
import Bar from './bar.vue'
import { scrollbarContextKey } from './constants'
import { scrollbarEmits, scrollbarProps } from './scrollbar'
import type { BarInstance } from './bar'
import type { CSSProperties, StyleValue } from 'vue'

const COMPONENT_NAME = 'ElScrollbar'

defineOptions({
  name: COMPONENT_NAME,
})

const props = defineProps(scrollbarProps)
const emit = defineEmits(scrollbarEmits)

const ns = useNamespace('scrollbar')

let stopResizeObserver: (() => void) | undefined = undefined
let stopResizeListener: (() => void) | undefined = undefined
let wrapScrollTop = 0
let wrapScrollLeft = 0

const scrollbarRef = ref<HTMLDivElement>()
const wrapRef = ref<HTMLDivElement>()
const resizeRef = ref<HTMLElement>()
const barRef = ref<BarInstance>()

const wrapStyle = computed<StyleValue>(() => {
  const style: CSSProperties = {}
  if (props.height) style.height = addUnit(props.height)
  if (props.maxHeight) style.maxHeight = addUnit(props.maxHeight)
  return [props.wrapStyle, style]
})

const wrapKls = computed(() => {
  return [
    props.wrapClass,
    ns.e('wrap'),
    { [ns.em('wrap', 'hidden-default')]: !props.native },
  ]
})

const resizeKls = computed(() => {
  return [ns.e('view'), props.viewClass]
})

const handleScroll = () => {
  if (wrapRef.value) {
    barRef.value?.handleScroll(wrapRef.value)
    wrapScrollTop = wrapRef.value.scrollTop
    wrapScrollLeft = wrapRef.value.scrollLeft

    emit('scroll', {
      scrollTop: wrapRef.value.scrollTop,
      scrollLeft: wrapRef.value.scrollLeft,
    })
  }
}

// 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) {
  if (isObject(arg1)) {
    wrapRef.value!.scrollTo(arg1)
  } else if (isNumber(arg1) && isNumber(arg2)) {
    wrapRef.value!.scrollTo(arg1, arg2)
  }
}

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
}

const update = () => {
  barRef.value?.update()
}

watch(
  () => props.noresize,
  (noresize) => {
    if (noresize) {
      stopResizeObserver?.()
      stopResizeListener?.()
    } else {
      ;({ stop: stopResizeObserver } = useResizeObserver(resizeRef, update))
      stopResizeListener = useEventListener('resize', update)
    }
  },
  { immediate: true }
)

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

provide(
  scrollbarContextKey,
  reactive({
    scrollbarElement: scrollbarRef,
    wrapElement: wrapRef,
  })
)

onActivated(() => {
  wrapRef.value!.scrollTop = wrapScrollTop
  wrapRef.value!.scrollLeft = wrapScrollLeft
})

onMounted(() => {
  if (!props.native)
    nextTick(() => {
      update()
    })
})
onUpdated(() => update())

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,
})
</script>
  1. template部分

scrollbarRefwrapRef 是用 ref 引用的 DOM 元素,它们分别指向滚动条的外层容器和包裹内容的滚动区域。wrapKlswrapStyle 是计算属性,用于动态设置包裹层的 CSS 类和样式。handleScroll 是在内容滚动时触发的事件处理函数,用于处理滚动条的位置更新和触发 scroll 事件。当 native 属性为 false 时,会渲染一个自定义的 bar 组件来代替默认的滚动条。component是用于渲染不同组件和HTML元素,tag 是一个动态属性,可以是 HTML 标签名(如 div, span, p 等)或自定义组件名,还有其他属性是父组件传递过来的props,也可以是代码中定义的变量,根据语义标签阅读即可。slot插槽用于渲染父组件传递过来的内容。

  1. script部分

defineOptions({ name: COMPONENT_NAME, })主要用于定义组件的名字,COMPONENT_NAME是一个常量,用大写来表示,const COMPONENT_NAME = 'ElScrollbar'

wrapStyle 是一个计算属性,根据传入的 heightmaxHeight 属性,动态生成滚动区域的样式,并结合用户传入的 wrapStylewrapKls 是另一个计算属性,根据 props.wrapClass 和命名空间生成的类名。resizeKls 是为内容视图设置的类名,用于控制内容区域的样式。

handleScroll 是滚动事件的处理函数。当滚动区域发生滚动时,它会调用自定义的 bar 组件的 handleScroll 方法来同步滚动条的位置,同时更新滚动位置的状态 wrapScrollTopwrapScrollLeft。这个函数还会触发 scroll 事件,将当前的 scrollTopscrollLeft 作为事件参数传递出去,供外部监听和使用。scrollTo 方法提供了两种重载方式:可以传入两个数字(xCord, yCord)表示滚动的坐标,也可以传入一个配置对象 ScrollToOptions 来控制滚动行为。setScrollTopsetScrollLeft 用于分别设置滚动区域的垂直和水平滚动位置。

update 方法用于更新滚动条的状态,确保当内容尺寸变化时,滚动条能够正确反映新的内容大小。

watch: 监听 noresize 属性的变化,如果不需要调整大小,停止监听,否则重新启用监听器。同时监听 maxHeightheight 属性的变化,更新滚动条状态。

provide: 使用 providescrollbarElementwrapElement 提供给子组件或其他依赖的部分。

onActivated: 当组件激活时,恢复滚动条位置。 onMounted: 组件挂载时,更新滚动条状态。 onUpdated: 组件更新时,再次更新滚动条状态。

defineExpose 将组件中的方法暴露给父组件,父组件可以通过ref访问暴露出去的子组件方法。

scrollbar.ts文件

import { buildProps, definePropType, isNumber } from '@element-plus/utils'
import { useAriaProps } from '@element-plus/hooks'
import type { ExtractPropTypes, StyleValue } from 'vue'
import type Scrollbar from './scrollbar.vue'

export const scrollbarProps = buildProps({
  /**
   * @description height of scrollbar
   */
  height: {
    type: [String, Number],
    default: '',
  },
  /**
   * @description max height of scrollbar
   */
  maxHeight: {
    type: [String, Number],
    default: '',
  },
  /**
   * @description whether to use the native scrollbar
   */
  native: {
    type: Boolean,
    default: false,
  },
  /**
   * @description style of wrap
   */
  wrapStyle: {
    type: definePropType<StyleValue>([String, Object, Array]),
    default: '',
  },
  /**
   * @description class of wrap
   */
  wrapClass: {
    type: [String, Array],
    default: '',
  },
  /**
   * @description class of view
   */
  viewClass: {
    type: [String, Array],
    default: '',
  },
  /**
   * @description style of view
   */
  viewStyle: {
    type: [String, Array, Object],
    default: '',
  },
  /**
   * @description do not respond to container size changes, if the container size does not change, it is better to set it to optimize performance
   */
  noresize: Boolean, // 如果 container 尺寸不会发生变化,最好设置它可以优化性能
  /**
   * @description element tag of the view
   */
  tag: {
    type: String,
    default: 'div',
  },
  /**
   * @description always show
   */
  always: Boolean,
  /**
   * @description minimum size of scrollbar
   */
  minSize: {
    type: Number,
    default: 20,
  },
  /**
   * @description id of view
   */
  id: String,
  /**
   * @description role of view
   */
  role: String,
  ...useAriaProps(['ariaLabel', 'ariaOrientation']),
} as const)
export type ScrollbarProps = ExtractPropTypes<typeof scrollbarProps>

export const scrollbarEmits = {
  scroll: ({
    scrollTop,
    scrollLeft,
  }: {
    scrollTop: number
    scrollLeft: number
  }) => [scrollTop, scrollLeft].every(isNumber),
}
export type ScrollbarEmits = typeof scrollbarEmits

export type ScrollbarInstance = InstanceType<typeof Scrollbar>

主要对数据进行定义,并针对类型用ts进行约束,将类型定义在单独的ts文件中代码结构清晰明了,方便组件后期的维护拓展。

组件测试

test文件中类似上章也是主要是用vitest工具进行组件测试,编写测试用例进行组件测试

小结

本节主要介绍了element plus组件库中的基础组件scrollbar,基础组件相对简单,参考上章Element Plus 源码阅读 Container 组件(基础组件) ,其实大致逻辑一样,包括组件的注册,公共类型的声明等,下一节我们将介绍选择table源码组件进行介绍