前言
我们在书写echarts组件的时候,往往会发现:
- 其打包的chunk包是全量的,比较大。
- 调用api麻烦,要注意dom的渲染时机,资源清除,大小自适应。
- 不支持css变量,无法动态换肤的。
本文就围绕以上几点进行提供一个解决方案。
技术栈
Vue/React Echarts TS
本文以Vue为例,文末会贴上React实现方案
github仓库地址: github.com/Freedom-FJ/…
依赖引入
首先为了解决echarts的全量引入问题,我们需要单独书写一个echarts依赖引入文件。
- 引入部分的
echarts/components
和charts
等,把我们项目中用到的echarts部分依赖引入进来 - 用
echarts.use
挂载到我们的echarts实例上。 - 书写 echarts.draw 语法糖,可以一键对dom节点进行初始化,清除并绘制新的echarts对象。
utils/echarts/index.ts
/*
* @Author: mjh
* @Date: 2023-08-11 12:16:33
* @LastEditors: mjh
* @LastEditTime: 2023-08-15 10:20:14
* @Description:
*/
import * as echarts from 'echarts/core'
import { GraphicComponent, GridComponent, LegendComponent, PolarComponent, TitleComponent, TooltipComponent } from 'echarts/components'
import { BarChart, BoxplotChart, LineChart, PieChart, RadarChart } from 'echarts/charts'
import { UniversalTransition } from 'echarts/features'
import { CanvasRenderer } from 'echarts/renderers'
echarts.use([
GraphicComponent,
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
LineChart,
BarChart,
PieChart,
BoxplotChart,
CanvasRenderer,
UniversalTransition,
RadarChart,
PolarComponent,
])
// import * as echarts from 'echarts'
// 初始化语法糖
const draw = (dom: HTMLElement, option: Record<string, any>) => {
const chart = echarts.init(dom)
chart.clear()
chart.setOption(option)
return chart
}
export default {
...echarts,
draw
} as any
接下来我们要书写一个 echarts 组件,只需要我们传入option 就可以自动的绘制echarts节点。
书写组件
简单组件
我们先写一个简单的vue显示echarts的组件,我们需要在初始化的时候获取到dom节点,并且监听option如参,动态的更新渲染echarts实例。
但是不要忘了在组件销毁的时候清除echarts实例,防止占用内存。
<template>
<div ref="domBox" :style="{ width, height }">
<div ref="domRef" :style="{ width, height }" />
</div>
</template>
<script lang="ts" setup>
import { watch, ref, onMounted, onUnmounted } from 'vue'
import type { ECharts } from 'echarts'
import echarts from './index'
const props = defineProps({
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: '100%',
},
options: {
type: Object,
default: null,
},
})
const domRef = ref(null)
const domBox = ref(null)
let chartObj: null | ECharts = null
onMounted(() => {
if (!domRef.value) return
init()
if (props.options)
drawOption()
})
onUnmounted(() => {
if (chartObj) {
chartObj.dispose()
chartObj = null
}
})
watch(() => props.options, () => drawOption())
// 初始化
const init = () => {
chartObj = (echarts.init(domRef.value) as any)
}
const drawOption = () => {
if(!chartObj) return
chartObj.setOption(props.options)
}
</script>
监听dom更新
不要忘了我们在绘制echarts实例的时候,如果我们已经绘制完了,但是我们的外部盒子尺寸发生变化了怎么办,这个时候我们就要监听我们的外部盒子的尺寸变化来动态的 resize 我们的echarts实例。
MutationObserver 接口提供了监视对 DOM 树所做更改的能力。创建并返回一个新的 MutationObserver
它会在指定的 DOM 发生变化时被调用。
其的 observe()
方法配置了 MutationObserver
对象的回调方法以开始接收与给定选项匹配的 DOM 变化的通知。
根据配置,观察者会观察 DOM 树中的单个 Node
,也可能会观察被指定节点的部分或者所有的子孙节点。
要停止 MutationObserver
(以便不再触发它的回调方法),需要调用 MutationObserver.disconnect()
方法。
语法
var mutationObserver = new MutationObserver(callback);
mutationObserver.observe(target[, options])
-
一个回调函数,每当被指定的节点或子树以及配置项有 DOM 变动时会被调用。回调函数拥有两个参数:一个是描述所有被触发改动的
MutationRecord
对象数组,另一个是调用该函数的MutationObserver
对象。
对于其option下面我们需要用到以下几个语法
-
subtree
可选当为
true
时,将会监听以target
为根节点的整个子树。包括子树中所有节点的属性,而不仅仅是针对target
。默认值为false
。 -
childList
可选当为
true
时,监听target
节点中发生的节点的新增与删除(同时,如果subtree
为true
,会针对整个子树生效)。默认值为false
。 -
attributes
可选当为
true
时观察所有监听的节点属性值的变化。默认值为true
,当声明了attributeFilter
或attributeOldValue
,默认值则为false
。 -
当为
true
时,监听声明的target
节点上所有字符的变化。默认值为true
,如果声明了characterDataOldValue
,默认值则为false
获取实例监听变化
const observer = new MutationObserver((mutationsList) => {
// 循环寻找我们的echarts dom实例,发现更新的内容包含则调用 resize 方法更新echarts的尺寸
for (const mutation of mutationsList)
if (mutation.target === echartDomBox.value) chartObj && chartObj.resize()
})
observer.observe(domBox.value, {
attributes: true,
childList: false,
characterData: true,
subtree: true
})
我们将其应用到组建内,同时为了增加交互的舒适我们还可以利用echarts api 的 showLoading
加上loading的动画效果,(也可以使用 hideLoading
方法隐藏效果),为我们组件在未渲染实例时提升交互体验。
<template>
<div ref="domBox" :style="{ width, height }">
<div ref="domRef" :style="{ width, height }" />
</div>
</template>
<script lang="ts" setup>
import { watch, ref, onMounted, onUnmounted, nextTick } from 'vue'
import type { ECharts } from 'echarts'
import echarts from './index'
const props = defineProps({
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: '100%',
},
options: {
type: Object,
default: null,
},
})
const domRef = ref(null)
const domBox = ref(null)
let chartObj: null | ECharts = null
let observer: null | MutationObserver = null // dom 监听
onMounted(() => {
if (!domRef.value) return
init()
!props.options && chartObj.showLoading({
text: '',
color: '#409eff',
textColor: '#000',
maskColor: 'rgba(255, 255, 255, .95)',
zlevel: 0,
lineWidth: 2,
})
if (props.options)
drawOption()
observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList)
if (mutation.target === domBox.value) chartObj && chartObj.resize()
})
// 注意: 要放在nextTick内,,因为初始化时我们已经进行了一次option的更新操作
nextTick(() => {
domBox.value && (observer as MutationObserver).observe(domBox.value, {
attributes: true,
childList: false,
characterData: true,
subtree: true
})
})
setTimeout(() => {
chartObj && chartObj.resize()
}, 1000)
})
onUnmounted(() => {
if (chartObj) {
chartObj.dispose()
chartObj = null
}
// 注意销毁监听器
observer && observer.disconnect()
})
watch(() => props.options, () => drawOption())
// 初始化
const init = () => {
chartObj = (echarts.init(domRef.value) as any)
}
const drawOption = () => {
if(!chartObj) return
chartObj.hideLoading()
chartObj.setOption(props.options)
}
</script>
皮肤切换
主题切换现在比较主流的而且适配最好的是CSS变量的方案,也就是所有的颜色都采用css变量进行替换。我们只需要引入新的样式表,不需要额外的js代码,浏览器会自动更新CSSOM树,重绘所有dom的颜色,实现主体切换。
但是echarts不支持皮肤切换,因为其颜色是通过option传入的,使用canvas渲染实例,而且其传入颜色变量的字符串是无法识别的,必须直接传入颜色值,不支持css变量动态渲染,但是我们可以帮他做这个操作。
我们在echarts 传入的opiton的所有颜色处,做一层代理,我们传入所有的颜色都是变量的字符串例如:
'var(--dv-color-1)'
在渲染的时候通过中间函数,将所有的颜色变量转化成真正的颜色值例如:#fff
那么只需要监听主题的切换然后重新在将传入的option进行更新颜色值再更新echarts即可。
好了思路有了,那我们开干!💪。
切换颜色值
首先书写一个 replaceVarStrings
中间函数用于将option内的所有颜色变量字符串都转化为真正的颜色值,匹配 var()
字符串并将其中间的颜色提取出来。
当然我们需要用到宏api getComputedStyle
实时动态计算获取我们的dom实例的样式属性,其返回值的 getPropertyValue
方法传入变量名称字符串就会返回实时的变量颜色值。
但是
getComputedStyle
会导致浏览器的重排,都为求一个“即时性”和“准确性”。
/**
* echarts样式
*/
export const useThemeValue = (styleVariables: string) => {
return getComputedStyle(document.documentElement).getPropertyValue(styleVariables)
}
export function replaceVarStrings(obj: Record<string, any>) {
const newObj: Record<string, any> = Array.isArray(obj) ? [] : {}
for (const key in obj) {
if (typeof obj[key] === 'object') {
newObj[key] = replaceVarStrings(obj[key]) // 递归处理子对象
}
else if (typeof obj[key] === 'string' && obj[key].startsWith('var(') && obj[key].endsWith(')')) {
const varContent = obj[key].slice(4, -1) // 提取括号内的内容
newObj[key] = useThemeValue(varContent) // 替换为括号内的内容
}
else {
newObj[key] = obj[key] // 其他情况直接复制值
}
}
return newObj
}
监听主题切换
以我们的element-plus
组件库主题切换为例,其切换为 dark 模式是在html 的标签上增加一个 dark的class类
<!-- 正常 模式 -->
<html> </html>
<!-- dark 模式 -->
<html class="dark"> </html>
通过一套 dark 的变量样式覆盖原来的html下的样式
html {
--dv-color-background-base: #000b1a;
--dv-color-background-overlay: #000b1a;
--dv-color-background-page: #000b1a;
}
/* dark 模式 */
html .dark {
--dv-color-background-base: #000b1a;
--dv-color-background-overlay: #000b1a;
--dv-color-background-page: #000b1a;
}
剩下只需要监听html dom节点的class列表即可。
但是监听 div span document
这种很常见,可是怎么监听 html啊?
其实html 标签也是一个标签,你可以将它当作一个dom盒子,只需要像获取div一样 直接 document.querySelector('html')
即可获取到他的对象了。
监听class类还是用上文讲到的 MutationObserver
,只是不同的是 observer
方法的入参属性加上了 attributes, attributeFilter
配置:
-
attributes
可选
当为true
时观察所有监听的节点属性值的变化。默认值为true
,当声明了attributeFilter
或attributeOldValue
,默认值则为false
。 -
attributeFilter
可选
一个用于声明哪些属性名会被监听的数组。如果不声明该属性,所有属性的变化都将触发通知。
当然监听dom的方法有很多 对于针对dom大小的监听也可以使用其他方法来实现,文末的React实现方案就是用
ResizeObserver
来监听的, 我们vue就以MutationObserver
为例。
接下来写一个简单的hook,返回一个响应式的isDark变量,用于让我们的echarts组件监听即可,在此我定义为 isDark: boolean
的形式,如果涉及到多套主题的话,也可以对下面代码进行修改,把返回值改成 theme: 'dark'
, 字符串的形式。
/**
* @name: 判断当前主题hook
* @desc:
* @return {*}
*/
export const useTheme = () => {
const htmlDom = document.querySelector('html')
if (!htmlDom) return { isDark: ref(false) }
const isDark = ref(!!htmlDom.classList.contains('dark'))
// 创建 MutationObserver 实例
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const currentClass = (mutation.target as any).className
isDark.value = currentClass.includes('dark')
}
}
})
// 配置 MutationObserver 监听的选项
const observerOptions = {
attributes: true,
attributeFilter: ['class'],
}
// 开始监听目标节点
observer.observe(htmlDom, observerOptions)
return {
isDark
}
}
有了这些前置条件,这不是咱们的组件就呼之欲出了,只需要修改 drawOption
方法进行中间颜色代理,并且增加一个监听器监听 isDark
变量即可。
<template>
<div ref="domBox" :style="{ width, height }">
<div ref="domRef" :style="{ width, height }" />
</div>
</template>
<script lang="ts" setup>
import type { ECharts } from 'echarts'
import { watch, ref, onMounted, onUnmounted, nextTick } from 'vue'
import { replaceVarStrings, useTheme } from './utils'
import echarts from './index'
const props = defineProps({
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: '100%',
},
options: {
type: Object,
default: null,
},
})
const { isDark } = useTheme()
const domRef = ref(null)
const domBox = ref(null)
let chartObj: null | ECharts = null
let observer: null | MutationObserver = null // dom 监听
onMounted(() => {
if (!domRef.value) return
init()
drawOption()
observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList)
if (mutation.target === domBox.value) chartObj && chartObj.resize()
})
nextTick(() => {
domBox.value && (observer as MutationObserver).observe(domBox.value, {
attributes: true,
childList: false,
characterData: true,
subtree: true
})
})
setTimeout(() => {
chartObj && chartObj.resize()
}, 1000)
})
onUnmounted(() => {
if (chartObj) {
chartObj.dispose()
chartObj = null
}
observer && observer.disconnect()
})
watch(() => props.options, () => drawOption())
watch(() => isDark.value, () => drawOption())
// 初始化
const init = () => {
chartObj = (echarts.init(domRef.value) as any)
}
const drawOption = () => {
if(!chartObj) return
if(!props.options) {
chartObj.clear()
chartObj.showLoading({
text: '',
color: '#409eff',
textColor: '#000',
maskColor: 'rgba(255, 255, 255, .95)',
zlevel: 0,
lineWidth: 2,
})
}
else {
chartObj.hideLoading()
chartObj.setOption(replaceVarStrings(props.options))
}
}
</script>
lazy模式
为了提高组建的扩展性,我们也可以为组建增加一个非自动化的lazy模式。当我们开启了lazy模式以后,组建将不会自动的收集option依赖更新dom,也不用传入响应式的option,而是需要外部调用组建expose的方法控制更新时机。
<template>
<div ref="domBox" :style="{ width, height }">
<div ref="domRef" :style="{ width, height }" />
</div>
</template>
<script lang="ts" setup>
import { watch, ref, onMounted, onUnmounted, nextTick } from 'vue'
import { replaceVarStrings, useTheme } from './utils'
import type { ECharts } from 'echarts'
import echarts from './index'
const props = defineProps({
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: '100%',
},
lazy: {
type: Boolean,
default: false,
},
options: {
type: Object,
default: null,
},
})
const { isDark } = useTheme()
const domRef = ref(null)
const domBox = ref(null)
let chartObj: null | ECharts = null
let observer: null | MutationObserver = null // dom 监听
onMounted(() => {
if (!domRef.value) return
init()
if(props.lazy) return
drawOption()
observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList)
if (mutation.target === domBox.value) resize()
})
nextTick(() => {
domBox.value && (observer as MutationObserver).observe(domBox.value, {
attributes: true,
childList: false,
characterData: true,
subtree: true
})
})
})
onUnmounted(() => {
if (chartObj) {
chartObj.dispose()
chartObj = null
}
observer?.disconnect()
})
watch(() => props.options, () => !props.lazy && drawOption())
watch(() => isDark.value, () => !props.lazy && drawOption())
// 绘制方法
const drawOption = (options = props.options) => {
if(!chartObj) return
if(!options) {
chartObj.clear()
chartObj.showLoading({
text: '',
color: '#409eff',
textColor: '#000',
maskColor: 'rgba(255, 255, 255, .95)',
zlevel: 0,
lineWidth: 2,
})
}
else {
chartObj.hideLoading()
chartObj.setOption(replaceVarStrings(options))
}
}
// 初始化
const init = () => {
chartObj = (echarts.init(domRef.value) as any)
}
// 重绘 自适应尺寸
const resize = () => {
chartObj?.resize()
}
defineExpose({
drawOption,
resize,
init
})
</script>
使用
<template>
<div style="height: 300px;width: 200px;">
<BaseECharts :options="options" />
</div>
</template>
<script setup lang="ts">
import BaseECharts from '@/utils/echarts/BaseECharts.vue'
const options = ref<any>({
xAxis: {
type: 'category',
data: [],
},
color: ['var(--dv-color-danger)'], // 颜色的变量字符串
yAxis: {
type: 'value',
},
series: [
{
data: [],
type: 'line',
},
],
})
setTimeout(() => {
options.value = {
xAxis: {
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
},
color: ['var(--dv-color-danger)'], // 颜色的变量字符串
yAxis: {
type: 'value',
},
series: [
{
data: [150, 230, 224, 218, 135, 147, 260],
type: 'line',
},
],
}
}, 3000)
</script>
react
对于react方案,我们在实现useTheme
上会有一些不一样,我们的变量需要用useState实现,注意 useState
避免放在条件return语句之后。
useTheme:
/**
* @name: 判断当前主题hook
* @desc:
* @return {*}
*/
export const useTheme = () => {
const htmlDom = document.querySelector('html');
const [isDark, setIsDark] = useState(!!htmlDom?.classList.contains('dark'));
const observer = useRef<MutationObserver>();
if (!htmlDom) return { isDark: false };
// 创建 MutationObserver 实例
if (!observer.current) {
observer.current = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const currentClass = (mutation.target as HTMLElement).className;
setIsDark(currentClass.includes('dark'));
}
}
});
}
// 配置 MutationObserver 监听的选项
const observerOptions = {
attributes: true,
attributeFilter: ['class'],
};
// 开始监听目标节点
observer.current.observe(htmlDom, observerOptions);
return {
isDark,
};
};
在组件实现这边我们换用更为简单的api ResizeObserver
接口可以监视 Element
内容盒或边框盒或者 SVGElement
边界尺寸的变化。
注意记得在 useEffect 的 return 语句中将我们的 ResizeObserver 监听器和实例销毁。
组件:
/*
* @Author: mjh
* @Date: 2023-11-25 16:04:13
* @LastEditors: mjh
* @LastEditTime: 2023-11-25 23:23:28
* @Description:
*/
import { useEffect, useRef } from 'react';
import echarts from './index';
import { replaceVarStrings, useTheme } from './utils';
export interface EchartControllerProps {
width?: string;
height?: string;
options?: Record<string, any> | null;
}
export default function EchartController(props: EchartControllerProps) {
const { height = '100%', width = '100%', options } = props;
const chartRef = useRef<any>();
const cInstance = useRef<any>();
const { isDark } = useTheme();
useEffect(() => {
if (!chartRef.current) return;
if (!cInstance.current) {
cInstance.current = echarts.init(chartRef.current);
}
const observer = new ResizeObserver(() => {
cInstance.current.resize();
});
observer.observe(chartRef.current);
return () => {
cInstance.current?.dispose()
observer.disconnect();
};
}, []);
useEffect(() => {
if (!cInstance.current) return;
if (!options) {
cInstance.current.showLoading({
text: '',
color: '#409eff',
textColor: '#000',
maskColor: 'rgba(255, 255, 255, .95)',
zlevel: 0,
lineWidth: 2,
});
return;
}
cInstance.current.hideLoading();
cInstance.current.setOption(replaceVarStrings(options));
}, [options, isDark]);
return (
<div ref={chartRef} style={{ height, width }} />
);
}
交互事件
- 我的大部分项目都不会涉及到echarts交互事件,所以暂时没有封装。
- 如果出现需求,可以简单做一个组件内部的事件代理即可,也可以根据需求做一些简单的语法弹,但是还是以场景和需求为准。
参考资料
developer.mozilla.org/zh-CN/docs/…
developer.mozilla.org/zh-CN/docs/…