iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Modern Web

30 天手把手學會 Chart.js v4:從圖表基礎到互動式資料視覺化實戰系列 第 27

Day 27 - 30 天手把手學會 Chart.js|與 Angular 整合

  • 分享至 

  • xImage
  •  

這兩天我們分別用 react-chartjs-2(Day 25)與 vue-chartjs(Day 26)把 Chart.js 帶進了 React 與 Vue 的世界。今天是框架整合系列的最後一站:Angular。Angular 是一個「電池全配(Batteries-included)」的框架,內建了完整的 Dependency Injection(依賴注入)系統、HttpClient、Router、RxJS 等工具,也因此與 Chart.js 的整合方式會跟 React/Vue 有不小的差異。今天會使用 Angular 官方推薦的 ng2-charts 套件,學習 Standalone 元件(Angular 現在的預設寫法,不再需要 NgModule)、provideCharts() 的設定方式、<canvas baseChart> 指令,並搭配 RxJS Observable 與 HttpClientInterceptor 機制,串接 Node.js 的 Express 後端,完成 React/Vue/Angular 三方框架整合的完整體驗。

本日範例程式碼:
前端:example01-angular-dashboard
後端 API Server:example02-api-server

一、為什麼不要在 Angular 裡直接用 new Chart()

延續前面 26 天的寫法,我們一直都是這樣建立圖表的:

const ctx = document.getElementById('myChart');
const chart = new Chart(ctx, { type: 'bar', data, options });

如果把這段程式碼直接搬進 Angular 元件的 ngAfterViewInit() 裡,畫面「能動」,但很快就會遇到跟 Angular 架構息息相關的麻煩:

  • Angular 的變更偵測(Change Detection)與 Chart.js 的動畫迴圈互相干擾:Angular 預設透過 Zone.js 監聽所有非同步事件(setTimeout、事件監聽、Promise 等),一有動靜就會觸發一次變更偵測,重新檢查整個元件樹要不要更新畫面。但 Chart.js 內部的動畫、滑鼠移動偵測、resize 監聽都是高頻率觸發的事件,如果讓這些事件通通被 Zone.js 攔截,會造成大量不必要的變更偵測,拖慢整個應用程式的效能。
  • 元件銷毀時忘記 destroy():跟 React/Vue 一樣,Angular 元件被銷毀時(例如切換路由)如果沒有呼叫 chart.destroy() 釋放資源,就會造成記憶體洩漏(Memory Leak)。
  • 資料更新要手動呼叫 API:Chart.js 本身不是響應式的,資料變了之後還是得手動呼叫 chart.update() 才會重繪。
  • 重複的樣板程式碼(Boilerplate):每個用到圖表的元件都要重複寫「建立 => 監聽輸入變化 => update() => 卸載時 destroy()」這一整套邏輯,而且還要自己處理跟 Angular Change Detection 之間的協調。

二、環境建置:用 npx 建立 Angular 22 Standalone 專案

2.1 建立前端專案

這裡我透過 npx 方式直接執行指定版本的 CLI,不需要在電腦上全域安裝 @angular/cli(也不會因為全域版本太舊或太新而跟教學步驟對不上):

npx @angular/cli@22 new chartjs-angular-demo --style=css --ssr=false
cd chartjs-angular-demo
  • --style=css:指定樣式表格式為原生 CSS(也可以選 scssless,這裡選最單純的 CSS,方便專注在圖表整合上)。
  • --ssr=false:關閉 Server-Side Rendering,這個範例只需要單純的瀏覽器端應用程式。
  • Angular 17 之後,CLI 建立的新專案預設就是 Standalone 架構(不會產生 app.module.ts),會直接產生 src/app/app.config.ts(應用程式層級的 Provider 設定)與 src/app/app.routes.ts(路由設定),這也是為什麼今天完全不會出現 @NgModule 的原因。

建立完成後,先確認可以正常啟動:

npm start

預設會在 http://localhost:4200 啟動開發伺服器,並支援檔案變更後自動重新整理。

2.2 安裝 Chart.js 與 ng2-charts

npm install chart.js@4.5.1
npm install ng2-charts@10.0.0

chart.jsng2-chartspeer dependency(同儕依賴),兩者需要一起安裝。根據 ng2-charts 官方的 Angular 版本相容表,v10.x 對應的是新版 Angular(Angular 21 以上),這裡指定 ng2-charts@10.0.0 搭配 Angular 22 使用。

2.3 全域設定:provideCharts(withDefaultRegisterables())

Chart.js 4.x 為了減少打包後的檔案大小,把所有圖表類型(Controller)、座標軸(Scale)、圖形元素(Element)、外掛(Plugin)都拆成獨立模組,需要手動 register() 才能使用。在 React/Vue 篇我們是在應用程式進入點呼叫 ChartJS.register(...);在 Angular 裡,ng2-charts 提供了對應 Standalone 架構的寫法:provideCharts() 這個 Provider 函式,搭配 withDefaultRegisterables() 一次註冊 Chart.js 所有內建的元件。

打開建立專案時自動產生的 src/app/app.config.ts,加入以下設定:

// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideCharts, withDefaultRegisterables } from 'ng2-charts';
import { routes } from './app.routes';
import { apiLoggerInterceptor } from './core/api-logger.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withInterceptors([apiLoggerInterceptor])),
    provideCharts(withDefaultRegisterables()),
  ],
};

apiLoggerInterceptor 是本篇會實作的 Interceptor,先照著打即可,稍後會補上這支檔案。)

main.ts 不需要修改,維持 Angular CLI 產生的預設內容即可:

// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err));

如果只想註冊實際會用到的圖表類型以縮小打包體積,可以不使用 withDefaultRegisterables(),改成手動列出需要的 Controller/Scale/Element/Plugin,例如:

import { BarController, CategoryScale, LinearScale, BarElement, Legend, Tooltip } from 'chart.js';
import { provideCharts } from 'ng2-charts';

provideCharts({ registerables: [BarController, CategoryScale, LinearScale, BarElement, Legend, Tooltip] });

教學階段建議先用 withDefaultRegisterables() 圖個方便,等專案上線前再依實際用到的圖表類型精簡。

三、<canvas baseChart> 指令用法

ng2-charts 不像 react-chartjs-2vue-chartjs 提供「具名圖表元件」(例如 <Bar><Line>),而是提供單一一個指令 baseChart,直接套用在原生的 <canvas> 元素上,用 [type] 屬性決定要畫哪一種圖表:

// src/app/components/monthly-sales-chart.component.ts
import { Component } from '@angular/core';
import { ChartConfiguration, ChartData } from 'chart.js';
import { BaseChartDirective } from 'ng2-charts';

@Component({
  selector: 'app-monthly-sales-chart',
  standalone: true,
  imports: [BaseChartDirective],
  template: `
    <div style="height: 360px">
      <canvas
        baseChart
        [data]="chartData"
        [options]="chartOptions"
        [type]="'bar'"
      ></canvas>
    </div>
  `,
})
export class MonthlySalesChartComponent {
  public chartData: ChartData<'bar'> = {
    labels: ['一月', '二月', '三月', '四月', '五月', '六月'],
    datasets: [
      {
        label: '銷售額(萬元)',
        data: [65, 59, 80, 81, 56, 72],
        backgroundColor: 'rgba(54, 162, 235, 0.6)',
      },
    ],
  };

  public chartOptions: ChartConfiguration<'bar'>['options'] = {
    responsive: true,
    maintainAspectRatio: false,
    plugins: {
      title: { display: true, text: '每月銷售額' },
    },
  };
}

因為 BaseChartDirectiveStandalone 指令,一定要記得把它加進元件的 imports 陣列,否則編譯時會出現 NG8002NG8001 之類的錯誤,提示 Angular 不認識 canvas 元素上的 baseChartdataoptions 這些屬性繫結。

跟前兩天一樣,maintainAspectRatio: false 搭配「外層容器給明確高度」是必備的組合,否則圖表可能無法正確填滿容器。

3.1 <canvas baseChart> 常用屬性一覽

屬性 型別 說明
[type] ChartType 圖表類型,例如 'line''bar''pie''doughnut''radar''polarArea''bubble''scatter'
[data] ChartData<TType> 完整的資料結構(labels + datasets),與原生 Chart.js 的 data 完全一致
[labels] TLabel[] 若不想一次傳整包 data,可以單獨傳 labels
[datasets] ChartDataset<TType>[] 搭配 [labels] 單獨傳入 datasets
[options] ChartOptions<TType> 圖表設定選項,與原生 Chart.js 的 options 完全一致
[legend] boolean 是否顯示圖例(會覆蓋 options.plugins.legend.display
[plugins] Plugin[] per-chart 的自訂外掛陣列
(chartClick) EventEmitter 點擊圖表時觸發,回傳 { event, active }
(chartHover) EventEmitter 滑鼠移動經過圖表時觸發,回傳 { event, active }

實務上通常直接用 [data] 傳整包資料(跟前兩天 React/Vue 篇的寫法一致),[labels][datasets] 則是給「只想單獨更新其中一部分」的情境使用。

四、元件化圖表設計:@Input()ChangeDetectorRef

4.1 用 @Input() 讓圖表元件可重複使用

上一節把資料寫死在元件內部,還沒有做到真正的「元件化」。實務上會用 @Input() 把資料抽出來,讓同一顆元件可以顯示不同的圖表:

// src/app/components/bar-chart.component.ts
import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core';
import { ChartConfiguration, ChartData } from 'chart.js';
import { BaseChartDirective } from 'ng2-charts';

@Component({
  selector: 'app-bar-chart',
  standalone: true,
  imports: [BaseChartDirective],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div style="height: 320px">
      <canvas baseChart [data]="chartData" [options]="chartOptions" [type]="'bar'"></canvas>
    </div>
  `,
})
export class BarChartComponent implements OnChanges {
  @Input({ required: true }) title = '';
  @Input({ required: true }) labels: string[] = [];
  @Input({ required: true }) values: number[] = [];
  @Input() color = 'rgba(54, 162, 235, 0.6)';

  public chartData: ChartData<'bar'> = { labels: [], datasets: [] };
  public chartOptions: ChartConfiguration<'bar'>['options'] = {
    responsive: true,
    maintainAspectRatio: false,
    plugins: { legend: { display: false } },
  };

  // 每次任何一個 @Input() 改變(收到新的參照),Angular 都會呼叫這裡重建 chartData
  ngOnChanges(): void {
    this.chartData = {
      labels: this.labels,
      datasets: [{ label: this.title, data: this.values, backgroundColor: this.color }],
    };
    this.chartOptions = {
      ...this.chartOptions,
      plugins: { title: { display: true, text: this.title }, legend: { display: false } },
    };
  }
}

使用時,只要傳入不同的 @Input(),就能重複利用同一顆元件畫出不同的圖表:

<!-- 使用範例(節錄自父元件樣板) -->
<app-bar-chart title="每月銷售額(萬元)" [labels]="['一月', '二月', '三月', '四月']" [values]="[65, 59, 80, 81]" />
<app-bar-chart
  title="每月退貨數量"
  [labels]="['一月', '二月', '三月', '四月']"
  [values]="[3, 5, 2, 4]"
  color="rgba(255, 99, 132, 0.6)"
/>

這就是「元件化圖表設計」的核心精神:圖表元件只負責「怎麼畫」,資料則由外部透過 @Input() 決定「畫什麼」,跟前兩天 React(props)、Vue(defineProps)的設計理念完全一致,只是換成了 Angular 的裝飾器語法。

4.2 陷阱:ngOnChanges 是靠「參照(Reference)比對」,不是靠「內容比對」

這裡有一個 Angular 特有、也是最容易讓新手卡關的細節:Angular 的 ngOnChanges()(無論是我們自己寫的元件,還是 BaseChartDirective 內部自己的 ngOnChanges())都是透過 SimpleChanges 比較 @Input() 的「參照(Reference)」是否改變,而不是深入比較內容。這代表:

// ❌ 錯誤示範:直接 push 到陣列裡,參照沒有變,Angular 偵測不到變化
this.values.push(100);

// ✅ 正確示範:建立一個新陣列(新的參照),才會觸發 ngOnChanges
this.values = [...this.values, 100];

同樣的道理,如果要更新 chartDataBaseChartDirective 重新渲染,也不要直接修改 this.chartData.datasets[0].data,而是要指派一個全新的物件:

// ❌ 錯誤:BaseChartDirective 偵測不到 data 的參照改變,圖表不會更新
this.chartData.datasets[0].data = newValues;

// ✅ 正確:給一個新的 data 物件參照
this.chartData = {
  ...this.chartData,
  datasets: [{ ...this.chartData.datasets[0], data: newValues }],
};

這一點跟 React(useState 需要新的物件觸發 re-render)、Vue(vue-chartjswatch(..., { deep: true }) 深層監聽)的處理方式不同:Angular 預設只做「淺層」的參照(Reference)比較,凡事都要養成「回傳新物件、而不是原地修改(mutate)」的習慣,這是 Angular 應用程式效能與正確性的重要基石,也是 OnPush 策略能夠正常運作的前提。

4.3 ChangeDetectionStrategy.OnPushChangeDetectorRef

上面的 BarChartComponent 特地標註了 changeDetection: ChangeDetectionStrategy.OnPush。這是 Angular 一個重要的效能優化策略:預設情況下(ChangeDetectionStrategy.Default),只要應用程式裡任何地方發生非同步事件,Angular 就會檢查每一個元件要不要更新畫面;改成 OnPush 之後,這顆元件只有在以下情況才會被重新檢查:

  1. 元件自己的某個 @Input() **參照(Reference)**改變了(這也是為什麼上一節要特別強調「給新參照(Reference)」的原因)。
  2. 元件內部觸發了某個有繫結的事件(例如按鈕 (click))。
  3. 開發者手動呼叫 ChangeDetectorRef.markForCheck(),主動告訴 Angular「這顆元件需要被重新檢查」。

前兩種情況 Angular 會自動幫我們處理,但第三種情況——元件內部因為「非 Angular 觸發的非同步來源」(例如 HttpClient 訂閱、WebSocket、第三方函式庫的 callback)而改變了內部欄位——就必須自己呼叫 markForCheck(),否則即使資料真的變了,Angular 也不會主動重新檢查這顆 OnPush 元件、畫面就不會更新。這個情境會在第七章「即時資料更新」的範例中實際出現。

五、事件綁定:chartClickchartHover

BaseChartDirective 提供了 (chartClick)(chartHover) 兩個輸出事件,搭配 @ViewChild(BaseChartDirective) 可以進一步操作圖表本身(例如 update()hideDataset()):

// src/app/components/clickable-bar-chart.component.ts
import { Component, ViewChild } from '@angular/core';
import { ChartConfiguration, ChartData, ChartEvent } from 'chart.js';
import { BaseChartDirective } from 'ng2-charts';

@Component({
  selector: 'app-clickable-bar-chart',
  standalone: true,
  imports: [BaseChartDirective],
  template: `
    <p style="font-size: 0.85rem; color: #666">{{ message }}</p>
    <div style="height: 320px">
      <canvas
        baseChart
        [data]="chartData"
        [options]="chartOptions"
        [type]="'bar'"
        (chartClick)="onChartClick($event)"
        (chartHover)="onChartHover($event)"
      ></canvas>
    </div>
  `,
})
export class ClickableBarChartComponent {
  @ViewChild(BaseChartDirective) chart?: BaseChartDirective<'bar'>;

  public message = '點擊長條可以看到對應的資料點資訊';

  public chartData: ChartData<'bar'> = {
    labels: ['北區', '中區', '南區', '東區'],
    datasets: [{ label: '本月訂單數', data: [42, 35, 58, 20], backgroundColor: 'rgba(76, 110, 245, 0.6)' }],
  };

  public chartOptions: ChartConfiguration<'bar'>['options'] = {
    responsive: true,
    maintainAspectRatio: false,
  };

  public onChartClick({ event, active }: { event?: ChartEvent; active?: object[] }): void {
    if (!active || active.length === 0) return;

    // active 陣列裡的每個項目都帶有 datasetIndex / index,可以反查回原始資料
    const { datasetIndex, index } = active[0] as { datasetIndex: number; index: number };
    const dataset = this.chartData.datasets[datasetIndex];
    const label = this.chartData.labels?.[index];
    const value = dataset.data[index];
    this.message = `點擊到「${dataset.label}」的「${label}」:${value}`;
  }

  public onChartHover({ active }: { event: ChartEvent; active: object[] }): void {
    // 可以在這裡實作「滑鼠移到哪個長條,就同步高亮某個 UI 區塊」等互動效果
  }

  public randomize(): void {
    this.chartData = {
      ...this.chartData,
      datasets: [{ ...this.chartData.datasets[0], data: this.chartData.datasets[0].data.map(() => Math.round(Math.random() * 100)) }],
    };
    this.chart?.update();
  }
}
  • @ViewChild(BaseChartDirective):因為 BaseChartDirective 是套用在 <canvas> 上的指令,Angular 會把它當成一般的子元件/指令來查詢,透過 chart.chart 就能拿到 Chart.js 的原生 instance(例如呼叫 this.chart?.chart?.toBase64Image() 匯出圖片)。
  • randomize() 手動呼叫 this.chart?.update():大部分情況下,只要給 chartData 新的參照(Reference),BaseChartDirectivengOnChanges 就會自動偵測並呼叫內部的 update();但如果是在同一個參照(Reference)上做了進一步的細部調整(不常見的情境),也可以像這樣手動呼叫 update() 強制重繪。

六、與 RxJS Observable 資料流結合:即時資料更新

Angular 從骨子裡就是圍繞著 RxJS 打造的框架(HttpClient、Router、FormControlvalueChanges 都回傳 Observable),這讓「定時向後端拉取資料、更新圖表」這件事有非常道地的寫法。

6.1 建立一支 StatsService 封裝 API 呼叫

// src/app/core/stats.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';

export interface StatsPayload {
  time: string;
  onlineUsers: number;
  orders: number;
}

@Injectable({ providedIn: 'root' })
export class StatsService {
  private http = inject(HttpClient);
  private readonly baseUrl = 'http://localhost:4000/api/stats';

  getStats(): Observable<StatsPayload> {
    return this.http.get<StatsPayload>(this.baseUrl);
  }

  getHistory(count = 10): Observable<StatsPayload[]> {
    const params = new HttpParams().set('count', count);
    return this.http.get<StatsPayload[]>(`${this.baseUrl}/history`, { params });
  }
}

@Injectable({ providedIn: 'root' }) 讓這個 Service 成為應用程式層級的單例(Singleton),不需要在 app.config.ts 額外註冊,任何元件都可以透過 inject(StatsService) 直接取得同一個實例。

6.2 用 timer + switchMap 實作定時拉取

// src/app/components/live-stats-chart.component.ts
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { switchMap, timer } from 'rxjs';
import { ChartConfiguration, ChartData } from 'chart.js';
import { BaseChartDirective } from 'ng2-charts';
import { StatsService } from '../core/stats.service';

const MAX_POINTS = 10; // 圖表上最多保留幾個資料點(Sliding Window)
const POLL_INTERVAL_MS = 3000;

@Component({
  selector: 'app-live-stats-chart',
  standalone: true,
  imports: [BaseChartDirective],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p *ngIf="errorMessage" style="color: #e03131; font-size: 0.85rem">{{ errorMessage }}</p>
    <div style="height: 320px">
      <canvas baseChart [data]="chartData" [options]="chartOptions" [type]="'line'"></canvas>
    </div>
  `,
})
export class LiveStatsChartComponent implements OnInit {
  private statsService = inject(StatsService);
  private cdr = inject(ChangeDetectorRef);
  private destroyRef = inject(DestroyRef);

  private labels: string[] = [];
  private onlineUsers: number[] = [];

  public errorMessage: string | null = null;

  public chartData: ChartData<'line'> = { labels: [], datasets: [{ label: '線上人數', data: [] }] };
  public chartOptions: ChartConfiguration<'line'>['options'] = {
    responsive: true,
    maintainAspectRatio: false,
    animation: { duration: 300 }, // 縮短動畫時間,讓即時更新看起來更順暢
    scales: { y: { beginAtZero: true } },
  };

  ngOnInit(): void {
    timer(0, POLL_INTERVAL_MS) // 立刻先發一次,之後每 3 秒重複
      .pipe(
        switchMap(() => this.statsService.getStats()), // 若前一筆還沒回來,切換到最新的一次請求
        takeUntilDestroyed(this.destroyRef), // 元件銷毀時自動取消訂閱,不需要手動寫 ngOnDestroy
      )
      .subscribe({
        next: (stats) => {
          this.labels = [...this.labels, stats.time].slice(-MAX_POINTS);
          this.onlineUsers = [...this.onlineUsers, stats.onlineUsers].slice(-MAX_POINTS);

          this.chartData = {
            labels: this.labels,
            datasets: [
              {
                label: '線上人數',
                data: this.onlineUsers,
                borderColor: 'rgb(255, 99, 132)',
                backgroundColor: 'rgba(255, 99, 132, 0.2)',
                fill: true,
                tension: 0.3,
              },
            ],
          };
          this.errorMessage = null;
          this.cdr.markForCheck(); // OnPush 元件務必主動通知 Angular:這次的資料變化需要重新檢查畫面
        },
        error: (err) => {
          this.errorMessage = `無法連線到後端 API:${err.message ?? err}`;
          this.cdr.markForCheck();
        },
      });
  }
}

幾個實務上很重要的細節:

  1. timer(0, 3000):第一個參數 0 代表「訂閱後立刻觸發第一次」,第二個參數代表「之後每 3000 毫秒觸發一次」,等同於 React/Vue 篇 setInterval + 「先呼叫一次再排程」的組合寫法,但用 RxJS 一行就能表達完整。
  2. switchMap:如果上一次的 API 請求還沒回應、下一個計時器又觸發了,switchMap 會自動取消上一個尚未完成的請求,改用最新的這一個,避免「回應順序錯亂」(例如網路慢的時候,舊的回應比新的回應晚到,導致圖表顯示過期資料)的問題。
  3. takeUntilDestroyed(this.destroyRef):這是 Angular 16 之後提供的工具,會在元件被銷毀時自動完成(complete)這個 Observable,等同於 React 篇的 useEffect cleanup、Vue 篇的 onUnmounted 清除 setInterval,但不需要自己額外寫 ngOnDestroy() 手動 unsubscribe(),大幅降低忘記取消訂閱、造成記憶體洩漏的風險。
  4. ChangeDetectorRef.markForCheck():因為這顆元件是 OnPush 策略,而資料來源是 HttpClient 的訂閱(非同步、且不是透過 @Input() 傳入),Angular 不會自動知道要重新檢查這顆元件,必須subscribe 的 callback 裡呼叫 markForCheck(),這正是第四章提到「第三種觸發 OnPush 重新檢查」的實際案例。

6.3 認識 Interceptor:攔截所有 HttpClient 請求

Angular 的 Interceptor 是一個很強大的機制:它可以攔截應用程式裡「所有」透過 HttpClient 發出的請求與回應,很適合用來做統一的日誌記錄(logging)、加上共用的 Header(例如 Authorization Token)、統一的錯誤處理、或是 Loading 狀態管理,而不需要在每一支 Service 裡重複寫這些邏輯。

Angular 15 之後推出了搭配 Standalone 架構設計的函式型 InterceptorHttpInterceptorFn),寫法比舊版的 class-based HttpInterceptor 更簡潔:

// src/app/core/api-logger.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { tap } from 'rxjs';

export const apiLoggerInterceptor: HttpInterceptorFn = (req, next) => {
  const startedAt = performance.now();
  console.log(`[API] → ${req.method} ${req.url}`);

  return next(req).pipe(
    tap({
      next: () => {
        const elapsedMs = Math.round(performance.now() - startedAt);
        console.log(`[API] ← ${req.method} ${req.url}(${elapsedMs}ms)`);
      },
      error: (err) => {
        console.error(`[API] ✕ ${req.method} ${req.url} 失敗`, err);
      },
    }),
  );
};

註冊方式就是第三章 app.config.ts 裡出現過的那一行:

provideHttpClient(withInterceptors([apiLoggerInterceptor])),

有了這支 Interceptor,前面 LiveStatsChartComponent 每次呼叫 /api/stats,主控台都會自動印出請求開始、結束(或失敗)的紀錄,完全不需要在 StatsService 裡額外寫任何程式碼——這就是 Interceptor「集中處理橫切關注點(Cross-cutting Concern)」的價值所在。

如果專案是走舊版 NgModule 架構、或是使用 class-based 的 HttpInterceptor(實作 intercept() 方法),註冊方式會不同:需要透過 provideHttpClient(withInterceptorsFromDi()) 搭配 HTTP_INTERCEPTORS 這個 multi-provider token。今天使用的是 Angular 現代 Standalone 專案預設推薦的函式型寫法,兩種不要混用。

七、Lazy Loading:讓圖表頁面成為獨立路由

Chart.js 加上 ng2-charts 打包後有一定的體積,如果整個應用程式只有一小部分頁面需要圖表(例如「儀表板」頁面),沒有必要讓每個使用者一開啟網站就下載這些程式碼。Angular Router 的 Lazy Loading 可以讓某個路由對應的 Standalone 元件,只在使用者真正瀏覽到該路徑時,才透過動態 import() 載入對應的程式碼區塊(chunk)。

// src/app/app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
  {
    path: 'dashboard',
    loadComponent: () => import('./pages/dashboard/dashboard.component').then((m) => m.DashboardComponent),
  },
];
  • loadComponent:Angular 14 之後提供的路由層級 Lazy Loading API,直接對應到單一個 Standalone 元件,寫法比舊版「用 loadChildren 載入整個 Feature Module」更簡潔。
  • 執行 ng build 之後,可以在輸出結果看到 dashboard-component 被拆成一支獨立的 chunk 檔案,只有在使用者導覽到 /dashboard 時瀏覽器才會下載它,首頁(/)的初始載入體積因此變小。
  • 如果未來圖表相關頁面越來越多,也可以改用 loadChildren 載入一整組子路由(例如把好幾個報表頁面規劃成一個 Feature 區塊一起延遲載入),概念相同,只是延遲載入的顆粒度從「單一元件」變成「一組路由」。

八、實作:整合前後端的銷售儀表板

現在把前面學到的技巧整合起來,做一個完整的小型儀表板(Dashboard):固定資料的長條圖、可點擊的長條圖、每 3 秒更新一次的即時折線圖,資料來源涵蓋「寫死的靜態資料」與「Express API」兩種情境。

8.1 後端:用 Express 建立簡單的 API

跟 Day 25(React)、Day 26(Vue)用的是同一支後端 API,這正是「前後端分離」架構的優勢:同一支 API 可以同時服務 React、Vue、Angular 三種不同的前端框架。

建立一個獨立的 Node.js Express API Server 專案:

mkdir chartjs-api-server
cd chartjs-api-server
npm init -y
npm install express cors

建立 server.js 檔案,核心程式碼內容:

// server.js
const express = require('express');
const cors = require('cors');

const app = express();
const PORT = 4000;

app.use(cors()); // 開發階段允許前端(不同 port,例如 Angular 的 4200)跨來源請求

// GET /api/stats -> { time, onlineUsers, orders }
app.get('/api/stats', (req, res) => {
  const now = new Date();
  const label = now.toLocaleTimeString('zh-TW', { hour12: false });
  const onlineUsers = Math.floor(50 + Math.random() * 50); // 50 ~ 99
  const orders = Math.floor(Math.random() * 20); // 0 ~ 19

  res.json({ time: label, onlineUsers, orders });
});

// GET /api/stats/history?count=10 -> [{ time, onlineUsers, orders }, ...]
const history = [];
const MAX_HISTORY = 50;

function pushHistorySnapshot() {
  const now = new Date();
  history.push({
    time: now.toLocaleTimeString('zh-TW', { hour12: false }),
    onlineUsers: Math.floor(50 + Math.random() * 50),
    orders: Math.floor(Math.random() * 20),
  });
  if (history.length > MAX_HISTORY) {
    history.shift();
  }
}

for (let i = 0; i < 10; i += 1) {
  pushHistorySnapshot();
}
setInterval(pushHistorySnapshot, 3000);

app.get('/api/stats/history', (req, res) => {
  const count = Math.min(Number(req.query.count) || 10, MAX_HISTORY);
  res.json(history.slice(-count));
});

app.listen(PORT, () => {
  console.log(`Day27 即時統計 API 已啟動:http://localhost:${PORT}`);
});

8.2 前端:組合成儀表板頁面

// src/app/pages/dashboard/dashboard.component.ts
import { Component } from '@angular/core';
import { BarChartComponent } from '../../components/bar-chart.component';
import { ClickableBarChartComponent } from '../../components/clickable-bar-chart.component';
import { LiveStatsChartComponent } from '../../components/live-stats-chart.component';

@Component({
  selector: 'app-dashboard',
  standalone: true,
  imports: [BarChartComponent, ClickableBarChartComponent, LiveStatsChartComponent],
  template: `
    <div style="padding: 24px">
      <h1>Day 27|Chart.js 與 Angular 整合:銷售儀表板</h1>
      <div
        style="display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 32px"
      >
        <section>
          <h2>本季銷售額(元件化 + @Input)</h2>
          <app-bar-chart title="銷售額(萬元)" [labels]="['一月', '二月', '三月']" [values]="[65, 59, 80]" />
        </section>

        <section>
          <h2>各區訂單數(chartClick 點擊事件)</h2>
          <app-clickable-bar-chart />
        </section>

        <section>
          <h2>即時線上人數(RxJS + HttpClient + Interceptor)</h2>
          <app-live-stats-chart />
        </section>
      </div>
    </div>
  `,
})
export class DashboardComponent {}

搭配第七章的 app.routes.tsloadComponent 指向這支 DashboardComponent),開發時分別在兩個終端機視窗執行:

# 終端機 1:啟動後端 API
cd chartjs-api-server
node server.js

# 終端機 2:啟動前端開發伺服器
cd chartjs-angular-demo
npm start

打開瀏覽器造訪 http://localhost:4200/dashboard,就可以看到三個區塊分別呈現不同的資料流動方式,完整體驗「Angular Standalone 元件 + ng2-charts + RxJS + Interceptor + Lazy Loading」的整合流程。

https://ithelp.ithome.com.tw/upload/images/20260827/201718295NlPXRp8rl.png

九、常見誤區與注意事項

  1. 忘記把 BaseChartDirective 加進 Standalone 元件的 imports 陣列:因為 <canvas baseChart> 是靠指令套用在原生元素上,如果沒有 import,編譯時會出現「不認識 dataoptionstype 這些屬性」的錯誤(NG8002)。
  2. 忘記在 app.config.ts 呼叫 provideCharts(withDefaultRegisterables()):圖表會渲染失敗,主控台通常會出現「"bar" is not a registered controller」之類的錯誤,因為 Chart.js 4.x 需要的 Controller/Scale/Element 沒有被註冊。
  3. 原地修改(mutate)陣列或物件,卻期待畫面自動更新:如同第四章提到的,Angular 的 ngOnChanges(無論是自訂元件還是 BaseChartDirective)都只比較參照(Reference),array.push(...)obj.prop = x 這種原地修改的寫法偵測不到變化,一定要建立新的物件/陣列參照(Array Reference)。
  4. OnPush 元件中,非同步資料改變了卻忘記呼叫 ChangeDetectorRef.markForCheck():常發生在 HttpClient 訂閱、setInterval、第三方 SDK 的 callback 更新元件內部欄位時,資料其實已經變了,但畫面沒有跟著更新。
  5. RxJS Subscription 忘記取消訂閱:如果沒有搭配 takeUntilDestroyed()(或舊版寫法的 ngOnDestroy() 手動 unsubscribe()),元件銷毀後訂閱仍會在背景持續執行,是 Angular 應用程式常見的記憶體洩漏來源。
  6. 外層容器沒有明確高度又忘記關閉 maintainAspectRatio:與 React/Vue 篇相同,圖表可能顯示異常。
  7. Interceptor 註冊方式搞混:Standalone 專案要用 provideHttpClient(withInterceptors([...])) 搭配函式型 HttpInterceptorFn;如果是舊版 NgModule + class-based HttpInterceptor,則要改用 withInterceptorsFromDi() 搭配 HTTP_INTERCEPTORS 這個 multi-provider token,兩種寫法邏輯不同,不要混用。
  8. Lazy Loading 路徑或匯出名稱寫錯loadComponent: () => import('./pages/dashboard/dashboard.component').then((m) => m.DashboardComponent) 裡的路徑或 .then() 裡取用的類別名稱一旦拼錯,瀏覽器主控台會出現動態載入失敗(找不到模組)的錯誤,且通常要實際導覽到該路由才會觸發,容易被忽略到上線後才發現。

至此,我們用三天的時間分別體驗了 Chart.js 與 React(Day 25)、Vue(Day 26)、Angular(Day 27)三大主流框架的整合方式:三者的核心觀念其實相通——「不要直接操作 <canvas>new Chart(),交給官方推薦的 Wrapper 套件處理生命週期」「資料驅動圖表,圖表元件只負責畫、資料由外部決定」——差異則來自每個框架自己的資料流哲學(React 的顯式狀態更新、Vue 的 Proxy 響應式系統、Angular 的參照比對與 RxJS)。從明天(Day 28)開始,我們將進入「收尾週」,動手規劃並實作一個綜合實戰專案,把這 27 天學到的圖表技巧整合成一個完整的儀表板應用程式。

參考資源


上一篇
Day 26 - 30 天手把手學會 Chart.js|與 Vue 整合
系列文
30 天手把手學會 Chart.js v4:從圖表基礎到互動式資料視覺化實戰27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言