用vue3封装一个无缝滚动组件

1,128 阅读5分钟

前言

相信大家肯定遇到过一个这样的需求:

产品:这边的列表需要支持自动滚动,并且鼠标移上去可以暂停,移开可以自动滚动。

本文将会教大家使用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传递一个组件来实现渲染,为了实现这些功能.

  1. 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>()
  };
};
  1. 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>;
          })}
        </>
      );
    };

让他滚动起来,这里使用了requestAnimationFrameapi,它在浏览器下一帧执行,用来写动画非常丝滑。当滚动距离超过最大滚动距离就变成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>
  );
  }

看一下效果,动起来了~ 屏幕录制2022-07-10 17.50.31.2022-07-10 17_52_15.gif

控制滚动的速度

对于滚动的速度只需要通过一个变量控制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>

看看效果,好像有点太快了

屏幕录制2022-07-10 18.39.49.2022-07-10 18_40_17.gif

支持鼠标滑入暂停滑出继续滚动

增加一个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方法
  • 新增initmovestartmovestopmovemove方法
  • 添加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();
        }
      });
    };

效果 屏幕录制2022-07-10 22.34.47.2022-07-10 22_36_27.gif

支持鼠标滚动,列表也滚动

对于这个功能,只要给当前容器增加一个滚轮监听事件,修改一下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();
        }
      }
    );

写到最后

本次组件封装完成了,希望对大家有所帮助。

以上,码字作图很辛苦,还望不要吝啬手中的赞,你的点赞是我继续更新的最大动力😊!