這兩天我們分別用
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 與HttpClient的 Interceptor 機制,串接 Node.js 的 Express 後端,完成 React/Vue/Angular 三方框架整合的完整體驗。
本日範例程式碼:
前端:example01-angular-dashboard
後端 API Server:example02-api-server
new Chart()?延續前面 26 天的寫法,我們一直都是這樣建立圖表的:
const ctx = document.getElementById('myChart');
const chart = new Chart(ctx, { type: 'bar', data, options });
如果把這段程式碼直接搬進 Angular 元件的 ngAfterViewInit() 裡,畫面「能動」,但很快就會遇到跟 Angular 架構息息相關的麻煩:
Zone.js 監聽所有非同步事件(setTimeout、事件監聽、Promise 等),一有動靜就會觸發一次變更偵測,重新檢查整個元件樹要不要更新畫面。但 Chart.js 內部的動畫、滑鼠移動偵測、resize 監聽都是高頻率觸發的事件,如果讓這些事件通通被 Zone.js 攔截,會造成大量不必要的變更偵測,拖慢整個應用程式的效能。destroy():跟 React/Vue 一樣,Angular 元件被銷毀時(例如切換路由)如果沒有呼叫 chart.destroy() 釋放資源,就會造成記憶體洩漏(Memory Leak)。chart.update() 才會重繪。update() => 卸載時 destroy()」這一整套邏輯,而且還要自己處理跟 Angular Change Detection 之間的協調。npx 建立 Angular 22 Standalone 專案這裡我透過 npx 方式直接執行指定版本的 CLI,不需要在電腦上全域安裝 @angular/cli(也不會因為全域版本太舊或太新而跟教學步驟對不上):
npx @angular/cli@22 new chartjs-angular-demo --style=css --ssr=false
cd chartjs-angular-demo
--style=css:指定樣式表格式為原生 CSS(也可以選 scss/less,這裡選最單純的 CSS,方便專注在圖表整合上)。--ssr=false:關閉 Server-Side Rendering,這個範例只需要單純的瀏覽器端應用程式。app.module.ts),會直接產生 src/app/app.config.ts(應用程式層級的 Provider 設定)與 src/app/app.routes.ts(路由設定),這也是為什麼今天完全不會出現 @NgModule 的原因。建立完成後,先確認可以正常啟動:
npm start
預設會在 http://localhost:4200 啟動開發伺服器,並支援檔案變更後自動重新整理。
npm install chart.js@4.5.1
npm install ng2-charts@10.0.0
chart.js是ng2-charts的 peer dependency(同儕依賴),兩者需要一起安裝。根據ng2-charts官方的 Angular 版本相容表,v10.x 對應的是新版 Angular(Angular 21 以上),這裡指定ng2-charts@10.0.0搭配 Angular 22 使用。
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-2/vue-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: '每月銷售額' },
},
};
}
因為
BaseChartDirective是 Standalone 指令,一定要記得把它加進元件的imports陣列,否則編譯時會出現NG8002/NG8001之類的錯誤,提示 Angular 不認識canvas元素上的baseChart、data、options這些屬性繫結。跟前兩天一樣,
maintainAspectRatio: false搭配「外層容器給明確高度」是必備的組合,否則圖表可能無法正確填滿容器。
<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@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 的裝飾器語法。
ngOnChanges 是靠「參照(Reference)比對」,不是靠「內容比對」這裡有一個 Angular 特有、也是最容易讓新手卡關的細節:Angular 的 ngOnChanges()(無論是我們自己寫的元件,還是 BaseChartDirective 內部自己的 ngOnChanges())都是透過 SimpleChanges 比較 @Input() 的「參照(Reference)」是否改變,而不是深入比較內容。這代表:
// ❌ 錯誤示範:直接 push 到陣列裡,參照沒有變,Angular 偵測不到變化
this.values.push(100);
// ✅ 正確示範:建立一個新陣列(新的參照),才會觸發 ngOnChanges
this.values = [...this.values, 100];
同樣的道理,如果要更新 chartData 讓 BaseChartDirective 重新渲染,也不要直接修改 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-chartjs靠watch(..., { deep: true })深層監聽)的處理方式不同:Angular 預設只做「淺層」的參照(Reference)比較,凡事都要養成「回傳新物件、而不是原地修改(mutate)」的習慣,這是 Angular 應用程式效能與正確性的重要基石,也是OnPush策略能夠正常運作的前提。
ChangeDetectionStrategy.OnPush 與 ChangeDetectorRef上面的 BarChartComponent 特地標註了 changeDetection: ChangeDetectionStrategy.OnPush。這是 Angular 一個重要的效能優化策略:預設情況下(ChangeDetectionStrategy.Default),只要應用程式裡任何地方發生非同步事件,Angular 就會檢查每一個元件要不要更新畫面;改成 OnPush 之後,這顆元件只有在以下情況才會被重新檢查:
@Input() **參照(Reference)**改變了(這也是為什麼上一節要特別強調「給新參照(Reference)」的原因)。(click))。ChangeDetectorRef.markForCheck(),主動告訴 Angular「這顆元件需要被重新檢查」。前兩種情況 Angular 會自動幫我們處理,但第三種情況——元件內部因為「非 Angular 觸發的非同步來源」(例如 HttpClient 訂閱、WebSocket、第三方函式庫的 callback)而改變了內部欄位——就必須自己呼叫 markForCheck(),否則即使資料真的變了,Angular 也不會主動重新檢查這顆 OnPush 元件、畫面就不會更新。這個情境會在第七章「即時資料更新」的範例中實際出現。
chartClick 與 chartHoverBaseChartDirective 提供了 (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),BaseChartDirective 的 ngOnChanges 就會自動偵測並呼叫內部的 update();但如果是在同一個參照(Reference)上做了進一步的細部調整(不常見的情境),也可以像這樣手動呼叫 update() 強制重繪。Angular 從骨子裡就是圍繞著 RxJS 打造的框架(HttpClient、Router、FormControl 的 valueChanges 都回傳 Observable),這讓「定時向後端拉取資料、更新圖表」這件事有非常道地的寫法。
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) 直接取得同一個實例。
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();
},
});
}
}
幾個實務上很重要的細節:
timer(0, 3000):第一個參數 0 代表「訂閱後立刻觸發第一次」,第二個參數代表「之後每 3000 毫秒觸發一次」,等同於 React/Vue 篇 setInterval + 「先呼叫一次再排程」的組合寫法,但用 RxJS 一行就能表達完整。switchMap:如果上一次的 API 請求還沒回應、下一個計時器又觸發了,switchMap 會自動取消上一個尚未完成的請求,改用最新的這一個,避免「回應順序錯亂」(例如網路慢的時候,舊的回應比新的回應晚到,導致圖表顯示過期資料)的問題。takeUntilDestroyed(this.destroyRef):這是 Angular 16 之後提供的工具,會在元件被銷毀時自動完成(complete)這個 Observable,等同於 React 篇的 useEffect cleanup、Vue 篇的 onUnmounted 清除 setInterval,但不需要自己額外寫 ngOnDestroy() 手動 unsubscribe(),大幅降低忘記取消訂閱、造成記憶體洩漏的風險。ChangeDetectorRef.markForCheck():因為這顆元件是 OnPush 策略,而資料來源是 HttpClient 的訂閱(非同步、且不是透過 @Input() 傳入),Angular 不會自動知道要重新檢查這顆元件,必須在 subscribe 的 callback 裡呼叫 markForCheck(),這正是第四章提到「第三種觸發 OnPush 重新檢查」的實際案例。HttpClient 請求Angular 的 Interceptor 是一個很強大的機制:它可以攔截應用程式裡「所有」透過 HttpClient 發出的請求與回應,很適合用來做統一的日誌記錄(logging)、加上共用的 Header(例如 Authorization Token)、統一的錯誤處理、或是 Loading 狀態管理,而不需要在每一支 Service 裡重複寫這些邏輯。
Angular 15 之後推出了搭配 Standalone 架構設計的函式型 Interceptor(HttpInterceptorFn),寫法比舊版的 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 專案預設推薦的函式型寫法,兩種不要混用。
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」兩種情境。
跟 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}`);
});
// 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.ts(loadComponent 指向這支 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」的整合流程。

BaseChartDirective 加進 Standalone 元件的 imports 陣列:因為 <canvas baseChart> 是靠指令套用在原生元素上,如果沒有 import,編譯時會出現「不認識 data/options/type 這些屬性」的錯誤(NG8002)。app.config.ts 呼叫 provideCharts(withDefaultRegisterables()):圖表會渲染失敗,主控台通常會出現「"bar" is not a registered controller」之類的錯誤,因為 Chart.js 4.x 需要的 Controller/Scale/Element 沒有被註冊。ngOnChanges(無論是自訂元件還是 BaseChartDirective)都只比較參照(Reference),array.push(...) 或 obj.prop = x 這種原地修改的寫法偵測不到變化,一定要建立新的物件/陣列參照(Array Reference)。OnPush 元件中,非同步資料改變了卻忘記呼叫 ChangeDetectorRef.markForCheck():常發生在 HttpClient 訂閱、setInterval、第三方 SDK 的 callback 更新元件內部欄位時,資料其實已經變了,但畫面沒有跟著更新。takeUntilDestroyed()(或舊版寫法的 ngOnDestroy() 手動 unsubscribe()),元件銷毀後訂閱仍會在背景持續執行,是 Angular 應用程式常見的記憶體洩漏來源。maintainAspectRatio:與 React/Vue 篇相同,圖表可能顯示異常。provideHttpClient(withInterceptors([...])) 搭配函式型 HttpInterceptorFn;如果是舊版 NgModule + class-based HttpInterceptor,則要改用 withInterceptorsFromDi() 搭配 HTTP_INTERCEPTORS 這個 multi-provider token,兩種寫法邏輯不同,不要混用。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 天學到的圖表技巧整合成一個完整的儀表板應用程式。
provide* 系列 API)
loadComponent / loadChildren)
takeUntilDestroyed 與 DestroyRef
switchMap