【项目-喵内0∞0记账-meowney-07】功能扩展包 DLC-使用统计图表 echarts

大纲

[toc]


统计图表

【数据可视化】Echarts 使用指南

分别介绍

  • JS里使用 echarts
  • Vue里使用 echarts
  • React里使用 echarts

echarts 图表绘制思路

  1. 获取一个有宽高的 DOM 元素
  2. 初始化echarts实例(const myChart = echarts.init(chartDom))
  3. 指定图表的配置项和数据(option={...})
  4. 使用指定的配置项和数据显示图表(myChart.setOption(chartOptions)))

引入 echarts

无打包工具(webpack/parcel)用于快速调试demo

  • 直接在 index.html引入<script src="[path]/echarts.min.js"></script>,使用 bootCDN
  • 使用全局变量window.echarts

src/index.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>echarts-demo-1</title>
</head>

<body>
<script src="https://cdn.bootcdn.net/ajax/libs/echarts/5.1.1/echarts.min.js"></script>
<script>
// 查看控制台是否正确引入
  console.log(echarts)
</script>
</body>
</html>


打包工具搭建/调试测试项目

  • 安装http-server
1
yarn global add http-server
  • 使用http-server打开src/index.html
1
hs src/ -c-1

使用webpack/parcel(vue/cli vite) + TS 用于一般项目

安装(以使用 Parcel 1+ 为例)

1
2
3
4
yarn global add parcel-bundler
# yarn init -y # enter all the time # 无需此步骤 第一次安装包 自动初始化项目
yarn add echarts
yarn add --dev @types/echarts
  • 安装yarn global add parcel-bundler不是yarn global add parcel
    • yarn global add parcel 安装的是 Parcel 2+
  • 安装yarn add --dev @types/echarts是为了在WebStorm中输入代码会有提示
  • 遇坑,不显示属性名提示(灰色表示)
    • 保证安装包安装在项目目录下的node_modules,而不是安装在~/根目录
    • 去掉 设置 > 终端 > “将'node_modules/.bin'从项目根添加到%PATH%” 前面的勾选
    • 重新初始化一遍项目

src/index.html中引入入口文件main.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>echarts-demo-1</title>
</head>

<body>
<script src="main.js"></script>
</body>
</html>

src/main.js

1
console.log('hi')
  • import * as echarts from 'echarts';' 全局引入

或者按需导入模块:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import {
    TitleComponent,
    TooltipComponent,
    GridComponent,
    LegendComponent
} from 'echarts/components';
import {
    BarChart
} from 'echarts/charts';
import {
    CanvasRenderer
} from 'echarts/renderers';

echarts.use(
    [TitleComponent, TooltipComponent, GridComponent, LegendComponent, BarChart, CanvasRenderer]
);

src/main.js

1
2
3
// import echarts from 'echarts' // 旧版本@5.00- 导入语句
import * as echarts from 'echarts'; // echarts@5.1.2
console.log(echarts)

使用parcel运行

1
2
parcel src/index.html --no-cache
# --no-cache 无缓存
  • 无缓存完整编译完成后,查看效果点击Server running at http://localhost:1234

CRM学习法

  • Copy 去官网炒栗子
  • Run 在自己的项目里运行
  • Modify 修改代码,理解作用(半黑箱)

echarts 第一个例子

index.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>echarts-demo-1</title>
</head>

<body>
<main>
  <section>
    <div id="barChart" style="width: 600px; height:400px;"></div>
  </section>
  <br>
</main>
<script src="main.js"></script>
</body>

</html>

  • 在绘图前需要为 ECharts 准备一个具备高宽的 DOM 容器 <div id="barChart" style="width: 600px; height:400px;"></div>

./modules/barChart.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import * as echarts from 'echarts/core';
import {
  TitleComponent,
  TooltipComponent,
  GridComponent,
  LegendComponent
} from 'echarts/components';
import {
  BarChart
} from 'echarts/charts';
import {
  CanvasRenderer
} from 'echarts/renderers';

echarts.use(
  [TitleComponent, TooltipComponent, GridComponent, LegendComponent, BarChart, CanvasRenderer]
);

export default function () {
  // 初始化加载DOM
  const chartDom = document.getElementById('barChart')
  const myChart = echarts.init(chartDom, 'dark')
// 配置选项
  const option = {
    title: {
      text: 'ECharts 柱状图示例'
    },
    tooltip: {},
    legend: {
      data: ['bug数']
    },
    xAxis: {
      data: ['1月', '2月', '3月', '4月', '5月', '6月']
    },
    yAxis: {},
    series: [{
      name: 'bug数',
      type: 'bar',
      data: [5, 20, 36, 10, 10, 20]
    }]
  }
// 使用配置项和数据显示图表
  option && myChart.setOption(option)
}

  • legend.dataseries.name的名称必须相互对应 ,一致时才能显示项目名称

src/main.js

1
2
3
import barChart from './modules/barChart.js'
barChart()


增加一个线形图

src/index.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>echarts-demo-1</title>
</head>

<body>
<main>
  <section>
    <div id="barChart" style="width: 600px; height:400px;"></div>
  </section>
  <br>
  <section>
    <div id="lineChart" style="width: 600px; height:400px;"></div>
  </section>
</main>
<script src="main.js"></script>
</body>

</html>

src/main.js

1
2
3
4
5
6
import barChart from './modules/barChart'
barChart()

import lineChart from './modules/lineChart'
lineChart()

./modules/lineChart.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import * as echarts from 'echarts/core';
import {
  GridComponent
} from 'echarts/components';
import {
  LineChart
} from 'echarts/charts';
import {
  CanvasRenderer
} from 'echarts/renderers';

echarts.use(
  [GridComponent, LineChart, CanvasRenderer]
);

export default function () {
  // 初始化加载DOM
  const chartDom = document.getElementById('lineChart')
  const myChart = echarts.init(chartDom, 'light')
// 配置选项
  const option = {
    title: {
      text: 'ECharts 线形图示例'
    },
    legend: {
      data: ['bug数']
    },
    xAxis: {
      type: 'category',
      data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
    },
    yAxis: {
      type: 'value'
    },
    series: [{
      name: 'bug数',
      data: [150, 230, 224, 218, 135, 147, 260],
      type: 'line'
    }]
  }
// 使用配置项和数据显示图表
  option && myChart.setOption(option)
}


echarts功能

换主题
  • ecahrts.init支持传第二个参数echarts.init(xxx, 'dark')
    • 主题默认default
    • 暗系主题dark
    • 亮系主题light
WebStorm/VSCode技巧
  • 安装@types/echarts加强代码提示
    • 遇坑,不显示属性名提示(灰色表示)
      • 保证安装包安装在项目目录下的node_modules,而不是安装在~/根目录
      • 去掉 设置 > 终端 > “将'node_modules/.bin'从项目根添加到%PATH%” 前面的勾选
      • 重新初始化一遍项目
  • WebStorm查看历史版本local history > show history,比较对应变更
细节配置

以重构lineCharts.js为例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// import ...;

export default function () {
  // 初始化加载DOM
  const chartDom = document.getElementById('barChart')
  if (!chartDom) {return}
  const myChart = echarts.init(chartDom, 'dark')

// 使用配置项和数据显示图表
  myChart.setOption({
    title: {
      text: 'ECharts 柱状图示例'
    },
    tooltip: {},
    legend: {
      data: ['bug数']
    },
    xAxis: {
      data: ['1月', '2月', '3月', '4月', '5月', '6月']
    },
    yAxis: {},
    series: [{
      name: 'bug数',
      type: 'bar',
      data: [5, 20, 36, 10, 10, 20]
    }]
  });
}
  • 直接将配置写在myChart.setOption里,由于安装了@types/echarts,写属性时会有代码提示
echarts 如何改外观/数据
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// import ...;

export default function () {
  // 初始化加载DOM
  const chartDom = document.getElementById('lineChart');
  if (!chartDom) {return;}
  const myChart = echarts.init(chartDom, 'light');

// 使用配置项和数据显示图表
  myChart.setOption({
    title: {
      show: true,
      text: '销售数据',
      left: 20
    },
    legend: {
      data: ['金额']
    },
    tooltip: {
      show: true
    },
    xAxis: {
      axisLine: {
      },
      data: ['2021-06-01', '2021-06-02', '2021-06-03', '2021-06-04', '2021-06-05']
    },
    yAxis: {
      type: 'value'
    },
    series: [{
      name: '金额',
      data: [150, 230, 224, 218, 135],
      type: 'line',
      lineStyle: {
        color: "#0074d9"
      },
      itemStyle: {
        borderWidth: 20,
        color: "#ff4136"
      }
    }]
  });
}


echarts 更新数据

目前只能显示静态的数据,需要更新数据,只需更新配置后,再次 setOption 即可

  • option只需更新需要改的部分配置属性
    • 但是 坐标轴 等数据需要包括 原来的旧数据新数据
  • echarts会自动找出差异,并更新图表
  • 更复杂的示例

封装模拟数据storage/chartData.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
let n = 0
let m = 0

export function createKey() {
  n += 1
  return `2021-6-${n}`
}

export function createValue() {
  m += 1
  return m
}

export default {
  dateList: [createKey(), createKey(), createKey(), createKey(), createKey()],
  valueList: [createValue(), createValue(), createValue(), createValue(), createValue()]
}

src/modules/lineCharts.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// import ...;

echarts.use(
  [GridComponent, LineChart, CanvasRenderer]
)

import chartData from '../storage/chartData'

// 初始化加载DOM
const chartDom = document.getElementById('lineChart')

if (!chartDom) {return}
export const myChart = echarts.init(chartDom, 'light')

export default function () {
// 使用配置项和数据显示图表
  myChart.setOption({
    title: {
      show: true,
      text: '销售数据',
      left: 20
    },
    legend: {
      data: ['金额']
    },
    tooltip: {
      show: true
    },
    xAxis: {
      axisLine: {
        lineStyle: {
          color: '#0074d9'
        }
      },
      data: chartData.dateList
    },
    yAxis: {
      type: 'value'
    },
    series: [{
      name: '金额',
      data: chartData.valueList,
      type: 'line',
      lineStyle: {
        color: '#28a745'
      },
      itemStyle: {
        borderWidth: 20,
        color: '#ff4136'
      }
    }]
  })
}

src/utils/loadMoreButton.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import chartData, {createKey, createValue} from '../storage/chartData'

const loadMoreButton = document.getElementById('loadMore')

export default function (myChart) {
  loadMoreButton.addEventListener('click', () => {
    const key = createKey()
    const value = createValue()
    chartData.dateList = [...chartData.dateList, key]
    chartData.valueList = [...chartData.valueList, value]
    
    myChart.setOption({
      xAxis: {
        data: chartData.dateList
      },
      series: [{
        data: chartData.valueList
      }]
    })

  })
}

main.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import barChart from './modules/barChart.js'
barChart()

import lineChart, {myChart} from './modules/lineChart.js'
lineChart()

import loadMoreButton from './modules/loadMoreButton'

loadMoreButton(myChart)

index.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>echarts-demo-1</title>
</head>

<body>
<main>
  <section>
    <div id="lineChart" style="width: 600px; height:400px;"></div>
    <button id="loadMore">加载更多</button>
  </section>
</main>
<script src="main.js"></script>
</body>

</html>

echarts 展示 loading

  • 使用内置的loading动画:showLoading() / hideLoading() 显示/隐藏加载中动画
  • 事件锁控制触发事件的时机,防止频繁触发 let isLoading = false
    • 设定计时器前myChart.showLoading(); isLoading = true
    • 计时器完毕myChart.hideLoading(); isLoading = false

src/utils/loadMoreButton.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import chartData, {createKey, createValue} from '../storage/chartData'

const loadMoreButton = document.getElementById('loadMore')

let isLoading = false
export default function (myChart) {
  loadMoreButton.addEventListener('click', () => {
    if(isLoading) {return}
    const key = createKey()
    const value = createValue()
    chartData.dateList = [...chartData.dateList, key]
    chartData.valueList = [...chartData.valueList, value]

    myChart.showLoading()
    isLoading = true
    setTimeout(() => {
      myChart.setOption({
        xAxis: {
          data: chartData.dateList
        },
        series: [{
          data: chartData.valueList
        }]
      })
      myChart.hideLoading()
      isLoading = false
    }, 1500)

  })
}


echarts 点击事件

让用户可以和图表进行交互

在初始化后的 echarts 实例( const myChart = echarts.init(chartDom, 'light') )上使用 API on 即可

  • 只有图表中允许用户操作(比如点击)的部分才能设置用户交互
  • 获取dataIndexseriesIndex
  • 其他查看文档:ECharts 中的事件和行为

src/utils/clickChart.js

1
2
3
4
5
6
7
8
9
export default function (myChart) {
  myChart.on('click', (e) => {
    console.log("e.name: ", e.name)
    console.log("e.dataIndex: ",e.dataIndex)
    console.log("e.data: ", e.data)
    window.open(`https://www/baidu.com/?time=${e.name}`)
  })
}

src/main.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import barChart from './modules/barChart.js'
barChart()

import lineChart, {myChart} from './modules/lineChart.js'
lineChart()

import loadMoreButton from './modules/loadMoreButton'

loadMoreButton(myChart)

import clickChart from './modules/clickChart.js'
clickChart(myChart)


echarts移动端适配

常规技巧
  • meta:viewport 抄淘宝手机版
  • JS获取屏幕宽度设置在div
    • 设定宽高比
  • 使用echarts提供的媒体查询API

main.m.taobao.com

1
2
3
<meta name="viewport"
    content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="ie=edge">

iPhoneX 的宽度 375px PC端设计稿是 1920*1080

  • 用代码获取屏幕宽度,来适配
1
const width = document.documentElement.clientWidth
echarts提供的媒体查询功能
  • baseOption + media
    • 将共有的选项放入 baseOption: {...}
    • 将独有的选项放入 media: [{query: {...}, option: {...}}, ...]

封装自动获取容器尺寸的方法 ./src/utils/fitScreen.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 当前视口宽度
const myScreenWidth = document.documentElement.clientWidth

export default function (chartDom) {
  if (!chartDom) {return}
  let coefficient = 1
  if(myScreenWidth > 500) {
    coefficient = .45
  }
  chartDom.style.width = `${myScreenWidth * coefficient}px`
  chartDom.style.height = `${myScreenWidth * coefficient * 1.2}px`
}

src/modules/lineChart.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// import ...

echarts.use(
  [GridComponent, LineChart, CanvasRenderer]
)

import chartData from '../storage/chartData'
import fitScreen from './fitScreen.js'

// 初始化加载DOM
export const chartDom = document.getElementById('lineChart')

if (!chartDom) {return}
fitScreen(chartDom)

export const myChart = echarts.init(chartDom, 'light')

export default function () {
// 使用配置项和数据显示图表
  myChart.setOption({
    baseOption: {
      title: {
        show: true,
        text: '数据',
        left: 20
      },
      legend: {
        data: ['金额']
      },
      tooltip: {
        show: true
      },
      xAxis: {
        axisLine: {
          lineStyle: {
            color: '#0074d9'
          }
        },
        data: chartData.dateList
      },
      yAxis: {
        type: 'value'
      },
      series: [{
        name: '金额',
        data: chartData.valueList,
        type: 'line',
        lineStyle: {
          color: '#28a745'
        },
        itemStyle: {
          borderWidth: 5,
          color: '#ff4136'
        }
      }]
    },
    media: [
      {
        query: {
          maxWidth: 500
        },
        option: {
          title: {
            show: true,
            text: '移动端数据',
            left: 20
          },
          series: [{
            itemStyle: {
              borderWidth: 25,
              color: '#ff4136'
            }
          }]
        }
      }
    ]
  })
}


字体

按比例缩放字体

  • 图表中的fontSizelegend的大小等默认都是px单位
    • legend中的itemWidthitemHeightitemGap
    • 柱状图中的barWidth,坐标系中的axisLinewidth
    • 传入vwrem单位是没有用
    • 定位方式传的单位可以是百分比,大小尺寸不能
  • 不只是字体的问题 各种图的比例也是
  • 将实际窗口的大小与设计图窗口大小做比得到要给相对的比率
  • 每个单位数值和这个比率相乘即可
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 当前视口宽度
const myScreenWidth = document.documentElement.clientWidth

// 换算方法
function nowSize(originalFontSizePX, initWidth = 1920) {
  return originalFontSizePX * (myScreenWidth / initWidth)
}

const barChartOption = {
  backgroundColor: 'transparent',
  tooltip: {
    trigger: 'axis',
    axisPointer: {type: 'shadow'}
  },
  legend: {
    data: ['门禁进入', '门禁外出'],
    align: 'left',
    top: nowSize(18),
    right: nowSize(20),
    textStyle: {
      color: '#c1c5cd',
      fontSize: nowSize(13)
    },
    itemWidth: nowSize(10),
    itemHeight: nowSize(10),
    itemGap: nowSize(12)
  },
  grid: {top: '24%', left: '3%', right: '3%', bottom: '3%', containLabel: true},
  xAxis: [{
    type: 'category',
    data: ['1号楼', '2号楼', '3号楼', '4号楼', '5号楼', '6号楼', '7号楼', '8号楼',],
    axisLine: {show: true, lineStyle: {color: '#45647f', width: nowSize(1), type: 'solid'}},
    axisTick: {show: false,},
    axisLabel: {show: true, textStyle: {color: '#a1d8f1', fontSize: nowSize(12)}},
  }],
  yAxis: [{
    type: 'value',
    axisTick: {show: false,},
    axisLine: {show: true, lineStyle: {color: '#45647f', width: nowSize(1), type: 'solid'},},
    splitLine: {show: false},
    axisLabel: {show: true, textStyle: {color: '#a1d8f1', fontSize: nowSize(12)}}
  }],
  series: [{
    name: '门禁进入', type: 'bar', data: [20, 50, 80, 58, 83, 68, 57, 100],
    barWidth: nowSize(8), // 柱子宽度
    // barGap: 1, // 柱子之间间距
    // itemStyle: { color: '#14e3cc' }}, { name: '门禁外出', type: 'bar', data: [50, 70, 60, 61, 75, 87, 60, 62],
    // barWidth: nowSize(8), // barGap: 1, itemStyle: { color: '#f84f55' } }]
  }]
}

参考


Vue里使用 echarts

自己封装组件

准备一个vue-index.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel="stylesheet" href="style/reset.scss">
  <title>echarts-demo-1</title>
</head>

<body>
<main>
  <section id="vueChartDemo">
  </section>
</main>
<script src="vue-main.js"></script>
</body>

</html>


安装演示用的依赖

1
2
yarn add vue@2.6.11
yarn add --dev @types/vue

遇坑

  • vue版本依赖包(vue@2.6.11 ) (vue-template-compiler@2.6.14)不匹配
  • 把版本号改成一样yarn add --dev vue-template-compiler@2.6.11
  • 在 vue 工程中,安装依赖时,需要 vuevue-template-compiler 版本必须保持一致,否则会报错

入口文件vue-main.js

1
2
3
4
5
6
7
import Vue from 'vue'
import VueApp from './vue-app.vue'

new Vue({
  render: h => h(VueApp)
}).$mount(document.getElementById('vueChartDemo'))

入口组件vue-app.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<template>
  <div>
    Hi
  </div>
</template>

<script>
export default {
  name: 'vue-app'
};
</script>

启动服务运行,自动安装所需依赖

1
parcel src/vue-index.html --no-cache

遇坑

  • 注意之前安装的是yarn global add parcel-bundler,而 不是yarn global add parcel
  • 查看版本是否一致:parcel --versionparcel -V,返回的是否是1.12.5,非@2.0.0beta
  • 官方地址:https://www.parces.cn/
  • 否则会报错
    • console: [@vue/compiler-sfc] compileTemplate now requires the id option..`
    • xxx Uncaught TypeError: _vue.withScopeId is not a function

更换Vue 版本:完整版/runtime版(默认) package.json

1
2
3
4
5
...
    "alias": {
        "vue": "./node_modules/vue/dist/vue.common.js"
    }
...

Vue引入外部 js 变量和方法
  • Vue中引入静态JS文件需要在有特殊含义的路径下,否则无效
  • store 数据
  • view 展示页面
  • components 组件
  • utils 工具函数
  • vendor或者libs第三方库
  • 脚本代码不放在assets或者static目录下

Vue中局部使用 echarts

演示组件./view/vue-charts.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<template>
  <div ref="container">
    vue-echarts
  </div>
</template>

<script>
export default {
  name: 'vue-echarts',
  mounted() {
    console.log(this.$refs.container);
  }
}
</script>

  • Vue中,通过另一种方式获取组件的 DOM,代替使用document.getElementById('...')
    • 因为Vue是单页面应用,如果将以上的组件使用两次,一个页面内 id 是不允许相同
    • 否则会出现第一个组件正常显示,第二个组件无法显示
  • 使用Vue$refs对象,只要将组件注册属性ref="xxx"
  • mounted时调用this.$refs.xxx,避免 echarts 的容器还没有生成就进行初始化

引入到vue-app.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<template>
  <div id="vueChartDemo">
    <h2> Vue 中使用 echarts</h2>
    <vue-echarts></vue-echarts>
  </div>
</template>

<script>
import VueEcharts from './components/vue-echarts.vue'
export default {
  name: 'vue-app',
  components: {
    VueEcharts
  }
}
</script>


拆开并封装组件./src/modules/lineChart.js./src/store/options/lineChartOptions.js

重构./src/store/chartData.js./src/utils/loadMoreButton.js

1
2
3
4
5
6
7
8
// lineChartOptions.js
import {dateList, valueList} from '../chartData.js'

export const chartOptions = {
  baseOption: {...},
  media: [...]
}

src/modules/lineChart.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// import ...
import fitScreen from '../utils/fitScreen.js'
import {chartOptions as lineChartOptions} from '../store/options/lineChartOptions.js'

echarts.use(
  [GridComponent, LineChart, CanvasRenderer]
)

// 初始化加载DOM
const chartDom = document.getElementById('lineChart')
if (!chartDom) {return}
fitScreen(chartDom)

const myChart = echarts.init(chartDom, 'light')
const chartOptions
  = lineChartOptions

export {
  chartDom,
  myChart,
  chartOptions
}

chartData.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
let n = 0
let m = 0

function createKey() {
  n += 1
  return `2021-6-${n}`
}

function createValue() {
  m += 1
  return m
}

let dateList = [createKey(), createKey(), createKey(), createKey(), createKey()]
let valueList = [createValue(), createValue(), createValue(), createValue(), createValue()]

export {
  m, n,
  createKey, createValue,
  dateList, valueList
}

src/utils/loadMoreButton.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import {dateList, valueList, createKey, createValue} from '../store/chartData.js'

const loadMoreButton = document.getElementById('loadMore')
let isLoading = false
let newDateList = [...dateList]
let newValueList = [...valueList]

const renewData = function() {
  const key = createKey()
  const value = createValue()
  newDateList = [...newDateList, key]
  newValueList = [...newValueList, value]
}

const resetOption = function(myChart) {
  myChart.setOption({
    xAxis: {
      data: newDateList
    },
    series: [{
      data: newValueList
    }]
  })
}

const mockLoadData = function(myChart) {
  setTimeout(() => {
    resetOption(myChart)
    myChart.hideLoading()
    isLoading = false
  }, 1500)
}

const loadMoreData = function (myChart) {
  if (isLoading) {return}
  renewData()
  myChart.showLoading()
  isLoading = true
  mockLoadData(myChart)
}

export default function (buttonElement, myChart) {
  buttonElement.addEventListener('click', () => {
    loadMoreData(myChart)
  })
}

export {
  loadMoreButton,
  newDateList,
  newValueList,
  loadMoreData,
  renewData,
  resetOption
}

vue-charts.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<template>
  <div ref="container">
    vue-echarts
  </div>
</template>

<script>
// 按需引入 echarts 模块
import * as echarts from 'echarts/core'
import {
  TitleComponent, TooltipComponent, LegendComponent, GridComponent, TimelineComponent
} from 'echarts/components'
import {
  LineChart
} from 'echarts/charts'
import {
  CanvasRenderer
} from 'echarts/renderers'
import fitScreen from '../utils/fitScreen.js'
echarts.use(
  [TitleComponent, LegendComponent, TooltipComponent, TimelineComponent, GridComponent, LineChart, CanvasRenderer]
)

export default {
  name: 'vue-echarts',
  props: ['option', 'moreData', 'isLoading'],
  mounted() {
    fitScreen(this.$refs.container)
    this.chart = echarts.init(this.$refs.container, 'light')
    this.chart.setOption(this.option)
  },
  watch: {
    option() {
      this.chart.setOption(this.option)
    },
    moreData() {
      this.$emit('giveMoreData', this.chart)
    },
    isLoading() {
      this.isLoading ? this.chart.showLoading() : this.chart.hideLoading()
    }
  }
}
</script>

  • 将初始化后的chart挂在this上:this.chart = myChart.setOption({...})
    • 可访问this.chart
    • 当外部数据moreData改变,触发自定义事件giveMoreData给父组件vue-app.vue,并传参this.chart
  • 外部数据props传入echars所需的option

vue-app.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<template>
  <div>
    <h2> Vue 中使用 echarts</h2>
    <vue-echarts :option="option"
                 :moreData="n"
                 @giveMoreData="renewOptions($event)">
    </vue-echarts>
    <button @click="loadMore">加载更多</button>
  </div>
</template>

<script>
// import ...;

echarts.use(
  [GridComponent, LineChart, CanvasRenderer]
)

import VueEcharts from './view/vue-echarts.vue'
import {chartOptions as lineChartOptions} from './store/options/lineChartOptions.js'
import {resetOption, renewData} from './utils/loadMoreButton.js'

export default {
  name: 'vue-app',
  components: {
    VueEcharts
  },
  data() {
    return {
      n: 0,
      isLoading: false,
      option: lineChartOptions,
    }
  },
  methods: {
    loadMore() {
      this.n++
    },
    renewOptions(container) {
      if (this.isLoading) {return}
      renewData()
      container.showLoading()
      this.isLoading = true
      setTimeout(() => {
        resetOption(container)
        container.hideLoading()
        this.isLoading = false
      }, 1500)
    }
  }
}
</script>

Vue中全局使用 echarts

在项目文件的入口js文 main.js 中引入 echarts,并使用该插件,这样就可以对其进行全局使用

1
2
3
4
import echarts from 'echarts'

Vue.use(echarts)
Vue.prototype.$echarts = echarts
1
2
// 基于准备好的dom,初始化ECharts实例
const myChart = this.$echarts.init(document.getElementById('main'));
封装一个动态渲染数据的Echarts折线图组件
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<template>
  <div class="content" ref="content"></div>
</template>

<script lang="ts">
// 自己封装echarts组件 可替换为使用vue-charts的官方封装
import {Component, Prop, Watch, Vue} from 'vue-property-decorator';
import echart, {EChartOption, ECharts} from 'echarts';

@Component
export default class Chart extends Vue {
  name = 'Chart';
  chart?: ECharts;

  @Prop() options?: EChartOption;

  @Watch('options', {immediate: true, deep: true})
  onOptionChange(newOption: EChartOption) {
    if (this.chart) {
      this.chart.setOption(newOption);
    }
  }

  initChart() {
    // 挂载时 必须先初始化 echart
    // 挂载先于@Watch('options') 监听变化,即使用了 immediate 参数
    if (this.options === undefined) {
      return console.error('options is empty');
    }
    this.chart = echart.init(this.$refs.content as HTMLDivElement);
    this.chart.setOption(this.options);
  }

  mounted() {
    this.initChart();
  }

}
</script>


引入其第三方封装好的库

参考

代码仓库


React里使用 echarts

自己封装组件
引入其他封装

参考


总结

  • echarts x Vue x TypeScript
    • 监听图表option的变化,执行setOption({...})
  • echarts x React x TypeScript

什么是数据可视化

  • echarts做页面是最初级的可视化
  • 深入d3.js
  • 大屏项目

参考


在 Vue 记账项目中加入 ECharts

  • 先完善 Money.vueFormItem.vue 组件,添加日期选择组件
  • 使用vue-charts的官方封装
  • 自己封装echarts组件

参考

  • 设计稿:Figma
  • 初始代码:https://github.com/FrankFang/morney-test-9
  • 最终代码:https://github.com/FrankFang/morney-test-vue-echarts

添加日期选择组件

重构FormItem.vue,添加外部数据type,控制<input/>的类型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
<template>
  <div class="form-wrapper">
    <label class="form-item">
      <span class="name">{{ fieldName }}</span>
      <template v-if="type === 'date'">
        <input :type="type || 'text'"
               :placeholder="placeholder"
               :value="dateFormat(inputValue)"
               @input="oninputValueChanged($event.target.value)"/>
      </template>
      <template v-else>
        <input :type="type || 'text'"
               :placeholder="placeholder"
               :value="inputValue"
               @input="oninputValueChanged($event.target.value)"/>
      </template>
    </label>
  </div>
</template>
<script lang="ts">
import Vue from 'vue';
import {Component, Prop} from 'vue-property-decorator';
import dayjs from 'dayjs';

@Component
export default class FormItem extends Vue {
  @Prop({default: ''}) inputValue!: string;
  @Prop({required: true}) fieldName!: string;
  @Prop({default: ''}) placeholder?: string;
  @Prop() type?: string;

  oninputValueChanged(newValue: string) {
    this.$emit('update:inputValue', newValue);
    if(this.type === 'date') {
      this.$store.commit('updateDateStore', newValue)
    } else if(this.type === 'text') {
      this.$store.commit('updateTipsText', newValue)
    }
  }

  dateFormat(isoString: string) {
    return dayjs(isoString).format('YYYY-MM-DD')
  }
}
</script>

<style lang="scss" scoped>
.form-wrapper {
  background: transparent;

  .form-item {
    align-items: center;
    display: flex;
    font-size: 14px;
    padding-left: 16px;

    .name {
      padding-right: 16px;
    }

    input {
      height: 40px;
      flex-grow: 1;
      background: transparent;
      padding-right: 16px;
    }
  }
}
</style>

Money.vue

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
<template>
  <Layout class-prefix="layout" class="layout-content">
    <HeaderBar :header-title="'记账'"
               :hasIcon="false">
    </HeaderBar>
    <Tags @update:selectedTags="pickTags"
          :is-deselect-tags="emptyTags"
          class="tags"/>
    <FormItem class="form-item"
              field-name="备注"
              placeholder="在这里输入备注"
              type="text"
              :inputValue.sync="record.tips"/>
    <div class="creatAt">
      <FormItem class="form-item"
                field-name="日期"
                placeholder="在这里选择日期"
                type="date"
                :inputValue.sync="record.createdAt"/>
    </div>
    <div class="datePicker">
      <date-getter></date-getter>
    </div>
    <Tabs :data-source="recordTypeList"
          :type.sync="record.type"
          class="fuckAnt-tabs"/>
    <Numpad :amount.sync="record.amount"
            @submit="submit"
            @update:deselectTags="deselectTags"
            @checkZero="alertInform('case3')"
            :is-reset="checkoutResult"/>
  </Layout>
</template>

<script lang="ts">
// 框架组件
import Vue from 'vue';
import Numpad from '@/components/Money/Numpad.vue';
import {Component, /*Vue*/} from 'vue-property-decorator';
import {NavigationGuardNext, Route} from 'vue-router';
// 页面模块组件
import HeaderBar from '@/components/HeaderBar.vue';
import Tags from '@/components/Money/Tags.vue';
import FormItem from '@/components/Money/FormItem.vue';
import Tabs from '@/components/Tabs.vue';
import DateGetter from '@/components/Money/DateGetter.vue';
// 数据
import recordTypeList from '@/constants/recordTypeList.ts';

@Component({
  components: {HeaderBar, Tabs, FormItem, Tags, Numpad, DateGetter},
  // 路由守卫放在@Component选项中
  beforeRouteEnter(to: Route, from: Route, next: NavigationGuardNext): void {
    console.log('beforeRouteEnter');
    next(vm => {
      // 通过 `vm` 访问组件实例 代替this
      vm.$store.commit('loadMoneySessionStore');
    });
    next()
  },
  beforeRouteLeave(to: Route, from: Route, next: NavigationGuardNext): void {
    console.log('beforeRouteLeave');
    this.$store.commit('saveMoneySessionStore');
    next();
  }
})

export default class Money extends Vue {
  // data
  record: RecordItem = {
    tags: [],
    tips: '',
    type: '-',
    amount: 0,
    createdAt: new Date().toISOString(),
  };
  recordTypeList = recordTypeList;
  checkoutResult = false;
  emptyTags = false;

  // computed
  get recordList() {
    return this.$store.state.recordStore.recordList as RecordItem[];
  }

  // methods
  pickTags(selectedTags: Tag[]) {
    this.emptyTags = false;
    this.record.tags = selectedTags;
    // 页面暂存 selectedTags
    this.$store.commit('updateTagsList', selectedTags);
  }

  deselectTags(deselect: boolean) {...}
  checkoutRecord() {...}
  alertInform(caseName: 'case1' | 'case2' | 'case3') {...}
  saveRecord() {...}
  reset() {...}
  submit() {...}

}

</script>

<style lang="scss" scoped>
...
</style>

  • 查看<input type="datetime-local"> MDN兼容性
  • 检查{{record.createdAt}}显示为202X-XX-XXTXX:XX:XX.***Z
    • 去掉毫秒部分.***Z之后的值传给inputvalue就可以正常显示日期了
    • 格式化dayjs(isoString).format('YYYY-MM-DDTHH:mm:ss')
    • 精确日期到分钟.format('YYYY-MM-DDTHH:mm')
  • 只要日期部分,改用<input type="date"> MDN

使用vue-charts的官方封装

安装echarts@4.8.0vue-echarts@4.1.0@types/echarts

1
yarn add echarts@4.8.0 vue-echarts@4.1.0 @types/echarts

引入

1
2
3
4
5
6
7
import Vue from 'vue'
import ECharts from 'vue-echarts'

// 引入 ECharts 各模块来减小打包体积
import 'echarts/lib/chart/bar'
import 'echarts/lib/component/tooltip'
...
  • 由于vue-echarts无TS类型声明,改用const ECharts = require('vue-echarts').default;
1
2
3
4
5
// echarts
const ECharts = require('vue-echarts').default;
import 'echarts/lib/component/tooltip';
import 'echarts/lib/chart/line';
//...

配置Vue CLI 3+vue.config.js 中的 transpileDependencies 增加 vue-echartsresize-detector

1
2
3
4
5
6
7
// vue.config.js
module.exports = {
  transpileDependencies: [
    'vue-echarts',
    'resize-detector'
  ]
}

抄一个示例

./src/view/Statistics.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// ...
<script lang="ts">
// basic
import Vue from 'vue';
import {Component} from 'vue-property-decorator';
// vendor
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
// utils
import clone from '@/lib/clone.ts';
import clearJetLag from '@/lib/clearJetLag';
// components
import Tabs from '@/components/Tabs.vue';
import HeaderBar from '@/components/HeaderBar.vue';
import recordTypeList from '@/constants/recordTypeList.ts';
// echarts
const ECharts = require('vue-echarts').default;
import 'echarts/lib/chart/line';
import 'echarts/lib/component/tooltip';

@Component({
  components: {HeaderBar, Tabs, ECharts},
})
export default class Statistics extends Vue {
  type = '-';
  recordTypeList = recordTypeList;

  beforeCreate() {
    this.$store.commit('fetchRecords');
  }

  get showEChart() {
    return {
      xAxis: {
        type: 'category',
        data: [
          'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun',
          'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun',
          'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun',
          'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun',
          'Mon', 'Tue'
        ]
      },
      yAxis: {
        type: 'value'
      },
      tooltip: {
        show: true,
        triggeron: 'click'
      },
      series: [{
        data: [
          820, 932, 901, 934, 1290, 1330, 1320,
          820, 932, 901, 934, 1290, 1330, 1320,
          820, 932, 901, 934, 1290, 1330, 1320,
          820, 932, 901, 934, 1290, 1330, 1320,
          820, 932
        ],
        type: 'line',
        showBackground: true
      }],
      animationDuration: 888
    };

  }

  get recordList() {...}
  get groupedList() {...}
  tagToString(tags: Tag[]) {...}
  showDay(someday: string) {...}

}
</script>

使用封装好的组件<ECharts :options="showEChart"/>

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<template>
  <Layout class="statistics">
    <HeaderBar :header-title="'统计'" router-path="/money"></HeaderBar>
    <Tabs class-prefix="type" :data-source="recordTypeList" :type.sync="type"/>
    <ECharts :options="showEChart"/>
    <ol v-if="groupedList.length > 0">
      <li v-for="(group, index) in groupedList" :key="index">
        <h3 class="title">{{ showDay(group.title) }} <span>共计 {{ group.total }}</span></h3>
        <ol>
          <li class="record" v-for="item in group.items" :key="item.id">
            <span class="recordTag">{{ tagToString(item.tags) }}</span>
            <div class="notes">
              <span class="tips">备注:</span><span class="text">{{ item.tips }}</span>
            </div>
            <span>¥ {{ item.amount }}</span>
          </li>
        </ol>
      </li>
    </ol>
    <div v-else class="noResult">
      目前没有相关记录
    </div>
  </Layout>
</template>

调整样式

在外部包裹一层div,加上echarts-wrapper样式

1
2
3
4
5
<template>
    <div class="echarts-wrapper" ref="vChartWrapper">
      <ECharts class="echarts" :options="showEChart"/>
    </div>
</template>
1
2
3
4
5
.echarts {
    margin: 0 auto;
    max-width: 80%;
      height: 400px;
  }

拉长图表,可滚动显示

  • 父元素加上overflow: auto; 可滚动
  • 计算一屏显示7个数据 30 / 7 -> 4---3
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// ...
    .echarts {
      margin: 0 auto;
      width: 430%;
      height: 180px;

      &-wrapper {
        overflow: auto;
      }
    }
// ...

隐藏滚动条

  • 使用伪类选择器 ::-webkit-scrollbar {display: none;}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@import "~@/assets/style/global.scss";

%sticky {
  position: sticky;
  z-index: 1;
}

.statistics {
  max-width: 100%;

  ::-webkit-scrollbar {
    display: none; /* Chrome Safari */
  }

  scroll-behavior: smooth;
  ...
  }
}

实现滚动到最新数据


参考

https://www.codeleading.com/article/80172487941/ | vue设置scrollLeft 一直为0的原因 - 代码先锋网 https://www.geek-share.com/detail/2789848542.html | vue设置scrollLeft 一直为0的原因 - 极客分享 https://www.jc2182.com/javascript/javascript-element-scrollleft-attr.html | JavaScript Element scrollLeft 属性 - 蝴蝶教程 https://blog.csdn.net/a393007511/article/details/103026943 | vue设置scrollLeft 一直为0的原因_a393007511的博客-CSDN博客 https://www.codenong.com/js3a2317be4a44/ | vue绑定scroll-top、scroll-left属性 | 码农家园 https://codepen.io/Jayesh_v/pen/oMgwRO | VueJs Scroll Horizontally https://www.programmersought.com/article/1465747270/ | The VUE acquires the scroll bar position of the element or component according to the ref. - Programmer Sought https://js.devexpress.com/Documentation/ApiReference/UI_Components/dxScrollView/Methods/ | Documentation 21.1: DevExtreme - JavaScript Scroll View Methods https://stackoverflow.com/questions/51222035/horizontal-scroll-using-buttons-in-vuejs | javascript - Horizontal Scroll Using Buttons in VueJS - Stack Overflow https://forum.vuejs.org/t/help-with-scrollleft-interactions/3209 | Help with scrollLeft interactions - Get Help - Vue Forum https://www.cnblogs.com/liAnran/p/12069953.html | vue 横向滚动样式&&$ref.scrollLeft初始化数据滚动位置 - liAnran - 博客园 https://segmentfault.com/q/1010000016689396 | vue关于设置scrollLeft值问题 - SegmentFault 思否 https://blog.csdn.net/qq_39224266/article/details/107958068 | vue 中横向滚动设置scrollLeft,并且加上过渡动画_小白阿里里的博客-CSDN博客 https://my.oschina.net/u/4405061/blog/3326999 | vue 横向滚动样式&&$ref.scrollLeft初始化数据滚动位置 - osc_ozlday8e的个人空间 - OSCHINA - 中文开源技术交流社区 https://github.com/metawin-m/vue-scroll-sync/blob/master/src/ScrollSync.vue | vue-scroll-sync/ScrollSync.vue at master · metawin-m/vue-scroll-sync https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLElement/offsetLeft | HTMLElement.offsetLeft - Web API 接口参考 | MDN https://blog.csdn.net/qq_22222499/article/details/52863951 | 完美理解csss中offsetLeft,offsetWidth,scrollLeft区别。_csdn问鼎-CSDN博客 https://blog.csdn.net/qq_43353619/article/details/86703253 | JS中的offsetLeft和clientLeft和scrollLeft的一些区别_小傲哥哥的博客-CSDN博客 https://stackoverflow.com/questions/18498652/scrollleft-to-end-of-main-div-not-offset-left | javascript - scrollLeft: to END OF MAIN DIV not offset().left - Stack Overflow https://github.com/pramper/blog/issues/10 | 一张图彻底掌握scrollTop, offsetTop, scrollLeft, offsetLeft…… · Issue #10 · pramper/Blog https://juejin.cn/post/6844903443383975949 | 一张图彻底掌握 scrollTop, offsetTop, scrollLeft, offsetLeft…… https://developer.mozilla.org/zh-CN/docs/Web/API/Element/scrollLeft | Element.scrollLeft - Web API 接口参考 | MDN https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame | window.requestAnimationFrame - Web API 接口参考 | MDN https://stackoverflow.com/questions/41205139/javascript-element-scrollleft-not-working | scroll - javascript element.scrollLeft not working - Stack Overflow https://developer.mozilla.org/zh-CN/docs/Web/API/Window/scroll | Window.scroll() - Web API 接口参考 | MDN https://developer.mozilla.org/zh-CN/docs/Web/API/ScrollToOptions | ScrollToOptions - Web API 接口参考 | MDN https://developer.mozilla.org/en-US/docs/Web/API/ScrollToOptions#examples | ScrollToOptions - Web APIs | MDN https://developer.mozilla.org/zh-CN/docs/Web/API/Element/scrollBy | Element.scrollBy() - Web API 接口参考 | MDN


自己封装echarts

./src/components/Chart.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<template>
  <div>
    Chart
  </div>
</template>

<script lang="ts">
import Vue from 'vue';
import {Component,} from 'vue-property-decorator';

@Component
export default class Chart extends Vue {
  name = 'Chart';
}
</script>

<style lang="scss" scoped>

</style>

  • import Chart from '@/components/Statistics/Chart.vue'中会报错的情况
    • <script lang="ts">中不写TS
    • '@/components/Statistics/Chart.vue'路径中没写.vue

构造数据数据排序(计数排序的变形) 前端用到的算法

  • 遍历对象时,遍历的顺序是否固定
    • 遍历对象的key,遍历的顺序是否固定
    • hashTable中的顺序是用户输入得到的,输入顺序不一定符合预期

JS 中对象的 key 是有顺序的

  • JS 中对象的字段遍历顺序是没有保证的,例如 Object.keys函数产生的数组顺序没有保证
  • 实际上在 ES2015 之后标准规定了 key 的顺序(准确的说是规定了 [[OwnPropertyKeys]]()这个内部方法返回的 key 的顺序)

结论:keys 数组分为三个部分:

  • 可以作为数组索引的 key 按照升序排列,例如 1、2、3
  • 是字符串不是 symbol 的 key,按照创建顺序排列。
  • symbol 类型的 key 也按照创建顺序排列。
  • 参考链接 ECMAScript 9.1.11
1
2
3
4
5
6
7
8
9
Reflect.ownKeys({
  [Symbol('88888')]: '',
  18: '',
  star: '☆★',
  4: '',
  hahaha: '',
})

// ['4', '18', 'star', 'hahaha', Symbol(88888)]

原始数据必须转换为一个数组,才能排序

  • 按时间排序,近的排在前面
  • 显示的顺序为 今天 昨天 (本年内)具体日期 (去年以及之前)具体日期
1
2
3
4
5
6
7
/*
* [
*   {title, items},
*   {title, items},
*   {title, items},
* ]
* */

首先拷贝原始数据 clone.ts

1
2
3
4
5
6
function clone<T>(data: T): T {
  return JSON.parse(JSON.stringify(data));
}

export default clone;

  • TS 声明类型:
    • type HashTableValue = { title: string; items: RecordItem[] };
  • 查询到对应的title,将recordList排序,依次推入数组
  • recordListRecordItem的数组,进行排序.sort((a, b) => {})
    • const n = recordList.sort((a, b) => {a.createdAt});
    • a.createdAt的值为字符串,按ASCII顺序比较大小,不是预期的顺序,'a' - 'b' // NaN 不能用字符串的减法
    • 使用.sort()必须变为数字类型,用.valueOf()
      • dayjs(a.createdAt).valueOf()
  • const newList = recordList.sort((a: RecordItem, b: RecordItem) => ( dayjs(a.createdAt).valueOf() - dayjs(b.createdAt).valueOf() ));
  • 需要逆向从近期到远期的( dayjs(b.createdAt).valueOf() - dayjs(a.createdAt).valueOf() )
  • 注意.sort()改变原数组自身,保留原数组,使用 深克隆 clone.ts
    • 导入之前的工具函数import clone from '@/lib/clone.ts';clone()
    • function clone(data: any) { return JSON.parse(JSON.stringify(data)) as RecordItem; }
    • 由于接受的参数类型是any,而JSON.parse()返回值类型也是any
    • 使用泛型function clone<T>(data: T): T {...} 统一入参和返回值的类型
      • 在尖括号中声明类型
    • 之后使用.sort() TS 会自动推断类型

重构Statistic.vue时间排序逻辑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
  //...
  get result() {
    const {recordList} = this;
    type HashTableValue = { title: string; items: RecordItem[] };
    const hashTable: HashTableValue[] = [];
    const newList = clone(recordList).sort((a: RecordItem, b: RecordItem) => (
        dayjs(b.createdAt).valueOf() - dayjs(a.createdAt).valueOf()
    ));
    console.log(newList.map(i => i.createdAt));
    return [];
  }
  //...
  • 先将局部数据recordList排序,再push到数据中

数据排序后分组

  • 判断数组长度if(recordList.length === 0) {return []};,确保存在可操作数据
  • 取出新的newList的第一个数据
    • const x = [{title: dayjs(recordList[0].createdAt).format('YYYY-MM-DD'), items: [recordList[0]]}];
  • 从第二个数据的.createdAt和第一个数据的title和开始循环比较
    • 新的数据和分组的title是否一致
      • 一致放入当前组的items
      • 不一致,作为新的一组的title,放入新分组的items
1
2
3
4
5
6
7
8
/*
* [
*   {title: '11', items: [{11...}, {11...}]},
*   {title: '12', items: [{12...}, {12...}, {12...}]},
*   {title: '13', items: [{13...}, {13...}]},
*   {title: <string>, items: [{13...}, {13...}]},
* ]
* */

统一显示时间戳比较 - 标记问题ok

  • const localDay = dayjs(current.createdAt.split('T')[0]);
  • 由于有时区的概念 dayjs(current.createdAt)算上时间部分的日期会延后一天
  • 封装倒时差函数clearJetLag(new Date(), '-')

src/lib/clearJetLag.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function clearJetLag(isoDate: Date = new Date(), offsetType: '-' | '+' | '' = '') {
  let localClock;
  if (offsetType === '') {
    localClock = new Date(isoDate.getTime()).toISOString();
  }else if(offsetType === '-'){
    localClock = new Date(isoDate.getTime() - (isoDate.getTimezoneOffset() * 60000)).toISOString();
  }else{
    localClock = new Date(isoDate.getTime() + (isoDate.getTimezoneOffset() * 60000)).toISOString();
  }
  return localClock
}

export default clearJetLag;

重构src/store/modules/recordStore.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import clone from '@/lib/clone';
import store from '@/store';
import clearJetLag from '@/lib/clearJetLag.ts';

const recordStore = {
  namespace: true,
  state() {
    return {
      recordList: [],
      localTimeStamp: ''
    };
  },
  mutations: {
    getLocalTimeStamp(state: recordState) {
      state.localTimeStamp = clearJetLag(new Date(), '-');
    },
    fetchRecords(state: recordState) {/*...*/},
    saveRecords(state: recordState) {/*...*/},
    createRecord(state: recordState, record: RecordItem) {
      const clonedRecord = clone(record);
      store.commit('getLocalTimeStamp');
      clonedRecord.createdAt = state.localTimeStamp;
      state.recordList.push(clonedRecord);
      store.commit('saveRecords');
    },
  }
};

export default recordStore;

重构命名

  • get groupedList() { ... return rusult;}

更改template 循环渲染的:key

  • 数据由对象变为数组 方便排序
  • li v-for="(group, index) in groupedList" :key="index">

类似桶排序

echartsaxisLabel

1
2
3
4
5
axisLabel: {
          formatter: (value: string) => {
            return value.substr(5);
          }
        }

optioins 变化时,更新 chart

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
  get chartDataKeyValueList() {
    let array = []; // 排序桶
    // 原数据按录入的顺序排列,数据先要排序,按时间排序
    // 显示最多 31 天的数据, 获取 最近 31天的日期
    // key为日期, value为当天总计金额
    // lastDay - i * 24 * 3600 * 1000 得到每天的日期
    // 等价于 day(lastDay).subtract(i, 'day') 每次减去一天
    for (let i = 0; i <= 30; i++) {
      const everyLastDateString = dayjs(new Date())
        .subtract(i, 'day') // 每次减去一天
        .format('YYYY-MM-DD'); // 格式化
      // 找到 在 recordList 中, 每项日期对应的记录
      const foundRecord = _.find(this.groupedList, {
        title: everyLastDateString
      });
      // foundRecord?.amount 相当于 foundRecord ? foundRecord.amount: 0
      array.push(
        {
          key: everyLastDateString,
          value: foundRecord?.total || 0
        }
      );
    }
    // 图表的数据
    array = array.reverse();
    this.groupedList
      .forEach(record => _.pick(record, ['createdAt', 'amount']));
    return array;
  }

  get myChartOption() {
    const keys = this.chartDataKeyValueList.map(item => item.key);
    const values = this.chartDataKeyValueList.map(item => item.value);

    return {
      xAxis: {
        type: 'category',
        data: keys,
        axisTick: {
          alignWithLabel: true
        },
        axisLine: {
          lineStyle: {
            color: '#666'
          }
        },
        axisLabel: {
          formatter: (value: string) => {
            return value.substr(5);
          }
        }
      },
      yAxis: {
        type: 'value',
        show: false
      },
      tooltip: {
        show: true,
        triggeron: this.triggerMethod,
        confine: true,
        position: 'top',
        formatter: '{c}'
      },
      series: [{
        symbol: 'circle',
        data: values,
        type: 'line',
        itemStyle: {
          borderWidth: 3,
          borderColor: '#aaa',
          color: '#666'
        },
        symbolSize: 12,
        showBackground: true
      }],
      animationDuration: 888,
      grid: {
        left: 3,
        right: 0,
        top: 8
      }
    };
  }
1
2
3
4
5
<template>
  <ECharts class="echarts"
            :options="myChartOption"
            ref="vChartContent"/>
</template>

完整代码

./src/view/Statistics.vue

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
<template>
  <Layout class="statistics">
    <HeaderBar :header-title="'统计'"
               router-path="/money"/>
    <Tabs class-prefix="type"
          :data-source="recordTypeList" 
          :type.sync="type"/>
    <div class="echarts-wrapper"
         ref="vChartWrapper">
      <ECharts class="echarts" 
               :options="myChartOption"
               ref="vChartContent"/>
    </div>
    <ol v-if="groupedList.length > 0">
      <li v-for="(group, index) in groupedList" 
          :key="index">
        <h3 class="title">
          {{ showDay(group.title) }} 
          <span>
            共计: ¥{{ group.total }}
          </span>
        </h3>
        <ol>
          <li class="record"
              v-for="{amount, id, tags, tips} in group.items"
              :key="id">
            <span class="recordTag">{{ tagToString(tags) }}</span>
            <div class="notes">
              <span class="tips">备注</span>
              <span class="text">{{ tips }}</span>
            </div>
            <span> {{ amount }}</span>
          </li>
        </ol>
      </li>
    </ol>
    <div v-else class="noResult">
      目前没有相关记录
    </div>
  </Layout>
</template>

<script lang="ts">
// basic
import {Component, Vue} from 'vue-property-decorator';
// vendor
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import _ from 'lodash';

dayjs.locale('zh-cn');

// utils
import clone from '@/lib/clone.ts';
import clearJetLag from '@/lib/clearJetLag';
// components
import Tabs from '@/components/Tabs.vue';
import HeaderBar from '@/components/HeaderBar.vue';
import recordTypeList from '@/constants/recordTypeList.ts';

// echarts
const ECharts = require('vue-echarts').default;
import 'echarts/lib/chart/line';
import 'echarts/lib/component/tooltip';
import getClientWidth from '@/lib/getClientWidth.ts';

@Component({
  components: {HeaderBar, Tabs, ECharts}
})
export default class Statistics extends Vue {
  type = '-';
  recordTypeList = recordTypeList;
  client = getClientWidth();

  get triggerMethod(): 'click' | 'mousemove' {
    if (this.client === 'mobile') {
      return 'click';
    } else if (this.client === 'PC') {
      return 'mousemove';
    } else {
      return 'click';
    }
  }

  get chartDataKeyValueList() {
    let array = []; // 排序桶
    // 原数据按录入的顺序排列,数据先要排序,按时间排序
    // 显示最多 31 天的数据, 获取 最近 31天的日期
    // key为日期, value为当天总计金额
    // lastDay - i * 24 * 3600 * 1000 得到每天的日期
    // 等价于 day(lastDay).subtract(i, 'day') 每次减去一天
    for (let i = 0; i <= 30; i++) {
      const everyLastDateString = dayjs(new Date())
        .subtract(i, 'day') // 每次减去一天
        .format('YYYY-MM-DD'); // 格式化
      // 找到 在 recordList 中, 每项日期对应的记录
      const foundRecord = _.find(this.groupedList, {
        title: everyLastDateString
      });
      // foundRecord?.amount 相当于 foundRecord ? foundRecord.amount: 0
      array.push(
        {
          key: everyLastDateString,
          value: foundRecord?.total || 0
        }
      );
    }
    // 图表的数据
    array = array.reverse();
    this.groupedList
      .forEach(record => _.pick(record, ['createdAt', 'amount']));
    return array;
  }

  get myChartOption() {
    const keys = this.chartDataKeyValueList.map(item => item.key);
    const values = this.chartDataKeyValueList.map(item => item.value);
    return {
      xAxis: {
        type: 'category',
        data: keys,
        axisTick: {
          alignWithLabel: true
        },
        axisLine: {
          lineStyle: {
            color: '#666'
          }
        },
        axisLabel: {
          formatter: (value: string) => {
            return value.substr(5);
          }
        }
      },
      yAxis: {
        type: 'value',
        show: false
      },
      tooltip: {
        show: true,
        triggeron: this.triggerMethod,
        confine: true,
        position: 'top',
        formatter: '{c}'
      },
      series: [{
        symbol: 'circle',
        data: values,
        type: 'line',
        itemStyle: {
          borderWidth: 3,
          borderColor: '#aaa',
          color: '#666'
        },
        symbolSize: 12,
        showBackground: true
      }],
      animationDuration: 888,
      grid: {
        left: 3,
        right: 0,
        top: 8
      }
    };
  }

  // 读取 记录列表 // computed
  get recordList() {
    // 包括 支出和收入
    return this.$store.getters.recordList;
  }

  // 计算 分组列表 分为 支出/收入
  get groupedList() {
    const {recordList} = this;
    // newList: { tags: Tag[]; tips: string; type: string; amount: number; createdAt: string; }[]
    const newList = clone(recordList)
      .filter((r: RecordItem) => r.type === this.type) // 按 支出/收入 type: '-' || '+'
      .sort((a: RecordItem, b: RecordItem) => (
        dayjs(b.createdAt).valueOf() - dayjs(a.createdAt).valueOf()
      ));
    // newList ?[] return
    if (newList.length === 0) {return [] as GroupedType[];}
    // 排序后的第一项 newList[0] 处理后 作为初始项
    const result: GroupedType[] = [{
      title: dayjs(newList[0].createdAt.split('T')[0]).format('YYYY-MM-DD'),
      items: [newList[0],]
    }];

    // 判断 newList[i] 从第二项开始的每一项的 title: '20XX-XX-XX'  是否符合当前 分组项
    for (let i = 1; i < newList.length; i++) {
      const current = newList[i]; // 当前项
      const lastGroupItem = result[result.length - 1]; // 分组数据的最后一项
      const localDay = dayjs(current.createdAt.split('T')[0]);

      if (dayjs(lastGroupItem.title).isSame(localDay, 'day')) {
        lastGroupItem.items.push(current);
      } else {
        result.push({
          title: localDay.format('YYYY-MM-DD'),
          items: [current]
        });
      }
    }
    // 为 result.group 添加 计算总额 group.total 属性
    // result.group.items: { tags: Tag[]; tips: string; type: string; amount: number; createdAt: string; }[]
    result.forEach(group => {
      group.total = group.items.reduce((sum, item) => sum + item.amount, 0);
    });
    return result;
  }

  get tempResult() {
    console.log(this.$store.getters.groupedList);
    console.log(this.$store.getters.switchTriggerMethod);
    console.log(this.$store.getters.myChartOption);
    return 'test tempResult';
  }

  // 显示项目 title 标签组合
  tagToString(tags: Tag[]) {
    return tags.length === 0 ? '无' : tags.map(tag => tag.name).join('、');
  }

  // 显示标题为 日期分组
  /**
   * @param {string} someday: from group.title YY-MM-DD
   * @return {string} formattedDate:
   */
  showDay(someday: string) {
    const now = dayjs(new Date().toISOString());
    const thatDay = dayjs(clearJetLag(new Date(someday), '-'));
    let formattedDate;
    if (thatDay.isSame(now, 'day')) {
      formattedDate = '今天';
    } else if (thatDay.isSame(now.subtract(1, 'day'), 'day')) {
      formattedDate = '昨天';
    } else if (thatDay.isSame(now.subtract(2, 'day'), 'day')) {
      formattedDate = '前天';
    } else if (thatDay.isSame(now, 'year')) {
      formattedDate = thatDay.format('M月D日');
    } else {
      formattedDate = thatDay.format('YYYY年M月D日');
    }
    return formattedDate;
  }

  // lifeCircle hooks
  protected beforeCreate(): void {
    this.$store.commit('fetchRecords');
  }

  protected mounted() {
    const echartDiv = (this.$refs.vChartWrapper as HTMLDivElement);
    setTimeout(() => {
      echartDiv.scrollLeft = echartDiv.scrollWidth;
      echartDiv.scrollBy(echartDiv.scrollWidth, 0);
    }, 800);
    console.log(this.tempResult);
  }
}
</script>

<style lang="scss" scoped>
@import "~@/assets/style/global.scss";

%sticky {
  position: sticky;
  z-index: 1;
}

.statistics {
  max-width: 100%;

  ::-webkit-scrollbar {
    display: none; /* Chrome Safari */
  }

  scroll-behavior: smooth;

  &::v-deep {
    .layout-content {
      display: flex;
      justify-content: center;
      align-items: center;
      flex-direction: column;
    }

    .headerBar {
      @extend %sticky;
      top: 0;

      > .left-icon {
        transform: rotate3d(0, 1, 0, 180deg);
      }
    }

    .type-tabs {
      @extend %sticky;
      top: 50px;
    }

    .echarts {
      margin: 0 auto;
      width: 430%;
      height: 220px;
      overflow: auto;

      &-wrapper {
        overflow: auto;
      }
    }

    @media (min-width: 500px) {
      .echarts {
        width: 430%;
        height: 220px;
        overflow: auto;

        &-wrapper {
          overflow: auto;
        }
      }
    }
  }

  %item {
    padding: 8px 16px;
    line-height: 24px;
    //min-height: 40px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 1px;
  }

  .title {
    @extend %item;
    margin-top: 8px;
    box-shadow: rgba(0, 0, 0, 0.15) 0 3px 3px 0, rgba(7, 7, 7, 0.05) 0 0 0 1px;
  }

  .record {
    background: #fff;
    @extend %item;

    .recordTag {
      @include multiline-ellipsis(2, 40px, 5em);
    }

    .notes {
      display: flex;
      margin-right: auto;
      margin-left: 16px;
      color: #999;

      .tips {
        @include multiline-ellipsis(1, 40px, 3em);
      }

      .text {
        @include multiline-ellipsis(1, 40px, 3em);
      }
    }
  }

  .noResult {
    padding: 16px;
    text-align: center;
  }

}

</style>


参考