目录
一、WebSocket【双向通信】的使用
1.1、前端
1.2、后端
二、前端组件的合并与优化
三、全屏切换
3.1、单页面切换
3.2、同页面多端联动
四、主题切换
4.1、单页面切换
4.2、同页面多端联动
一、WebSocket【双向通信】的使用
1.1、前端
在utils文件夹里创建socket_service.js,并在main.js里进行挂载
(1)、定义类SocketService,并定义成单例设计模式;
(2)、定义连接服务器的方法connect,(前端项目不用下载,直接window.socket即可);
(3)、监听事件:onopen、onclose、onmessage;
(4)、存储回调函数;
(5)、接收数据的处理;
(6)、定义发送数据的方法;
(7)、挂载SocketService对象到vue的原型对象上;
import SocketService from '@/utils/socket_service'
// 对服务端进行websocket的连接
SocketService.Instance.connect()
// 通过this.$socket.xxx获取所有方法
Vue.prototype.$socket = SocketService.Instance
export default class SocketService {
// 单例
static instance = null
static get Instance() {
if (!this.instance) {
this.instance = new SocketService()
} return this.instance
}
// 和服务端连接的socket对象
ws = null
// 存储回调函数
callBackMapping = {}
// 标识是否连接成功
connected = false
// 记录重试次数
sendRetryCount = 0
// 重新连接尝试的次数
connectRetryCount = 0
// 定义连接服务器的方法
connect() {
if (!window.WebSocket) {
return console.log('您的浏览器不支持WebSocket')
}
this.ws = new WebSocket('ws://localhost:9998')
// 连接成功的事件
this.ws.onopen = () => {
console.log('连接服务端成功了');
this.connected = true
this.connectRetryCount = 0
}
// 连接连接服务端失败
this.ws.onclose = () => {
console.log('连接服务端失败');
this.connected = false
this.connectRetryCount++
setTimeout(() => {
this.connect()
}, this.connectRetryCount * 500)
}
// 得到服务端发送过来的数据
this.ws.onmessage = msg => {
console.log('从服务端获取到了数据', msg.data)
const recvData = JSON.parse(msg.data)
const socketType = recvData.socketType
// 判断回调函数是否存在
if (this.callBackMapping[socketType]) {
const action = recvData.action
if (action === 'getData') {
const realData = JSON.parse(recvData.data)
this.callBackMapping[socketType].call(this, realData);//获取数据
} else if (action === 'fullScreen') {
this.callBackMapping[socketType].call(this, recvData);//全屏切换
} else if (action === 'themeChange') {
this.callBackMapping[socketType].call(this, recvData);//主题切换
}
}
}
}
// 回调函数的注册
registerCallBack(socketType, callBack) {
this.callBackMapping[socketType] = callBack
}
// 取消某一个回调函数
unRegisterCallBack(socketType) {
this.callBackMapping[socketType] = null
}
// 发送数据
send(data) {
if (this.connected) {//判断此时有没有连接成功
this.sendRetryCount = 0
this.ws.send(JSON.stringify(data))
} else {
this.sendRetryCount++
setTimeout(() => {
this.send(data)
}, this.sendRetryCount * 500)
}
}
}
1.2、后端
在service文件夹里创建web_socket_service.js,在app.js里引入
const WebSocketService = require('./service/web_socket_service')
// 开启服务端的监听,监听客户端的连接
// 当某一个客户端连接成功后,就会对这个客户端进行message事件的监听
WebSocketService.listen()
(1)、安装包 npm i ws -S;
(2)、创建对象;
(3)、监听事件;
(4)、发送数据;
const path = require('path')
const fileUtils = require('../utils/file_utils')
const WebSocket = require('ws')
const { log } = require('console')
// 创建WebSocket服务端的对象, 绑定的端口号是9998
const wss = new WebSocket.Server({
port: 9998
})
// 服务端开启了监听
module.exports.listen = () => {
// 对客户端的连接事件进行监听
// client:代表的是客户端的连接socket对象
wss.on('connection', client => {
// console.log('有客户端连接成功了...')
// 对客户端的连接对象进行message事件的监听
// msg: 由客户端发给服务端的数据
client.on('message', async msg => {
let payload = JSON.parse(msg)
console.log(action, '客户端发送数据给服务端了: ' + msg)
const action = payload.action
if (action === 'getData') {
let filePath = '../data/' + payload.chartName + '.json'
filePath = path.join(__dirname, filePath)
const ret = await fileUtils.getFileJsonData(filePath)
// 需要在服务端获取到数据的基础之上, 增加一个data的字段
// data所对应的值,就是某个json文件的内容
payload.data = ret
client.send(JSON.stringify(payload))
} else {
// 原封不动的将所接收到的数据转发给每一个处于连接状态的客户端
// wss.clients // 所有客户端的连接
wss.clients.forEach(client => {
//如果数据被转成了Buffer对象,故使用msg.toString()将Buffer转换为字符串,前端用JSON.parse(xxx)解析字符串为JSON对象即可
client.send(msg.toString())
})
}
})
})
}
// 备注:前后端约定的字段
// let msg = {
// "action": 'getData',//获取数据方法名
// "socketType": "trendData",//前端响应函数的标识
// "chartName": 'trend',
// "value": '',
// "data":'.json里的数据'
// }
二、前端组件的合并与优化
(1)、在组件创建完成后,进行回调函数的注册;在组件销毁时,进行回调函数的取消;
(2)、发送数据给服务器;
(3)、直接 getData(ret) 得到数据,不用再发请求了;
(4)、重发数据机制
让属性connected在onopen时设置为true,在onclose时设置为false,计算重发次数
(5)、断开重连机制
如果失败,则延时的时长随着尝试的次数而增加,如果成功,则将次数归0
以Trend.vue组件为例:
created() {
this.$socket.registerCallBack('trendData', this.getData);
},
destroyed() {
this.$socket.unRegisterCallBack('trendData');
},
mounted() {
......
// this.getData();
this.$socket.send({
action: "getData",
socketType: "trendData",
chartName: "trend",
value: "",
});//发送数据给服务器,告诉它,该组件需要数据
},
methods:{
// 旧版本
// async getData() {
// const { data: ret } = await this.$http.get("trend");
// this.allData = ret;
// this.updateChart();
// },
// websocket版本
getData(ret) {
this.allData = ret;
this.updateChart();
},
}
三、全屏切换
以"热销商品占比图表"为例:
(1)、定义全屏状态的数据、样式;
(2)、在点击指定组件的放大图标时传递自己的属性名,在方法里根据对应的属性值切换对应的样式和图标,并调用组件自己的screenAdapter();
(3)、联动效果:
发送全屏数据给服务器,服务器在收到这个数据时,会转发给每一个处于连接状态的客户端,前端在created()时注册回调、在destroyed()时取消回调、在recvData()里接收到数据,进行属性值处理。
3.1、单页面切换
3.2、同页面多端联动
四、主题切换
4.1、单页面切换
(1)、自己在echarts的"主题编辑器"里选择要切换的主题.json文件;
(2)、点击切换按钮,修改vuex中的theme数据(通过mutations修改state里的数据);
(3)、在每个组件里监听theme的变化,用xx.dispose()销毁当前图表,再重新渲染(在registerTheme()和init()函数里改为变化的theme)
(4)、HTML样式随之改变:
utils文件夹的theme_utils.js内容
const theme = {
chalk: {
backgroundColor: '#161522',
titleColor: '#fff',
logoSrc: 'logo_dark.png',//左上角logo图标路径
themeSrc: 'qiehuan_dark.png',//切换主题按钮的图片路径
headerBorderSrc: 'header_border_dark.png'//页面顶部的边框图片
},
vintage: {
backgroundColor: '#fff',
titleColor: '#000',
logoSrc: 'logo_light2.png',
themeSrc: 'qiehuan_light.png',
headerBorderSrc: 'header_border_light.png'
}
}
export function getThemeValue(themeName) {
return theme[themeName]
}
store文件夹里的index内容
state: {
theme: 'chalk',
},
mutations: {
changeTheme(state) {
if (state.theme == 'chalk') {
state.theme = 'vintage'
} else {
state.theme = 'chalk'
}
},
},
以Hot.vue组件为例:
<template>
<div class="com-container">
<div class="com-chart" ref="hot_ref"></div>
<!-- 左右箭头 -->
<span class="iconfont arr-left" @click="toLeft" :style="comStyle"></span>
<span class="iconfont arr-right" @click="toRight" :style="comStyle"></span>
<!-- 一级标题 -->
<span class="cat-name" :style="comStyle">{{ catName }}系列</span>
</div>
</template>
<script>
import * as ets from "echarts";
import { mapState } from "vuex";
import chalk from "../../../../public/static/theme/chalk.json"; //自己下载
import vintage from "../../../../public/static/theme/vintage.json"; //自己下载
import { getThemeValue } from "@/utils/theme_utils.js";
export default {
data() {
return {
isStyle: chalk,
};
},
computed: {
...mapState(["theme"]),
comStyle() {
return {
fontSize: `${this.titleFontSize}px`,
color: getThemeValue(this.theme).titleColor,//HTML字体颜色改变
};
},
},
watch: {
theme() {
if (this.theme == "chalk") {
this.isStyle = chalk;
} else {
this.isStyle = vintage;
}
this.chartInstance.dispose(); //销毁当前图表
// 重新初始化图表、完成屏幕适配、更新图表展示
this.initChart();
this.screenAdapter();
this.updateChart();
},
},
methods: {
// 初始化echartsInstance对象
initChart() {
// ets.registerTheme("chalk", chalk);
// this.chartInstance = ets.init(this.$refs.hot_ref, "chalk");
ets.registerTheme("isStyle", this.isStyle);
this.chartInstance = ets.init(this.$refs.hot_ref, "isStyle");
},
},
};
</script>
4.2、同页面多端联动
可参考全屏切换模块
echartsPage.vue页面(整合所有组件)代码:
<template>
<div class="screen-container" :style="containStyle">
<header class="screen-header">
<div>
<img :src="headerSrc" alt="" />
</div>
<span class="logo">
<img :src="logoSrc" alt="" />
</span>
<span class="title">电商平台实时监控系统</span>
<div class="title-right">
<img :src="themeSrc" class="qiehuan" @click="handleChangeTheme" />
<span class="datetime">2049-01-01 00:00:00</span>
</div>
</header>
<div class="screen-body">
<section class="screen-left">
<div
id="left-top"
:class="[fullScreenStatus.trend ? 'fullscreen' : '']"
>
<!-- 销量趋势图表 -->
<Trend ref="trend"></Trend>
<div class="resize">
<span
@click="changeSize('trend')"
:class="[
'iconfont',
fullScreenStatus.trend
? 'icon-compress-alt'
: 'icon-expand-alt',
]"
></span>
</div>
</div>
<div
id="left-bottom"
:class="[fullScreenStatus.seller ? 'fullscreen' : '']"
>
<!-- 商家销售金额图表 -->
<Seller ref="seller"></Seller>
<div class="resize">
<span
@click="changeSize('seller')"
:class="[
'iconfont',
fullScreenStatus.seller
? 'icon-compress-alt'
: 'icon-expand-alt',
]"
></span>
</div>
</div>
</section>
<section class="screen-middle">
<div
id="middle-top"
:class="[fullScreenStatus.map ? 'fullscreen' : '']"
>
<!-- 商家分布图表 -->
<Map ref="map"></Map>
<div class="resize">
<span
@click="changeSize('map')"
:class="[
'iconfont',
fullScreenStatus.map ? 'icon-compress-alt' : 'icon-expand-alt',
]"
></span>
</div>
</div>
<div
id="middle-bottom"
:class="[fullScreenStatus.rank ? 'fullscreen' : '']"
>
<!-- 地区销量排行图表 -->
<Rank ref="rank"></Rank>
<div class="resize">
<span
@click="changeSize('rank')"
:class="[
'iconfont',
fullScreenStatus.rank ? 'icon-compress-alt' : 'icon-expand-alt',
]"
></span>
</div>
</div>
</section>
<section class="screen-right">
<div id="right-top" :class="[fullScreenStatus.hot ? 'fullscreen' : '']">
<!-- 热销商品占比图表 -->
<Hot ref="hot"></Hot>
<div class="resize">
<span
@click="changeSize('hot')"
:class="[
'iconfont',
fullScreenStatus.hot ? 'icon-compress-alt' : 'icon-expand-alt',
]"
></span>
</div>
</div>
<div
id="right-bottom"
:class="[fullScreenStatus.stock ? 'fullscreen' : '']"
>
<!-- 库存销量分析图表 -->
<Stock ref="stock"></Stock>
<div class="resize">
<span
@click="changeSize('stock')"
:class="[
'iconfont',
fullScreenStatus.stock
? 'icon-compress-alt'
: 'icon-expand-alt',
]"
></span>
</div>
</div>
</section>
</div>
</div>
</template>
<script>
import Hot from "./components/Hot.vue";
import Map from "./components/Map.vue";
import Rank from "./components/Rank.vue";
import Seller from "./components/Seller.vue";
import Stock from "./components/Stock.vue";
import Trend from "./components/Trend.vue";
import { mapState } from "vuex";
import { getThemeValue } from "@/utils/theme_utils.js";
export default {
data() {
return {
// 定义每一个图表的全屏状态
fullScreenStatus: {
trend: false,
seller: false,
map: false,
rank: false,
hot: false,
stock: false,
},
};
},
created() {
this.$socket.registerCallBack("fullScreen", this.recvData);
this.$socket.registerCallBack("themeChange", this.recvThemeChange);
},
destroyed() {
this.$socket.unRegisterCallBack("fullScreen");
this.$socket.unRegisterCallBack("themeChange");
},
components: {
Hot,
Map,
Rank,
Seller,
Stock,
Trend,
},
computed: {
...mapState(["theme"]),
headerSrc() {
return "/static/img/" + getThemeValue(this.theme).headerBorderSrc;
},
logoSrc() {
return "/static/img/" + getThemeValue(this.theme).logoSrc;
},
themeSrc() {
return "/static/img/" + getThemeValue(this.theme).themeSrc;
},
containStyle() {
return {
backgroundColor: getThemeValue(this.theme).backgroundColor,
color: getThemeValue(this.theme).titleColor,
};
},
},
methods: {
changeSize(chartName) {
// 全屏切换单页面写法
// 改变fullScreenStatus中组件对应的属性值
// this.fullScreenStatus[chartName] = !this.fullScreenStatus[chartName];
// 调用每个组件里的屏幕适配方法
// this.$nextTick(() => {
// this.$refs[chartName].screenAdapter();
// });
// 同页面多端联动切换写法
const targetValue = !this.fullScreenStatus[chartName];
this.$socket.send({
action: "fullScreen",
socketType: "fullScreen",
chartName: chartName,
value: targetValue,
});
},
// 处理接收到的全屏数据
recvData(data) {
const chartName = data.chartName;
const targetValue = data.value;
this.fullScreenStatus[chartName] = targetValue;
this.$nextTick(() => {
this.$refs[chartName].screenAdapter();
});
},
handleChangeTheme() {
// this.$store.commit("changeTheme"); //修改VueX里的数据
this.$socket.send({
action: "themeChange",
socketType: "themeChange",
chartName: "",
value: "",
});
},
recvThemeChange() {
this.$store.commit("changeTheme");
},
},
};
</script>
<style lang="less" scoped>
// 全屏样式的定义
.fullscreen {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
margin: 0 !important;
z-index: 100;
}
.screen-container {
width: 100%;
height: 100%;
padding: 0 20px;
background-color: #161522;
color: #fff;
box-sizing: border-box;
}
.screen-header {
width: 100%;
height: 64px;
font-size: 20px;
position: relative;
> div {
img {
width: 100%;
}
}
.title {
position: absolute;
left: 50%;
top: 50%;
font-size: 20px;
transform: translate(-50%, -50%);
}
.title-right {
display: flex;
align-items: center;
position: absolute;
right: 0px;
top: 50%;
transform: translateY(-80%);
}
.qiehuan {
width: 28px;
height: 21px;
cursor: pointer;
}
.datetime {
font-size: 15px;
margin-left: 10px;
}
.logo {
position: absolute;
left: 0px;
top: 50%;
transform: translateY(-80%);
img {
height: 35px;
width: 128px;
}
}
}
.screen-body {
width: 100%;
height: 100%;
display: flex;
margin-top: 10px;
.screen-left {
height: 100%;
width: 27.6%;
#left-top {
height: 53%;
position: relative;
}
#left-bottom {
height: 31%;
margin-top: 25px;
position: relative;
}
}
.screen-middle {
height: 100%;
width: 41.5%;
margin-left: 1.6%;
margin-right: 1.6%;
#middle-top {
width: 100%;
height: 56%;
position: relative;
}
#middle-bottom {
margin-top: 25px;
width: 100%;
height: 28%;
position: relative;
}
}
.screen-right {
height: 100%;
width: 27.6%;
#right-top {
height: 46%;
position: relative;
}
#right-bottom {
height: 38%;
margin-top: 25px;
position: relative;
}
}
}
.resize {
position: absolute;
right: 20px;
top: 20px;
cursor: pointer;
}
</style>