背景
最近做一个大屏需求,第一次做酷炫的大屏项目,把其中有特点的组件总结下,以备后用。组件主要有以下4个。因为代码比较多,就创建了项目放到github上了,项目基于vite脚手架建立,vue3版本。如果需要可以直接下载代码本地运行查看:bigscreen-comp
- 翻牌器组件
- 简单轮播
- 双向折线图
- 3d饼图
最近开始学习react, 所以基于一个react的后台模板又把前3个组件写成了react hook版本,github地址
一、翻牌器组件
这个效果,挺常见的,但是要自己写还真是无从下手,所以一顿操作从网上汲取灵感(banyunxiugai)。看了之后发现这个组件的核心逻辑有2个:
- 单个卡片翻牌效果实现,这个效果参考 干货满满!如何优雅简洁地实现时钟翻牌器(支持JS/Vue/React) 来实现,改成了vue3组件版本。
- 数字组合翻牌,上面的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>
二、简单轮播
这个组件,效果就更常见了吧,我有3种思路:
- 自己写,代码少,可以参考以下2个文章:
- 指令形式: vue — 列表滚动(跑马灯)--指令实现方法,也可以实现,但好像用组件的也ok。
- 引入第三方组件 vue-seamless-scroll,为了这个组件引入一个库,代码有点多, 而我们是最基础的组件,不需要那么多功能。参考文章
最后,选择了自己写,因为这个比较简单,而且我们没有其他功能,就一个轮播,所以就自己写了。这个组件的核心动画就是一个上移的动画:
- 开始动画,将整个列表上移列表项的高度,就是将list的margin-top更改为-item高度,并且使用css3
的
transition: all 0.5s ease-out
完成动画的移动。 - 动画完成后,将第一个元素插入到最后一个后面,并将第一个元素删除。
<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>
三、双向饼图
这个组件是用的echarts实现的,因为echarts用的比较少,所以其实并不清楚如何写,找到一个网站makeapie,里面有很多echarts的demo,所以通过这个网站找到了类似的效果,然后在对照着官网写出来了效果,主要的options在github上。
四、3D饼图
还有一个3d饼图的效果,点击有动画效果。options主要参考makeapie, 实现代码:3d饼图
总结
写了几个组件后,发现大屏的项目重在动画效果,确实之前写的动画不多,二是实践了echarts的使用,拓展了项目技能。
其实刚开始写这个大屏需求的时候,先是搜索到了几个常见的大屏库,然后在库里面找自己对应的效果,还真找到了几个,然后看这些高star的大屏项目效果真的挺酷炫,各种动画效果,niubihonghong,但是最后还是以单个组件逐渐攻破来写的,下面的项目基本没用到,但之前也都调研了下,所以也总结下看到的几个常见的大屏相关的库,以备后用。
项目 | 类型 | 技术栈 | 使用 |
---|---|---|---|
datav | 大屏常用组件的组件库 | 有vue2、vue3、react版本,vue3支持的少。文档齐全,提供3个官方demo。 | 直接安装npm包直接引入自己的项目。 |
vue-big-screen | 基于datav和echarts的大屏项目 | 有vue2、vue3、react版本,是一个大屏项目,文档还行,可以参考效果 | 直接npm i 运行项目看效果 |
geekerAdmin | vue3+ts的后台管理项目,里面包含一个大屏页面 | Vue3、ts,大屏的组件基本自己实现,有掘金文章 | 直接npm i 运行项目看效果 |
idatav、visual-large-screen | 大屏项目的集合,iDatav有15个,visual-large-screen有74个(真多啊) | 基于echarts、jquery,比较老了,可以看参考里面的效果。 | 直接clone下来打开html看效果 |