前言
日常开发中,
echarts
使用是很频繁的一个可视化库。今天手摸手教学如何基于vue3.x
自己封装一个简单、可拓展的可视化组件。
准备工作
node
版本>=v14.18.1
typescript
版本>=v4.9.0
vue
版本v3.2.37
echarts
版本>=5.0.0
vite
版本v4.1.4
预览
思路
graph TD
DOM容器 --> Vue处理配置项 --> 注册并生成ECharts --> 保存ECharts实例 --> 根据实例进行拓展
开始
这里就不做如何配置、启动项目工程了,默认大家都配置好了。并且这里演示如何使用
tsx
进行组件开发。
import './index.scss'
import * as echarts from 'echarts/core' // `echarts` 核心模块
import {
TitleComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent,
LegendComponent,
ToolboxComponent,
AriaComponent,
} from 'echarts/components' // 提示框, 标题, 直角坐标系, 数据集, 内置数据转换器等组件(组件后缀都为 `Component`)
import {
BarChart,
LineChart,
PieChart,
CandlestickChart,
ScatterChart,
PictorialBarChart,
} from 'echarts/charts' // 系列类型(后缀都为 `SeriesOption`)
import { LabelLayout, UniversalTransition } from 'echarts/features' // 标签自动布局, 全局过渡动画等特性
import { CanvasRenderer } from 'echarts/renderers' // `echarts` 渲染器
import { useSetting } from '@/store'
import { cloneDeep, debounce } from 'lodash-es'
import { on, off, addStyle } from '@/utils/element'
import type { PropType } from 'vue'
export type AutoResize =
| boolean
| {
width: number
height: number
}
export interface LoadingOptions {
text: string // 文本内容
color: string // 颜色
textColor: string // 字体颜色
maskColor: string // 遮罩颜色
zlevel: number // 水平
fontSize: number // 字体大小
showSpinner: boolean // 是否显示旋转动画(`spinner`)
spinnerRadius: number // 旋转动画(`spinner`)的半径
lineWidth: number // 旋转动画(`spinner`)的线宽
fontWeight: string // 字体粗细
fontStyle: string // 字体风格
fontFamily: string // 字体系列
}
export type ChartTheme = 'dark' | '' | object
/**
*
* @returns LoadingOptions
*
* 为了方便使用加载动画, 写了此方法, 虽然没啥用
*/
export const loadingOptions = (options?: LoadingOptions) =>
Object.assign(
{},
{
text: 'loading',
color: '#c23531',
textColor: '#000',
maskColor: 'rgba(255, 255, 255, 0.9)',
zlevel: 0,
fontSize: 12,
showSpinner: true,
spinnerRadius: 10,
lineWidth: 5,
fontWeight: 'normal',
fontStyle: 'normal',
fontFamily: 'sans-serif',
},
options,
)
const RayChart = defineComponent({
name: 'RayChart',
props: {
width: {
/**
*
* chart 容器初始化宽度
*
* 如果未能继承宽度, 则会以 200px 宽度填充
*/
type: String,
default: '100%',
},
height: {
/**
*
* chart 容器初始化高度
*
* 如果未能继承高度, 则会以 200px 宽度填充
*/
type: String,
default: '100%',
},
autoResize: {
/**
*
* `chart` 是否跟随窗口尺寸变化自动变化
*
* 如果为对象, 则可以指定其变化尺寸, 实现图表大小不等于容器大小的效果
*/
type: [Boolean, Object] as PropType<AutoResize>,
default: true,
},
canvasRender: {
/**
*
* `chart` 渲染器, 默认使用 `canvas`
*
* 考虑到打包体积与大多数业务场景缘故, 暂时移除 `SVGRenderer` 渲染器的默认导入
*/
type: Boolean,
default: true,
},
showAria: {
/**
*
* 是否开启 `chart` 无障碍访问
*
* 此选项会覆盖 `options` 中的 `aria` 配置
*/
type: Boolean,
default: false,
},
options: {
type: Object as PropType<echarts.EChartsCoreOption>,
default: () => ({}),
},
success: {
/**
*
* 返回 chart 实例
*
* 渲染成功回调函数
*
* () => EChartsInstance
*/
type: Function,
default: () => ({}),
},
error: {
/**
*
* 渲染失败回调函数
*
* () => void
*/
type: Function,
default: () => ({}),
},
theme: {
type: [String, Object] as PropType<ChartTheme>,
default: '',
},
autoChangeTheme: {
/**
*
* 是否自动跟随模板主题切换
*
* 如果开启此属性, 则会覆盖 `theme` 属性
*
* 注意: 这个属性重度依赖此模板, 所以默认不开启. 并且动态切换主题有一定的性能问题
*/
type: Boolean,
default: false,
},
use: {
/**
*
* 拓展 `echarts` 图表
*
* 由于官方并没有提供该类型, 手动去复刻成本过高, 故而采用 `any`
*/
type: Array,
default: () => [],
},
watchOptions: {
/** 主动监听 options 变化 */
type: Boolean,
default: true,
},
loading: {
/** 加载动画 */
type: Boolean,
default: false,
},
loadingOptions: {
/** 配置加载动画样式 */
type: Object as PropType<LoadingOptions>,
default: () => loadingOptions(),
},
},
setup(props) {
const settingStore = useSetting()
const { themeValue } = storeToRefs(settingStore)
const rayChartRef = ref<HTMLElement>() // `echart` 容器实例
const echartInstanceRef = ref<EChartsInstance>() // `echart` 拷贝实例, 解决直接使用响应式实例带来的问题
let echartInstance: EChartsInstance // `echart` 实例
let resizeDebounce: AnyFunc // resize 防抖方法实例
const cssVarsRef = computed(() => {
const cssVars = {
'--ray-chart-width': props.width,
'--ray-chart-height': props.height,
}
return cssVars
})
const modelLoadingOptions = computed(() =>
loadingOptions(props.loadingOptions),
)
/**
*
* 注册 `echart` 组件, 图利, 渲染器等
*
* 会自动合并拓展 `echart` 组件
* 该方法必须在注册图表之前调用
*/
const registerChartCore = async () => {
echarts.use([
TitleComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent,
LegendComponent,
ToolboxComponent,
AriaComponent,
]) // 注册组件
echarts.use([
BarChart,
LineChart,
PieChart,
CandlestickChart,
ScatterChart,
PictorialBarChart,
]) // 注册类型
echarts.use([LabelLayout, UniversalTransition]) // 注册布局, 过度效果
// 如果业务场景中需要 `svg` 渲染器, 手动导入渲染器后使用该行代码即可(不过为了体积考虑, 移除了 SVG 渲染器)
// echarts.use([props.canvasRender ? CanvasRenderer : SVGRenderer])
echarts.use([CanvasRenderer]) // 注册渲染器
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
echarts.use(props.use as any[])
} catch (e) {
console.error(
'Error: wrong property and method passed in extend attribute',
)
}
}
/**
*
* @returns `chart options`
*
* 合并配置项
*
* 如果有需要特殊全局配置的可以在此继续写...
*/
const useMergeOptions = () => {
let options = cloneDeep(props.options)
const merge = (opts: object) => Object.assign({}, options, opts)
if (props.showAria) {
options = merge({
aria: {
enabled: true,
decal: {
show: true,
},
},
})
}
return options
}
/**
*
* 渲染 `echart`
*
* 缓存两个实例
*
* 直接使用响应式代理实例会出现诡异的问题, 例如 `legend` 点击时报错
*/
const renderChart = (theme: ChartTheme) => {
/** 获取 dom 容器 */
const element = rayChartRef.value as HTMLElement
/** 获取配置项 */
const options = useMergeOptions()
/** 获取 dom 容器实际宽高 */
const { height, width } = element.getBoundingClientRect()
/** 如果高度为 0, 则以 200px 填充 */
if (height === 0) {
addStyle(element, {
height: '200px',
})
}
/** 如果款度为 0, 则以 200px 填充 */
if (width === 0) {
addStyle(element, {
width: '200px',
})
}
try {
/** 注册 chart */
echartInstance = echarts.init(element, theme)
echartInstanceRef.value = echartInstance
/** 设置 options 配置项 */
options && echartInstance.setOption(options)
/** 渲染成功回调 */
props.success?.(echartInstance)
} catch (e) {
/** 渲染失败回调 */
props.error?.()
console.error(e)
}
}
/**
*
* @param bool 渲染带有主题色的可视化图
*
* 区别自动跟随模板主题切换与指定主题切换
*/
const renderThemeChart = (bool?: boolean) => {
if (props.autoChangeTheme) {
bool ? renderChart('dark') : renderChart('')
return void 0
}
if (!props.theme) {
renderChart('')
}
}
/**
*
* 销毁 `chart` 实例, 释放资源
*/
const destroyChart = () => {
if (echartInstance) {
echartInstance.clear()
echartInstance.dispose()
}
}
/** 重置 echarts 尺寸 */
const resizeChart = () => {
if (echartInstance) {
echartInstance.resize()
}
}
/** 监听全局主题变化, 然后重新渲染对应主题 echarts */
watch(
() => [themeValue.value],
([theme]) => {
/**
*
* Q: 为什么需要重新卸载再渲染
* A: 因为 echarts 官方文档并未提供动态渲染方法
* A: 虽然原型上有 setTheme 方法, 但是官方标记是仅限于在类 ECharts 中访问
*/
if (props.autoChangeTheme) {
destroyChart()
renderThemeChart(theme)
}
},
)
watch(
() => props.showAria,
() => {
destroyChart()
/**
*
* 贴花跟随主题渲染
*
* 自动跟随模板主题或者指定主题皆可
*/
if (props.autoChangeTheme || props.theme) {
themeValue.value ? renderChart('dark') : renderChart('')
} else {
renderChart('')
}
},
)
/** 显示/隐藏加载动画 */
watch(
() => props.loading,
(newData) => {
newData
? echartInstance?.showLoading(modelLoadingOptions.value)
: echartInstance?.hideLoading()
},
)
/** 监听 options 变化 */
if (props.watchOptions) {
watch(
() => props.watchOptions,
() => {
/** 重新组合 options */
const options = useMergeOptions()
/** 如果 options 发生变动更新 echarts */
echartInstance?.setOption(options)
},
)
}
onBeforeMount(async () => {
/** 注册 echarts 组件与渲染器 */
await registerChartCore()
})
onMounted(() => {
nextTick(() => {
/** 注册 echarts */
if (props.autoChangeTheme) {
renderThemeChart(themeValue.value)
} else {
props.theme ? renderChart('dark') : renderChart('')
}
/** 注册事件 */
if (props.autoResize) {
resizeDebounce = debounce(resizeChart, 500)
on(window, 'resize', resizeDebounce)
}
})
})
onBeforeUnmount(() => {
/** 卸载 echarts */
destroyChart()
/** 卸载事件柄 */
off(window, 'resize', resizeDebounce)
})
return {
rayChartRef,
cssVarsRef,
echartInstance: echartInstanceRef,
}
},
render() {
return (
<div class="ray-chart" style={[this.cssVarsRef]} ref="rayChartRef"></div>
)
},
})
export default RayChart
/**
*
* 基于 `echarts` 的组件. 意在便捷的使用 `chart` 图
*
* 暂时不支持自动解析导入 `chart` 组件, 如果使用未注册的组件, 需要在顶部手动导入并且再使用 `use` 注册
*
* 预引入: 柱状图, 折线图, 饼图, k线图, 散点图等
* 预引入: 提示框, 标题, 直角坐标系, 数据集, 内置数据转换器等
*
* 如果需要大批量数据渲染, 可以通过获取实例后阶段性调用 `setOption` 方法注入数据
*
* 该组件会在卸载组件时, 自动释放资源
*
* 注意: 尽量别一次性倒入全部 `chart` 会造成打包体积异常大
*/
.ray-chart {
width: var(--ray-chart-width);
height: var(--ray-chart-height);
border: none;
outline: none;
box-sizing: border-box;
}
解析
代码都进行了逐行解析,该组件代码来自于 Ray Template,具体可以去该处查看。
使用
import './index.scss'
import { NCard, NSwitch, NSpace, NP, NH6, NH2, NH3 } from 'naive-ui'
import RayChart from '@/components/RayChart/index'
const Echart = defineComponent({
name: 'Echart',
setup() {
const baseChartRef = ref()
const chartLoading = ref(false)
const chartAria = ref(false)
const state = reactive({
loading: false,
})
const baseOptions = {
legend: {},
tooltip: {},
xAxis: {
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
},
yAxis: {
type: 'value',
},
series: [
{
name: '日期',
data: [120, 200, 150, 80, 70, 110, 130],
type: 'bar',
showBackground: true,
backgroundStyle: {
color: 'rgba(180, 180, 180, 0.2)',
},
},
],
}
const basePieOptions = {
title: {
text: 'Referer of a Website',
subtext: 'Fake Data',
left: 'center',
},
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
left: 'left',
},
series: [
{
name: 'Access From',
type: 'pie',
radius: '50%',
data: [
{ value: 1048, name: 'Search Engine' },
{ value: 735, name: 'Direct' },
{ value: 580, name: 'Email' },
{ value: 484, name: 'Union Ads' },
{ value: 300, name: 'Video Ads' },
],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
}
const baseLineOptions = {
title: {
text: 'Stacked Area Chart',
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985',
},
},
},
legend: {
data: ['Email', 'Union Ads', 'Video Ads', 'Direct', 'Search Engine'],
},
toolbox: {
feature: {
saveAsImage: {},
},
},
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],
},
],
}
const handleLoadingShow = (bool: boolean) => {
state.loading = bool
}
const handleAriaShow = (bool: boolean) => {
chartAria.value = bool
}
const handleChartRenderSuccess = (chart: EChartsInstance) => {
window.$notification.info({
title: '可视化图渲染成功回调函数',
content: '可视化图渲染成功, 并且返回了当前可视化图实例',
duration: 5 * 1000,
})
console.log(chart)
}
return {
baseOptions,
baseChartRef,
chartLoading,
handleLoadingShow,
chartAria,
handleAriaShow,
handleChartRenderSuccess,
basePieOptions,
baseLineOptions,
...toRefs(state),
}
},
render() {
return (
<div class="echart">
<NH2>RayChart 组件使用</NH2>
<NP>
该组件会默认以 200*200
宽高进行填充。预设了常用的图、方法组件,如果不满足需求,需要用 use
方法进行手动拓展。该组件实现了自动跟随模板主题切换功能,但是动态切换损耗较大,所以默认不启用
</NP>
<NH2>基础使用</NH2>
<div class="chart--container">
<RayChart options={this.baseOptions} />
</div>
<NH2>渲染成功后运行回调函数</NH2>
<div class="chart--container">
<RayChart
options={this.basePieOptions}
success={this.handleChartRenderSuccess.bind(this)}
/>
</div>
<NH2>能跟随主题切换的可视化图</NH2>
<div class="chart--container">
<RayChart
autoChangeTheme
options={this.baseLineOptions}
showAria={this.chartAria}
/>
</div>
<NH2>不跟随主题切换的暗色主题可视化图</NH2>
<div class="chart--container">
<RayChart theme="dark" options={this.baseOptions} />
</div>
<NH2>加载动画</NH2>
<NSwitch
v-model:value={this.chartLoading}
onUpdateValue={this.handleLoadingShow.bind(this)}
style={['padding: 18px 0']}
>
{{
checked: () => '隐藏加载动画',
unchecked: () => '显示加载动画',
}}
</NSwitch>
<div class="chart--container">
<RayChart loading={this.loading} options={this.baseOptions} />
</div>
<NH2>贴画可视化图</NH2>
<NSwitch
v-model:value={this.chartAria}
onUpdateValue={this.handleAriaShow.bind(this)}
style={['padding: 18px 0']}
>
{{
checked: () => '隐藏贴花',
unchecked: () => '显示贴花',
}}
</NSwitch>
<div class="chart--container">
<RayChart options={this.baseOptions} showAria={this.chartAria} />
</div>
</div>
)
},
})
export default Echart