前言
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>
-
template部分
scrollbarRef 和 wrapRef 是用 ref 引用的 DOM 元素,它们分别指向滚动条的外层容器和包裹内容的滚动区域。wrapKls 和 wrapStyle 是计算属性,用于动态设置包裹层的 CSS 类和样式。handleScroll 是在内容滚动时触发的事件处理函数,用于处理滚动条的位置更新和触发 scroll 事件。当 native 属性为 false 时,会渲染一个自定义的 bar 组件来代替默认的滚动条。component是用于渲染不同组件和HTML元素,tag 是一个动态属性,可以是 HTML 标签名(如 div, span, p 等)或自定义组件名,还有其他属性是父组件传递过来的props,也可以是代码中定义的变量,根据语义标签阅读即可。slot插槽用于渲染父组件传递过来的内容。
-
script部分
defineOptions({ name: COMPONENT_NAME, })主要用于定义组件的名字,COMPONENT_NAME是一个常量,用大写来表示,const COMPONENT_NAME = 'ElScrollbar'
wrapStyle 是一个计算属性,根据传入的 height 和 maxHeight 属性,动态生成滚动区域的样式,并结合用户传入的 wrapStyle。wrapKls 是另一个计算属性,根据 props.wrapClass 和命名空间生成的类名。resizeKls 是为内容视图设置的类名,用于控制内容区域的样式。
handleScroll 是滚动事件的处理函数。当滚动区域发生滚动时,它会调用自定义的 bar 组件的 handleScroll 方法来同步滚动条的位置,同时更新滚动位置的状态 wrapScrollTop 和 wrapScrollLeft。这个函数还会触发 scroll 事件,将当前的 scrollTop 和 scrollLeft 作为事件参数传递出去,供外部监听和使用。scrollTo 方法提供了两种重载方式:可以传入两个数字(xCord, yCord)表示滚动的坐标,也可以传入一个配置对象 ScrollToOptions 来控制滚动行为。setScrollTop 和 setScrollLeft 用于分别设置滚动区域的垂直和水平滚动位置。
update 方法用于更新滚动条的状态,确保当内容尺寸变化时,滚动条能够正确反映新的内容大小。
watch: 监听 noresize 属性的变化,如果不需要调整大小,停止监听,否则重新启用监听器。同时监听 maxHeight 和 height 属性的变化,更新滚动条状态。
provide: 使用 provide 将 scrollbarElement 和 wrapElement 提供给子组件或其他依赖的部分。
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源码组件进行介绍