简介
最近跟着黑马的视频,写了个简单的大数据可视化项目。项目的图表相对简单,数量也比较少,主要是让自己了解一下一些echarts的操作。
附上github地址:github.com/EdwinTsaiii…
效果如下,视频在b站:
交互效果主要是对各个图表的全屏和缩小,图表的定时切换,或者点击切换,换肤功能,都是些基础的效果。echarts的操作主要还是看笔记文档去学和写,也可以多看看网上一些大佬的做的模板,借鉴(cv)的地方比较多。各个模块的模板如下。
后端node的搭建
后端主要采用Koa2框架,此处涉及到洋葱模型的中间件。
简单来讲,当一个请求经过洋葱时,顺序为中间件1->中间件2->中间件3->中间件3->中间件2->中间件1。此处与axios的请求响应拦截器的通过顺序相似,都是先进后出。每一层都对请求处理了两次。
app.js:后端的主文件,分为这几步:创建koa实例对象——导入、绑定中间件——绑定端口号——开启服务端的监听
// 服务器入口文件
// 导入中间件
const resDurationMiddleware = require("./middleware/koa_response_duration");
const resHeaderMiddleware = require("./middleware/koa_response_header");
const resDataMiddleware = require("./middleware/koa_response_data");
// 1.创建koa实例对象
const Koa = require("koa");
const app = new Koa();
// 2.绑定中间件
// 绑定第一层中间件
app.use(resDurationMiddleware);
// 绑定第二层中间件
app.use(resHeaderMiddleware);
// 绑定第三层中间件
app.use(resDataMiddleware);
// 3.绑定端口号
app.listen(8888);
const webSocketService = require("./service/web_socket_service");
// 开启服务端的监听,监听客户端的连接
// 当某一个客户端连接成功之后,就会对这个客户端进行message事件的监听
webSocketService.listen();
然后就是各个中间件的编写,注意各个中间件之间,需要采用async/await进行异步处理,因为调用 next 函数得到的是 Promise 对象。
koa_response_duration:第1层中间件,用于计算服务器消耗时长:
module.exports = async (context, next) => {
// 记录开始时间
const start = Date.now();
// 执行第二层中间件
await next();
// 记录结束时间
const end = Date.now();
// 计算消耗时间
const duration = end - start;
// 设置响应头
context.set('X-Response-Time', duration + 'ms')
}
koa_response_header.js:第2层中间件,用于设置响应头
module.exports = async (context, next) => {
// 设置响应头和响应体
const contentType = "application/json;charset=utf-8";
context.set("Content-type", contentType);
// 跨域配置
context.set("Access-Control-Allow-Origin", "*");
context.set("Access-Control-Allow-Methods", "OPTIONS, GET, PUT, POST, DELETE");
// 进入第三层中间件
await next();
};
koa_response_data.js:第3层中间件,处理业务逻辑的中间件,读取某个json文件的数据,响应给前端浏览器。数据存在json文件中,没存在数据库。
const path = require("path");
const fileUtils = require("../utils/file_utils");
module.exports = async (context, next) => {
// 根据端口路径 得到json数据的绝对路径
const url = context.request.url;
let filePath = url.replace("/api", "");
filePath = "../data" + filePath + ".json";
filePath = path.join(__dirname, filePath);
// 处理对应的json数据与响应内容
try {
const ret = await fileUtils.getFileJsonData(filePath);
context.response.body = ret;
} catch (error) {
const errotMsg = {
message:'读取内容文件失败,文件资源不存在!',
status: 404
}
context.response.body = errotMsg;
}
await next();
};
前端vue+echarts项目的搭建
前期准备:
在index.html中引入echarts和各个主题的文件
在main.js中,把echarts对象挂载到vue原型对象上
Vue.prototype.$echarts = window.echarts;
在各个图表模块中,举个例子:
Rank.vue
<template>
<div class="com-container">
<div class="com-chart" ref="rank_ref"></div>
</div>
</template>
<script>
export default {
data() {
return {
chartInstance: null,
allData: null, // 服务器返回的数据
};
},
methods: {
initChart() {
this.chartInstance = this.$echarts.init(this.$refs.rank_ref); // 初始化echart实例对象
const initOption = {}; // 对图表初始化配置的控制
this.chartInstance.setOption(initOption);
},
async getData() { // 获取服务器的数据
this.updateChart(); // 更新图表
},
updateChart() { // 更新图表
const dataOption = {};
this.chartInstance.setOption(dataOption);
},
screenAdapter() { // 当浏览器的大小发生变化的时候,会调用的方法,来完成屏幕的适配
const adapterOption = {}; // 分辨率相关的配置项
this.chartInstance.setOption(adapterOption);
this.chartInstance.resize(); // 手动调用图标的resize才能产生效果
},
},
mounted() {
this.initChart();
this.getData();
window.addEventListener("resize", this.screenAdapter);
this.screenAdapter();
},
destroyed() {
window.removeEventListener("resize", this.screenAdapter);
},
};
</script>
<style lang="less" scoped></style>
RankPage.vue
<template>
<div class="com-page">
<Rank></Rank>
</div>
</template>
<script>
import Rank from "@/components/Rank.vue";
export default {
data() {
return {};
},
components: {
Rank
},
methods: {},
};
</script>
<style lang="less" scoped></style>
最终的Rank.vue如下,前端这一块主要还是对vue的操作和echarts的相关配置和运用,换汤不换药。其他的就不展示了。
<!-- 销量排行模块——柱状图 -->
<template>
<div class="com-container">
<div class="com-chart" ref="rank_ref"></div>
</div>
</template>
<script>
import { mapState } from "vuex";
export default {
data() {
return {
chartInstance: null,
allData: null,
startValue: 0, // 区域缩放的起点值
endValue: 9, // 区域缩放的终点值
timer: null, // 定时器
titleFontSize: 0,
};
},
computed: {
...mapState(["theme"]),
},
watch: {
theme() {
this.chartInstance.dispose(); // 销户当前的图表
this.initChart();
this.screenAdapter();
this.updateChart();
},
},
methods: {
initChart() {
this.chartInstance = this.$echarts.init(this.$refs.rank_ref, this.theme);
const initOption = {
title: {
text: "▍地区销售排行",
top: 20,
left: 20,
},
grid: {
top: "30%",
left: "5%",
right: "5%",
bottom: "5%",
containLabel: true,
},
tooltip: {
show: true,
},
xAxis: {
type: "category",
},
yAxis: {
type: "value",
},
series: [{ type: "bar" }],
};
this.chartInstance.setOption(initOption);
this.chartInstance.on("mouseover", () => {
clearInterval(this.timer);
});
this.chartInstance.on("mouseout", () => {
this.startInterval();
});
},
async getData(ret) {
this.allData = ret;
this.allData.sort((a, b) => b.value - a.value);
this.updateChart();
},
updateChart() {
const colorArr = [
["#0BA82C", "#4FF778"],
["#2E72BF", "#23E5E5"],
["#5052EE", "#AB6EE5"],
];
// 处理图表的数据
const provinceArr = this.allData.map((item) => item.name);
// 所有省份对应的销售金额
const valueArr = this.allData.map((item) => item.value);
const dataOption = {
// 区域缩放
dataZoom: {
show: false,
startValue: this.startValue,
endValue: this.endValue,
},
xAxis: {
data: provinceArr,
},
series: [
{
data: valueArr,
itemStyle: {
color: (arg) => {
let targetColorArr = null;
if (arg.value > 300) {
targetColorArr = colorArr[0];
} else if (arg.value > 200) {
targetColorArr = colorArr[1];
} else {
targetColorArr = colorArr[2];
}
return new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: targetColorArr[0] },
{ offset: 1, color: targetColorArr[1] },
]);
},
},
},
],
};
this.chartInstance.setOption(dataOption);
},
screenAdapter() {
const widthSize = (this.$refs.rank_ref.offsetWidth / 100) * 2.6;
const heightSize = (this.$refs.rank_ref.offsetHeight / 100) * 4;
this.titleFontSize =
widthSize > heightSize * 2.4 || widthSize < heightSize
? heightSize
: widthSize;
const adapterOption = {
title: {
textStyle: {
fontSize: this.titleFontSize,
},
},
series: [
{
barWidth: this.titleFontSize,
itemStyle: {
barBorderRadius: [
this.titleFontSize / 2,
this.titleFontSize / 2,
0,
0,
],
},
},
],
};
this.chartInstance.setOption(adapterOption);
this.chartInstance.resize();
},
startInterval() {
if (this.timer) {
clearInterval(this.timer);
}
this.timer = setInterval(() => {
if (this.endValue > this.allData.length - 1) {
this.startValue = 0;
this.endValue = 9;
}
this.startValue++;
this.endValue++;
this.updateChart();
}, 2000);
},
},
created() {
this.$socket.registerCallBack("rankData", this.getData);
},
mounted() {
this.initChart();
this.$socket.send({
action: "getData",
socketType: "rankData",
chartName: "rank",
value: "",
});
window.addEventListener("resize", this.screenAdapter);
this.screenAdapter();
this.startInterval();
},
destroyed() {
window.removeEventListener("resize", this.screenAdapter);
clearInterval(this.timer);
this.$socket.unRegisterCallBack("rankData");
},
};
</script>
<style lang="less" scoped></style>
websokect相关技术
个人觉得websocket这方面还是挺值得学习的,毕竟这些项目的vue和node的操作都是大同小异,websocket相关技术的运用还是比较少见的。此项目的websoket技术用于多端协同以及数据传输。
相关API不熟悉,看一下文档:developer.mozilla.org/zh-CN/docs/…
后端的websocket service
// webSocket
const webSocket = require("ws");
const path = require("path");
const fileUtils = require("../utils/file_utils");
// 创建服务对象,绑定端口号
const wss = new webSocket.Server({
port: 8890,
});
// 服务端开启监听
module.exports.listen = () => {
// 对客户端的连接事件进行监听
// client:代表的是可会端的连接socket对象
wss.on("connection", (client) => {
console.log("客户端连接成功了...");
// msg:由客户端发送给服务端的数据
client.on("message", async (msg) => {
console.log("客户端发送信息给服务端了:" + msg);
let payload = JSON.parse(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.forEach((client) => {
// 由服务端往客户端发送数据
client.send(msg);
});
}
});
});
};
后端主要是对服务端开启监听,连接客户端on("connection",(client=>{}) ,并接收客服端发送过来的数据client.on("message",(msg)=>{}) ,读取并返回数据client.send() 。如果是读取数据的其他业务,则把接收到的消息,广播给所有连接的客户端wss.clients.forEach((client) => {}) 。
数据都是从客户端流向服务端,再从服务端流向所有客户端,这样来实现多端联动。
接收的数据格式如下:
{
action: "getData",
socketType: "rankData",
chartName: "rank",
value: "",
}
action 表示客户端的业务需求,包括获取数据、全屏、改变主题这三个在多端联动上实现的业务。socketType 业务类型,主要用于取出对应的回调函数。
前端的websocket service的设计模式也是挺值得学习的。
export default class SocketService {
// 单例模式
static instance = null;
static get Instance() {
if (!this.instance) {
this.instance = new SocketService();
}
return this.instance;
}
// 和服务器连接的socket对象
ws = null;
// 连接状态
connected = false;
// 重试次数
sendRetryCount = 0;
// 重新连接尝试的次数
connectRetryCount = 0;
// 定义连接服务器的方法
connect() {
// 连接服务器
if (!window.WebSocket) {
console.log("您的浏览器不支持WebSocket!");
}
this.ws = new WebSocket("ws://localhost:8890");
// 监听连接成功
this.ws.onopen = () => {
console.log("连接服务端成功!");
this.connected = true;
this.connectRetryCount = 0;
};
// 1.服务器连接不成功 2.服务器关闭了连接
this.ws.onclose = () => {
console.log("连接服务端失败!");
this.connected = false;
this.connectRetryCount++;
setTimeout(() => {
this.connect();
}, this.connectRetryCount * 500); // 设置延时重连
};
// 监听服务端发来的消息
this.ws.onmessage = (msg) => {
console.log("从服务端获取到了数据");
// 取出服务端传递的数据
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);
}
}
};
}
// 业务类型和回调函数的对应关系
callBackMapping = {};
// 回调函数的注册
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);
}
}
}
前端的操作相对复杂,但主要分为以下几个步骤:
- 创建SocketService类,并采用单例模式,保证全局只有一个SocketService类的实例对象。
- 连接服务器,包括创建webSocket实例,监听连接成功
onopen、连接关闭onclose、接收消息onemssage - callBackMapping 用于存储业务类型和回调函数的对应关系。registerCallBack 和 unRegisterCallBack 用于对回调函数的注册和取消。
- send 用于发送数据,判断是否连接成功再发送,连接不成功即进行延时操作。
- 在onmessage监听服务端发来的数据,取出业务类型,根据 action 字段判断业务进入不同分支,再执行回调。
在main.js文件中,对websocket进行链接与挂载。
import SocketService from "@/utils/socket_service";
// 对服务端进行websocket连接
SocketService.Instance.connect();
// 把instance实例对象挂载到vue原型对象上
Vue.prototype.$socket = SocketService.instance;
在各个组件中,再拿Rank.vue举例
<!-- 销量排行模块——柱状图 -->
<template>
...
</template>
<script>
export default {
data() {
return {
chartInstance: null,
allData: null,
startValue: 0, // 区域缩放的起点值
endValue: 9, // 区域缩放的终点值
timer: null, // 定时器
titleFontSize: 0,
};
},
computed: {
...mapState(["theme"]),
},
watch: {
theme() {
...
},
},
methods: {
initChart() {
...
},
async getData(ret) {
this.allData = ret;
this.allData.sort((a, b) => b.value - a.value);
this.updateChart();
},
updateChart() {
...
},
screenAdapter() {
...
},
startInterval() {
...
},
},
created() {
// 绑定回调函数
this.$socket.registerCallBack("rankData", this.getData);
},
mounted() {
this.initChart();
// 采用websocket的方式,向服务端请求数据
this.$socket.send({
action: "getData",
socketType: "rankData",
chartName: "rank",
value: "",
});
window.addEventListener("resize", this.screenAdapter);
this.screenAdapter();
this.startInterval();
},
destroyed() {
window.removeEventListener("resize", this.screenAdapter);
clearInterval(this.timer);
// 解绑回调函数
this.$socket.unRegisterCallBack("rankData");
},
};
</script>
<style lang="less" scoped></style>
在此总结一下websocket实现前后端交互的全流程:
服务端启动时,listen开启对客户端的监听。客户端项目启动时,进行connect() ,与服务端进行连接。在各个组件的created() 生命周期中,绑定该模块对应业务的回调函数,在mounted() 中,向服务端请求数据。此时回调函数是储存在SocketService实例的callBackMapping中, 并且通过SocketService实例的send() 向服务端请求数据。服务端获取客户端发来的数据,判断是执行什么业务,若是读取数据,则在客户端发来的数据中添加对应的data,并将整个数据对象返回。若是执行其他业务,则原封不动广播给所有客户端。客户端的onmessage接收到服务端发来的消息,取出数据,并执行callBackMapping中的回调函数,通过call()进行调用与传递服务端发送的数据到各个组件的getData(ret)中,进行展示。
实现全屏切换和换肤的操作如下:
在总页面中:
<div id="left-top" :class="[fullScreenStatus.trend ? 'fullscreen' : '']">
...
data() {
return {
// 定义每个图表的全屏状态
fullScreenStatus: {
seller: false,
stock: false,
trend: false,
map: false,
hot: false,
rank: false,
},
time: null,
timer: null,
};
},
...
methods: {
handleChangeSize(chartName) {
// // 改变fullScreenStatus的数据
// this.fullScreenStatus[chartName] = !this.fullScreenStatus[chartName];
// // 调用图表组件对象的screenAdapter方法
// 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() {
// 修改vuex里面的数据
// this.$store.commit("changeTheme");
this.$socket.send({
action: "themeChange",
socketType: "themeChange",
chartName: "",
value: "",
});
},
// 接收到主题的处理
recvThemeChange() {
this.$store.commit("changeTheme");
},
}
...
.fullscreen {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
margin: 0 !important;
z-index: 100;
}
通过fullScreenStatus去控制添加对应样式,进而控制各个图表是否全屏展示。注意此时执行方法的时候,数据改变了,但DOM无法及时改变,所以需要采用$nextTick,在回调中执行适配函数screenAdapter(),对DOM进行及时更新。
切换主题则是把主题字段存在vuex中,使得屏幕的各个模块能够同时变化。