发布订阅模式处理echarts多图表联动

1,239 阅读4分钟

发布订阅是设计模式中比较出名的一个了,只要不是刚入门的同学,多多少少也有对这个词有所耳闻。 学习过VUE框架的同学都应该知道其 MVVM 架构的实现原理就是数据劫持+发布订阅模式

什么是发布订阅呢?

百度百科说的太详细了太复杂了“发布/订阅”,翻译过来就是:发布-订阅模式其实是一种对象间一对多的依赖关系,当一个对象的状态发送改变时,所有依赖于它的对象都将得到状态改变的通知。

啥意思呢?拿回 vue 架构作为例子,我们在使用 vue 的时候都知道,我们修改非对象的变量时,不论你有多少个地方用了这个变量,修改的变量都会实时重新渲染到 dom 上。这是因为在定义该变量的时候做数据劫持的时候就在 getter 和 setter 方法中使用了发布订阅模式,使其存在一对多的能力。想详细了解的小伙伴可以自行去查阅~


发布订阅看起来很高级,但是当项目中却忘了该在怎么样的场景去使用了。 今天刚好有一个 echarts 的需求可以使用,那就写下来吧~

QQ20220307-103118-HD.gif

需求情况:存在如上图的多个图表,但是点击不同的图表可能存在不同事件(如:点击1,2图表会在1,3中打上标记线,但是点击3却不触发标记线的修改)

按照业务优先的思想去实现这个功能的话,通常我们会直接在这些图表的父级组件上用 useState 存储起来当前的鼠标点击的位置,并将该位置派发给其他的子组件,每个子组件在根据该 state 变化后用该去调用什么方法。 但是考虑到扩展性,这里我选择了使用发布订阅模式。因为可以对每个图表注册对应的事件,让每个注册的事件被触发时自动去回调相应的行为。

发布订阅类

/*
 * 定义一个事件发布与新增订阅者的帮助类
 */
import type { EChartsInstance } from "echarts-for-react"

export default class Listener {
  events = {}
// 注册事件,事件被触发时候执行对应的回调
  on(name: string, cb: () => void) {
    if (this.events[name]) {
      (this.events[name].push(cb))
    } else {
      (this.events[name] = [cb])
    }
  }
  
// 触发事件,并将对应的参数传入回调
  trigger(name: string, ...args: any[]) {
    if (this.events[name]) {
      this.events[name].forEach((func: EChartsInstance) => {
        func(...args)
      })
    }
  }

}

export interface IListenerProps {
  on: (name: string, cb: (...arg: any) => void) => void
  trigger: (name: string, ...args: any) => void
}

定义好了这个帮助类,我们回到 echart 上处理图表对应的事件,因为我们点击图表,不仅仅在图例上才触发,而是空白处也会触发的。

监听空白处事件

在echart中,zrender 事件和 echarts 事件,zrender 事件与 echarts 事件不同。前者是当鼠标在任何地方都会被触发,而后者是只有当鼠标在图形元素上时才能被触发。事实上,echarts 事件是在 zrender 事件的基础上实现的,也就是说,当一个 zrender 事件在图形元素上被触发时,echarts 将触发一个 echarts 事件给开发者。

import ReactECharts from 'echarts-for-react';
import { useEffect, useRef, useState } from 'react';
import type { FC } from 'react';
import type { IListenerProps } from './lister';

interface IListener {
  listener: IListenerProps
}

const LineChart: FC<IListener> = (props) => {
 // 父类将帮助类实例 传到子组件中,方便注册事件。
  const { listener } = props
  const [option, setOption] = useState({})
  const ref = useRef<any>(null)
  const instance = ref?.current?.getEchartsInstance()
  useEffect(() => {
    setOption({
      grid: { top: 8, right: 8, bottom: 24, left: 36 },
      tooltip: {
        trigger: 'axis',
        showContent: false,
        axisPointer: {
          axis: 'x',
          snap: true,
          lineStyle: {
            color: 'rgba(86,86,86,0.50)'
          }
        }
      },
      xAxis: {
        type: 'category',
        data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
      },
      yAxis: {
        type: 'value',
      },
      series: [
        {
          data: new Array(7).fill(0).map(() => 1000 * Math.random()),
          type: 'line',
          smooth: 0.6,
          labelLine: {
            show: false
          },
          lineStyle: {
            type: 'solid',
            color: '#ffdd00',
            width: 5
          },
        },
      ],
    })
  }, [])


  if (instance) {
    const zr = instance?.getZr()
    // 订阅事件
    const marklineAction = (arg: object) => { instance.setOption(arg) }
    listener.on('markline', marklineAction)
    // 按下鼠标、移动鼠标
    zr.on('click', (event: any) => {
      const { offsetX, offsetY } = event
      const pointInPixel = [offsetX, offsetY]
      if (instance.containPixel('grid', pointInPixel)) {
        const xIndex = instance.convertFromPixel({ seriesIndex: 0 }, pointInPixel)[0]
        const params = {
          series: [{
            markLine: {
              lineStyle: {
                type: 'solid'
              },
              data: [{ xAxis: xIndex }]
            }
          }]
        }

        // 图例点击 发布给所有订阅者
        zr.on('click', listener.trigger('markline', params))
      }
    });
  }


  return <ReactECharts
    lazyUpdate={false}
    option={option}
    notMerge={false}
    ref={(e) => { ref.current = e; }}
    {...props}
  />
}

export default LineChart

此处是通过 ref 拿到图表的 eachrt 对象,通过调用对象上的 getEchartsInstance() 拿到实例,实例中能访问到 getZr() 方法,在该方法下调用on方法可以坚挺zrender对象的点击事件,拿到对应 offsetX 和 offsetY,通过 convertFromPixel、convertFromPixel、containPixel 对其转换成对应的坐标,通过setOption在图表上画上对应位置的标记线。

其实还可以注册其他的事件,这里就不再赘述了。 灵活变通一下工作中其实还是能常用的~