返回

解决 Vue Chart.js 报错:'addEventListener' of null

vue.js

解决 Vue Chart.js “Cannot read properties of null (reading 'addEventListener')” 错误

当在 Vue 应用中使用 Chart.js 构建图表,并且涉及到图表的动态更新,特别是在结合诸如 chartjs-plugin-zoom 等插件时,你可能会遇到 “Cannot read properties of null (reading 'addEventListener')” 的错误。这个错误通常指示程序尝试访问一个空对象 (null) 的属性,在这种情况下, addEventListener 函数隶属于 null ,导致了异常。 这个错误常见的原因是当图表实例被销毁后,试图在已销毁的实例上执行某些操作。以下探讨问题根源以及相应的解决方案。

问题分析

出现 Cannot read properties of null (reading 'addEventListener') 错误的一个典型情景是: 你使用 this.chart.destroy() 销毁 Chart.js 图表实例,而后又在尚未重新初始化的情况下尝试操作它,比如更新配置、绑定事件监听等等。 在示例代码中,组件中使用了 select 元素的 change 事件,当选中值变化时重新创建图表。如果组件最初渲染完成后再更新数据时,会调用 destroy(),这时已经销毁的图表依然尝试使用缩放插件进行操作,从而抛出此异常。chartjs-plugin-zoom 插件在每次重新创建图表时, 都需要绑定 addEventListener 来处理图表的交互。当 this.chart 已经被销毁变为 null,它自然会抛出“无法读取空值的 'addEventListener' 属性”错误。

解决方案

针对此问题,以下提供几个解决方案,逐步分析原理并提供相应的代码示例。

1. 条件渲染 (Conditional Rendering)

一个简便的解决方案是使用条件渲染。当需要重新创建图表时,先隐藏 canvas 元素,在图表重新初始化后再显示。这样确保图表实例被完全销毁和重新创建,避免操作到 null 实例。

原理:

v-if 指令值为 false时, canvas 元素会从 DOM 中移除,当条件再次变为真, 会创建一个全新的 canvas 元素和相关的 chart 实例。这样做,插件操作的实例总会是一个存在的且已经初始化好的对象,自然就避免了 null 的问题。

操作步骤:

  1. data 中增加一个 showChart 变量, 默认为 true
  2. v-ifcanvas 元素包裹起来。
  3. 在重新创建图表函数 (createChart) 之前, 将 showChart 设置为 false, 在重新创建图表函数 (createChart) 的末尾再设置为 true

代码示例:

<template>
    <div>
        <select v-model="selectedAttribute" @change="createChart" class="combobox">
            <option v-for="option in options" :key="option" :value="option" class="options">{{ option }}</option>
        </select>
      
        <div v-if="showChart">
         <canvas ref="barChart" />
        </div>
        
        <button @click="resetZoom">Reset Zoom</button>
    </div>
</template>

<script>
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, BarController } from 'chart.js';
import zoomPlugin from 'chartjs-plugin-zoom';

ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, BarController, zoomPlugin);

export default {
  props: {
    usersIn: {
      type: Array,
      required: true,
      default: () => []
    },
    usersOut: {
      type: Array,
      required: true,
      default: () => []
    }
  },
  data() {
    return {
        chart: null,
        showChart: true,
        selectedAttribute: 'Division',
        options: ['Division', 'Company', 'Type', 'Domain', 'Responsibility', 'Department', 'Location'],
    };
  },
  mounted() {
    this.createChart();
  },
    methods: {
        createChart() {
        this.showChart = false;
            
            if(this.chart) {
                 this.chart.destroy();
            }
         

          const canvas = this.$refs.barChart;
           
          if(!canvas) {
             return;
          }

          const ctx = canvas.getContext('2d');

          const { inData, outData } = this.calculateRatios(this.selectedAttribute);


          this.chart = new ChartJS(ctx, {
            type: 'bar',
            data: {
                labels: inData.labels,
              datasets: [
              {
                label: 'In Users',
                  data: inData.data,
                backgroundColor: 'rgba(75, 192, 192, 0.5)',
                borderColor: 'rgba(75, 192, 192, 1)',
                borderWidth: 1,
                },
                {
                  label: 'Out Users',
                  data: outData.data,
                    backgroundColor: 'rgba(255, 99, 132, 0.5)',
                  borderColor: 'rgba(255, 99, 132, 1)',
                    borderWidth: 1,
                  }
            ]
            },
              options: {
            responsive: true,
              animation: false,
              indexAxis: 'y',
              scales: {
                x: {
                  title: {
                    display: true,
                    text: 'Frequency',
                  },
                },
                y: {
                  title: {
                      display: true,
                      text: this.selectedAttribute,
                  },
                    showticklabels: false,
                  autorange: 'reversed',
                }
            },
                plugins: {
                  legend: {
                    position: 'top',
                  },
                    zoom: {
                    zoom: {
                      drag: {
                          enabled: true,
                          borderColor: 'rgba(54, 162, 235, 0.5)',
                            borderWidth: 1,
                          backgroundColor: 'rgba(54, 162, 235, 0.3)',
                      },
                      onZoom: ({ chart }) => {
                       console.log('Se realizó zoom en el gráfico', chart);
                     },
                    }
                  }
                },
             },
        });
      this.showChart = true;

    },
}
};
</script>

2. 使用 chartjs-plugin-zoom 提供的 API

另一种方案是,避免直接销毁和重建图表,使用chartjs-plugin-zoom提供的 resetZoom API 重置缩放,然后更新图表的数据。

原理:

resetZoom 方法能够重置图表缩放比例。只需要在更新 select 选项时调用此方法重置,避免直接 destroy。这种方法更高效,因为它不会导致 canvas 的销毁和重建。

操作步骤:

1.  更新 `createChart` 函数, 在其中更新 `chart` 实例中的数据, 之后,调用 chart的 `update` 函数,而不是调用 `chart.destroy()` 。
2.  在 resetZoom 函数里使用 Chart.js实例的 `resetZoom` 函数进行缩放重置

代码示例:

<template>
    <div>
        <select v-model="selectedAttribute" @change="createChart" class="combobox">
            <option v-for="option in options" :key="option" :value="option" class="options">{{ option }}</option>
        </select>
        <canvas ref="barChart"/>
        <button @click="resetZoom">Reset Zoom</button>
    </div>
</template>

<script>
    import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, BarController } from 'chart.js';
    import zoomPlugin from 'chartjs-plugin-zoom';

    ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, BarController, zoomPlugin);

    export default {
    props: {
    usersIn: {
        type: Array,
          required: true,
      default: () => []
    },
    usersOut: {
    type: Array,
        required: true,
        default: () => []
    }
    },
    data() {
    return {
        chart: null,
    selectedAttribute: 'Division',
        options: ['Division', 'Company', 'Type', 'Domain', 'Responsibility', 'Department', 'Location'],
    };
    },
      mounted() {
      this.createChart();
    },
    methods: {
    createChart() {
          
    const canvas = this.$refs.barChart;
      if (!canvas) {
        return;
    }
    const ctx = canvas.getContext('2d');

  const { inData, outData } = this.calculateRatios(this.selectedAttribute);
          if(this.chart){
      this.chart.data.labels = inData.labels;
       this.chart.data.datasets[0].data=inData.data;
              this.chart.data.datasets[1].data=outData.data;

          this.chart.update();
       } else{
    this.chart = new ChartJS(ctx, {
    type: 'bar',
            data: {
              labels: inData.labels,
      datasets: [
            {
                label: 'In Users',
                  data: inData.data,
                backgroundColor: 'rgba(75, 192, 192, 0.5)',
                  borderColor: 'rgba(75, 192, 192, 1)',
                borderWidth: 1,
            },
        {
            label: 'Out Users',
                data: outData.data,
              backgroundColor: 'rgba(255, 99, 132, 0.5)',
              borderColor: 'rgba(255, 99, 132, 1)',
                borderWidth: 1,
              }
          ]
          },
              options: {
          responsive: true,
            animation: false,
              indexAxis: 'y',
          scales: {
              x: {
                title: {
                    display: true,
                    text: 'Frequency',
                },
                },
              y: {
                    title: {
                    display: true,
                        text: this.selectedAttribute,
                  },
                  showticklabels: false,
                      autorange: 'reversed',
              }
          },
          plugins: {
              legend: {
                    position: 'top',
                },
                zoom: {
                  zoom: {
                  drag: {
                  enabled: true,
                        borderColor: 'rgba(54, 162, 235, 0.5)',
                  borderWidth: 1,
                  backgroundColor: 'rgba(54, 162, 235, 0.3)',
              },
                onZoom: ({ chart }) => {
                    console.log('Se realizó zoom en el gráfico', chart);
              },
                }
              }
          },
          },
    });
        }

  },
    resetZoom(){
      if (this.chart) {
      this.chart.resetZoom()
      }
   },
   }
  };
  </script>

3. 安全编程建议

除了以上方案,还需要注意一些编码安全问题。

  1. 判空检查: 在任何操作 chart 实例的方法内,先判断实例是否存在。 即使采取上述方案,仍需防御潜在的异步或者其他场景导致的 null
  2. 避免过度销毁: 尽量通过 Chart.js API 去更新配置和数据, 减少不必要的图表销毁操作。过度的销毁和重建可能引起性能问题。
  3. 及时解绑事件: 在组件卸载或销毁图表实例时,考虑手动清除插件添加的事件监听器,以防止内存泄漏。 chartjs-plugin-zoom 应该会管理自身的监听器, 但保持警惕永远是好的实践。

总结

解决 “Cannot read properties of null (reading 'addEventListener')” 的根本方法在于理解 Chart.js 图表实例生命周期和异步操作的影响。通过条件渲染、利用插件提供的API或遵循更加稳健的安全编程模式, 能够有效地避免这类错误的发生。这些技术方案和安全提示,能够使 Vue.js 应用在构建动态图表功能时,更加的稳定与健壮。