基于node+websocket+vue+echarts的数据可视化项目

739 阅读3分钟

简介

最近跟着黑马的视频,写了个简单的大数据可视化项目。项目的图表相对简单,数量也比较少,主要是让自己了解一下一些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);
    }
  }
}

前端的操作相对复杂,但主要分为以下几个步骤:

  1. 创建SocketService类,并采用单例模式,保证全局只有一个SocketService类的实例对象。
  2. 连接服务器,包括创建webSocket实例,监听连接成功onopen、连接关闭onclose、接收消息onemssage
  3. callBackMapping 用于存储业务类型和回调函数的对应关系。registerCallBackunRegisterCallBack 用于对回调函数的注册和取消。
  4. send 用于发送数据,判断是否连接成功再发送,连接不成功即进行延时操作。
  5. 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中,使得屏幕的各个模块能够同时变化。