前言
相信大家肯定遇到过一个这样的需求:
产品:这边的列表需要支持自动滚动,并且鼠标移上去可以暂停,移开可以自动滚动。
本文将会教大家使用vue3封装一个可复用的无缝滚动组件。
前期准备
本文使用vue3,使用vite初始化了项目,选择vue-ts
yarn create vite myvue
这边使用tsx编写组件,所以还要安装一个插件@vitejs/plugin-vue-jsx
来让vue支持jsx写法
yarn add @vitejs/plugin-vue-jsx
然后在vite.config.ts加上这个插件
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
vueJsx({
mergeProps: false,
enableObjectSlots: false
})
]
});
关于jsx在vue里的用法我这边就不多介绍了,大家可以去看我之前的写的一篇文章
组件的初始化和注册
咱们这个组件需要的文件不多,我把他分成了三个文件进行
- seamlessScroll.tsx 主文件
- SeamlessScrolltype.ts 类型文件
- index.ts 入口文件
seamlessScroll.tsx
在components文件夹中新建seamlessScroll文件,然后创建seamlessScroll.tsx。
import { defineComponent } from 'vue';
export default defineComponent({
name: 'seamlessScroll',
setup() {
// 先随便写点东西占位一下
return () => <div>scroll</div>;
}
});
index.ts
在seamlessScroll文件中创建一个入口文件index.ts,这个文件主要是用来导出组件,并使其可以被全局注册和局部注册
import { SeamlessScroll } from './seamlessScroll';
import type { App, Plugin } from 'vue';
// 给组件增加install方法,使其可以被app.use(SeamlessScroll) 这样使用
const install = (app: App) => {
app.component(SeamlessScroll.name, SeamlessScroll);
return app;
};
SeamlessScroll.install = install;
// 这里通过扩展一个Plugin,来保证在使用时候出现类型报错
export default SeamlessScroll as typeof SeamlessScroll & Plugin;
seamlessScrolltype.ts
在seamlessScroll文件中创建一个文件SeamlessScrolltype.ts,此文件,是用来写组件类型和props的。
我这里用到了一个库vue-types
主要用来方便我们编写props的类型,用法可以看这里
先加两个props属性
- modelValue 控制是否自动滚动,默认自动滚动
- list 组件需要使用的数据源,默认是一个空数组
import { bool,array } from 'vue-types';
export const seamlessScrollTypes = () => {
return {
// 是否自动滚动,默认自动滚动
modelValue: bool().def(true),
// 数据源,默认为空
list: array<unknown>().def([])
};
};
export default seamlessScrollTypes;
啊这!不空了
全局注册
上面几个文件完成后,咱们就可以在main.ts中引入并且注册他,这样可以在任何SFC文件中直接使用<seamless-scroll />
在tsx中还是得老老实实的引入后才能使用
import { createApp } from 'vue';
import App from './App.vue';
import SeamlessScroll from './components/seamlessScroll';
const app = createApp(App);
app.use(SeamlessScroll);
app.mount('#app');
在sfc单文件中使用
<template>
<seamless-scroll></seamless-scroll>
</template>
局部注册
1. SFC文件中使用
App.vue
<script setup lang="ts">
import SeamlessScroll from './components/seamlessScroll';
</script>
<template>
<seamless-scroll></seamless-scroll>
</template>
2. TSX文件中使用
import { defineComponent } from 'vue';
import SeamlessScroll from './components/seamlessScroll';
export default defineComponent({
name: 'App',
setup() {
return () => (
<>
<SeamlessScroll></SeamlessScroll>
</>
);
}
});
组件实现
支持插槽形式和props传递组件
最重要的就是ui的渲染,所以需要让组件支持插槽写法,当然在jsx中,我们也喜欢通过props传递一个组件来实现渲染,为了实现这些功能.
- seamlessScrolltype中增加一个html的props
import { bool, array, object } from 'vue-types';
export const seamlessScrollTypes = () => {
return {
// 是否自动滚动,默认自动滚动
modelValue: bool().def(false),
// 数据源,默认为空
list: array<unknown>().def([]),
// 支持传递jsx
html: object<JSX.Element>()
};
};
- seamlessScroll.tsx中使用它 这样子,既支持了props也支持了插槽用法,这里使用了默认插槽
import { defineComponent } from 'vue';
import { seamlessScrollTypes } from './seamlessScrollTypes';
export const SeamlessScroll = defineComponent({
name: 'SeamlessScroll',
props: seamlessScrollTypes(),
setup(props, { slots }) {
const { default: slotDefault } = slots;
// 对html进行一个适配处理
const { html = slotDefault } = props;
const getHtml = () => {
if (typeof html === 'function') {
return html();
}
return <>{html}</>;
};
return () => <div>{getHtml()}</div>;
}
});
使用
import { defineComponent, reactive } from 'vue';
import SeamlessScroll from './components/seamlessScroll';
export default defineComponent({
name: 'App',
setup() {
const list = reactive([
{
name: '测试1',
time: '2020-01-01'
},
{
name: '测试2',
time: '2020-01-02'
},
{
name: '测试3',
time: '2020-01-03'
},
{
name: '测试4',
time: '2020-01-04'
},
{
name: '测试5',
time: '2020-01-05'
},
{
name: '测试6',
time: '2020-01-06'
},
{
name: '测试7',
time: '2020-01-07'
}
]);
return () => (
<div class="box">
<SeamlessScroll>
{list.map(item => {
return (
<div key={item.time}>
<span>{item.name}</span>
<span>{item.time}</span>
</div>
);
})}
</SeamlessScroll>
</div>
);
}
});
支持自动和手动的无缝滚动
对于列表的滚动,
- 可以采用css3的transform改变其竖直方向的距离来实现
- 至于滚动的距离为当前传入dom的高度
- 这里需要准备至少2个列表,当第一个列表滚动完设定的高度,立马把移动的距离改为0,这样可以保证平滑的过渡 新增位移函数
setup(props, { slots }) {
// ...
// 需要移动的距离
const yPos = ref(0);
const realBoxStyle = computed<CSSProperties>(() => {
return {
transform: `translate3d(0px, ${yPos.value}px, 0px)`,
display: 'block',
overflow: 'hidden'
};
});
return () => (
<div ref={realBoxRef}>
<div class={realBoxRef} style={realBoxStyle.value}>
{getHtml()}
</div>
</div>
);
}
为了渲染多个列表,我们改写getHtml这个函数
const getHtml = () => {
const arr = new Array(1).fill(null);
if (typeof html === 'function') {
return (
<>
<div style={itemStyle.value} ref={htmlRef}>
{html()}
</div>
{arr.map(() => {
return <div style={itemStyle.value}>{html()}</div>;
})}
</>
);
}
return (
<>
<div style={itemStyle.value} ref={htmlRef}>
{html}
</div>
{arr.map(() => {
return <div style={itemStyle.value}>{html}</div>;
})}
</>
);
};
让他滚动起来,这里使用了requestAnimationFrame
api,它在浏览器下一帧执行,用来写动画非常丝滑。当滚动距离超过最大滚动距离就变成0,至于最大滚动距离需要使用offsetHeight
获取,由于我们之前重复渲染了2次,需要除2。封装一个animation
方法用来处理动画操作
setup(props, { slots }) {
// ...
// 记录是否可以滚动
const isScroll = computed(() => props.list.length >= props.limitScrollNum);
// 传入的列表dom
const htmlRef = ref<HTMLDivElement | null>(null);
// 滚动容器的宽高
const realBoxHeight = ref(0);
// 需要移动的距离
const yPos = ref(0);
const reqFrame = ref<number | null>(null);
onMounted(() => {
const htmlRef = htmlRef.value!.offsetHeight;
realBoxHeight.value = htmlRef / 2;
const animation = () => {
reqFrame.value = requestAnimationFrame(() => {
if (Math.abs(yPos.value) >= realBoxHeight.value) {
yPos.value = 0;
}
yPos.value -= 1;
animation();
});
};
// 执行动画前先判断是否可以滚动
isScroll.value&&props.modelValue && animation();
});
return () => (
<div ref={realBoxRef}>
<div class={realBoxRef} style={realBoxStyle.value}>
{getHtml()}
</div>
</div>
);
}
看一下效果,动起来了~
控制滚动的速度
对于滚动的速度只需要通过一个变量控制ypos每次执行的数值
修改seamlessScrolltype.ts,增加step
import { bool, array, object, number } from 'vue-types';
export const seamlessScrollTypes = () => {
return {
// 是否自动滚动,默认自动滚动
modelValue: bool().def(false),
// 数据源,默认为空
list: array<unknown>().def([]),
html: object<JSX.Element>(),
// 滚动的距离,默认为0
step: number().def(1)
};
};
修改seamlessScroll.tsx中的animation函数
const animation = () => {
reqFrame.value = requestAnimationFrame(() => {
if (Math.abs(yPos.value) >= realBoxHeight.value) {
yPos.value = 0;
}
yPos.value -= props.step;
animation();
});
};
修改App.tsx
<SeamlessScroll modelValue list={list} step={10}>
{list.map(item => {
return (
<div key={item.time} class="item">
<span>{item.name}</span>
<span>{item.time}</span>
</div>
);
})}
</SeamlessScroll>
看看效果,好像有点太快了
支持鼠标滑入暂停滑出继续滚动
增加一个hover的props 来控制是否鼠标悬停进行暂停
// seamlessScrollTypes.ts
import { bool, array, object, number } from 'vue-types';
export const seamlessScrollTypes = () => {
return {
// 是否自动滚动,默认自动滚动
modelValue: bool().def(false),
// 数据源,默认为空
list: array<unknown>().def([]),
html: object<JSX.Element>(),
// 滚动的距离,默认为0
step: number().def(1),
// 最低的滚动的条件,列表条数 默认为3
limitScrollNum: number().def(3),
hover: bool().def(false)
};
};
export default seamlessScrollTypes;
要实现这个功能,则需要对容器进行鼠标移入移出事件的监听。首先添加一个
isHover
来记录当前鼠标是否悬停- 拆分了之前
animation
方法 - 新增
initmove
,startmove
,stopmove
,move
方法 - 添加onMouseenter和onMouseleave事件
setup() {
// 省略代码...
// 记录当前是否hover
const isHover = ref(false);
const hoverStop = computed(() => props.hover && props.modelValue && isScroll.value); // 判断是否鼠标悬停停止滚动
const animation = () => {
const h = realBoxHeight.value / 2;
reqFrame.value = requestAnimationFrame(() => {
if (Math.abs(yPos.value) >= h) {
yPos.value = 0;
}
yPos.value -= props.step;
move();
});
};
// 添加一个move方法,在每次滚动前先清空之前的
const move = () => {
cancel();
if (isHover.value) {
return;
}
animation();
};
// 新增move开始
const startMove = () => {
isHover.value = false;
move();
};
// 新增move暂停
const stopMove = () => {
isHover.value = true;
cancel();
};
// 初始化move
const initMove = () => {
realBoxHeight.value = realBoxRef.value.offsetHeight;
if (isScroll.value && props.modelValue) {
move();
}
};
const cancel = () => {
reqFrame.value && cancelAnimationFrame(reqFrame.value);
reqFrame.value = null;
};
//...
return () => (
<div ref={realBoxRef}>
<div
onMouseenter={() => {
if (hoverStop.value) {
stopMove();
}
}}
onMouseleave={() => {
if (hoverStop.value) {
startMove();
}
}}
class={realBoxRef}
style={realBoxStyle.value}
>
{getHtml()}
</div>
</div>
);
}
支持自定义方向滚动
首先在props中添加一个direction来控制方向
// seamlessScrollTypes.ts
import { bool, array, object, number, string } from 'vue-types';
export type Direction = 'down' | 'up' | 'left' | 'right'
export const seamlessScrollTypes = () => {
return {
// ...
direction: string<Direction>().def("up")
};
};
修改realBoxStyle
const realBoxStyle = computed<CSSProperties>(() => {
return {
transform: `translate3d(${xPos.value}px, ${yPos.value}px, 0px)`,
display: 'block',
width: realBoxWidth.value ? `${realBoxWidth.value}px` : 'auto',
overflow: 'hidden'
};
});
修改animaltion
// seamlessScroll.tsx
import type { Direction } from './seamlessScrollTypes';
setup() {
const animation = (_direction: Direction) => {
const h = realBoxHeight.value / 2;
const w = realBoxWidth.value / 2;
reqFrame.value = requestAnimationFrame(() => {
if (_direction === 'up') {
if (Math.abs(yPos.value) >= h) {
yPos.value = 0;
}
yPos.value -= props.step;
} else if (_direction === 'down') {
if (yPos.value >= 0) {
yPos.value = -h;
}
yPos.value += props.step;
} else if (_direction === 'left') {
if (Math.abs(xPos.value) >= w) {
xPos.value = 0;
}
xPos.value -= props.step;
} else if (_direction === 'right') {
if (xPos.value >= 0) {
xPos.value = -w;
}
xPos.value += props.step;
}
move();
});
};
// 修改move,传入当前的方向
const move = () => {
cancel();
if (isHover.value) {
return;
}
animation(props.direction);
};
}
支持控制单步滚动和控制暂停时间
新增props singleWaitTime和singleHeight
export const seamlessScrollTypes = () => {
return {
//...
// 单步停止等待时间 (默认1000ms)
singleWaitTime: number().def(1000),
// 单步停止的高度
singleHeight: number().def(0)
};
};
继续修改animation这个函数,在后面补上一个延迟执行
const animation = (_direction: Direction) => {
const h = realBoxHeight.value / 2;
const w = realBoxWidth.value / 2;
reqFrame.value = requestAnimationFrame(() => {
if (_direction === 'up') {
if (Math.abs(yPos.value) >= h) {
yPos.value = 0;
}
yPos.value -= props.step;
} else if (_direction === 'down') {
if (yPos.value >= 0) {
yPos.value = -h;
}
yPos.value += props.step;
} else if (_direction === 'left') {
if (Math.abs(xPos.value) >= w) {
xPos.value = 0;
}
xPos.value -= props.step;
} else if (_direction === 'right') {
if (xPos.value >= 0) {
xPos.value = -w;
}
xPos.value += props.step;
}
// 添加一个延迟
singleWaitTimeout.value && clearTimeout(singleWaitTimeout.value);
if (!!props.singleHeight) {
if (Math.abs(yPos.value) % props.singleHeight === 0) {
singleWaitTimeout.value = setTimeout(() => {
move();
}, props.singleWaitTime);
} else {
move();
}
} else {
move();
}
});
};
效果
支持鼠标滚动,列表也滚动
对于这个功能,只要给当前容器增加一个滚轮监听事件,修改一下animation方法让他支持传入滚动的数值和是否是滚动
const animation = (_direction: Direction, _step: number, isWheel = false) => {
const h = realBoxHeight.value / (props.copyNum + 1);
const w = realBoxWidth.value / (props.copyNum + 1);
reqFrame.value = requestAnimationFrame(() => {
if (_direction === 'up') {
if (Math.abs(yPos.value) >= h) {
yPos.value = 0;
}
yPos.value -= _step;
} else if (_direction === 'down') {
if (yPos.value >= 0) {
yPos.value = -h;
}
yPos.value += _step;
} else if (_direction === 'left') {
if (Math.abs(xPos.value) >= w) {
xPos.value = 0;
}
xPos.value -= _step;
} else if (_direction === 'right') {
if (xPos.value >= 0) {
xPos.value = -w;
}
xPos.value += _step;
}
if (isWheel) return;
singleWaitTimeout.value && clearTimeout(singleWaitTimeout.value);
if (!!props.singleHeight) {
if (Math.abs(yPos.value) % props.singleHeight < _step) {
singleWaitTimeout.value = setTimeout(() => {
move();
}, props.singleWaitTime);
} else {
move();
}
} else if (!!props.singleWidth) {
if (Math.abs(xPos.value) % props.singleWidth < _step) {
singleWaitTimeout.value = setTimeout(() => {
move();
}, props.singleWaitTime);
} else {
move();
}
} else {
move();
}
});
};
setup(props, { slots }) {
// ...
const onWheel = (e: WheelEvent) => {
cancel();
const singleHeight = props.singleHeight ? props.singleHeight : 15;
const { deltaY } = e;
if (deltaY < 0) {
animation('down', singleHeight, true);
} else {
animation('up', singleHeight, true);
}
};
return () => (
<div style={{ position: 'relative', overflow: 'hidden' }}>
<div
onMouseenter={() => {
if (hoverStop.value) {
stopMove();
}
}}
onMouseleave={() => {
if (hoverStop.value) {
startMove();
}
}}
onWheel={e => {
if (hoverStop.value && props.wheel) {
onWheel(e);
}
}}
style={realBoxStyle.value}
ref={realBoxRef}
>
{getHtml()}
</div>
</div>
);
}
数据变化重新修改
监听list和modelValue的变化,从而重新设置动画
const reset = () => {
cancle();
isHover.value = false;
initMove();
}
watch(
() => props.list,
() => {
if (props.isWatch) {
nextTick(() => {
reset();
})
}
},
{
deep: true,
}
);
watch(
() => props.modelValue,
(newValue) => {
if (newValue) {
startMove();
} else {
stopMove();
}
}
);
写到最后
本次组件封装完成了,希望对大家有所帮助。
以上,码字作图很辛苦,还望不要吝啬手中的赞,你的点赞是我继续更新的最大动力😊!