Skip to content

可视化大屏项目积累

由于最近一直在做可视化大屏,所以积累了一些经验,分享给大家。内容主要是大屏一些可复用的模块,项目技术栈:vite + vue3/vue2.7 + echarts。

一、关于适配

1.vw、vh方案

此方案是利用插件postcss-px-to-viewport 将px单位转换成vw、vh相对单位,在开发时可以对标设计稿写px,插件会帮你自动转,大致实现代码如下: 首先安装插件

import createPlugin from 'postcss-px-to-viewport'

然后在根目录下新建postcss.config.js

export default {
  plugins: [
    createPlugin({
      // Options...
      unitToConvert: 'px',
      viewportWidth: 1920,
      unitPrecision: 2,
      propList: ['*'],
      viewportUnit: 'vw',
      fontViewportUnit: 'vw',
      selectorBlackList: [],
      minPixelValue: 2,
      mediaQuery: false,
      replace: true,
      exclude: undefined,
      include: undefined,
      landscape: false,
      landscapeUnit: 'vh',
      landscapeWidth: 568,
    }),
  ],
}

2.根节点css缩放方案

此方案就是使用css transform: scale(${scaleRatioX})属性去缩放根节点,采取了宽高比例一致,可根据不同场景自己衡量修改。代码如下:

 /* 可视化大屏自适应缩放 */
//css部分
body.page-pos{
  position: absolute;
  top: 0;
  left:50%;
  overflow: hidden;
  transform-origin: 0 0;
  --un-translate-x: -50%;
  --un-translate-y: 0;
  transform: translateX(var(--un-translate-x)) translateY(var(--un-translate-y));
  overflow: hidden;
}
//js部分
export default function useScalePage(option) {
  onMounted(() => {
    document.body.classList.add('page-pos')
    triggerScale()
    window.addEventListener('resize', triggerScale)
  })
  onUnmounted(() => {
    window.removeEventListener('resize', triggerScale) // 释放
    document.body.classList.remove('page-pos')
    document.body.style = ''
  })
  function triggerScale() {
    // 1.设计稿的尺寸
    const {targetX=1920,targetY=1080}=option
    // 2.拿到当前设备(浏览器)的宽度
    const currentX = document.documentElement.clientWidth || document.body.clientWidth
    // 3.计算缩放比例
    const scaleRatioX = currentX / targetX
    document.body.style = `width:${targetX}px; height:${targetY}px; transform: scale(${scaleRatioX}, ${scaleRatioX}) translateX(-50%)`
  }
}
//组件内使用Hook
useScalePage({ targetX: 3840, targetY: 2160 })

二、全屏组件

<template>
<div @click="onToggle">
	<img v-if="!fullScreen" src="@/assets/images/full.png" title="全屏" alt="">
	<img v-else src="@/assets/images/exitfull.png" title="退出全屏" alt="">
</div>
</template>

<script setup>
let map = null
const functionsMap = [
  [
    'requestFullscreen',
    'exitFullscreen',
    'fullscreenElement',
    'fullscreenEnabled',
    'fullscreenchange',
    'fullscreenerror',
  ],
  // New WebKit
  [
    'webkitRequestFullscreen',
    'webkitExitFullscreen',
    'webkitFullscreenElement',
    'webkitFullscreenEnabled',
    'webkitfullscreenchange',
    'webkitfullscreenerror',
  ],
  // Old WebKit
  [
    'webkitRequestFullScreen',
    'webkitCancelFullScreen',
    'webkitCurrentFullScreenElement',
    'webkitCancelFullScreen',
    'webkitfullscreenchange',
    'webkitfullscreenerror',
  ],
  [
    'mozRequestFullScreen',
    'mozCancelFullScreen',
    'mozFullScreenElement',
    'mozFullScreenEnabled',
    'mozfullscreenchange',
    'mozfullscreenerror',
  ],
  [
    'msRequestFullscreen',
    'msExitFullscreen',
    'msFullscreenElement',
    'msFullscreenEnabled',
    'MSFullscreenChange',
    'MSFullscreenError',
  ],
]
const fullScreen=ref(false)
const isSupported=ref(false)
const checkSupported = () => {
 if (!document) {
  return false
 }
 else {
  for (const m of functionsMap) {
   if (m[1] in document) {
    map = m
    //dispatch event
    return true
   }
  }
 }
 return false
}
const onToggle = () => {
  if (!isSupported.value) return
  const [REQUEST, EXIT, ELEMENT] = map
  if (!fullScreen.value) {
    document?.querySelector('html')?.[REQUEST]()
  } else {
    if (document?.[ELEMENT]) {
      document?.[EXIT]()
    }
  }
 }
}
const onChange=()=>{
 const [, , ELEMENT] = map
 fullScreen.value = !!document?.[ELEMENT]
}
onMounted(()=>{
 isSupported.value = checkSupported()
 const [, , , , EVENT] = map
 document.addEventListener(EVENT, onChange)
})
onUnmounted(() => {
 const [, , , , EVENT] = map
 window.removeEventListener(EVENT, onChange)
})
</script>

三、数字滚动组件

index.vue

<script setup>
const props = defineProps({
  targetNum: {
    type: Number,
    default: 0,
  },
  duration: Number,
  circleNum: {
    type: Number,
    default: 0,
  },
  isStart: {
    type: Boolean,
    default: false,
  },
  height: {
    type: Number,
    default: 40,
  },
})
const startNum = ref(0)
watch(() => props.targetNum, (n, o) => {
  startNum.value = o
})
const { targetNum, duration, circleNum, isStart, height } = toRefs(props)
const targetNumArr = computed(() => {
  return `${targetNum.value}`.split('')
})
const startNumArr = () => {
  const arr = `${startNum.value}`.split('')
  while (arr.length < targetNumArr.value.length)
    arr.unshift('0')
  return arr
}
// 获取同一数位上的两个起止数字
const getTwoFigure = (i) => {
  return [startNumArr()[i], targetNumArr.value[i]]
}
const direction = (i) => {
  const [start, target] = getTwoFigure(i)
  return +start > +target ? 'down' : 'up'
}
// 针对两个起止数字,生成动画需要的数组
const getFigureArr = (i) => {
  let [start, target] = getTwoFigure(i)
  const result = []
  let direction = 'up'
  if (+target === 0) {
    if (+start > 5) {
      for (let i = +start; i <= 9; i++)
        result.unshift(i)
      result.unshift(0)
    }
    else {
      direction = 'down'
      for (let i = 0; i <= +start; i++)
        result.push(i)
    }
  }
  else if (+start === 0) {
    if (+target > 5) {
      direction = 'down'
      for (let i = +target; i <= 9; i++)
        result.unshift(i)
      result.unshift(0)
    }
    else {
      for (let i = 0; i <= +target; i++)
        result.push(i)
    }
  }
  else {
    if (+start > +target) {
      direction = 'down';
      [start, target] = [target, start]
    }
    for (let i = +start; i <= +target; i++)
      result.push(i)
  }
  return result
}
</script>
<template>
  <div class="cui-ani-number">
    <SingleNumber v-for="figure, i in targetNumArr" :key="i" :direction="direction(i)" :figure-arr="getFigureArr(i)"
      :duration="duration" :is-start="isStart" :height="height" :delay="0.2" />
  </div>
</template>
<style scoped>
.digital-transform {
  display: inline-flex;
}
.digital-transform-item {
  display: inline-block;
  transition: opacity 0.3s, transform 0.3s;
}
.vdt-slide-y-enter,
.vdt-slide-y-leave-to {
  opacity: 0;
  transform: translateY(10px);
}
.cui-ani-number {
  display: inline-flex;
  justify-content: center;
  align-items: center;
  overflow: hidden;
}
</style>

SingleNumber.vue 单个数字滚动代码如下:

<script setup>
const props = defineProps({
  figureArr: {
    type: Array,
    default: () => [],
  },
  duration: {
    type: Number,
    default: 2,
  },
  direction: {
    type: String,
    default: 'up',
  },
  delay: {
    type: Number,
    default: 0,
  },
  isStart: {
    type: Boolean,
    default: false,
  },
  height: {
    type: Number,
    default: 40,
  },
})
const startAni = ref(false)
const { figureArr, duration, direction, delay, isStart, height } = toRefs(props)
watch(() => figureArr.value, (e) => {
  startAni.value = true
}, { immediate: true })

const figureArrReverse = computed(() => { return Array.isArray(figureArr) && figureArr.reverse() })
const heightPx = computed(() => { return `${height.value}px` })
const totalHeight = computed(() => { return height.value * (figureArr.value.length - 1) })

const translateValPx = computed(() => `-${totalHeight.value}px`)
const styleObj = computed(() => {
  return {
    '--translate': translateValPx.value,
    '--duration': `${duration.value}s`,
    '--delay': `${delay.value}s`,
    'height': heightPx.value,
  }
})
const itemStyleObj = computed(() => {
  return { 'line-height': heightPx.value, 'height': heightPx.value }
})
const onEnd = (e) => {
  startAni.value = false
}
</script>
<template>
  <div :style="styleObj" class="cui-ani-s-number">
    <div class="box" :class="[startAni ? `${direction} ani` : '', direction === 'up' ? 'trans' : '']" @webkitAnimationEnd="onEnd">
      <template v-if="Array.isArray(figureArr)">
        <div v-for="figure, i in figureArr" :key="i" class="item" :style="itemStyleObj">
          {{ figure }}
        </div>
      </template>
    </div>
  </div>
</template>
<style scoped>
.cui-ani-s-number {
  font-size:inherit;
  color: inherit;
  overflow: hidden;
}
.box {
  overflow: hidden;
}
.trans{
  transform: translateY(var(--translate));
}
.ani {
  animation-timing-function: ease-in-out;
  animation-iteration-count: 1;
  animation-duration: var(--duration);
  animation-fill-mode: both;
  animation-delay: var(--delay);
}
.up {
  animation-name: up
}
.down {
  animation-name: down;
}
.item {
  text-align: center;
}
@keyframes up {
  0% {
    transform: translateY(0);
  }
  100% {
    transform: translateY(var(--translate));
  }
}
@keyframes down {
  0% {
    transform: translateY(var(--translate));
  }
  100% {
    transform: translateY(0);
  }
}
</style>

四、有背景图,可拖缀缩放的echarts地图(须于UI设计师配合)

由于之前做的echarts地图需要撒点位,故需要拖缀缩放地图模块,网上一些资源如果要添加背景图的话就无法进行拖缀缩放,此处完美解决了美观(绚丽的地形图)与功能(撒点功能具备拖缀与缩放)兼得。

<template>
  <div id="eMap" ref="eMap"></div>
</template>
<script setup>
import echarts from "@/utils/echarts"
import mapBg from "@/assets/images/bg1840.png"//像素大小与#eMap地图div一致
import mapJson from "@/assets/json/mapgeo.json";
import wyJson from "@/assets/json/wy.json";
let mapChart = null
let domImg = null
const draw = () => {
  mapChart = echarts.init(this.$refs.eMap);
  echarts.registerMap("渭源", mapJson);
  echarts.registerMap("wy", wyJson);
  const option = {
    tooltip: {
      show: false,
    },
    series: [
      {
        type: "scatter",
        coordinateSystem: "geo",
        rippleEffect: {
          brushType: "fill",
        },
        label: {
          show: false,
          color: "#f00",
          offset: [0, 25],
          formatter(obj) {
            return obj.name;
          },
        },
        symbol(_, params) {
          return `image://${params.data.icon}`;
        },
        symbolSize: [72, 88],
        showEffectOn: "render", // 加载完毕显示特效
        itemStyle: {
          borderColor: "#fff",
        },
        zlevel: 20,
        data: pointAll //点位数据
      },
    ],
    geo: [
      {
        zlevel: 10,
        show: true,
        type: "map",
        geoId: "555",
        map: "渭源",
        zoom: 1,
        aspectScale: 1,
        scaleLimit: { min: 0.6 },
        layoutCenter: ["50%", "50%"],
        layoutSize: 1690,
        animationDurationUpdate: 0,
        tooltip: {
          show: true,
          position: "top",
          trigger: "item",
          transitionDuration: 0,
          formatter: (item) => {
            if (item.componentType === "series") return `${item.name}`;
          },
          textStyle: {
            fontSize: 28,
            color: "#fff",
          },
          backgroundColor: "rgba(50,50,50,0.7)",
        },
        label: {
          // 静态的时候展示样式
          show: true, // 是否显示地图省份得名称
          color: "#fff",
          fontSize: 28,
          fontFamily: "Arial",
        },
        blur: {
          label: { show: true, color: "#000" },
        },
        roam: true, // 是否开启鼠标滚轮缩放
        itemStyle: {
          label: {
            show: true,
            color: "#fff",
          },
          color: "#fff",
          borderColor: "#1db7ae",
          borderWidth: 1,
          opacity: 1,
          areaColor: {
            image: domImg,
            repeat: "no-repeat",
          },
        },
        emphasis: {
          label: {
            show: true,
            color: "#fff",
            fontSize: 28,
          },
          itemStyle: {
            color: "#fff",
            borderColor: "#01cebb",
            borderWidth: 2,
            areaColor: {
              image: domImg,
              repeat: "no-repeat",
            },
          },
        },
      },
      {
        show: true,
        geoId: "666",
        map: "wy",
        zoom: 1,
        aspectScale: 1,
        scaleLimit: { min: 0.6 },
        layoutCenter: ["50%", "50%"],
        layoutSize: 1690,
        animationDurationUpdate: 0,
        tooltip: {
          show: true,
          position: "top",
          trigger: "item",
          transitionDuration: 0,
          formatter: (item) => {
            if (item.componentType === "series") return `${item.name}`;
          },
          textStyle: {
            fontSize: 14,
            color: "#fff",
          },
          backgroundColor: "rgba(50,50,50,0.7)",
        },
        label: {
          // 静态的时候展示样式
          show: true, // 是否显示地图省份得名称
          color: "#fff",
          fontSize: 28,
          fontFamily: "Arial",
        },
        blur: {
          label: { show: true, color: "#000" },
        },
        roam: false, // 是否开启鼠标滚轮缩放
        itemStyle: {
          color: "#fff",
          borderColor: "#01cebb",
          borderWidth: 20,
          shadowColor: "#01cebb",
          shadowBlur: 20,
          shadowOffsetX: -5,
          shadowOffsetY: 15,
          opacity: 1,
          areaColor: {
            image: domImg,
            repeat: "no-repeat",
          },
        },
      },
    ],
  };
  mapChart.setOption(option);

  mapChart.on("georoam", function (params) {
    let option = mapChart.getOption(); //获得option对象
    if (params.zoom != null && params.zoom != undefined) {
      //捕捉到缩放时
      option.geo[1].zoom = option.geo[0].zoom; //下层geo的缩放等级跟着上层的geo一起改变
      option.geo[1].center = option.geo[0].center; //下层的geo的中心位置随着上层geo一起改变
    } else {
      //捕捉到拖曳时
      option.geo[1].center = option.geo[0].center; //下层的geo的中心位置随着上层geo一起改变
    }
    mapChart.setOption(option); //设置option
  });
}
domImg = document.createElement("img")
domImg.style.width = "1690px"
domImg.style.height = "1690px"//地图可视区域的宽高,即去除四周的白边部分
domImg.src = mapBg
domImg.onload = () => {
  draw()
}
</script>

<style scoped>
#eMap {
  position: relative;
  pointer-events: visible;
  width: 1840px;/*//地图的画布大小,拖缀缩放区域*/
  height: 1840px;
}
</style>

五、封装Chart组件

1.按需引入 src/components/chart/index.ts
import Chart from './Chart.vue'
import * as echarts from 'echarts/core'
import {
 BarChart,
} from 'echarts/charts'

import {
  TitleComponent,
  TooltipComponent,
  GridComponent,
  DatasetComponent,
  TransformComponent
} from 'echarts/components'

import { LabelLayout, UniversalTransition } from 'echarts/features'

import { CanvasRenderer } from 'echarts/renderers'
import { App } from 'vue'

export const install = (app: App) => {
 app.component('v-chart', Chart)
}

echarts.use([
 BarChart,
 TitleComponent,
 TooltipComponent,
 GridComponent,
 DatasetComponent,
 TransformComponent,
 LabelLayout,
 UniversalTransition,
 CanvasRenderer
])

export default {
 install
}
2.Chart 组件 src/components/chart/Chart.vue
<template>
 <div ref="chartRef" className='es-chart'></div>
</template>

<script setup lang='ts'>
import { onMounted, PropType, shallowRef, watch } from 'vue'
import * as echarts from 'echarts'
import { ECharts, EChartsCoreOption } from 'echarts'

const props = defineProps({
 option: {
  type: Object as PropType<EChartsCoreOption>,
  required: true,
  default: () => ({})
 },
 loading: Boolean
})
const chartRef = shallowRef<HTMLElement | null>(null)

const chart = shallowRef<ECharts | null>(null)
function init() {
 if (props.option) {
  chart.value = echarts.init(chartRef.value!)
  setOption(props.option)
 }
}
function setOption(option, notMerge?: boolean, lazyUpdate?: boolean) {
 chart.value!.setOption(option, notMerge, lazyUpdate)
}

function resize() {
 chart.value!.resize()
}

watch(() => props.option, () => {
 setOption(props.option)
})

// show loading
watch(() => props.loading, (val) => {
 if (!chart.value) return
 if (val) {
  chart.value!.showLoading()
 } else {
  chart.value!.hideLoading()
 }
})

onMounted(() => {
 init()
})

defineExpose({
 chart,
 setOption,
 resize
})
</script>

<style lang='scss' scoped>
.es-chart {
 width: 100%;
 height: 100%;
}
</style>
3.注册组件 src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import * as chart from './components/chart'
createApp(App).use(chart).mount('#app')

<template>
 <v-chart ref="chartRef" :option="option" />
</template>

<script setup lang='ts'>
import { ref } from 'vue'
const chartRef = ref()
const option = ref({
  xAxis: {
    type: 'category',
    data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
  },
  yAxis: {
    type: 'value'
  },
  series: [
    {
      data: [120, 200, 150, 80, 70, 110, 130],
      type: 'bar',
      showBackground: true,
      backgroundStyle: {
        color: 'rgba(180, 180, 180, 0.2)'
      }
    }
  ]
})
</script>

六、拖拽布局

这里使用了 SortableJS 来实现拖拽

SortableJS是一个强大的JavaScript库,用于创建可排序、可拖放和可交互的列表。它提供了一种简单的方法来实现拖放排序功能,使用户可以通过拖动列表项来重新排序它们。

1.安装依赖
pnpm add sortablejs
2.封装useSortable
// src/utils/useSortable.ts

import { ref, onMounted, Ref } from 'vue'
import Sortable from 'sortablejs'

export const useSortable = (listRef: Ref<any[]>) => {

 // 容器元素
 const containerRef = ref()

 onMounted(() => {
  Sortable.create(containerRef.value!, {
   swapThreshold: 1,
   animation: 150,
   onUpdate(e) {
    const item = listRef.value[e.oldIndex]
    listRef.value.splice(e.oldIndex, 1)
    listRef.value.splice(e.newIndex, 0, item)
   }
  })
 })

 return {
  containerRef
 }
}
3.使用方式
<template>
 <div ref="containerRef">
  <component
   v-for="item in components"
   :key="item.name"
   :is="item.component"
   class="es-screen-right-item"
  >
   {{ item.name }}
  </component>
 </div>
</template>

<script setup lang='ts'>
import { shallowRef } from 'vue'
import { useSortable } from '@/utils/useSortable'
import Right1 from './Right1.vue'
import Right2 from './Right2.vue'
import Right3 from './Right3.vue'
const components = shallowRef([
 { name: 'right1', component: Right1 },
 { name: 'right2', component: Right2 },
 { name: 'right3', component: Right3 }
])

const { containerRef } = useSortable(components)
</script>

<style lang='scss' scoped>
.es-screen-right-item {
 width: 100%;
 height: 300px;
 background-color: var(--es-block-bg);
 padding: 16px;
 & + & {
  margin-top: 20px;
 }
}
</style>

七、无缝滚动封装

无缝滚动在大屏可视化项目中非常常见,本小节使用animejs实现了一个支持横纵无缝滚动的自定义钩子

1.安装 animejs 依赖
pnpm add animejs
2.实现
import { onMounted, shallowRef, Ref } from 'vue'
import anime from 'animejs/lib/anime.es.js'

export type OptionsType = {
 direction?: 'horizontal' | 'vertical'
 gap?: number
 duration?: number
}

export function useSeamlessScroll(listRef: Ref<HTMLElement | null>, options: OptionsType = {}) {
 const {
  direction = 'horizontal',
  gap = 10,
  duration = 10000
 } = options
 const animation = shallowRef<ReturnType<typeof anime>>(null)

 function init() {
  const isHorizontal = direction === 'horizontal'

  const translateKey = isHorizontal ? 'translateX' : 'translateY'

  const transKey = isHorizontal ? 'x' : 'y'

  // items
  const children = listRef.value?.children || []
  if (!children.length) return

  // 第一个元素
  const firstEl =  children[0] as HTMLElement
  const firstDiff = (isHorizontal ? firstEl.offsetWidth : firstEl.offsetHeight ) + gap

  // 默认将list元素向左或向上移动一个item的距离
  listRef.value!.style.transform = `${translateKey}(-${firstDiff}px)`

  const transList: any = []
  let total = 0 // 总宽/高
  // 设置初始位置
  anime.set(children, {
   [translateKey]: (el: HTMLElement, i) => {

    const distance = (isHorizontal ? el.offsetWidth : el.offsetHeight ) + gap
    total += distance

    transList[i] = { [transKey]: total - distance }
   }
  })
  // 设置list容器的宽或高
  listRef.value!.style[isHorizontal ? 'width' : 'height'] = total + 'px'

  // 添加动画
  animation.value = anime({
   targets: transList,
   duration,
   easing: 'linear',
   direction: isHorizontal ? undefined : 'reverse',
   [transKey]: `+=${total}`,
   loop: true,
   update: () => {
    anime.set(children, {
     [translateKey]: (el, i) => {
      return transList[i][transKey] % total
     }
    })
   }
  })
 }
 // 暂停
 function pause() {
  animation.value!.pause()
 }
 // 停止
 function play() {
  animation.value!.play()
 }

 onMounted(() => {
  init()
 })

 return {
  listRef,
  pause,
  play,
  animation
 }
}

useSeamlessScroll 接受两个参数:listRef 和 options。listRef 是一个 Ref 对象,用于引用滚动列表的 DOM 元素。options 是一个配置对象,可以设置滚动的方向、间隙和持续时间。 步骤解析:

(1)根据传入的滚动方向,确定 translateKey 和 transKey,translateKey 表示 CSS 中的移动方向,transKey 表示animejs中的x/y轴方向。

(2)获取滚动列表的子元素,并计算第一个元素的偏移量。因为默认从从第二个元素开始,这样初始移动是才不会出现空白

(3)初始化滚动列表的位置,将其向左(横向滚动)或向上(纵向滚动)移动一个元素的距离。

(4)遍历子元素,计算每个元素的偏移量,并将其设置为初始位置。

(5)根据滚动方向,设置滚动列表容器的宽度或高度。

(6)使用 animejs 库实现无缝滚动效果,在动画更新时,根据计算出的偏移量更新子元素的位置。

3.使用 useSeamlessScroll
<template>
 <div class="es-center-bottom">
  <div ref="listRef" class="es-bottom-list">
   <div v-for="item in 10" class="es-bottom-item">
    {{ item }}
   </div>
  </div>
 </div>
</template>

<script setup lang='ts'>
import { ref } from 'vue'
import { useSeamlessScroll } from '@/utils/useSeamlessScroll'

const listRef = ref()
useSeamlessScroll(listRef)
</script>

<style lang='scss' scoped>
.es-center-bottom {
 position: relative;
 width: 100%;
 overflow: hidden;
 height: 150px;
 .es-bottom-item {
  position: absolute;
  top: 0;
  left: 0;
  width: 170px;
  height: 150px;
  display: flex;
                flex-direction: column;
                justify-content: center;
                align-items: center;
  background-color: var(--es-block-bg);
  font-size: 22px;
                font-weight: 600;
  .es-item-text {
                    margin-top: 16px;
                }
 }
}
</style>
4.抽离成组件简化使用方式

新建 src/components/SeamlessScroll.vue

<template>
 <div class="es-seamless-scroll">
  <div ref="listRef" class="es-seamless-scroll-list">
   <slot />
  </div>
 </div>
</template>

<script setup lang='ts'>
import { useSeamlessScroll, OptionsType } from '@/utils/useSeamlessScroll'
import { PropType, ref } from 'vue'

const props = defineProps({
 width: {
  type: [String, Number]
 },
 height: {
  type: [String, Number]
 },
 option: {
            type: Object as PropType<OptionsType>,
            default: () => ({})
 }
})
const listRef = ref()
useSeamlessScroll(listRef, props.option)

</script>

<style lang='scss' scoped>
.es-seamless-scroll {
 overflow: hidden;
 width: 100%;
 height: 100%;
}
</style>

使用组件

<template>
 <SeamlessScroll class="es-center-bottom">
  <div v-for="item in 10" class="es-bottom-item">
   {{ item }}
  </div>
 </SeamlessScroll>
</template>

<script setup lang='ts'>
import SeamlessScroll from '@/components/SeamlessScroll.vue'
</script>

<style lang='scss' scoped>
.es-center-bottom {
 position: relative;
 width: 100%;
 overflow: hidden;
 height: 150px;
 .es-bottom-item {
  position: absolute;
  top: 0;
  left: 0;
  width: 170px;
  height: 150px;
  display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
  background-color: var(--es-block-bg);
  font-size: 22px;
    font-weight: 600;
  .es-item-text {
      margin-top: 16px;
    }
 }
}
</style>

效果和直接使用钩子一样,不过组件使用起来还是要简单一点; 组件默认是横向滚动,如果想纵向滚动传入direction为vertical即可。

结尾

更多组件钩子后续更新...