iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Modern Web

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

Day 25 - 30 天手把手學會 Chart.js|與 React 整合

  • 分享至 

  • xImage
  •  

昨天(Day 24)我們把視角拉高到「整個專案的視覺風格管理」,學會用 Chart.defaults 打造主題系統與漸層色背景。從今天開始,我們要走出「純 JavaScript + <canvas>」的世界,把 Chart.js 帶進現代前端框架裡。第一站是 React:學習使用官方推薦的 react-chartjs-2 套件,把圖表拆解成可重複使用的 Component(元件),透過 props 傳遞資料,並搭配 useStateuseEffect 實現資料的動態更新。今天也會示範如何用 Node.js 的 Express 架一個簡單的後端 API,讓圖表資料不再是寫死的假資料,而是真正從伺服器抓回來的即時資料。

本日範例程式碼:

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

在前面 24 天的內容裡,我們都是這樣建立圖表的:

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

如果直接把這段程式碼複製貼到 React 元件裡,理論上也「能動」,但馬上就會遇到麻煩:

  • 生命週期不同步:React 用 Virtual DOM 管理畫面更新,而 Chart.js 是直接操作真實的 <canvas>。如果沒有妥善处理,React 重新渲染(re-render)時可能會不小心把 <canvas> 整個換掉,但舊的 Chart instance 沒有被銷毀,造成記憶體洩漏(Memory Leak)或畫面殘影。
  • 資料更新要手動呼叫 API:Chart.js 本身不是響應式(Reactive)的,資料變了之後必須自己呼叫 chart.update() 才會重繪,這件事很容易忘記寫、或寫在錯誤的時機點。
  • 重複的樣板程式碼(Boilerplate):每個用到圖表的元件都要重複寫「建立 => 監聽資料變化 => update => 卸載時 destroy」這一整套邏輯。

react-chartjs-2 就是為了解決上述問題而生的官方推薦套件:它把「建立圖表」「隨 props 更新」「元件卸載時銷毀圖表」這些瑣碎的生命週期邏輯都封裝好了,我們只需要用 React 熟悉的方式傳入 dataoptions 這兩個 props,其餘的事情都交給套件處理。

react-chartjs-2 內部仍然是呼叫 Chart.js 的 API,它並沒有重新實作一套繪圖引擎,所以我們前面學過的 dataoptions、Scale、Plugin 等觀念完全可以沿用,只是「建立與更新圖表」的方式换成了 React 元件。

二、環境建置:用 Vite 建立 React + TypeScript 專案

2.1 建立前端專案

我們使用 Vite 這套現代前端建構工具來初始化專案,它的開發伺服器啟動速度快、也內建了對 TypeScript 的支援:

npm create vite@latest chartjs-react-demo -- --template react-ts
cd chartjs-react-demo
npm install

安裝完成後,再加裝 Chart.jsreact-chartjs-2

npm install chart.js@4.5.1 react-chartjs-2@5.3.1

chart.jsreact-chartjs-2peer dependency(同儕依賴),也就是說 react-chartjs-2 本身不包含 Chart.js 的原始碼,兩者需要一起安裝,而且版本要互相搭配(react-chartjs-2 v5 對應 chart.js v4)。

2.2 註冊需要的元件(Tree Shaking)

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。

2.3 啟動開發伺服器

npm run dev

Vite 預設會在 http://localhost:5173 啟動開發伺服器,並支援 HMR(Hot Module Replacement),修改程式碼後畫面會立即更新,不需要手動整頁重整。

三、元件化圖表設計:把圖表拆成可重複使用的 Component

3.1 最基本的用法

react-chartjs-2 針對每種圖表類型都提供了對應的 Component,例如 LineBarPieDoughnutRadarPolarAreaBubbleScatter,用法跟平常寫 Chart.js 的設定幾乎一樣,只是 dataoptions 變成了 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>
  );
}

https://ithelp.ithome.com.tw/upload/images/20260825/201718296qyOzjDrYB.png

maintainAspectRatio: false 這行很重要:Chart.js 預設會依照固定的長寬比例縮放圖表,如果外層容器(例如上面的 <div>)用 CSS 指定了高度,卻沒有關閉 maintainAspectRatio,圖表可能不會乖乖填滿容器。這是 react-chartjs-2 官方 FAQ 裡最常見的問題之一,記得養成「外層 <div> 給高度 + maintainAspectRatio: false」這個習慣。

3.2 用 TypeScript 定義 Props,讓圖表元件可重複使用

上面的例子把 data 寫死在元件內部,沒有真正做到「元件化」。實務上我們會把 dataoptions 抽出來當作 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 決定「畫什麼」,兩者職責分離,元件才能真正被重複使用在不同的頁面或情境。

3.3 陷阱:多個 dataset 更新時互相「合併」的問題

react-chartjs-2 官方文件特別提醒了一個常見陷阱:當圖表因為互動(例如 hover 顯示 tooltip)而重新渲染時,如果多個 dataset 沒有可以辨識身份的 key,套件會沒辦法分辨「這是原本的第幾筆 dataset」,導致資料更新時第一個 dataset 的內容被複製覆蓋到其他 dataset,造成折線或長條「黏在一起」的詭異畫面。

解決方法有兩種:

  1. 讓每個 dataset 都有唯一的 labelreact-chartjs-2 預設用 label 當作識別 key)。
  2. 如果 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 實作動態更新

元件化只是第一步,實務上圖表的資料通常不是寫死的,而是會隨著使用者操作、或後端資料變化而更新。這裡示範兩種最常見的情境。

4.1 情境一:使用者互動觸發更新(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 套件最大的好處——資料驅動畫面,而不是命令式地控制圖表。

4.2 情境二:useEffect 定時向後端拉取即時資料

接下來我們架一個簡單的 Express 後端 API,模擬「每隔幾秒回傳一次最新統計資料」的情境,前端則用 useEffect 搭配 setInterval 定時拉取資料並更新圖表。

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

先建立一個獨立的後端資料夾(與前端專案分開):

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() 的允許來源限制為前端實際的網域,這裡為了聚焦在圖表整合上,先用最單純的寫法。

4.2.2 前端: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>
  );
}

這個範例展示了三個實務上很重要的細節:

  1. useEffect 的清除函式(cleanup function)一定要記得清 setInterval:如果沒有在 return 裡呼叫 clearInterval,即使元件已經卸載,計時器還是會持續在背景執行、持續呼叫 setLabelssetOnlineUsers,這是 React 應用程式常見的記憶體洩漏來源。
  2. useState 搭配函式式更新((prev) => ...:因為 setInterval 的 callback 是在建立當下被「凍結」的閉包(Closure),如果直接寫 setLabels([...labels, stats.time]) 可能會抓到過期(stale)的 labels,用 (prev) => [...prev, stats.time] 這種函式式寫法可以確保永遠是根據最新的 state 做更新。
  3. 限制資料點數量(slice(-MAX_POINTS):即時資料如果無限累加,陣列會越來越大,圖表也會越畫越擠,實務上通常會像「移動視窗(Sliding Window)」一樣,只保留最近 N 筆資料。

五、進階:透過 ref 取得 Chart.js 原生實例

雖然大部分情境只需要 dataoptions 這兩個 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 + 動態更新」的整合流程。

https://ithelp.ithome.com.tw/upload/images/20260825/20171829agHOZQmpLV.png

七、常見誤區與注意事項

  1. 忘記 register() 對應的元件:Chart.js 4.x 需要手動註冊 Scale、Element、Plugin,這件事和是否使用 React 無關,很多初學者以為 react-chartjs-2 會自動處理,結果一用就報錯。
  2. data 物件在每次 render 都重新建立,造成不必要的重繪或效能問題:如果 dataoptions 是在元件內用普通變數直接建立(而非用 useMemo 包裝),即使實際內容沒變,React 每次 render 都會產生新的物件參照,導致 Chart.js 誤以為資料變了而重新繪製。資料來源穩定、不常變動時,建議用 useMemo 快取。
  3. 多 dataset 沒有設定 datasetIdKey 或唯一 label:如同第四章提到的,會造成資料更新時圖表「黏成一團」的詭異畫面。
  4. 外層容器沒有明確高度又忘記關閉 maintainAspectRatio:圖表可能顯示異常(太小、太大、或是不斷長高)。
  5. useEffect 忘記清除 setInterval / setTimeout / 訂閱:這是 React 應用程式最常見的記憶體洩漏來源之一,尤其在即時資料圖表中特別容易忽略。
  6. 直接把整個 Chart.js instance 存進 useState:Chart.js instance 是一個複雜的物件,不適合放進 React state 裡(會被當作一般物件比較、也可能觸發不必要的 re-render),如果需要保存 instance 的參照,應該用 useRef

明天(Day 26)我們會繼續框架整合的旅程,換到 Vue 的世界:學習使用 vue-chartjs 套件,體驗 Vue 3 的 Composition API 如何搭配圖表元件,並認識 Vue 響應式系統(Reactivity)下的資料綁定與自動更新機制,跟今天 React 的 useStateuseEffect 做個有趣的對照。

參考資源


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

尚未有邦友留言

立即登入留言