文章目录
- thumb
- clickThumbHandler
- startDrag
- mouseMoveDocumentHandler
- mouseUpDocumentHandler
- clickTrackHandler
- 其他
- bar
- Scrollbar
- 导出的方法
- noresize
- 更新滚动条相关属性
- utils
- runtime.ts
- buildProps
看源码时候做的笔记。若有问题,请指出!
路径相关格式请看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
此方法处理了鼠标点击滚动条可拖动部分事件,做了如下事情:
- 阻止事件冒泡
- 按下ctrl键或鼠标中/右键则return
- 清空选区,方便拖动
startDrag()
- 拖动完毕后,计算鼠标点击位置相对于滚动条起始位置的距离
其中,计算鼠标点击位置相对于滚动条起始位置的距离
是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
包含两个滚动条,水平滚动和垂直滚动。
导出两个方法,handleScroll
和update
。分别是:滚动时更新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)
}
}
setScrollTop
和setScrollLeft
:设置滚动条到顶部/左边的距离。传入参数赋值即可。
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,表示容器尺寸不会变化,就会调用这两个“停止监听”的方法。
- 若noresize初始为true且一直为true,则stopResizeObserver和stopResizeListener一直是undefined,无事发生,不会启动监听;
- 若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.maxHeight
和props.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