昨天(Day 24)我們把視角拉高到「整個專案的視覺風格管理」,學會用
Chart.defaults打造主題系統與漸層色背景。從今天開始,我們要走出「純 JavaScript +<canvas>」的世界,把 Chart.js 帶進現代前端框架裡。第一站是 React:學習使用官方推薦的react-chartjs-2套件,把圖表拆解成可重複使用的 Component(元件),透過 props 傳遞資料,並搭配useState/useEffect實現資料的動態更新。今天也會示範如何用 Node.js 的 Express 架一個簡單的後端 API,讓圖表資料不再是寫死的假資料,而是真正從伺服器抓回來的即時資料。
本日範例程式碼:
- 前端:react-dashboard
- 後端 API Server:api-server
new Chart()?在前面 24 天的內容裡,我們都是這樣建立圖表的:
const ctx = document.getElementById('myChart');
const chart = new Chart(ctx, { type: 'bar', data, options });
如果直接把這段程式碼複製貼到 React 元件裡,理論上也「能動」,但馬上就會遇到麻煩:
<canvas>。如果沒有妥善处理,React 重新渲染(re-render)時可能會不小心把 <canvas> 整個換掉,但舊的 Chart instance 沒有被銷毀,造成記憶體洩漏(Memory Leak)或畫面殘影。chart.update() 才會重繪,這件事很容易忘記寫、或寫在錯誤的時機點。react-chartjs-2 就是為了解決上述問題而生的官方推薦套件:它把「建立圖表」「隨 props 更新」「元件卸載時銷毀圖表」這些瑣碎的生命週期邏輯都封裝好了,我們只需要用 React 熟悉的方式傳入 data 和 options 這兩個 props,其餘的事情都交給套件處理。
react-chartjs-2內部仍然是呼叫 Chart.js 的 API,它並沒有重新實作一套繪圖引擎,所以我們前面學過的data、options、Scale、Plugin 等觀念完全可以沿用,只是「建立與更新圖表」的方式换成了 React 元件。
我們使用 Vite 這套現代前端建構工具來初始化專案,它的開發伺服器啟動速度快、也內建了對 TypeScript 的支援:
npm create vite@latest chartjs-react-demo -- --template react-ts
cd chartjs-react-demo
npm install
安裝完成後,再加裝 Chart.js 與 react-chartjs-2:
npm install chart.js@4.5.1 react-chartjs-2@5.3.1
chart.js是react-chartjs-2的 peer dependency(同儕依賴),也就是說react-chartjs-2本身不包含 Chart.js 的原始碼,兩者需要一起安裝,而且版本要互相搭配(react-chartjs-2v5 對應chart.jsv4)。
Chart.js 4.x 為了減少打包後的檔案大小,把所有的圖表類型、Scale、Plugin 都拆成獨立的模組,預設不會自動全部載入,需要我們手動 register() 註冊實際會用到的部分(這一點我們在 Day 1~Day 2 已經介紹過,這裡是 React 版本的寫法)。建議把註冊的程式碼獨立成一個檔案,在應用程式進入點只 import 一次:
// src/chartSetup.ts
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
} from 'chart.js';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
);
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './chartSetup'; // 確保進入點就完成註冊
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
如果忘記註冊對應的元件,畫面上會出現類似
"category" is not a registered scale的錯誤,這是初學者最常遇到的第一個坑,遇到時回頭檢查register()有沒有涵蓋你用到的 Scale/Element/Plugin。
npm run dev
Vite 預設會在 http://localhost:5173 啟動開發伺服器,並支援 HMR(Hot Module Replacement),修改程式碼後畫面會立即更新,不需要手動整頁重整。
react-chartjs-2 針對每種圖表類型都提供了對應的 Component,例如 Line、Bar、Pie、Doughnut、Radar、PolarArea、Bubble、Scatter,用法跟平常寫 Chart.js 的設定幾乎一樣,只是 data 和 options 變成了 props:
// src/components/MonthlySalesChart.tsx
import { Bar } from 'react-chartjs-2';
import type { ChartData, ChartOptions } from 'chart.js';
const data: ChartData<'bar'> = {
labels: ['一月', '二月', '三月', '四月', '五月', '六月'],
datasets: [
{
label: '銷售額(萬元)',
data: [65, 59, 80, 81, 56, 72],
backgroundColor: 'rgba(54, 162, 235, 0.6)',
},
],
};
const options: ChartOptions<'bar'> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: { display: true, text: '每月銷售額' },
},
};
export function MonthlySalesChart() {
return (
<div style={{ height: '360px' }}>
<Bar data={data} options={options} />
</div>
);
}

maintainAspectRatio: false這行很重要:Chart.js 預設會依照固定的長寬比例縮放圖表,如果外層容器(例如上面的<div>)用 CSS 指定了高度,卻沒有關閉maintainAspectRatio,圖表可能不會乖乖填滿容器。這是react-chartjs-2官方 FAQ 裡最常見的問題之一,記得養成「外層<div>給高度 +maintainAspectRatio: false」這個習慣。
上面的例子把 data 寫死在元件內部,沒有真正做到「元件化」。實務上我們會把 data、options 抽出來當作 props,讓同一顆圖表元件可以顯示不同的資料:
// src/components/BarChart.tsx
import { Bar } from 'react-chartjs-2';
import type { ChartData, ChartOptions } from 'chart.js';
interface BarChartProps {
title: string;
labels: string[];
values: number[];
color?: string;
}
export function BarChart({ title, labels, values, color = 'rgba(54, 162, 235, 0.6)' }: BarChartProps) {
const data: ChartData<'bar'> = {
labels,
datasets: [
{
label: title,
data: values,
backgroundColor: color,
},
],
};
const options: ChartOptions<'bar'> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: { display: true, text: title },
legend: { display: false },
},
};
return (
<div style={{ height: '320px' }}>
<Bar data={data} options={options} />
</div>
);
}
使用時,只要傳入不同的 props,就能重複利用同一顆元件畫出不同的圖表:
// src/App.tsx
import { BarChart } from './components/BarChart';
function App() {
return (
<div style={{ display: 'grid', gap: '24px', padding: '24px' }}>
<BarChart
title="每月銷售額(萬元)"
labels={['一月', '二月', '三月', '四月']}
values={[65, 59, 80, 81]}
/>
<BarChart
title="每月退貨數量"
labels={['一月', '二月', '三月', '四月']}
values={[3, 5, 2, 4]}
color="rgba(255, 99, 132, 0.6)"
/>
</div>
);
}
export default App;
這就是「元件化圖表設計」的核心精神:圖表元件只負責「怎麼畫」,資料則由外部透過 props 決定「畫什麼」,兩者職責分離,元件才能真正被重複使用在不同的頁面或情境。
react-chartjs-2 官方文件特別提醒了一個常見陷阱:當圖表因為互動(例如 hover 顯示 tooltip)而重新渲染時,如果多個 dataset 沒有可以辨識身份的 key,套件會沒辦法分辨「這是原本的第幾筆 dataset」,導致資料更新時第一個 dataset 的內容被複製覆蓋到其他 dataset,造成折線或長條「黏在一起」的詭異畫面。
解決方法有兩種:
label(react-chartjs-2 預設用 label 當作識別 key)。label 不方便當作唯一識別(例如 label 是給圖例顯示用的中文說明),可以額外給每個 dataset 一個 id 欄位,並透過 datasetIdKey prop 告訴元件要用哪個欄位當 key:<Line
datasetIdKey="id"
data={{
labels: ['六月', '七月', '八月'],
datasets: [
{ id: 1, label: '今年', data: [5, 6, 7] },
{ id: 2, label: '去年', data: [3, 2, 1] },
],
}}
/>
記住這個原則:只要圖表有兩個以上的 dataset,就要主動確認每個 dataset 有沒有唯一的識別值,否則等到資料動態更新時才發現圖表跑掉,會比較難排查問題根源。
useState / useEffect 實作動態更新元件化只是第一步,實務上圖表的資料通常不是寫死的,而是會隨著使用者操作、或後端資料變化而更新。這裡示範兩種最常見的情境。
useState)例如做一個「切換時間區間」的下拉選單,選擇不同區間時圖表資料跟著換掉:
// src/components/RangeSalesChart.tsx
import { useMemo, useState } from 'react';
import { Line } from 'react-chartjs-2';
import type { ChartData, ChartOptions } from 'chart.js';
const DATASET_BY_RANGE: Record<string, { labels: string[]; values: number[] }> = {
week: { labels: ['一', '二', '三', '四', '五', '六', '日'], values: [12, 19, 8, 15, 22, 30, 25] },
month: { labels: ['第1週', '第2週', '第3週', '第4週'], values: [80, 95, 70, 110] },
year: { labels: ['Q1', 'Q2', 'Q3', 'Q4'], values: [300, 420, 380, 460] },
};
export function RangeSalesChart() {
const [range, setRange] = useState<'week' | 'month' | 'year'>('week');
// 只有 range 改變時才重新計算 data,避免每次 re-render 都建立新物件
const data: ChartData<'line'> = useMemo(() => {
const source = DATASET_BY_RANGE[range];
return {
labels: source.labels,
datasets: [
{
label: '銷售趨勢',
data: source.values,
borderColor: 'rgb(75, 192, 192)',
tension: 0.3,
},
],
};
}, [range]);
const options: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
};
return (
<div>
<select value={range} onChange={(e) => setRange(e.target.value as typeof range)}>
<option value="week">本週</option>
<option value="month">本月</option>
<option value="year">今年</option>
</select>
<div style={{ height: '320px', marginTop: '12px' }}>
<Line data={data} options={options} />
</div>
</div>
);
}
重點在於:只要 data 這個 props 的內容變了,react-chartjs-2 就會自動偵測並呼叫內部的 chart.update(),開發者完全不需要自己手動操作 Chart.js 的 API。這正是使用 Wrapper 套件最大的好處——資料驅動畫面,而不是命令式地控制圖表。
useEffect 定時向後端拉取即時資料接下來我們架一個簡單的 Express 後端 API,模擬「每隔幾秒回傳一次最新統計資料」的情境,前端則用 useEffect 搭配 setInterval 定時拉取資料並更新圖表。
先建立一個獨立的後端資料夾(與前端專案分開):
mkdir chartjs-api-server
cd chartjs-api-server
npm init -y
npm install express cors
撰寫一支最簡單的 API 伺服器:
// server.js
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors()); // 開發階段允許前端(不同 port)跨來源請求
// 模擬即時的線上人數/訂單數資料
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);
const orders = Math.floor(Math.random() * 20);
res.json({ time: label, onlineUsers, orders });
});
const PORT = 4000;
app.listen(PORT, () => {
console.log(`API server running at http://localhost:${PORT}`);
});
node server.js
實務上会用
nodemon讓伺服器程式碼修改後自動重啟,也會把cors()的允許來源限制為前端實際的網域,這裡為了聚焦在圖表整合上,先用最單純的寫法。
useEffect + setInterval 定時拉取並更新圖表// src/components/LiveStatsChart.tsx
import { useEffect, useRef, useState } from 'react';
import { Line } from 'react-chartjs-2';
import type { ChartData, ChartOptions } from 'chart.js';
const MAX_POINTS = 10; // 圖表上最多保留幾個資料點
export function LiveStatsChart() {
const [labels, setLabels] = useState<string[]>([]);
const [onlineUsers, setOnlineUsers] = useState<number[]>([]);
const intervalRef = useRef<number | undefined>(undefined);
useEffect(() => {
const fetchStats = async () => {
try {
const res = await fetch('http://localhost:4000/api/stats');
const stats: { time: string; onlineUsers: number; orders: number } = await res.json();
setLabels((prev) => [...prev, stats.time].slice(-MAX_POINTS));
setOnlineUsers((prev) => [...prev, stats.onlineUsers].slice(-MAX_POINTS));
} catch (err) {
console.error('取得即時資料失敗', err);
}
};
fetchStats(); // 先立即抓一次,不用等第一個 interval
intervalRef.current = window.setInterval(fetchStats, 3000); // 每 3 秒更新一次
// 元件卸載時務必清除計時器,避免記憶體洩漏
return () => window.clearInterval(intervalRef.current);
}, []);
const data: ChartData<'line'> = {
labels,
datasets: [
{
label: '線上人數',
data: onlineUsers,
borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgba(255, 99, 132, 0.2)',
fill: true,
tension: 0.3,
},
],
};
const options: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 300 }, // 縮短動畫時間,讓即時更新看起來更順暢
scales: {
y: { beginAtZero: true },
},
};
return (
<div style={{ height: '320px' }}>
<Line data={data} options={options} />
</div>
);
}
這個範例展示了三個實務上很重要的細節:
useEffect 的清除函式(cleanup function)一定要記得清 setInterval:如果沒有在 return 裡呼叫 clearInterval,即使元件已經卸載,計時器還是會持續在背景執行、持續呼叫 setLabels/setOnlineUsers,這是 React 應用程式常見的記憶體洩漏來源。useState 搭配函式式更新((prev) => ...):因為 setInterval 的 callback 是在建立當下被「凍結」的閉包(Closure),如果直接寫 setLabels([...labels, stats.time]) 可能會抓到過期(stale)的 labels,用 (prev) => [...prev, stats.time] 這種函式式寫法可以確保永遠是根據最新的 state 做更新。slice(-MAX_POINTS)):即時資料如果無限累加,陣列會越來越大,圖表也會越畫越擠,實務上通常會像「移動視窗(Sliding Window)」一樣,只保留最近 N 筆資料。ref 取得 Chart.js 原生實例雖然大部分情境只需要 data 和 options 這兩個 props 就能滿足需求,但有時候還是需要直接呼叫 Chart.js 原生的 API(例如手動觸發 resize()、或是取得使用者點擊了圖表上的哪個元素)。這時可以透過 React 的 ref 拿到底層的 Chart.js instance:
import { useRef } from 'react';
import { Bar, getElementAtEvent } from 'react-chartjs-2';
import type { ChartJSOrUndefined } from 'react-chartjs-2/dist/types';
export function ClickableBarChart({ data }: { data: ChartData<'bar'> }) {
const chartRef = useRef<ChartJSOrUndefined<'bar'>>(null);
const handleClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
if (!chartRef.current) return;
const [element] = getElementAtEvent(chartRef.current, event);
if (element) {
const { datasetIndex, index } = element;
console.log('點擊到第', datasetIndex, '個 dataset,第', index, '筆資料');
}
};
return <Bar ref={chartRef} data={data} onClick={handleClick} />;
}
react-chartjs-2 提供了三個常用的輔助函式,都是傳入「chart ref」和「原生的滑鼠事件」,回傳使用者點擊到的資料:
| 輔助函式 | 說明 |
|---|---|
getDatasetAtEvent(chart, event) |
回傳點擊位置對應的整個 dataset |
getElementAtEvent(chart, event) |
回傳點擊位置對應的單一資料點(element) |
getElementsAtEvent(chart, event) |
回傳點擊位置對應的所有重疊的資料點 |
這些函式底層其實就是呼叫 Chart.js 原本就有的
chart.getElementsAtEventForMode(),react-chartjs-2只是把常用的呼叫方式包成更簡潔的函式,觀念與 Day 1 ~ Day 20 學過的 Chart.js 原生事件處理完全相通。
現在把前面學到的技巧整合起來,做一個完整的小型儀表板(Dashboard):左側顯示固定的月銷售長條圖,右側顯示每 3 秒更新一次的即時線上人數折線圖,資料則分別來自「寫死的靜態資料」與「Express API」。
// src/App.tsx
import './chartSetup';
import { BarChart } from './components/BarChart';
import { LiveStatsChart } from './components/LiveStatsChart';
function App() {
return (
<div style={{ padding: '24px' }}>
<h1>銷售儀表板</h1>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '24px',
}}
>
<section>
<h2>本季銷售額</h2>
<BarChart
title="銷售額(萬元)"
labels={['一月', '二月', '三月']}
values={[65, 59, 80]}
/>
</section>
<section>
<h2>即時線上人數</h2>
<LiveStatsChart />
</section>
</div>
</div>
);
}
export default App;
開發時分別在兩個終端機視窗執行:
# 終端機 1:啟動後端 API
cd chartjs-api-server
node server.js
# 終端機 2:啟動前端開發伺服器
cd chartjs-react-demo
npm run dev
打開瀏覽器就可以看到左側是固定資料的長條圖,右側則每 3 秒自動更新一次的即時折線圖,完整體驗了「前端元件化 + 後端 API + 動態更新」的整合流程。

register() 對應的元件:Chart.js 4.x 需要手動註冊 Scale、Element、Plugin,這件事和是否使用 React 無關,很多初學者以為 react-chartjs-2 會自動處理,結果一用就報錯。data 物件在每次 render 都重新建立,造成不必要的重繪或效能問題:如果 data、options 是在元件內用普通變數直接建立(而非用 useMemo 包裝),即使實際內容沒變,React 每次 render 都會產生新的物件參照,導致 Chart.js 誤以為資料變了而重新繪製。資料來源穩定、不常變動時,建議用 useMemo 快取。datasetIdKey 或唯一 label:如同第四章提到的,會造成資料更新時圖表「黏成一團」的詭異畫面。maintainAspectRatio:圖表可能顯示異常(太小、太大、或是不斷長高)。useEffect 忘記清除 setInterval / setTimeout / 訂閱:這是 React 應用程式最常見的記憶體洩漏來源之一,尤其在即時資料圖表中特別容易忽略。useState:Chart.js instance 是一個複雜的物件,不適合放進 React state 裡(會被當作一般物件比較、也可能觸發不必要的 re-render),如果需要保存 instance 的參照,應該用 useRef。明天(Day 26)我們會繼續框架整合的旅程,換到 Vue 的世界:學習使用 vue-chartjs 套件,體驗 Vue 3 的 Composition API 如何搭配圖表元件,並認識 Vue 響應式系統(Reactivity)下的資料綁定與自動更新機制,跟今天 React 的 useState/useEffect 做個有趣的對照。
useEffect 完整指南