likes
comments
collection
share

实现一个vue3组件库 - scrollbar滚动条

作者站长头像
站长
· 阅读数 23

实现一个vue3组件库 - scrollbar滚动条

前言

思来想去很久,我都不知道该最先介绍哪一个组件才好?虽然我写的第一个组件是button按钮, 但是也是因为简单所以第一个写,逻辑代码不是很多,样式倒是一大堆...感觉不适合用作开篇介绍,最后选择了scrollbar滚动条组件。

本组件将会涉及vueuse的一些hook函数 和 一些不是很难的计算

组件最终的呈现请移步: Scrollbar 滚动条 | SSS UI Plus

code

组件目录结构

由于是开篇组件,所以将会再次介绍组件的目录结构,在这之后将不会有此导航。若是不感兴趣可以跳到下一个同级导航

packages

index.ts用于导出所有的组件 实现一个vue3组件库 - scrollbar滚动条

```ts
import SScrollbar from "./SScrollbar";
//import 其余组件

export {
    SScrollbar,
    //....

}
```

其中,每一个组件结构都是一个src文件夹和一个index.ts组成,index.ts用于插入一个注册函数并导出此组件

实现一个vue3组件库 - scrollbar滚动条

```ts
// SScrollbar->index.ts
import Scrollbar from "./src/scrollbar.vue";
import {App} from "vue";

Scrollbar.install = function (Vue:App) {
    Vue.component('SScrollbar',Scrollbar);
}

export default Scrollbar;
```

installer.ts

此文件用于注册所有的组件

import {App} from "vue";
import * as comps from "./packages";

const installer = function (Vue:App) {
    for (let key in comps){
        Vue.component(key, comps[key]);
    }
}

export default installer

/index.ts

此文件用于导出组件库

/*css引入 特别注意全局样式最先引入*/
import "./src/styles/animate.css"
import "./src/styles/variable.less"
import "./src/styles/global.less"
import "./src/styles/icons/iconfont.css"


import installer from "./installer";
export * from "./packages"
export * from "./packages/SMessage"



export default installer

scrollbar的html结构

简化结构

<div>  //最外层容器
    <div><slot></slot></div> 需要添加滚动条的元素
    
    <div></div> 垂直滚动条
    <div></div> 水平滚动条
    
</div>

实际结构

sss-ui-plus/packages/SScrollbar/src/scrollbar.vue

scrollbar 样式文件

sss-ui-plus/packages/SScrollbar/src/scrollbar.less

scrollbar 逻辑

数据约定:

实现一个vue3组件库 - scrollbar滚动条

  • wrap 整个滑动区域
  • view 视口,也就是你看到的元素
  • bar 滚动条的轨道
  • thumb 滚动条的滑块

对了....代码中的wrap我全部写成warp了,全部改起来很麻烦,请允许这个错误😭

计算滑块大小(核心函数)

滚动条实际和缩略图很像,我们将整个滑动区域映射为滚动条的轨道,将视口映射为滚动条的滑块。 因此我们可以得到:

 view高度/wrap高度=thumb高度/bar高度\ view高度 / wrap高度 = thumb高度 / bar高度 view高度/wrap高度=thumb高度/bar高度

当然,对于水平滚动条的宽度也是相同的计算方式

最后我们可以写出一个函数,专门用于计算滚动条滑块的大小:

const computedThumbSize = () => {
    const warpEl = unrefElement(warp);
    const barYEl = unrefElement(barY);
    const barXEl = unrefElement(barX);
    const {scrollHeight:warpHeight, scrollWidth:warpWidth,offsetHeight: viewHeight, offsetWidth:viewWidth} = warpEl!;

    const barHeight = barYEl!.offsetHeight;
    const barWidth = barXEl!.offsetWidth;

    const thumbHeight = viewHeight * barHeight / warpHeight;
    const thumbWidth = viewWidth * barWidth / warpWidth;

    thumbYStyle.value.height = `${thumbHeight}px`;
    thumbXStyle.value.width = `${thumbWidth}px`;

}

fix: 在视口不可以滚动时,应该设置滚动条的可见度为0,代码修改为:

const computedThumbSize = () => {
    const warpEl = unrefElement(warp);
    const barYEl = unrefElement(barY);
    const barXEl = unrefElement(barX);
    const {scrollHeight:warpHeight, scrollWidth:warpWidth,offsetHeight: viewHeight, offsetWidth:viewWidth} = warpEl!;

    const barHeight = barYEl!.offsetHeight;
    const barWidth = barXEl!.offsetWidth;

    const thumbHeight = viewHeight * barHeight / warpHeight;
    const thumbWidth = viewWidth * barWidth / warpWidth;


    // 在不可滚动时,设置滚动条不可见
    if (thumbHeight === viewHeight) {
       unrefElement(barY)!.style.opacity = '0';

    }else {
       unrefElement(barY)!.style.opacity = '';
    }

    if (thumbWidth === viewWidth) {
       unrefElement(barX)!.style.opacity = '0';
    }else {
       unrefElement(barX)!.style.opacity = '';

    }

    thumbYStyle.value.height = `${thumbHeight}px`;
    thumbXStyle.value.width = `${thumbWidth}px`;

}

计算滑块偏移量(核心函数)

滑块thumb的偏移量完全受控于视口view的偏移量,在之后我们拖拽滑块时,实际上修改的也是视口的偏移量

同样的逻辑,对于偏移量也是一个映射关系:

 view偏移量/warp高度=thumb偏移量/bar高度\ view偏移量 / warp高度 = thumb偏移量 / bar高度 view偏移量/warp高度=thumb偏移量/bar高度
const computedThumbPos = () => {
    const warpEl = unrefElement(warp);
    const barYEl = unrefElement(barY);
    const barXEl = unrefElement(barX);

    const {scrollHeight:warpHeight, scrollWidth:warpWidth,scrollTop: viewOffsetY, scrollLeft:viewOffsetX} = warpEl!;

    const barHeight = barYEl!.offsetHeight;
    const barWidth = barXEl!.offsetWidth;


    const thumbOffsetY = viewOffsetY * barHeight / warpHeight;
    const thumbOffsetX = viewOffsetX * barWidth / warpWidth;


    //滑块偏移量受控于视口偏移量
    thumbYStyle.value.top = `${thumbOffsetY}px`;
    thumbXStyle.value.left = `${thumbOffsetX}px`;


}

为warp添加滚动事件

只需要一句话,滑块就可以滚动了✨

useEventListener(warp, 'scroll', () => {
    computedThumbPos();
})

注意这里使用了vueuse的useEventListener

为滑块添加"拖拽"事件

严格来讲,并不是拖拽事件,而是由mousedown mousemove mouseup结合成的事件

首先我们有几个变量需要介绍:

// 记录偏移量 也就是记录滑块被拖拽的距离
const offset = {
    x: 0,
    y: 0
};
// 记录点击的坐标 也就是在滑块被点击时的位置
const down = {
    x: 0,
    y: 0
};
// 记录移动距离 在滑块移动时,鼠标的位置
const move = {
    x: 0,
    y: 0
};
// 记录原本位置 也就是视口原本的偏移量
const origin = {
    x: 0,
    y: 0
}

let flag:'thumbX' | 'thumbY'; 标记点击的是垂直滑块还是水平滑块

在滑块被点击时,需要记录点击的位置,和视口原本的偏移量,也就是记录down origin 同时启用mousemove事件。

需要注意的是,鼠标移动事件要添加到body上面,因为鼠标可以移动出整个视口

useEventListener(thumbY, "mousedown", (evt: MouseEvent) => {
    down.y = evt.clientY;    //记录点击位置
    origin.y = unrefElement(warp)!.scrollTop;    //记录原本的偏移量
    flag = 'thumbY';    //标记点击的是垂直滑块
    active.value = true;  //控制样式的,与逻辑无关

    //为body添加mousemove事件
    unrefElement(document.body)!.addEventListener('mousemove', handleMove);  
})

useEventListener(thumbX, "mousedown", (evt: MouseEvent) => {
    down.x = evt.clientX;
    origin.x = unrefElement(warp)!.scrollLeft;
    flag = 'thumbX';
    active.value = true;


    unrefElement(document.body)!.addEventListener('mousemove', handleMove);
})

相反的,在mouseup时,需要移除这个mousemove事件

useEventListener(document.body, 'mouseup', () => {
    active.value = false;

    unrefElement(document.body)!.removeEventListener('mousemove', handleMove);

})

最后是如何处理鼠标移动,其实很简单,也是一开始的那一套映射关系

 view偏移量/warp高度(宽度)=thumb偏移量/bar高度(宽度)\ view偏移量 / warp高度(宽度) = thumb偏移量 / bar高度(宽度) view偏移量/warp高度(宽度)=thumb偏移量/bar高度(宽度)

此时thumb偏移量就是下面的offset变量了,而要计算的结果也变成了view偏移量

const handleMove = (evt: MouseEvent) => {
    move.x = evt.clientX;  //这里获取的是鼠标移动时的位置
    move.y = evt.clientY;

    offset.x = move.x - down.x;  //计算偏移量
    offset.y = move.y - down.y;


    const warpEl = unrefElement(warp);
    const barYEl = unrefElement(barY);
    const barXEl = unrefElement(barX);
    const warpHeight = warpEl!.scrollHeight;
    const warpWidth = warpEl!.scrollWidth;
    const barHeight = barYEl!.offsetHeight;
    const barWidth = barXEl!.offsetWidth;


    //最后根据点击的是哪一个滑块而设置视口的偏移量就行
    if (flag === 'thumbY') {
       unrefElement(warp)!.scrollTop = warpHeight * offset.y / barHeight + origin.y;
    }
    else if (flag === 'thumbX') {
       unrefElement(warp)!.scrollLeft = warpWidth * offset.x / barWidth + origin.x;
    }
}

还记得滑块的偏移量受控于视口偏移量么?当我们手动设置了视口的偏移量(scrollTop,scrollLeft)之后,会自动触发视口的滚动事件,进而触发 computedThumbPos()函数

为轨道添加点击事件

有时候在点击轨道时,我们希望能够快速导航到对应的位置,所以在点击轨道时,我们应该计算出点击位置距离轨道顶部(左边)的距离,这个距离减去滑块高度(宽度)便是滑块偏移量,最后通过映射关系计算出视口偏移量。

需要注意的是, 在计算滑块偏移量时,应该考虑边界情况(最值)

const handleClick = (evt:MouseEvent) => {
    if (evt.target !== evt.currentTarget) return;
    evt.stopPropagation();

    const warpEl = unrefElement(warp);
    const barYEl = unrefElement(barY);
    const thumbYEl = unrefElement(thumbY);
    const thumbXEl = unrefElement(thumbX);
    const barXEl = unrefElement(barX);
    const warpHeight = warpEl!.scrollHeight;
    const warpWidth = warpEl!.scrollWidth;
    const barHeight = barYEl!.offsetHeight;
    const barWidth = barXEl!.offsetWidth;

    // 计算滑块偏移量
    offset.y = evt.clientY - barYEl!.getBoundingClientRect().top - thumbYEl!.offsetHeight / 2;
    offset.x = evt.clientX - barXEl!.getBoundingClientRect().left - thumbXEl!.offsetWidth / 2;

    // 处理边界
    offset.y = Math.min(Math.max(0,offset.y), barYEl!.offsetHeight - thumbYEl!.offsetHeight);
    offset.x = Math.min(Math.max(0, offset.x), barXEl!.offsetWidth - thumbXEl!.offsetWidth);


    // 通过flag判断点击的滑块
    if (flag === 'thumbY') {
       unrefElement(warp)!.scrollTop = warpHeight * offset.y / barHeight;
    }
    else if (flag === 'thumbX') {
       unrefElement(warp)!.scrollLeft = warpWidth * offset.x / barWidth;
    }
}


//添加回调
useEventListener(barY, "click",(evt:MouseEvent) =>{
    flag = 'thumbY';
    handleClick(evt);
});
useEventListener(barX, "click",(evt:MouseEvent) =>{
    flag = 'thumbX';
    handleClick(evt);
});


为视口添加"resize"事件

实际上,只有浏览器视口才有resize事件,因此你直接为某个元素设置resieze事件是没用的,我们可以通过observer监听元素的大小进而实现这个事件。幸运的是,vueuse为我们提供了useElementSize

if (!props.noResize) {
    // 监听元素大小变化
    useResizeObserver(warp,() => {
       computedThumbPos();
       computedThumbSize();
    })
}

监听warp的子元素变化

但warp内部元素发生变化时,可能需要重新计算滑块的大小和位置,vueuse提供了useMutationObserver可以很方便的实现这个功能!

useMutationObserver(warp, () => {
    computedThumbPos();
    computedThumbSize();
}, {
    attributes:true,   //是否观察节点属性变化
    childList:true,   //是否观察子节点变化
    subtree:true,  //子节点是否继承这个观察器
})

完整逻辑

sss-ui-plus/packages/SScrollbar/src/scrollbar.vue

写在最后

这个组件也许有很多不完善的地方,欢迎指出!

这个项目的地址是:lastertd/sss-ui-plus: 适用于vue3的组件库 (github.com)在这里求一个star✨

感谢看到最后💟💟💟