You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Ionic中Chart.js图表渲染优化及标签切换问题解决方案

问题解决方案

核心问题分析

  1. 渲染缓慢:默认Chart动画耗时+异步数据加载后不合理的延迟等待,导致初始化慢
  2. 切换标签报错:DOM元素复用/销毁时Chart实例未彻底清理,ID冲突,订阅未取消导致重复触发创建逻辑

优化后的代码实现

HTML部分

<ion-content [fullscreen]="true">
  @if (loading) {
    <ion-spinner name="crescent" id="spinner" style="--width: 50px; --height: 50px;"></ion-spinner>
  } @else if (noData) {
    <div id="no-data" class="no-data">
      <ion-title>No data available</ion-title>
    </div>
  } @else {
    <div style="position: relative; height: 40vh;" id="expense-chart">
      <div id="chart">
        <ion-title class="chart-title">Expenses:</ion-title>
        <canvas #chartCanvas style="height: 100%; width: 100%;"></canvas>
      </div>
    </div>

    <div class="percentages">
      <ion-title class="chart-title">Percentages:</ion-title>
      <div class="percentages-container">
        <div *ngFor="let pair of expensePercentages; trackBy: trackByCategory; let i = index" class="percentage">
          <ion-label id="category">{{pair.category}}:</ion-label>
          <ion-label>{{pair.percentage | percent}}</ion-label>
        </div>
      </div>
    </div>
  }
</ion-content>

TypeScript部分

import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ArcElement, Chart, ChartConfiguration, DoughnutController, Legend, Tooltip } from 'chart.js';
import { FirestoreService } from '../firestore.service';
import { Observable, Subject, takeUntil } from 'rxjs';
import { BudgetService } from '../budget.service';
import { IonicModule } from '@ionic/angular';

// 只注册必需的Chart组件,减少冗余
Chart.register(ArcElement, Tooltip, Legend, DoughnutController);

@Component({
  selector: 'app-expenses-stats',
  templateUrl: './expenses-stats.page.html',
  styleUrls: ['./expenses-stats.page.scss'],
  standalone: true,
  imports: [IonicModule, CommonModule, FormsModule]
})
export class ExpensesStatsPage implements OnInit, OnDestroy, AfterViewInit {
  noData = false;
  loading = true;
  labels: string[] = [];
  selectedMonth$!: Observable<number>;
  selectedMonth = new Date().getMonth();
  changedBudget$!: Observable<'Expense' | 'Income' | null>;
  expensePercentages!: { category: string; percentage: number }[];
  
  // 使用ViewChild获取canvas元素,避免全局ID冲突
  @ViewChild('chartCanvas') chartCanvas!: ElementRef<HTMLCanvasElement>;
  private myChart1: Chart | null = null;
  // 用于统一取消订阅的信号量
  private destroy$ = new Subject<void>();

  constructor(
    private firestoreService: FirestoreService,
    private budgetService: BudgetService
  ) {
    this.changedBudget$ = budgetService.changedBudget$;
    this.selectedMonth$ = firestoreService.month$;
  }

  ngOnInit() {
    // 订阅月份变化,自动更新图表
    this.selectedMonth$
      .pipe(takeUntil(this.destroy$))
      .subscribe(month => {
        this.selectedMonth = month;
        this.loadAndRenderChart();
      });

    // 订阅支出数据变化,更新图表
    this.changedBudget$
      .pipe(takeUntil(this.destroy$))
      .subscribe(type => {
        if (type === 'Expense') {
          this.loadAndRenderChart();
        }
      });
  }

  ngAfterViewInit() {
    this.loadAndRenderChart();
  }

  ngOnDestroy() {
    // 取消所有订阅,防止内存泄漏
    this.destroy$.next();
    this.destroy$.complete();
    // 彻底销毁图表实例
    this.destroyChart();
  }

  /**
   * 封装数据加载和图表渲染逻辑,分离关注点
   */
  private async loadAndRenderChart() {
    this.loading = true;
    this.noData = false;
    this.destroyChart();

    try {
      const uid = localStorage.getItem('userId')!;
      this.labels = await this.firestoreService.getCategories('Expense');
      const data = await this.firestoreService.getExpenseData(uid, this.selectedMonth);

      if (Object.keys(data).length === 0) {
        this.noData = true;
        return;
      }

      // 处理数据,转换为Chart可用格式
      const { arrayData, total } = this.processChartData(data);
      this.expensePercentages = this.calculatePercentages(arrayData, total);

      // 渲染图表
      this.renderChart(arrayData);
    } catch (error) {
      console.error('加载图表数据失败:', error);
      this.noData = true;
    } finally {
      this.loading = false;
    }
  }

  /**
   * 处理原始数据为Chart可用格式
   */
  private processChartData(rawData: Record<string, number>) {
    let total = 0;
    const arrayData = this.labels.map(label => {
      const value = rawData[label] || 0;
      total += value;
      return value;
    });
    return { arrayData, total };
  }

  /**
   * 计算各分类百分比
   */
  private calculatePercentages(data: number[], total: number) {
    return data.map((value, index) => ({
      category: this.labels[index],
      percentage: total > 0 ? value / total : 0
    }));
  }

  /**
   * 渲染Chart实例
   */
  private renderChart(data: number[]) {
    if (!this.chartCanvas.nativeElement) return;

    const chartConfig: ChartConfiguration = {
      type: 'pie',
      data: {
        labels: this.labels,
        datasets: [{
          data,
          backgroundColor: this.generateHexColors(this.labels.length)
        }]
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        // 关闭动画,大幅加快渲染速度
        animation: {
          duration: 0
        },
        plugins: {
          tooltip: {
            callbacks: {
              label: (tooltipItem) => `$${tooltipItem.raw?.toFixed(2) || 0}`
            }
          }
        },
        layout: {
          padding: 10
        }
      }
    };

    this.myChart1 = new Chart(this.chartCanvas.nativeElement, chartConfig);
  }

  /**
   * 销毁现有图表实例,避免内存泄漏和ID冲突
   */
  private destroyChart() {
    if (this.myChart1) {
      this.myChart1.destroy();
      this.myChart1 = null;
    }
    // 额外清理Chart全局缓存的实例,彻底解决残留问题
    Chart.instances.forEach(instance => {
      if (instance.canvas === this.chartCanvas?.nativeElement) {
        instance.destroy();
      }
    });
  }

  /**
   * 生成均匀分布的美观颜色
   */
  private generateHexColors(count: number): string[] {
    const colors: string[] = [];
    // 用黄金比例生成视觉更和谐的颜色
    const goldenRatio = 0.618033988749895;
    let hue = Math.random();

    for (let i = 0; i < count; i++) {
      hue += goldenRatio;
      hue %= 1;
      colors.push(`hsl(${hue * 360}, 70%, 60%)`);
    }

    return colors;
  }

  /**
   * 优化*ngFor渲染性能,避免不必要的DOM重绘
   */
  trackByCategory(index: number, item: { category: string }) {
    return item.category;
  }
}

关键优化说明

  • 解决ID冲突:用ViewChild直接获取canvas元素,替代全局ID选择,彻底消除「Chart with id X必须删除」的错误
  • 提升渲染速度:关闭Chart动画(animation.duration:0),移除无用的setTimeout,大幅缩短初始化等待时间
  • 实例彻底清理:封装destroyChart方法,同时销毁组件内实例和Chart全局缓存的实例,确保标签切换时无残留
  • 订阅管理:用takeUntil统一管理订阅,组件销毁时自动取消所有订阅,防止重复触发图表创建逻辑
  • 代码解耦:将数据加载、处理、渲染拆分到独立方法,提升代码可维护性
  • 性能优化:添加trackBy优化*ngFor重渲染,用HSL颜色生成替代Hex,颜色分布更均匀美观
  • 异常处理:用try/catch包裹异步数据加载,避免数据异常导致图表崩溃

内容的提问来源于stack exchange,提问作者georgio bou sleimen

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.14 02:59:49