Code前端首页关于Code前端联系我们

Vue3+ECharts怎么写才不踩坑?从0搭复用组件到实时刷新优化全讲透

terry 1小时前 阅读数 21 #Vue

最近刷前端群和技术论坛,发现好多刚接触Vue3的小伙伴都在问ECharts的问题:比如setup语法糖里怎么初始化实例?复用图表组件总报重复实例或者ResizeObserver重复注册?大数据量刷新的时候页面卡爆?还有暗黑模式切换时样式怎么同步?别慌,今天把我踩过的所有坑都整理成干货,连行业里常用的高复用组件写法、无感刷新优化都给你唠明白,全是能直接用到项目里的东西。

为什么Vue3+ECharts不能完全照搬Vue2的写法?

先别急着写代码,搞懂底层差异才是避坑的第一步,Vue2到Vue3最大的变化就是引入了Composition API和响应式原理的重写——Vue2用的是Object.defineProperty,只能监听对象已有的属性和数组的长度、索引变化(还得靠变异方法);Vue3换成了Proxy+Reflect,能直接监听整个对象的增删改查、数组的任意操作,甚至是Map、Set这些ES6+的数据结构。 这对ECharts有什么影响?举个最简单的例子:Vue2里你可能习惯把ECharts实例放在data里,虽然Vue2不会自动监听函数类型的数据,但在极端场景下(比如实例里不小心挂了个普通响应式数据)可能会有不必要的更新;但到了Vue3,哪怕是ref/ reactive包裹的空对象里挂了ECharts实例,Vue3的Proxy也会尝试监听这个实例上的所有DOM操作相关的属性,轻则占用内存,重则导致ECharts渲染异常。 Vue3的setup语法糖生命周期钩子函数(onMounted、onBeforeUnmount)都是独立导入的,不能像Vue2那样直接写在组件对象里;还有Ref的.value访问机制,初始化实例时如果不用nextTick,可能会找不到DOM元素——这些都是新手最容易踩的第一个坑。

从0搭一个Vue3+ECharts的高复用通用组件

要说Vue3+ECharts最实用的技能,绝对是写通用复用组件,毕竟一个项目里图表少说有七八个,多则几十上百个,每个都单独初始化、单独处理Resize、单独切换主题太麻烦了,还容易出错。

第一步:设计组件的props

通用组件的核心就是“可配置化”,所以props要覆盖日常开发的90%需求,我通常会设置这些属性:

  • chartId:给每个图表生成一个唯一的ID,防止多个图表Resize或销毁时互相干扰(也可以用组件的uid,但uid有时候在SSR或者动态挂载场景下不太稳定,自己传个带业务含义的ID会更直观)
  • width:图表宽度,默认100%,可以传字符串(800px'、'50%')也可以传数字(单位默认px)
  • height:图表高度,这个必须要有默认值,不然ECharts会因为找不到容器尺寸渲染失败,我一般设为400px
  • option:图表的配置项,这是核心,类型是Object,必填
  • theme:图表主题,默认'light',可以传'dark'或者自定义主题的名称(自定义主题后面会讲怎么弄)
  • notMerge:更新图表配置项时是否合并,默认false(行业里推荐传true,除非你要完全替换配置项,比如切换图表类型的时候)
  • lazyUpdate:大数据量更新时是否启用懒更新,默认false(后面优化实时刷新会用到,设为true能大幅提升性能)

第二步:初始化ECharts实例

刚才提到了,ECharts实例不能放在响应式数据里,所以我们要用Vue3的shallowRef或者markRaw来包裹,这两个方法有什么区别?shallowRef只会监听.value的变化,不会递归监听.value里面的属性;markRaw会让数据完全失去响应式,哪怕重新赋值也不会触发视图更新。 这里推荐用shallowRef,为什么?因为有时候我们可能需要在组件外部手动获取实例调用ECharts的方法(比如resize、dispatchAction),markRaw的话如果你在父组件里用ref绑定了通用组件,拿到的实例.value.chartInstance是普通引用,但如果用shallowRef包裹,父组件修改不了通用组件内部的chartInstance.value(除非通过通用组件暴露的方法),安全性更高。 初始化的时候还要注意两点:一是必须用nextTick确保DOM已经挂载完成;二是判断一下当前chartId对应的DOM元素里有没有已经存在的ECharts实例,如果有的话先销毁再初始化,防止重复实例导致的内存泄漏或者渲染异常。

第三步:处理响应式配置项更新

通用组件的配置项肯定是要从父组件传过来的,而且父组件可能会随时修改配置项,所以我们需要用watch监听option的变化,不过这里有个坑:watch默认是浅监听,如果option是一个嵌套很深的对象,父组件只修改了series里的data,浅监听是不会触发的,所以要加deep: true。 更新图表的时候要用ECharts的setOption方法,记得把props里的notMerge和lazyUpdate传进去,还有一个小细节:如果option是空对象或者undefined,最好不要调用setOption,直接返回就行,避免报错。

第四步:处理容器尺寸变化(Resize)

容器尺寸变化是图表开发中最常见的需求,比如窗口缩放、侧边栏折叠/展开、父元素flex布局变化等等,Vue2里我们可能会用window.addEventListener('resize'),但这样有两个问题:一是每个图表都要单独注册一个监听器,内存占用大;二是只能监听窗口的变化,监听不到父元素的变化。 现在行业里通用的做法是用ResizeObserver,ResizeObserver是浏览器原生的API,能监听任意DOM元素的尺寸变化,性能也比window.addEventListener好很多,不过用ResizeObserver的时候要注意:一是每个图表只能注册一个ResizeObserver实例,销毁的时候要记得断开(disconnect),否则会报重复注册的错误;二是窗口缩放或者父元素尺寸变化时,可能会触发多次Resize,导致图表频繁重绘,所以最好加个防抖函数(debounce),把重绘的频率控制在100-200ms一次,既保证了视觉流畅性,又不会占用太多CPU。 这里给大家分享一个我常用的轻量级防抖函数,不用引入lodash,直接写在组件里就行:

const debounce = (fn, delay = 200) => {
  let timer = null
  return (...args) => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      fn.apply(this, args)
    }, delay)
  }
}

第五步:处理生命周期钩子函数

通用组件的生命周期主要有三个:onMounted、onBeforeUnmount,还有可能用到watchEffect监听theme的变化。

  • onMounted:初始化ECharts实例,注册ResizeObserver,监听theme的变化
  • onBeforeUnmount:销毁ECharts实例,断开ResizeObserver,清除防抖函数的定时器(防止定时器泄漏)
  • watchEffect:监听theme的变化,如果theme变了,就销毁当前实例,用新的theme重新初始化(自定义主题的话可以先引入主题文件再用)

第六步:暴露必要的方法给父组件

有时候父组件需要手动控制图表,比如点击按钮触发图表缩放、高亮某个数据点、清空图表数据等等,所以我们要用Vue3的defineExpose把这些方法暴露出去,通常暴露的方法有:

  • getChartInstance:获取ECharts实例,方便父组件调用ECharts的原生方法
  • resize:手动触发图表重绘
  • clear:清空图表数据
  • dispose:手动销毁图表实例

踩过的那些坑及解决方案

刚才讲通用组件的时候提到了一些小细节,现在把我踩过的所有大坑单独列出来,每个都给你讲清楚原因和解决方案。

坑1:用ref/reactive包裹ECharts实例导致渲染异常或内存泄漏

刚才说过,Vue3的Proxy会尝试监听ECharts实例上的所有属性,而ECharts实例上有很多DOM操作相关的属性(dom、_zrender),这些属性频繁变化会导致Vue3的响应式系统做很多无用功,轻则占用内存,重则导致ZRender渲染异常(ZRender是ECharts的底层渲染引擎)。 解决方案:用shallowRef或者markRaw包裹ECharts实例,推荐用shallowRef。

坑2:初始化时找不到DOM元素

在setup语法糖里,DOM元素是在onMounted之后才挂载完成的,如果直接在setup的顶层代码里初始化ECharts,肯定会找不到DOM元素。 解决方案:必须在onMounted里初始化,或者用nextTick包裹初始化代码。

坑3:多个图表Resize时互相干扰

如果多个图表都用chartId对应的DOM元素的class或者tagName来注册ResizeObserver,肯定会互相干扰;如果不用唯一标识销毁ResizeObserver,还会报重复注册的错误。 解决方案:给每个图表生成一个唯一的chartId(可以用Date.now() + Math.random().toString(36).substr(2, 9),或者用组件的uid加业务前缀),用chartId对应的DOM元素的id来获取DOM,每个ResizeObserver实例只监听一个DOM元素,onBeforeUnmount的时候记得disconnect。

坑4:大数据量刷新时页面卡爆

当图表的series.data里有几万甚至几十万条数据时,每次调用setOption都会触发全量重绘,导致页面卡顿、响应式操作延迟。 解决方案

  1. 开启ECharts的懒更新:把setOption的lazyUpdate参数设为true,这样ECharts会在下一次动画帧(requestAnimationFrame)才重绘,而不是每次setOption都重绘。
  2. 只更新变化的数据:不要每次都传整个option,只传变化的部分(比如series.data),并且把notMerge设为false(默认),这样ECharts会合并配置项,只重绘变化的部分。
  3. 开启ECharts的大数据量渲染优化:ECharts 5.0之后引入了专门的大数据量渲染模式,比如line图表可以设置series[0].sampling为'lttb'(最大三角形三桶采样算法),能大幅减少渲染的数据量,同时保留数据的趋势;scatter图表可以设置series[0].large为true,series[0].largeThreshold为1000(默认是2000),当数据量超过这个阈值时,ECharts会开启WebGL渲染,性能能提升几十倍。
  4. 加个节流函数(throttle)而不是防抖函数:如果是实时刷新的数据(比如股票行情、传感器数据),用防抖函数会导致数据更新不及时,这时候应该用节流函数,把重绘的频率控制在50-100ms一次。

坑5:暗黑模式切换时样式不同步

暗黑模式现在是很多网站的标配,ECharts本身有内置的light和dark主题,但如果只是简单地修改theme,图表的背景色可能不会和网站的暗黑模式背景色完全一致,而且有些自定义的颜色(比如series的颜色、坐标轴的颜色)可能需要同步修改。 解决方案

  1. 引入ECharts的内置暗黑主题:先从ECharts官网下载内置的dark主题文件,或者直接在项目里引入(ECharts 5.0之后内置主题可以直接从echarts/themes/dark导入)。
  2. 监听网站的暗黑模式变化:可以用window.matchMedia('(prefers-color-scheme: dark)')来监听系统的暗黑模式变化,也可以用Vuex/Pinia来存储网站的暗黑模式状态,然后在通用组件里用watchEffect监听这个状态。
  3. 自定义主题适配:如果内置的主题不符合需求,可以用ECharts官网的主题编辑器(Theme Editor)自定义一个符合网站设计规范的暗黑主题,下载主题文件后引入项目,然后在通用组件里切换。
  4. 设置图表的背景色为透明:把option.backgroundColor设为'transparent',这样图表的背景色就会和网站的背景色完全一致,不用单独修改。

坑6:SSR(服务端渲染)时报错window is not defined

如果你的项目用的是Nuxt3或者VitePress等SSR框架,直接在setup的顶层代码里引入ECharts或者初始化ECharts实例,肯定会报错window is not defined,因为ECharts是依赖浏览器环境的(需要DOM、window对象)。 解决方案

  1. 在客户端渲染时才引入ECharts:用Vue3的onMounted或者Nuxt3的client-only组件包裹图表组件。
  2. 用动态导入(import()):在onMounted里用动态导入的方式引入ECharts,这样只有在客户端才会加载ECharts的代码。
  3. 在Nuxt3里配置build.transpile:如果引入的是ECharts的自定义主题文件,可能需要在nuxt.config.ts里配置build.transpile把主题文件转译一下。

项目实战:用通用组件写一个实时刷新的折线图

讲了这么多理论,现在给大家看一个项目实战的例子,用刚才的通用组件写一个模拟股票行情的实时刷新折线图。 先把刚才的通用组件写出来,保存为components/BaseECharts.vue:

<template>
  <div
    :id="chartId"
    class="base-echarts"
    :style="{ width: formatSize(width), height: formatSize(height) }"
  ></div>
</template>
<script setup>
import {
  ref,
  shallowRef,
  onMounted,
  onBeforeUnmount,
  watch,
  watchEffect,
  defineProps,
  defineExpose,
  nextTick
} from 'vue'
import * as echarts from 'echarts'
import 'echarts/themes/dark' // 引入内置暗黑主题
// 定义props
const props = defineProps({
  chartId: {
    type: String,
    default: () => `chart_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
  },
  width: {
    type: [String, Number],
    default: '100%'
  },
  height: {
    type: [String, Number],
    default: '400px'
  },
  option: {
    type: Object,
    required: true
  },
  theme: {
    type: String,
    default: 'light'
  },
  notMerge: {
    type: Boolean,
    default: true
  },
  lazyUpdate: {
    type: Boolean,
    default: false
  }
})
// 格式化尺寸
const formatSize = (size) => {
  return typeof size === 'number' ? `${size}px` : size
}
// 定义shallowRef包裹的ECharts实例
const chartInstance = shallowRef(null)
// 定义防抖函数
const debounce = (fn, delay = 200) => {
  let timer = null
  return (...args) => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      fn.apply(this, args)
    }, delay)
  }
}
// 定义ResizeObserver的回调函数(加防抖)
const resizeHandler = debounce(() => {
  chartInstance.value?.resize()
})
// 定义ResizeObserver实例
let resizeObserver = null
// 初始化ECharts实例
const initChart = async () => {
  await nextTick()
  const dom = document.getElementById(props.chartId)
  if (!dom) return
  // 先销毁已存在的实例
  chartInstance.value?.dispose()
  // 初始化新实例
  chartInstance.value = echarts.init(dom, props.theme)
  // 设置配置项
  chartInstance.value.setOption(props.option)
  // 注册ResizeObserver
  if (resizeObserver) {
    resizeObserver.disconnect()
  }
  resizeObserver = new ResizeObserver(resizeHandler)
  resizeObserver.observe(dom)
}
// 更新图表配置项
const updateChart = () => {
  if (!chartInstance.value || !props.option) return
  chartInstance.value.setOption(props.option, props.notMerge, props.lazyUpdate)
}
// 监听option的变化(深度监听)
watch(
  () => props.option,
  () => {
    updateChart()
  },
  { deep: true }
)
// 监听theme的变化
watchEffect(() => {
  initChart()
})
// 暴露方法给父组件
defineExpose({
  getChartInstance: () => chartInstance.value,
  resize: () => chartInstance.value?.resize(),
  clear: () => chartInstance.value?.clear(),
  dispose: () => {
    chartInstance.value?.dispose()
    chartInstance.value = null
    if (resizeObserver) {
      resizeObserver.disconnect()
      resizeObserver = null
    }
  }
})
// 生命周期钩子函数
onMounted(() => {
  initChart()
})
onBeforeUnmount(() => {
  // 销毁ECharts实例
  chartInstance.value?.dispose()
  chartInstance.value = null
  // 断开ResizeObserver
  if (resizeObserver) {
    resizeObserver.disconnect()
    resizeObserver = null
  }
  // 清除防抖函数的定时器
  resizeHandler.clear?.()
})
</script>
<style scoped>
.base-echarts {
  box-sizing: border-box;
}
</style>

在父组件(比如pages/StockChart.vue)里写模拟股票行情的实时刷新代码:

<template>
  <div class="stock-chart-container">
    <h2>模拟股票行情实时刷新</h2>
    <div class="toggle-theme-btn" @click="toggleTheme">
      切换{{ isDark ? '浅色' : '深色' }}模式
    </div>
    <BaseECharts
      chartId="stock_chart"
      height="500px"
      :option="chartOption"
      :theme="isDark ? 'dark' : 'light'"
      :lazyUpdate="true"
      ref="baseEChartsRef"
    />
  </div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import BaseECharts from '@/components/BaseECharts.vue'
// 定义是否暗黑模式
const isDark = ref(false)
// 定义通用组件的ref
const baseEChartsRef = ref(null)
// 定义图表配置项
const chartOption = ref({
  backgroundColor: 'transparent', {
    text: '模拟股票价格走势',
    left: 'center'
  },
  tooltip: {
    trigger: 'axis',
    formatter: (params) => {
      const data = params[0]
      return `${data.name}<br/>价格:${data.value.toFixed(2)}元`
    }
  },
  grid: {
    left: '3%',
    right: '4%',
    bottom: '3%',
    containLabel: true
  },
  xAxis: {
    type: 'category',
    boundaryGap: false,
    data: []
  },
  yAxis: {
    type: 'value',
    scale: true,
    min: (value) => value.min - 1,
    max: (value) => value.max + 1
  },
  series: [
    {
      name: '股票价格',
      type: 'line',
      smooth: true,
      symbol: 'none',
      sampling: 'lttb', // 开启最大三角形三桶采样算法
      large: true, // 开启大数据量渲染模式
      largeThreshold: 500, // 数据量超过500条时开启WebGL渲染
      areaStyle: {
        opacity: 0.3
      },
      lineStyle: {
        width: 2
      },
      data: []
    }
  ]
})
// 定义模拟股票数据的变量
let initialPrice = 100
let time = 0
let timer = null
// 定义节流函数(实时刷新用)
const throttle = (fn, delay = 50) => {
  let lastTime = 0
  return (...args) => {
    const now = Date.now()
    if (now - lastTime >= delay) {
      lastTime = now
      fn.apply(this, args)
    }
  }
}
// 定义更新股票数据的函数(加节流)
const updateStockData = throttle(() => {
  // 模拟价格波动
  const change = (Math.random() - 0.5) * 2
  initialPrice += change
  initialPrice = Math.max(50, Math.min(150, initialPrice)) // 限制价格在50-150元之间
  // 生成时间标签
  const timeLabel = `10:${String(Math.floor(time / 60)).padStart(2, '0')}:${String(time % 60).padStart(2, '0')}`
  time++
  // 更新图表配置项(只更新变化的部分)
  chartOption.value.xAxis.data.push(timeLabel)
  chartOption.value.series[0].data.push(initialPrice.toFixed(2))
  // 只保留最近500条数据,防止内存占用过大
  if (chartOption.value.xAxis.data.length > 500) {
    chartOption.value.xAxis.data.shift()
    chartOption.value.series[0].data.shift()
  }
})
// 切换主题
const toggleTheme = () => {
  isDark.value = !isDark.value
  // 切换主题后手动触发一次重绘,确保样式完全同步
  setTimeout(() => {
    baseEChartsRef.value?.resize()
  }, 100)
}
// 生命周期钩子函数
onMounted(() => {
  // 初始化100条数据
  for (let i = 0; i < 100; i++) {
    updateStockData()
  }
  // 开启定时器,每50ms更新一次数据(但实际重绘频率是50ms,因为加了节流)
  timer = setInterval(updateStockData, 50)
})
onBeforeUnmount(() => {
  // 清除定时器
  clearInterval(timer)
  timer = null
})
</script>
<style scoped>
.stock-chart-container {
  padding: 20px;
  max-width: 1200px;
  margin: 0 auto;
}
.toggle-theme-btn {
  display: inline-block;
  margin-bottom: 20px;
  padding: 8px 16px;
  background-color: #409eff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.3s;
}
.toggle-theme-btn:hover {
  background-color: #66b1ff;
}
</style>

Vue3+ECharts的开发其实不难,只要搞懂底层的响应式原理差异,写一个高复用的通用组件,避开常见的坑,比如不用ref/reactive包裹ECharts实例、用nextTick确保DOM挂载完成、用ResizeObserver加防抖处理容器尺寸变化、开启ECharts的大数据量渲染优化,就能写出性能好、复用性高的图表组件。 大家平时可以多去ECharts官网看看文档和示例,ECharts的功能非常强大,除了基础的折线图、柱状图、饼图,还有地图、热力图、关系图、桑基图等等,能满足99%的可视化需求,如果遇到问题,也可以去ECharts的GitHub仓库提Issue,或者去技术论坛提问,很多热心的开发者会帮你解决。 希望这篇文章能帮到刚接触Vue3+ECharts的小伙伴,如果觉得有用的话,记得点赞、收藏、转发哦!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门