【总结】4个大屏常见组件实践

4,772 阅读8分钟

背景

最近做一个大屏需求,第一次做酷炫的大屏项目,把其中有特点的组件总结下,以备后用。组件主要有以下4个。因为代码比较多,就创建了项目放到github上了,项目基于vite脚手架建立,vue3版本。如果需要可以直接下载代码本地运行查看:bigscreen-comp

  1. 翻牌器组件
  2. 简单轮播
  3. 双向折线图
  4. 3d饼图

最近开始学习react, 所以基于一个react的后台模板又把前3个组件写成了react hook版本,github地址

一、翻牌器组件

Kapture 2023-10-27 at 15.20.56.gif 这个效果,挺常见的,但是要自己写还真是无从下手,所以一顿操作从网上汲取灵感(banyunxiugai)。看了之后发现这个组件的核心逻辑有2个:

  1. 单个卡片翻牌效果实现,这个效果参考 干货满满!如何优雅简洁地实现时钟翻牌器(支持JS/Vue/React) 来实现,改成了vue3组件版本。
  2. 数字组合翻牌,上面的demo是时钟,我要实现的是从数字从初始值a不断增加到结束值b,目标是15分钟运行完毕。
  • 如果 b - a > 900, 则每s变一次,那就是变15 * 60 = 900次。计算出来每次变化的step = (b-a)/900,如果有余数,将余数加到最后的余数次。
  • 如果b-a小于900,会调整间隔,按照每次增加1,计算每次动画的时间间隔即900 / (b - a)。

这里的翻牌效果,如果前后两次同一个位置的翻牌器数字相同,则不变,不同才会变化。组件包含两个:

  • Flipper:一张牌,处理动画效果,上翻、下翻
  • FlipNumber:包含多个Flipper,需要几位就包含几位

下面列一下Flipper的代码,其余的放到github上了。

<template>
  <div class="FlipNumber">
    <Flipper v-for="n in 10" :key="n" :ref="(el: refItem) => setRefMap(el)" />
  </div>
</template>

<script setup lang="ts">
import {onMounted, onUnmounted, defineExpose } from 'vue';
import Flipper from './Flipper.vue';

const props = defineProps({
  initNum: {
    type: Number,
    default: 0,
  }
});

type refItem = FlipperCtx | null;
const flipObjs: (FlipperCtx | null)[] = [];
const setRefMap = (el: refItem) => {
  if (el) {
	flipObjs.push(el);
  }
};

const SUM_COUNT = 90;

type FlipperCtx = InstanceType<typeof Flipper>;


let flipObjsLen = 9;

onMounted(() => {
	console.log('flipper', flipObjs);
	flipObjsLen = flipObjs.length;
	// run(0, props.initNum);
})

let timer: ReturnType<typeof setTimeout>;
let initNumStr = '';

// 获取动画相关的step、duration,差值,翻牌类型
function getAnmiInfo(initNum: number, nextNum: number) {
	// 15min翻完,每s翻一次,可以翻900次,用diff除以900,如果不能整除,则余数加到剩余的最后余数次数,每次多加1
	const diff = Math.abs(nextNum - initNum);
	const flipType = nextNum - initNum > 0 ? 'down' : 'up';
	// 如果diff大于SUM_COUNT,则1s翻一次,否则间隔改为SUM_COUNT / diff
	let duration = 1000;
	let step = Math.floor(diff / SUM_COUNT);
	const left = diff % SUM_COUNT;
	if (diff < SUM_COUNT) {
		duration = SUM_COUNT / diff * 1000;
		step = 1;
	}
	return { step, duration, left, diff, flipType };
}

// 
function setFlipper(nextShowNum: number, nowStr: string, flipType: string) {
	// 转为字符串
	let nextTimeStr = nextShowNum + '';
	let index = flipObjsLen;
	for (let i = nextTimeStr.length - 1; i >= 0 && index >= 0; i--) {
		index--;
		if (nowStr[i] === nextTimeStr[i]) {
			continue;
		}
		if (flipType === 'down') {
			flipObjs[index]?.flipDown(
				nowStr[i],
				nextTimeStr[i]
			)
		} else {
			flipObjs[index]?.flipUp(
				nowStr[i],
				nextTimeStr[i]
			)
		}
	}
}


const isFlipDown = (flipType: string) => flipType === 'down';

function setNextNum(flipType: string, cur: number, step: number) {
	return isFlipDown(flipType) ? cur + step : cur - step;
}

function run(initNum: number, nextNum: number) {
	clearInterval(timer);
	const { step, duration, left, diff, flipType } = getAnmiInfo(initNum, nextNum);

	let nowStr = initNumStr;
	// 下一个动画显示的数字
	let nextShowNum = initNum;
	let counter = 0;
	timer = setInterval(() => {
		// 如果diff大于SUM_COUNT可能会有余数,余数加到后面的次数,如果diff小于SUM_COUNT,每次动画+1;
		if (diff > SUM_COUNT) {
			if (counter < SUM_COUNT - left) {
				nextShowNum = setNextNum(flipType, nextShowNum, step);
			} else {
				nextShowNum = setNextNum(flipType, nextShowNum, step + 1);
			}
		} else {
			nextShowNum = setNextNum(flipType, nextShowNum, step);
		}
		// 如果显示的数字超过了最终显示的数字,则取消定时器
		if (isFlipDown(flipType) && nextShowNum >= nextNum
			|| !isFlipDown(flipType) && nextShowNum <= nextNum) {
			clearInterval(timer);
		}
		counter++;
		// console.log('nextShowNum', nextShowNum, nextTimeStr);
		// 设置翻牌器的数字
		setFlipper(nextShowNum, nowStr, flipType);
		nowStr = nextShowNum + '';
	}, duration)
}

onUnmounted(() => {
    clearInterval(timer);
})
</script>

上面的代码中有个小知识点,vue3的composition api如何循环收集ref变量?vue2中定义相同的ref值,然后获取的方式不好使了。用了 vue3 设置动态 ref 并获取的方法才获取到了。

vue2中可以用同一个ref名字,vue会帮我们收集到数组里,但这里的问题是如果是多层for循环,都会收集到这里,这样就不明确了。但如果是单层for循环可以这样写。

vue3中这种用法就不好使了,只会收集到最后一个元素,vue3中使用v-for 循环时, 使用ref总会获取到的是最后的元素, 必须使用函数, 手动赋值。vue3 和vue2 使用ref操作dom、以及数组循环中的ref操作dom这篇文章说不能使用push?但这个组件比较简单并没有遇到这个问题,如果真的遇到问题了可以参考这里面的说法。

<template>
  <div class="rain">
    vue2 使用ref操作dom
    <div
      v-for="(item, index) in 5"
      :key="index"
      ref="rains"
      :data-set="'data' + index"
    >
      out{{ index }}
      <div
        v-for="(item, index1) in 5"
        :key="index1"
        ref="rains"
        :data-set="'data-rains' + index"
      >
        inner{{ index1 }}
      </div>
    </div>
    <div ref="single" />
  </div>
</template>
<script>
export default {  
    mounted () {    
        console.log('single', this.$refs.single)  
        // 会收集到30个ref,内外层都在一起了,如果是单层的就没有问题
        console.log('循环操dom-rains', this.$refs.rains) 
    }
}
</script>

image.png

二、简单轮播

Kapture 2023-10-27 at 15.17.53.gif

这个组件,效果就更常见了吧,我有3种思路:

  1. 自己写,代码少,可以参考以下2个文章:
  2. 指令形式: vue — 列表滚动(跑马灯)--指令实现方法,也可以实现,但好像用组件的也ok。
  3. 引入第三方组件 vue-seamless-scroll,为了这个组件引入一个库,代码有点多, 而我们是最基础的组件,不需要那么多功能。参考文章

最后,选择了自己写,因为这个比较简单,而且我们没有其他功能,就一个轮播,所以就自己写了。这个组件的核心动画就是一个上移的动画:

  1. 开始动画,将整个列表上移列表项的高度,就是将list的margin-top更改为-item高度,并且使用css3 的transition: all 0.5s ease-out 完成动画的移动。
  2. 动画完成后,将第一个元素插入到最后一个后面,并将第一个元素删除。
<template>
  <div class="vueBox">
    <div class="marquee" :style="{ height: height + 'px' }">
      <div class="marquee_box">
        <div class="title" :style="{ height: headerHeight + 'px', lineHeight: headerHeight + 'px' }">
          <div v-for="item in headerList">{{ item }}</div>
        </div>
        <ul
          ref="listRef"
          :class="['marquee_list', animate ? 'marquee_top' : '']"
        >
          <li
            v-for="(item, index) in list"
            :class="'item' + item.index"
            :key="index"
            @mouseenter="handleStop()"
            @mouseleave="handleUp()"
          >
            <div class="name">
              <div class="no">{{ item.index }}</div>
              <div>{{ item.name }}</div>
            </div>
            <span> {{ item.amount }} </span>
          </li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed, PropType } from "vue";

interface IListItem {
    name: string,
    amount: number,
    index?: number
}

const props = defineProps({
    hasAnim:{
        type: Boolean,
        default: true
    },
  marqueeList: {
    type: Array as PropType<IListItem[]>,
    default: () => {
      return [];
    },
  },
  headerList: {
    type: Array as PropType<string[]>,
    default: () => {
      return [];
    },
  },
  waitTime: {
    type: Number,
    required: false,
    default: 2000,
  },
  animTime: {
    type: Number,
    required: false,
    default: 500,
  },
  itemHeight: {
    type: Number,
    default: 36,
  },
  headerHeight: {
    type: Number,
    default: 36,
  },
  showNumber: {
    type: Number,
    default: 4,
  },
});

// 计算容器高度
const height = computed(() => {
  let sum = props.itemHeight * props.showNumber;
  if (props.headerList) {
    sum += props.headerHeight || props.itemHeight;
  }
  return sum;
});

const list = computed(() => {
    let count = 0;
    return props.marqueeList.map((item:IListItem) => {
        item.index = ++count;
        return item;
    });
});


let animate = ref(false);
let timer: number;
let listRef = ref<HTMLInputElement | null>(null);

watch(animate, (cur: boolean) => {
    if (!listRef.value) {
        return;
    }
    if (cur) {
        listRef.value.style.marginTop = `-${props.itemHeight}px`;
    } else {
        listRef.value.style.marginTop = '0';
    }
});

onMounted(() => {
    if (props.hasAnim) {
        startAnim();
    }
});

onUnmounted(() => {
  stopAnim();
});
function showMarquee() {
  animate.value = true;
  setTimeout(() => {
    list.value.push(list.value[0]);
    list.value.shift();
    animate.value = false;
  }, props.animTime);
}

function startAnim() {
  timer = setInterval(showMarquee, props.waitTime);
}

function stopAnim() {
  clearInterval(timer);
}

function handleStop() {
  stopAnim();
}

function handleUp() {
  startAnim();
}
</script>

三、双向饼图

image.png

这个组件是用的echarts实现的,因为echarts用的比较少,所以其实并不清楚如何写,找到一个网站makeapie,里面有很多echarts的demo,所以通过这个网站找到了类似的效果,然后在对照着官网写出来了效果,主要的options在github上

四、3D饼图

还有一个3d饼图的效果,点击有动画效果。options主要参考makeapie, 实现代码:3d饼图

Kapture 2023-10-27 at 16.09.10.gif

总结

写了几个组件后,发现大屏的项目重在动画效果,确实之前写的动画不多,二是实践了echarts的使用,拓展了项目技能。

其实刚开始写这个大屏需求的时候,先是搜索到了几个常见的大屏库,然后在库里面找自己对应的效果,还真找到了几个,然后看这些高star的大屏项目效果真的挺酷炫,各种动画效果,niubihonghong,但是最后还是以单个组件逐渐攻破来写的,下面的项目基本没用到,但之前也都调研了下,所以也总结下看到的几个常见的大屏相关的库,以备后用。

项目类型技术栈使用
datav大屏常用组件的组件库有vue2、vue3、react版本,vue3支持的少。文档齐全,提供3个官方demo。直接安装npm包直接引入自己的项目。
vue-big-screen基于datav和echarts的大屏项目有vue2、vue3、react版本,是一个大屏项目,文档还行,可以参考效果直接npm i运行项目看效果
geekerAdminvue3+ts的后台管理项目,里面包含一个大屏页面Vue3、ts,大屏的组件基本自己实现,有掘金文章直接npm i运行项目看效果
idatavvisual-large-screen大屏项目的集合,iDatav有15个,visual-large-screen有74个(真多啊)基于echarts、jquery,比较老了,可以看参考里面的效果。直接clone下来打开html看效果

参考文档