需求
- 背景:数据统计常需要实现内容定向移动的走马灯效果,便于用户实时观察数据变动。
- 目标:实现一个vue组件,可以上下左右实现走马灯效果,且走马灯元素可自定义。
技术选型
比较常用的方式是如下几种
- js定时移动元素
- 通过marquee标签实现
- css动画实现
第一种方式生成的动画相比较而言比较消耗性能。第二种方式已经过时,marquee标签已被废弃,因此采用css动画来实现。
实现思路
- 为元素设置transform控制移动方向,再结合animation控制移动速度与循环次数。
- 初始化定时器以指定间隔时间添加移动元素,并删除“已过时”元素。
效果
效果视频
图中有些许抖动是生成gif并压缩导致的,真实效果不存在一点抖动情况。
主要代码
/**
* <marquee-infinite v-model="${滑块数据项数组}" :totalInView="${跑马灯元素个数}" :elementSize="${元素高度|宽度}" :moveInMill="${元素切入切出总耗时ms}" direction="up(默认值)|down|left|right" :render="${跑马灯元素渲染函数}"></marquee-infinite>
* example:<marquee-infinite v-model="datas" :totalInView="3" :elementSize="250" :moveInMill="15000" direction="down" :render="renderSlide"></marquee-infinite>
*/
Vue.component('marquee-infinite', {
props: {
dataArr: {//滑块数组,数据项,v-model=dataArr
type: Array,
require: true
},
totalInView: {//页面最多展示多少个完整元素
type: Number,
require: true
},
elementSize: {//每个滑动元素高度,横向移动则是宽度,单位px
type: Number,
require: true
},
moveInMill: {//单个元素移动完成所需时间,毫秒
type: Number,
require: true
},
render: {//滑块渲染函数,返回Html字符串
type: Function,
require: true
},
direction: {//移动方向 up|down|left|right,默认从下向上移动
type: String,
default: 'up'
}
},
model: {
prop: 'dataArr',
event: 'change'
},
data(){
return {
slideRoadId: this.buildId(),//滑道id
animationName: this.buildId(),//动画css id
marqueeClsName: this.buildId(),//滑动元素class名
runningElements: new Map(),//key: 在跑马灯的dom id,value: 已经移动了多少个元素高度
intervals: null
}
},
computed: {
/** 滑道样式 */
slideRoadStyle(){
let size = this.totalInView * this.elementSize;
let template = '';
switch (this.direction) {
case 'up':
case 'down':
template = 'height: $size;position: relative;overflow: hidden;';
return template.replace('$size', size + 'px');
case 'left':
case 'right':
template = 'width: $size;min-height: $height;position: relative;overflow: hidden;';
return template.replace('$size', size + 'px')
.replace('$height', this.elementSize + 'px');
}
return '';
}
},
methods: {
/** 初始化跑马灯 */
initMarquee(){
//移动单位元素高度所需时间
let periodInMill = (this.moveInMill / this.totalInView) * (this.totalInView + 1) / (this.totalInView + 2);
this.intervals = window.setInterval(() => {
//删除已过的
//加入新元素
//移动元素
}, periodInMill);
},
/** 添加样式 */
setStyle(){
//动画样式
let animationStyle = this.buildAnimationStyle();
let marqueeStyle = this.buildMarqueeStyle();
let totalStyle = animationStyle + '\n' + marqueeStyle;
let style = document.createElement('style');
style.type = 'text/css';
style.rel = 'stylesheet';
style.appendChild( document.createTextNode(totalStyle) );
let head = document.getElementsByTagName('head')[0];
head.appendChild(style);
},
/** 生成css移动动画 */
buildAnimationStyle(){
let template = '@keyframes $name {0% {transform: $begin;} 100% {transform: $end;}}';
template = template.replace('$name', this.animationName);
let beginPx = '';
switch (this.direction) {
case 'up':
beginPx = (this.totalInView + 1) * this.elementSize + 'px';
template = template.replace('$begin', 'translateY(' + beginPx + ')')
.replace('$end', 'translateY(-100%)');
break;
case 'down':
beginPx = - (this.totalInView + 1) * this.elementSize + 'px';
template = template.replace('$begin', 'translateY(' + beginPx + ')')
.replace('$end', 'translateY(100%)');
break;
case 'left':
beginPx = (this.totalInView + 1) * this.elementSize + 'px';
template = template.replace('$begin', 'translateX(' + beginPx + ')')
.replace('$end', 'translateX(-100%)');
break;
case 'right':
beginPx = - (this.totalInView + 1) * this.elementSize + 'px';
template = template.replace('$begin', 'translateX(' + beginPx + ')')
.replace('$end', 'translateX(100%)');
break;
}
return template;
},
/** 滑动元素样式 */
buildMarqueeStyle(){
let shift = '';
switch (this.direction) {
case 'up':
shift = 'top:$top'.replace('$top', - this.elementSize + 'px');
break;
case 'down':
shift = 'bottom:$bottom'.replace('$bottom', - this.elementSize + 'px');
break;
case 'left':
shift = 'left:$left'.replace('$left', - this.elementSize + 'px');
break;
case 'right':
shift = 'right:$right'.replace('$right', - this.elementSize + 'px');
break;
}
let totalTime = (this.moveInMill / this.totalInView) * (this.totalInView + 1);
let style = '.$marqueeClsName{ animation: $animation $time linear 0s; position: absolute; $shift;}';
style = style.replace('$marqueeClsName', this.marqueeClsName)
.replace('$animation', this.animationName)
.replace('$time', totalTime + 'ms')
.replace('$shift', shift);
return style;
},
//删除已过的
removeOldMarquee(){
if (this.runningElements.size > this.totalInView){
var keys = this.runningElements.keys();
var id = Array.from(keys)[0];
if (id){
this.removeNode(id);
this.runningElements.delete(id);
}
}
},
//加入新元素
addNewMarquee(){
var temps = this.dataArr.splice(0, 1);
//加入新元素
if (temps.length > 0){
//添加新元素
var domId = this.buildId();
var element = this.render(temps[0], domId);
var div = document.createElement("div");
div.id = domId;
div.innerHTML = element;
div.className = this.marqueeClsName;
this.addChild(this.slideRoadId, div);
this.runningElements.set(domId, 0);
//更新到父组件
this.$emit('change', this.dataArr);
}
},
//移动元素
moveMarqueeElement(){
//上一位置有空位的上移
var keyArr = Array.from( this.runningElements.keys() );
var comparator = this.totalInView + 1;
if (keyArr.length <= this.totalInView){
comparator--;
}
for (let i = 0; i < keyArr.length; i++) {
const key = keyArr[i];
const offset = this.runningElements.get(key);
const dom = document.getElementById(key);
if (offset < comparator - i){
//移动
dom.style.setProperty('animation-play-state', 'running');
this.runningElements.set(keyArr[i], offset + 1);
} else {
//停止移动
dom.style.setProperty('animation-play-state', 'paused');
}
}
}
},
template: `<div :id="slideRoadId" :style="slideRoadStyle"></div>`
});
全部代码下载
使用demo
- 后端通过websocket定时往前端推送数据。
@ServerEndpoint("/ws/{sid}")
@Component
public class WebSocketServer {
private static Map<String, Session> sessionMap = new ConcurrentHashMap<>(4);
@OnOpen
public void openSocket(Session session, @PathParam("sid") String sid){
sessionMap.put(sid, session);
}
@OnClose
public void closeSocket(@PathParam("sid") String sid){
sessionMap.remove(sid);
}
public static void sendToAll(String msg){
for (Session session : sessionMap.values()) {
try {
session.getBasicRemote().sendText(msg);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
public void sendMsg(){
ScheduledExecutorService svr = Executors.newSingleThreadScheduledExecutor();
svr.scheduleAtFixedRate(() -> {
String msg = String.valueOf( System.currentTimeMillis() );
WebSocketServer.sendToAll(msg);
}, 0, 5, TimeUnit.SECONDS);
}
- 前端与后端建立ws连接获取后端来的数据。
initWs: function () {
this.ws = this.createConnection();
//定义回调
var that = this;
this.ws.onmessage = function (evt) {
that.datas.push( JSON.parse(evt.data) );
};
this.ws.onclose = function ( evt) {
if (evt.code === 1000){//正常断开
return;
}
//重连
window.setTimeout(function () {
that.initWs();
}, 3000);
};
},
createConnection: function(){
var path = this.getWsPath() + '/' + this.wsId;
return new WebSocket(path);
},
getWsPath: function () {
var loc = window.location, new_uri;
if (loc.protocol === "https:") {
new_uri = "wss:";
} else {
new_uri = "ws:";
}
new_uri += "//" + loc.host + "/ws";
return new_uri;
}
- 引入组件,并传入数据。
<marquee-infinite v-model="datas" :totalInView="3" :elementSize="250" :moveInMill="15000" :render="renderSlide"></marquee-infinite>
renderSlide: function (data, domId) {
var template = '<img class="marquee-img" src="$img" width="$size" height="$size"/>';
return template.replace('$img', data.img)
.replaceAll('$size', this.elementSize);
}