Ionic中Chart.js图表渲染优化及标签切换问题解决方案
问题解决方案
核心问题分析
- 渲染缓慢:默认Chart动画耗时+异步数据加载后不合理的延迟等待,导致初始化慢
- 切换标签报错: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
相关产品推荐
相关产品推荐

