likes
comments
collection
share

手摸手教学如何使用tsx + vue3.x + echarts + vueuse封装高级特性的可视化组件

作者站长头像
站长
· 阅读数 4

前言

在日常开发任务中,chart 图表是打交道最多的库之一。并且有许多优秀的开源二次封装库,但是这里就不做赘述。

今天带大家学习一波,如何基于 vue3.x, vueuse, echarts 封装一个具有以下高级特性的 chart 组件:

  • 异步渲染
  • 贴花配置
  • 加载配置
  • 视窗区域渲染
  • 自动更新尺寸
  • 节流更新图表
  • 自动根据 options 更新更新图表
  • 自动销毁
  • useChart 方法
  • 还有一些不值一提的基础特性...

由于篇幅的原因,只贴出一些核心的实现细节,如果需要查看具体代码,可以点击查看:

项目初始化

如何初始化项目这里就不做赘述,直接进入主题。

不会的同学,可以去看对应的官网文档:

上手起步

创建一个 RChart 文件夹,包含以下的文件与文件夹:Chart.tsx, types.ts, props.ts, index.scss, hooks

RChart
├── Chart.tsx
├── types.ts
├── props.ts
├── index.scss
└── hooks
    ├── useChart.ts

props.ts

Q: 为什么需要把 props 配置项单独提出来呢?

A:一部分原因是为了更好的支持 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 特性封装为配置项的形式去使用。

// 监听是否启用了贴花
watch(
  () => props.showAria,
  () => {
    destroyChart()
    updateChartTheme()
  },
)
// 监听 loading 变化
watchEffect(() => {
  props.loading
    ? echartInst?.showLoading(props.loadingOptions)
    : echartInst?.hideLoading()
})

没想到吧,就这么简单的实现了。

异步渲染

当渲染的数据过多、一次性渲染的图表过多,可能会导致页面卡顿,这时候就需要异步渲染。

其实核心的思想就是利用 nextTick api 去实现,将一些关键的操作丢至下一个队列中执行。

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 方法去实现。

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 方法去实现。

// 如果配置启用 intersectionObserver,则监听图表是否在可视区域内
if (props.intersectionObserver) {
  intersectionObserverReturn = useIntersectionObserver(
    props.intersectionObserverTarget || rayChartWrapperRef,
    ([entry]) => {
      targetIsVisible.value = entry.isIntersecting
    },
    props.intersectionOptions,
  )
}

当然,该配置项需要浏览器支持 IntersectionObserver API。并且,在初始化渲染了 chart 后需要移除,避免重复监听。

// 初始化完成后移除 intersectionObserver 监听
const mount = () => {
  // 省略部分代码...

  intersectionObserverReturn?.stop()
}

经过如上的操作,我们就实现了视窗区域渲染。组件仅仅会在视窗区域内渲染,避免了一次性渲染过多图表导致页面卡顿。

自动销毁

在组件销毁的时候,我们需要手动销毁 chart 实例,避免内存泄漏。仅需在 vue 组件卸载之前调用 onBeforeUnmount 钩子即可。

const unmount = () => {
  // 省略部分代码...
}

onBeforeUnmount(() => {
  unmount()
  watchThrottledCallback?.()
})

节流更新图表

该方法可以基于 vueuse 提供的 watchThrottled 方法去实现。

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 官方还提供了主题编辑器,我们只需要按照以下步骤即可完成:

  1. 配置、选择主题
  2. 点击下载主题
  3. 选择 json 类型,然后复制
  4. 选择一个存放主题包的文件夹
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 方法,我们就可以获取到所有的主题配置项。

import { use, registerTheme, init } from 'echarts/core' // echarts 核心模块

// 获取 chart 主题
const echartThemes = setupChartTheme()

// 注册主题
echartThemes.forEach((curr) => {
  registerTheme(curr.name, curr.theme)
})

useChart 方法

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 方法。

const useChart = () => {
  // 省略部分代码...

  return [
    register,
    {
      getChartInstance,
      isDispose,
      dispose,
      render,
    },
  ] as const
}

然后我们现在来对比一下同样的功能代码编写:

<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>

使用

当我们封装好该组件后,可以快速上手体验一下。

<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>

最后

这里就不做赘述,想给大家提供一个封装的思路。

感谢大家的阅读,谢谢~~~

转载自:https://juejin.cn/post/7354929098098262028
评论
请登录