echarts+大数据量。这是个无解的问题!

大数据量,什么样的数据才算大呢?在echart 4.5.0版本中,画折线图,数据线一共1001000条,每条数据5002200个数据点,即最小数据50000个点(五万个点),最大数据2200000个点(两百二十万个点)。在不同数据线、不同数据点的情况下,任意情况下出现不能够使图表进行流畅的放大缩小的现象,即可认为其数据量大。当放大缩小无法流畅地进行时,也意味着图表的数据交互,包括数据点的拖拽、图表平移、显示鼠标悬浮处的点的数据(tooltip)等都会有很明显的卡顿。

需要注意的是,echarts的图表会卡顿一般是分两种情况,一种是对于数据过多地绑定了事件(尤其是一些容易频繁触发的事件,比如数据点拖拽、监听鼠标移动事件等)或者进行大量图形的操作(比如换算数据单位,从g转kg之类的操作,然后重新画图)时交互体验较差,一种是展示大量的数据,导致做任何交互动作都会有明显的卡顿,甚至浏览器会提示页面无响应的情况。当然,两者混合的可能是比较常见的,很多需求都是既要求数据量大又要求交互体验好。老实说,很难实现,除非js的性能能进一步提高。

图表加载大数据量出现的问题以其影响因子

问题

数据从后台传到前端,前端再渲染成图表。这个流程很简单易懂。问题在于,当数据量达到一定量时(为了方便,数据量用后台传过来的json数据的实际大小表示),比如50M大小数据,以网速5M/s来算,需要10s以上(毕竟后台可能需要处理一下数据才能发过来),渲染图表的时间随着数据量的增大而加长,50M数据需要15s以上才能渲染出来。而且渲染出来后,进行图表的缩放或者其它交互仍然会有明显的卡顿。(卡顿现象是否明显、渲染时间是否缩短或延长,有很大一部分取决于电脑硬件配置不同)

影响因子

1.后台处理数据所需的时间

2.被传输数据的文件大小

3.网络状况

4.前端图表框架

5.图表交互

6.客户使用的电脑硬件配置

图表加载大量数据的一些解决思路

尽管可以使用一些方式来减轻卡顿,但是卡顿现象是无法彻底解决的。以下是一些解决思路(默认图表框架:echarts):

1.如非必要,取消一切动画效果

2.通过设置echarts的配置属性,如silent属性(是否响应和触发鼠标事件),减少不必要的数据触发事件的频次;showSymbol属性(是否显示点),不画数据点能减少部分卡顿压力,建议showSymbol和tooltip不一起使用。

3.通过提高硬件性能(这个难度是最高的,不太现实,很难。毕竟成本很高。)

4.和后台开发人员商量讨论,对数据进行压缩,减少数据量(这比较有效,特别是在需求中一定要有大量交互的情况下有显著效果)

5.如果echart框架实现效果实在不理想,可以尝试其它框架。就个人实践而言,在画大数据量的彩图(百万级别)时,echarts需要画的时间按分钟计,而plotly.js花的时间按秒计(具体看电脑性能),远比echart强。当然,使用其它框架会面临各种问题,比如文档不完善(尤其没有中文文档)、官网需要翻墙访问、出现错误在百度很难找到解决方案、有些交互难以实现(可能没有相应的API可用)、影响整个项目状态(打包后文件大小)等。所以请谨慎使用,迫不得已,实在没有办法了,再尝试这个最终方案。

我自己的解决方案(勉强使用)

使用场景

事先说明,我这个解决方案的使用场景极为有限,而且并不能解决全部的问题,瑕疵较多。但总体而言,这算是”总的来说还行吧“这种感觉的解决方案。

需求:图表加载大量数据,并在缩放时出现明显的卡顿现象;对特定部分数据图形有交互的需求,允许其它数据不进行任何交互;需求要求交互顺畅,没有明显卡顿,且数据量20W~500W不等。

比如,画一个折线图,要求数据线共201条,200条数据线(参考线)用于参考,1条数据线(调整线)用于调整,需要根据参考线对调整线进行数值设置。

解决思路

核心:我只关注我需要交互的数据,其它数据只是背景。

将数据分为两部分,一部分是参考数据,一部分是调整数据。在HTML文件中使用两个div标签,一个是参考数据使用,另一个是调整数据使用。将两个div块在页面上重叠,画调整数据的div放置最上方,画不需要进行任何交互行为的数据的div放置最下方。之后使用框架分别加载数据到特定div块中,每次交互有且只能操作被置于最上方的div块中的canvas画布(默认使用echarts图表框架)。

优点和缺点

优点:

1.需要关注的数据量总是较少的,无论图形需要什么交互,都能较为顺畅地进行。

2.在一定程度上能够像操作一个图表一样操纵两个重叠的图表(比如缩放,下载成图片等)

缺点:

1.很难对参考数据添加交互功能,参考数据的更多是一个背景的角色。

2.图表缩放时,仍会出现卡顿问题

3.仍然存在内存崩溃的风险

实践

1.准备数据

数据函数(用于生成数据使用,来自highChart图表框架):

function getData (n) {
      var arr = []
      var i
      var a
      var b
      var c
      var spike
      for (i = 0; i < n; i = i + 1) {
        if (i % 100 === 0) {
          a = 2 * Math.random()
        }
        if (i % 1000 === 0) {
          b = 2 * Math.random()
        }
        if (i % 10000 === 0) {
          c = 2 * Math.random()
        }
        if (i % 50000 === 0) {
          spike = 10
        } else {
          spike = 0
        }
        arr.push([
          i,
          2 * Math.sin(i / 100) + a + b + c + spike + Math.random()
        ])
      }
      return arr
    }

为了方便,我们可以先用node.js来生成数据,代码如下–

const fs = require('fs')
function getData (n) {
  var arr = []
  var i
  var a
  var b
  var c
  var spike
  for (i = 0; i < n; i = i + 1) {
    if (i % 100 === 0) {
      a = 2 * Math.random()
    }
    if (i % 1000 === 0) {
      b = 2 * Math.random()
    }
    if (i % 10000 === 0) {
      c = 2 * Math.random()
    }
    if (i % 50000 === 0) {
      spike = 10
    } else {
      spike = 0
    }
    arr.push([
      i,
      2 * Math.sin(i / 100) + a + b + c + spike + Math.random()
    ])
  }
  return arr
}
const allData = []
for (let i = 0; i < 200; i++) {
  allData.push(getData(1000))
}
// 为了使调整数据和参考数据有明显区别,在这一步对数据的值进行加法,最好同时在图表中把参考数据和调整数据的数据线颜色进行不同设置
const oneData = getData(1000).map(item => {
  item[1] = item[1] + 2
  return item
})

fs.writeFile('oneData.json', JSON.stringify(oneData), (err) => {
  if (err) {
    throw err
  }
  console.log('Write Success')
})

fs.writeFile('allData.json', JSON.stringify(allData), (err) => {
  if (err) {
    throw err
  }
  console.log('Write Success')
})
2.html代码
<!--
vue框架+echarts图表框架
-->
<template>
  <div>
    <div style="z-index:99;position:absolute;">
      <div id="chart1" style="width:1000px;height:750px;"></div>
    </div>
    <div style="z-index:100;position:absolute;">
      <div id="chart2" style="width:1000px;height:750px;"></div>
    </div>
  </div>
</template>
3.js代码
<script>
import echarts from 'echarts'
import allData from '../../static/allData.json' // 使用getData()函数生成的参考数据
import oneData from '../../static/oneData.json' // 使用getData()函数生成的调整数据
export default {
  data () {
    return {
      mychart1: null,
      mychart2: null,
      selectData: [], // myChart2图表记录的需要进行拖拽的数据点
      selsectIndex: null, // myChart2图表用户点击位置最靠近的左侧的点
      imgUrl: '' // 用于图表下载功能的路径
    }
  },
  mounted () {
    this.drawLineOS()
    this.limitOS()
  },
  methods: {
    async limitOS () {
      const fun = async function onPointDragging (dataIndex, dx, dy) {
        console.log(dataIndex, 'dataIndex')
        const arr = self.myChart2.convertFromPixel('grid', this.position)
        Data[Number(dataIndex)][1] = arr[1] // 将坐标值(x, y)还原为数组的项[a,b]
        // 更新图表
        await self.myChart2.setOption({
          series: [{
            id: 'a',
            data: Data
          }]
        })
        self.myChart2.setOption({
          // 声明一个 graphic component,里面有若干个 type 为 'circle' 的 graphic elements。
          // 这里使用了 echarts.util.map 这个帮助方法,其行为和 Array.prototype.map 一样,但是兼容 es5 以下的环境。
          // 用 map 方法遍历 data 的每项,为每项生成一个圆点。
          // graphic: echarts.util.map(this.selectData, (dataItem, dataIndex) => {
          graphic: echarts.util.map(oneData, (dataItem, dataIndex) => {
            return {
            // 'circle' 表示这个 graphic element 的类型是圆点。
              type: 'circle',
              $action: 'merge',
              shape: {
              // 圆点的半径。
                r: 5
              },
              style: {
                fill: '#FFFFFF',
                stroke: '#000000'
              },
              // 用 transform 的方式对圆点进行定位。position: [x, y] 表示将圆点平移到 [x, y] 位置。
              // 这里使用了 convertToPixel 这个 API 来得到每个圆点的位置,下面介绍。
              position: self.myChart2.convertToPixel('grid', dataItem),
              // 这个属性让圆点不可见(但是不影响他响应鼠标事件)。
              // invisible: true,
              invisible: true,
              // 这个属性让圆点可以被拖拽。
              draggable: true,
              // 把 z 值设得比较大,表示这个圆点在最上方,能覆盖住已有的折线图的圆点。
              z: 201,
              // 此圆点的拖拽的响应事件,在拖拽过程中会不断被触发。下面介绍详情。
              // 这里使用了 echarts.util.curry 这个帮助方法,意思是生成一个与 onPointDragging
              // 功能一样的新的函数,只不过第一个参数永远为此时传入的 dataIndex 的值。
              ondrag: echarts.util.curry(onPointDragging, dataIndex)
            }
          })
        })
        const maxAndMin = self.myChart2.getModel().getComponent('yAxis').axis.scale._extent
        if (self.max !== null || self.min !== null) {
          if (self.max !== maxAndMin[1] || self.min !== maxAndMin[0]) {
            self.myChart1.setOption({
              yAxis: {
                name: `yAxis`,
                type: 'value',
                axisLine: {
                  onZero: false
                },
                max: maxAndMin[1],
                min: maxAndMin[0]
              }
            })
          }
          self.max = maxAndMin[1]
          self.min = maxAndMin[0]
        } else {
          self.max = maxAndMin[1]
          self.min = maxAndMin[0]
        }
      }
      // 基于准备好的dom,初始化echarts实例
      this.myChart2 = this.$echarts.init(document.getElementById('chart2'))
      // 数据联动--用于将两个图表的数据在一张图上显示(下载功能)
      this.myChart1.group = 'group1'
      this.myChart2.group = 'group1'
      echarts.connect('group1')

      const arrdata = []
      let Data = oneData
      arrdata.push({
        id: 'a',
        type: 'line', // 数据类型,画一条折线
        animation: false, // 是否开启动画
        showSymbol: true, // 是否画点
        symbolSize: 6, // 数据点大小
        // 降采样策略,详情可查echarts文档。主要用于在数据量过大,甚至屏幕上一个像素点存在
        // 多个数据点时能够过滤数据使一个像素点只画一个点。
        sampling: 'lttb',
        color: 'blue', // 数据线颜色
        data: Data // 要渲染的数据
      })
      // 设置图表配置项
      const option = {
        // 图表位置调整
        grid: {
          left: '5%', // 距离div左边的距离
          right: '20%', // 距离div右边的距离
          bottom: '10%', // 距离div下边的距离
          top: '10%' // 距离div上边的距离
        },
        // y轴
        yAxis: {
          name: `yAxis`,
          type: 'value'
        },
        // x轴
        xAxis: {
          name: `xAxis`,
          type: 'value',
          scale: true // 是否是脱离 0 值比例。设置成 true 后坐标刻度不会强制包含零刻度
        },
        // 工具栏
        toolbox: {
          show: true, // 是否显示工具栏
          feature: {
            // 自定义下载图片工具
            myDownloadImage: {
              show: true,
              title: '下载图片',
              iconStyle: {
                color: '#7d7d7d',
                borderColor: '#7d7d7d'
              },
              // 显示的图标
              icon: 'path://M884.736 897.024H139.264c-18.432 0-32.768 14.336-32.768 32.768 0 18.432 14.336 32.768 32.768 32.768h745.472c18.432 0 32.768-14.336 32.768-32.768 0-18.432-14.336-32.768-32.768-32.768z m-397.312-69.632c12.288 12.288 32.768 12.288 47.104 0l313.344-313.344c6.144-6.144 10.24-14.336 10.24-24.576 0-18.432-14.336-32.768-32.768-32.768-8.192 0-16.384 4.096-22.528 10.24L544.768 722.944V94.208c0-16.384-14.336-32.768-32.768-32.768-18.432 0-32.768 14.336-32.768 32.768v628.736L221.184 466.944c-6.144-6.144-14.336-10.24-22.528-10.24-18.432 0-34.816 14.336-34.816 32.768 0 8.192 4.096 18.432 10.24 24.576l313.344 313.344z',
              // 点击的回调函数
              onclick: function () {
                // 获取两个重叠图表的图片数据并调用下载函数
                const imgUrl = self.myChart2.getConnectedDataURL({
                  type: 'png',
                  excludeComponents: ['yAxis', 'xAxis']
                })
                self.download(imgUrl)
              }
            },
            // 缩放功能
            dataZoom: {
              show: true,
              xAxisIndex: 0,
              yAxisIndex: 0
            }
          }
        },
        series: arrdata
      }
      // 绘制图表
      // 等待调整数据的图表画完,将参考数据图表的x、y轴最大值和最小值跟
      // 调整数据的图表的x、y轴最大值和最小值保持一致
      await this.myChart2.setOption(option)
      this.myChart1.setOption({
        yAxis: {
          show: false,
          name: `yAxis`,
          type: 'value',
          axisLine: {
            onZero: false
          },
          max: this.myChart2.getModel().getComponent('yAxis').axis.scale._extent[1],
          min: this.myChart2.getModel().getComponent('yAxis').axis.scale._extent[0]
        },
        xAxis: {
          show: false,
          name: `xAxis`,
          type: 'value',
          scale: true,
          max: this.myChart2.getModel().getComponent('xAxis').axis.scale._extent[1],
          min: this.myChart2.getModel().getComponent('xAxis').axis.scale._extent[0]
        }
      })
      const self = this
      this.myChart2.setOption({
        // 声明一个 graphic component,里面有若干个 type 为 'circle' 的 graphic elements。
        // 这里使用了 echarts.util.map 这个帮助方法,其行为和 Array.prototype.map 一样,但是兼容 es5 以下的环境。
        // 用 map 方法遍历 data 的每项,为每项生成一个圆点。
        graphic: echarts.util.map(oneData, (dataItem, dataIndex) => {
          console.log(dataIndex)
          return {
            // 'circle' 表示这个 graphic element 的类型是圆点。
            type: 'circle',
            shape: {
            // 圆点的半径。
              r: 5
            },
            style: {
              fill: '#FFFFFF',
              stroke: '#000000'
            },
            // 用 transform 的方式对圆点进行定位。position: [x, y] 表示将圆点平移到 [x, y] 位置。
            // 这里使用了 convertToPixel 这个 API 来得到每个圆点的位置,下面介绍。
            position: self.myChart2.convertToPixel('grid', dataItem),
            // 这个属性让圆点不可见(但是不影响他响应鼠标事件)。
            invisible: true,
            // 这个属性让圆点可以被拖拽。
            draggable: true,
            // 把 z 值设得比较大,表示这个圆点在最上方,能覆盖住已有的折线图的圆点。
            z: 201,
            // 此圆点的拖拽的响应事件,在拖拽过程中会不断被触发。下面介绍详情。
            // 这里使用了 echarts.util.curry 这个帮助方法,意思是生成一个与 onPointDragging
            // 功能一样的新的函数,只不过第一个参数永远为此时传入的 dataIndex 的值。
            ondrag: echarts.util.curry(fun, dataIndex)
          }
        })
      })
        // 监听图表缩放事件,每一次缩放都需要对调整数据图表拖拽功能进行更新,同时对参考数据图表的坐标轴范围进行更新
      this.myChart2.on('dataZoom', (params) => {
        self.myChart2.setOption({
          // 声明一个 graphic component,里面有若干个 type 为 'circle' 的 graphic elements。
          // 这里使用了 echarts.util.map 这个帮助方法,其行为和 Array.prototype.map 一样,但是兼容 es5 以下的环境。
          // 用 map 方法遍历 data 的每项,为每项生成一个圆点。
          // graphic: echarts.util.map(this.selectData, (dataItem, dataIndex) => {
          graphic: echarts.util.map(oneData, (dataItem, dataIndex) => {
            return {
              position: self.myChart2.convertToPixel('grid', dataItem)
            }
          })
        })

        this.myChart1.setOption({
          yAxis: {
            show: false,
            name: `yAxis`,
            type: 'value',
            axisLine: {
              onZero: false
            },
            max: this.myChart2.getModel().getComponent('yAxis').axis.scale._extent[1],
            min: this.myChart2.getModel().getComponent('yAxis').axis.scale._extent[0]
          },
          xAxis: {
            show: false,
            name: `xAxis`,
            type: 'value',
            scale: true,
            max: this.myChart2.getModel().getComponent('xAxis').axis.scale._extent[1],
            min: this.myChart2.getModel().getComponent('xAxis').axis.scale._extent[0]
          }
        })
      })
    },
    drawLineOS () {
      // 基于准备好的dom,初始化echarts实例
      this.myChart1 = this.$echarts.init(document.getElementById('chart1'))
      const arrdata = []
      let obj = allData
      obj.map((item, index) => {
        let data = []
        item.map((e, index) => {
          data.push(e)
        })
        arrdata.push({
          silent: false,
          clip: true,
          type: 'line',
          name: `${item.name}`,
          animation: false,
          showSymbol: false,
          sampling: 'lttb',
          itemStyle: {
            normal: {
              symbol: 'none',
              lineStyle: {
                width: 2 // 设置线条粗细
              }
            }
          },
          data: data
        })
      })
      const option = {
        grid: {
          left: '5%', // 距离div左边的距离
          right: '20%', // 距离div右边的距离
          bottom: '10%', // 距离下面
          top: '10%'
        },
        yAxis: {
          show: false,
          name: `yAxis`,
          type: 'value'
        },
        xAxis: {
          show: false,
          name: `xAxis`,
          type: 'value',
          scale: true
        },
        series: arrdata
      }
      // 绘制图表
      this.myChart1.setOption(option)
    },
    // 无闪现下载图片
    download (srcData) {
      const aElement = document.createElement('a')
      aElement.style.display = 'none'
      aElement.download = 'echarts.png'
      aElement.href = srcData
      aElement.click()
      aElement.remove()
    }
  }
}
</script>

echarts series里数据 echarts大量数据_echarts series里数据

以上示例的数据量为20万1千。这个数据量不算大,后续有兴趣的可以自己调整下数据量,我这个方案是为了实现在200万这个数据量下进行能够实现需求所要的交互效果走的偏路。可以参考,但是不能一条路走到底,毕竟使用场景真的少,而且问题确实不少。

比如,

1.对图表进行缩放后再进行数据点的拖拽。如果放大图表时选中的范围高度较小,当拖拽点超出这个高度时,会发现这个点在图表背消失了,同时之前与这个点相连的左右两个点,会直接相连。但是一旦把图表进行缩放还原,你会发现那个之前消失的点出现了,确确实实那个点的y值是你之前拖拽的到的位置的值。

2.如果单条数据量较大,比如一条1万个点的数据,那么进行拖拽交互时,会出现明显的卡顿。同时进行图表缩放也会有一定程度的卡顿,但是这个是可以接受的,不是特别明显。

3.当数据量较大时会内存崩溃。在我自己的电脑上,差不多300万到500万的数据进行图表渲染时,浏览器有可能会出现内存崩溃的提示。

4.如果需要对参考数据进行一些交互,那么这个是难以实现的。

5.虽然下载图片时能够把两个图表数据重叠输出为图片。但是必须数据输出时必须限制图片是没有背景的,不能设置任何背景颜色。有时无法满足客户下载图片用来做报告用的实际需求。需要通过PS等工具或其它手段实现背景设置。

6.因为图表的缩放完全按照调整数据的图表来,导致参考数据的数据往往不能按照用户想要的显示。比如用户想看参考数据的一些细节,专门放大了这部分,但是有部分数据是看不到的。例子:

echarts series里数据 echarts大量数据_拖拽_02

echarts series里数据 echarts大量数据_echarts series里数据_03

按照用户的想法,应该是要显示所有的参考数据的,但是实际上有部分参考数据被遮住了。

题外话

图表组件切换会卡顿

在vue+echart中,切换渲染不同的图表组件,比如line.vue和bar.vue两个图表组件(每个组件都有单独的echart实例),在切换组件时,开发者很容易忘掉,需要把组件内的echart实例销毁的事情。如果在切换组件(销毁组件)时,不进行echart实例的删除,可能导致内存中echart的实例一直存在,CPU占比会比较高。这种情况,可以在vue的生命周期函数beforeDestroy中使用this.chart.clear()或者this.chart.dispose()进行内存的释放。