前言
在日常开发任务中,chart
图表是打交道最多的库之一。并且有许多优秀的开源二次封装库,但是这里就不做赘述。
今天带大家学习一波,如何基于 vue3.x
, vueuse
, echarts
封装一个具有以下高级特性的 chart
组件:
- 异步渲染
- 贴花配置
- 加载配置
- 视窗区域渲染
- 自动更新尺寸
- 节流更新图表
- 自动根据
options
更新更新图表 - 自动销毁
useChart
方法- 还有一些不值一提的基础特性...
由于篇幅的原因,只贴出一些核心的实现细节,如果需要查看具体代码,可以点击查看:
项目初始化
如何初始化项目这里就不做赘述,直接进入主题。
不会的同学,可以去看对应的官网文档:
上手起步
创建一个 RChart
文件夹,包含以下的文件与文件夹:Chart.tsx
, types.ts
, props.ts
, index.scss
, hooks
。
sh
RChart
├── Chart.tsx
├── types.ts
├── props.ts
├── index.scss
└── hooks
├── useChart.ts
props.ts
Q:
为什么需要把 props
配置项单独提出来呢?
A:
一部分原因是为了更好的支持 ts
,另一个原因是为了更好的维护与方便后续的拓展。
ts
const props = {
/**
*
* @description
* 是否开启 IntersectionObserver 监听,用于监听图表是否在可视区域内再进行渲染。
* 默认监听图表容器是否在可视区域内,也可以配置 intersectionObserverTarget 属性监听指定元素。
*
* 该方法需要浏览器支持 IntersectionObserver API。
*
* @default true
*/
intersectionObserver: {
type: Boolean,
default: true,
},
/**
*
* @description
* 指定 IntersectionObserver 监听的目标元素。
*
* 该属性需要开启 intersectionObserver 才能生效。
*
* @default null
*/
intersectionObserverTarget: {
type: Object as PropType<MaybeComputedElementRef<MaybeElement>>,
default: null,
},
/**
*
* @description
* IntersectionObserver 配置项。
*
* 该属性需要开启 intersectionObserver 才能生效。
*
* @see https://www.vueusejs.com/core/useIntersectionObserver/
*
* @default {threshold:0.1}
*/
intersectionOptions: {
type: Object as PropType<UseIntersectionObserverOptions>,
default: {
threshold: 0.1,
},
},
/**
*
* @description
* chart 默认宽度,默认为 100%。
*
* 但是,如果未获取到实际宽度,那么会以 200px 宽度填充。
*
* @default 100%
*/
width: {
type: String,
default: '100%',
},
/**
*
* @description
* chart 默认高度,默认为 100%。
*
* 但是,如果未获取到实际高度,那么会以 200px 高度填充。
*
* @default 100%
*/
height: {
type: String,
default: '100%',
},
/**
*
* @description
* 是否启用自动调整大小,默认跟随图表容器尺寸变化。
*
* @default true
*/
autoResize: {
type: Boolean,
default: true,
},
/**
*
* @description
* 是否启用 chart 无障碍模式。
* 启用该配置项后会覆盖 options 中的 aria。
*
* @default false
*/
showAria: {
type: Boolean,
default: false,
},
/**
*
* @description
* chart 图表配置项。
*
* @default {}
*/
options: {
type: Object as PropType<echarts.EChartsCoreOption>,
default: () => ({}),
},
/**
*
* @description
* chart 渲染成功回调函数。
*
* @default null
*/
onSuccess: {
type: [Function, Array] as PropType<MaybeArray<(e: ECharts) => void>>,
default: null,
},
/**
*
* @description
* chart 渲染失败回调函数。
*
* @default null
*/
onError: {
type: [Function, Array] as PropType<MaybeArray<() => void>>,
default: null,
},
/**
*
* @description
* chart 渲染结束后的回调函数,不论是否成功都会执行。
*/
onFinally: {
type: [Function, Array] as PropType<MaybeArray<() => void>>,
default: null,
},
/**
*
* @description
* 手动指定 chart 主题配置项。
*
* @default null
*/
theme: {
type: String as PropType<ChartTheme>,
default: null,
},
/**
*
* @description
* 是否自动跟随模板主题切换。
* 该配置项会覆盖 theme 配置项。
*
* @default true
*/
autoChangeTheme: {
type: Boolean,
default: true,
},
/**
*
* @description
* 手动拓展 chart 图的相关组件。
*
* 该配置项不支持动态调用,及时动态更新了该属性,也不会生效。
* 并且,该配置项必须在 RChart 组件初始化时候配置。
*
* @default []
*/
use: {
type: Array as PropType<EChartsExtensionInstallRegisters[]>,
default: () => [],
},
/**
*
* @description
* 是否开启 watch 监听 options 配置项。
*
* @default true
*/
watchOptions: {
type: Boolean,
default: true,
},
/**
*
* @description
* 是否启用 chart 加载动画。
*
* @default false
*/
loading: {
type: Boolean,
default: false,
},
/**
*
* @description
* chart 加载动画配置项。
*
* @default {}
*/
loadingOptions: {
type: Object as PropType<LoadingOptions>,
default: () => loadingOptions(),
},
/**
*
* @description
* 手动设置 autoResize 监听的元素。
* 该元素必须是一个有效的 DOM 元素,并且需要开启 autoResize 才能生效。
*
* 默认以图表容器元素作为监听对象。
*
* @default null
*/
autoResizeObserverTarget: {
type: Object as PropType<MaybeComputedElementRef<MaybeElement>>,
default: null,
},
/**
*
* @description
* 是否开启 watchThrottle 监听 options 配置项更新。
* 该配置项适合在需要频繁更新 chart options 的场景下使用。
*
* 但是该配置项需要开启 watchOptions 才能生效。
*
* @default 500
*/
watchOptionsThrottleWait: {
type: Number,
default: 500,
},
/**
*
* @description
* 是否将渲染放置下一个队列。
*
* @default true
*/
nextTick: {
type: Boolean,
default: true,
},
/**
*
* @description
* 设置 setOptions 方法配置项。
*
* @default {notMerge:false,lazyUpdate:true,silent:false,replaceMerge:[]}
*/
setChartOptions: {
type: Object as PropType<SetOptionOpts>,
default: () => ({
notMerge: false,
lazyUpdate: true,
silent: false,
replaceMerge: [],
}),
},
/**
*
* @description
* RChart 注册挂载成功后触发的事件。
* 可以结合 useChart 方法中的 register 方法使用,然后便捷的使用 hooks。
*
* @default null
*/
onRegister: {
type: [Function, Array] as PropType<
MaybeArray<(chartInst: ECharts, render: VoidFC, dispose: VoidFC) => void>
>,
default: null,
},
}
具体的每一个配置项功能都写了详细的注释,这里就不做赘述。
实现细节
贴花、加载
利用组件的特性,我们可以将这两个 EChart
特性封装为配置项的形式去使用。
tsx
// 监听是否启用了贴花
watch(
() => props.showAria,
() => {
destroyChart()
updateChartTheme()
},
)
// 监听 loading 变化
watchEffect(() => {
props.loading
? echartInst?.showLoading(props.loadingOptions)
: echartInst?.hideLoading()
})
没想到吧,就这么简单的实现了。
异步渲染
当渲染的数据过多、一次性渲染的图表过多,可能会导致页面卡顿,这时候就需要异步渲染。
其实核心的思想就是利用 nextTick api
去实现,将一些关键的操作丢至下一个队列中执行。
tsx
const renderChart = (theme: string = echartTheme) => {
// 省略部分代码...
try {
// 省略部分代码...
// 是否强制下一队列渲染图表
if (props.nextTick) {
echartInst.setOption({})
nextTick(() => {
options && echartInst?.setOption(options)
})
} else {
options && echartInst?.setOption(options)
}
} catch (e) {
// 省略部分代码...
} finally {
// 省略部分代码...
}
}
自动更新尺寸
ECharts
天生支持了 autoResize
方法,我们可以利用 vueuse
提供的 useResizeObserver
方法去实现。
当然,还可能需要考虑到,如果频繁触发更新,肯定也是不是一个理智的行为,还可以在此基础上添加一个节流锁。我们可以用 lodash-es
提供的 throttle
方法去实现。
tsx
const resizeChart = () => {
if (echartInst) {
echartInst.resize()
}
}
const mount = () => {
// 省略部分代码...
if (props.autoResize) {
if (!resizeThrottleReturn) {
resizeThrottleReturn = throttle(resizeChart, 500)
}
/**
*
* 监听内容区域尺寸变化更新 chart。
* 如果没有传入 autoResizeObserverTarget 属性,则默认监听容器尺寸变化。
*/
if (!resizeObserverReturn) {
resizeObserverReturn = useResizeObserver(
props.autoResizeObserverTarget || rayChartWrapperRef,
resizeThrottleReturn as AnyFC,
)
}
}
}
视窗区域渲染
同样的,我们可以利用 vueuse
提供的 useIntersectionObserver
方法去实现。
tsx
// 如果配置启用 intersectionObserver,则监听图表是否在可视区域内
if (props.intersectionObserver) {
intersectionObserverReturn = useIntersectionObserver(
props.intersectionObserverTarget || rayChartWrapperRef,
([entry]) => {
targetIsVisible.value = entry.isIntersecting
},
props.intersectionOptions,
)
}
当然,该配置项需要浏览器支持 IntersectionObserver API
。并且,在初始化渲染了 chart
后需要移除,避免重复监听。
tsx
// 初始化完成后移除 intersectionObserver 监听
const mount = () => {
// 省略部分代码...
intersectionObserverReturn?.stop()
}
经过如上的操作,我们就实现了视窗区域渲染。组件仅仅会在视窗区域内渲染,避免了一次性渲染过多图表导致页面卡顿。
自动销毁
在组件销毁的时候,我们需要手动销毁 chart
实例,避免内存泄漏。仅需在 vue
组件卸载之前调用 onBeforeUnmount
钩子即可。
tsx
const unmount = () => {
// 省略部分代码...
}
onBeforeUnmount(() => {
unmount()
watchThrottledCallback?.()
})
节流更新图表
该方法可以基于 vueuse
提供的 watchThrottled
方法去实现。
tsx
watchEffect(() => {
/** 监听 options 变化 */
if (props.watchOptions) {
watchThrottledCallback = watchThrottled(
() => props.options,
(ndata) => {
// 重新组合 options
const options = combineChartOptions(ndata)
const setOpt = Object.assign(
{},
props.setChartOptions,
defaultChartOptions,
)
// 如果 options 发生变动更新 echarts
echartInst?.setOption(options, setOpt)
},
{
// 深度监听 options
deep: true,
throttle: props.watchOptionsThrottleWait,
},
)
} else {
watchThrottledCallback?.()
}
})
主题注册
在 ECharts
中,我们可以通过 echarts.registerTheme
方法去注册主题。
并且,ECharts
官方还提供了主题编辑器,我们只需要按照以下步骤即可完成:
- 配置、选择主题
- 点击下载主题
- 选择 json 类型,然后复制
- 选择一个存放主题包的文件夹
ts
export const setupChartTheme = () => {
// 获取所有主题
const themeRawModules: Record<string, ChartThemeRawModules> =
// 该地址换位你的主题包存放地址
import.meta.glob('@/app-config/echart-themes/**/*.json', {
eager: true,
})
const regex = /\/([^/]+)\.json$/
const rawThemes = Object.keys(themeRawModules).reduce((pre, curr) => {
const name = curr.match(regex)?.[1]
if (name) {
pre.push({
name,
theme: themeRawModules[curr].default,
})
return pre
} else {
throw new Error(`[RChart Theme Error]: name ${curr} is invalid!`)
}
}, [] as ChartThemeRawArray[])
return rawThemes
}
调用 setupChartTheme
方法,我们就可以获取到所有的主题配置项。
tsx
import { use, registerTheme, init } from 'echarts/core' // echarts 核心模块
// 获取 chart 主题
const echartThemes = setupChartTheme()
// 注册主题
echartThemes.forEach((curr) => {
registerTheme(curr.name, curr.theme)
})
useChart 方法
在 vue
中,如果我们需要使用组件实例上的方法,我们需要进过以下的步骤去使用:
vue
<template>
<RChart ref="chartRef" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { RChartInst } from 'RChart/types'
const chartRef = ref<RChartInst>()
const isDispose = () => {
return !chartRef.value?.getDom()
}
</script>
不仅仅需要定义 ref
,并且还要关注组件的生命周期,还有则是这么写的代码好像没有那么的优雅,也不方便拓展。
所以,我们需要一个 useChart
方法。
ts
const useChart = () => {
// 省略部分代码...
return [
register,
{
getChartInstance,
isDispose,
dispose,
render,
},
] as const
}
然后我们现在来对比一下同样的功能代码编写:
vue
<template>
<RChart @register="register" />
</template>
<script setup lang="ts">
import { useChart } from 'RChart/hooks/useChart'
const [register, { getChartInstance, isDispose, dispose, render }] = useChart()
const isDispose = () => {
return isDispose()
}
</script>
使用
当我们封装好该组件后,可以快速上手体验一下。
vue
<template>
<RChart @register="register" :options="options" />
</template>
<script setup lang="ts">
import { useChart } from 'RChart/hooks/useChart'
const [register, { getChartInstance, isDispose, dispose, render }] = useChart()
const options = ref({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985',
},
},
},
legend: {
data: ['Email', 'Union Ads', 'Video Ads', 'Direct', 'Search Engine'],
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: [
{
type: 'category',
boundaryGap: false,
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
},
],
yAxis: [
{
type: 'value',
},
],
series: [
{
name: 'Email',
type: 'line',
stack: 'Total',
areaStyle: {},
emphasis: {
focus: 'series',
},
data: [120, 132, 101, 134, 90, 230, 210],
},
{
name: 'Union Ads',
type: 'line',
stack: 'Total',
areaStyle: {},
emphasis: {
focus: 'series',
},
data: [220, 182, 191, 234, 290, 330, 310],
},
{
name: 'Video Ads',
type: 'line',
stack: 'Total',
areaStyle: {},
emphasis: {
focus: 'series',
},
data: [150, 232, 201, 154, 190, 330, 410],
},
{
name: 'Direct',
type: 'line',
stack: 'Total',
areaStyle: {},
emphasis: {
focus: 'series',
},
data: [320, 332, 301, 334, 390, 330, 320],
},
{
name: 'Search Engine',
type: 'line',
stack: 'Total',
label: {
show: true,
position: 'top',
},
areaStyle: {},
emphasis: {
focus: 'series',
},
data: [820, 932, 901, 934, 1290, 1330, 1320],
},
],
})
</script>
最后
这里就不做赘述,想给大家提供一个封装的思路。
感谢大家的阅读,谢谢~~~