這是 Chart.js 30 天學習計畫的最後一天。Day 28 我們把「個人財務儀表板」從零想清楚,Day 29 把藍圖真正做成一個能互動的網頁——四張圖表、三種篩選器、CSV 匯出,全部串接完成。今天要做兩件事:第一,回頭檢視這個專案,討論當交易筆數從 70 筆成長到幾千筆時可能遇到的效能瓶頸,學習 Chart.js 內建的效能優化技巧與
decimation(資料抽稀)外掛,並針對main.js略顯集中的渲染邏輯做進一步的重構與模組化;第二,把這 30 天學過的所有知識點做一次系統性的總複習,並整理延伸學習資源,作為這趟學習旅程的收尾與下一步的起點。
在動手改任何程式碼之前,必須先建立一個重要觀念:效能優化的第一步永遠是「量測」,不是「猜測」。沒有數據支持的優化,很容易花時間改善一個其實不是瓶頸的地方,卻忽略真正拖慢畫面的原因。
console.time 做最簡單的量測最快能上手的工具就是瀏覽器內建的 console.time() / console.timeEnd():
console.time('renderTransactionsTable');
renderTransactionsTable();
console.timeEnd('renderTransactionsTable'); // 主控台會印出:renderTransactionsTable: 12.3ms
把這組計時包在懷疑有效能問題的函式外層,就能得到具體的毫秒數,而不是「感覺變慢了」這種模糊的印象。
想看到更完整的畫面,可以打開 Chrome 開發者工具的 Performance 面板,按下錄製、操作一次篩選器切換,再停止錄製,就能看到:
aggregate.js 的 reduce/filter 運算)<canvas> 內容)三者的時間分佈,就能判斷「慢」的原因究竟是資料運算、DOM 更新,還是圖表繪製本身。
Day 29 動手練習第 2 題已經提示過:把 server/data/generate-mock-data.js 裡的 months 陣列從 6 個月延長成 12 個月,甚至更多年份,重新執行 node generate-mock-data.js 產生更大量的交易資料,就能真實重現「資料變多之後畫面會不會變慢」的情境,而不是憑空想像。建議在正式優化前,先花五分鐘做這個實驗,並用上面兩種方式量測「篩選前」與「篩選後」的耗時,作為本篇後續優化的對照基準(Baseline)。
把 Day 29 的儀表板放大資料量之後,實務上常見的效能瓶頸可以歸納成下表:
| 現象 | 常見原因 | 對應策略 |
|---|---|---|
| 折線圖資料點暴增後,繪製、互動變得卡頓 | 每次繪製都要處理所有原始資料點 | decimation 外掛、parsing: false、必要時關閉動畫 |
| 交易明細表格筆數變多後,切換篩選器有明顯延遲 | innerHTML 整包字串重建、大量 DOM 節點同時插入 |
分頁(Pagination),縮小單次重繪範圍 |
| 篩選器切換時圖表閃爍、記憶體持續上升 | 誤用 chart.destroy() + new Chart() 重建圖表 |
延續 Day 13 的原則:只呼叫 chart.update()(Day 29 已經這樣做,本篇會再次確認) |
專案越改越難維護,main.js 越寫越肥 |
狀態、DOM 參照、渲染邏輯、事件綁定全部混在同一支檔案 | 模組化拆分,落實單一職責 |
值得說明的是:目前儀表板的四張圖表(趨勢、分類、預算、資產配置)資料點都不多——趨勢圖最多 6~12 個月份、分類與預算圖最多 8 個類別、資產配置圖最多 4 個帳戶,這些圖表本身其實還沒有真正的「大量資料點」問題。真正會隨資料量線性成長的,反而是交易明細表格(第六章)。不過為了完整示範 Chart.js 處理大量資料點折線圖的技巧,第五章會延伸一個「假設情境」:如果日後儀表板新增一張「每日資產淨值走勢」圖,資料點可能上看數百甚至數千筆,這時候就是 decimation 外掛真正派上用場的地方。
Chart.js 官方文件的 Performance 章節整理了幾項不需要額外外掛、單靠設定就能生效的效能技巧,這裡搭配專案情境逐一說明。
parsing: false + 提供正規化資料Chart.js 預設會把你傳入的 data(不論是純數字陣列還是 {x, y} 物件陣列)解析、正規化成內部格式,這個「解析」的過程本身就有運算成本。如果你能自己先把資料整理成 Chart.js 內部期待的格式(依 x 軸遞增排序的 {x, y} 物件),就可以設定 parsing: false 跳過這個步驟:
options: {
parsing: false, // 資料已經是排序好的 { x, y } 格式,不需要 Chart.js 再解析一次
normalized: true // 進一步告知 Chart.js:索引唯一、已排序,可以套用更快速的內部演算法
}
aggregate.js 裡的彙整函式本來就會用 Array.prototype.sort 或依照月份順序組資料,這種「資料先處理乾淨、圖表只管畫」的分工,正好是套用 parsing: false 的先決條件。
options: {
animation: false // 大量資料時,關閉動畫可以省下多次重繪的成本,只需要畫一次最終結果
}
動畫預設會讓 Chart.js 在一段時間內(預設約 1 秒)反覆重繪畫面,資料點越多,每一次重繪的成本就越高。如果圖表資料量本來就大,關閉動畫能讓 CPU 的負擔從「畫很多次」降為「只畫一次」。不過要注意:儀表板目前的四張圖表資料點都不多,動畫帶來的視覺回饋(例如切換篩選器時長條圖的漸變效果)對使用者體驗是加分的,不需要為了「预防性优化」而关闭——這一點會在第九章的常見誤區再次強調。
spanGaps:省去線段分段的判斷options: {
spanGaps: true // 資料中間即使有 null/缺漏值,也直接連成一條線,不用另外判斷斷點分段
}
如果資料集本身有缺漏值(null),Chart.js 預設會把線段從缺漏處斷開,形成好幾條獨立線段;開啟 spanGaps 可以省去這個分段判斷,用一條完整的線直接跨過缺漏點。這對資料量大、又允許「用直線帶過缺漏」的圖表(例如趨勢觀察用途)能帶來效能提升,但要注意 null 代表「沒有預算資料」不該被畫出來是不同情境,必須依照圖表想傳達的意義決定要不要開啟。
showLine: false 與 pointRadius: 0datasets: [{
showLine: false, // 只畫點,不畫連接線(適合純粹想呈現「分佈」而非「趨勢」的情境)
// 或者
pointRadius: 0 // 只畫線,不畫資料點圓圈(資料點很密集時,圓圈幾乎重疊,畫了也看不出來)
}]
資料點數量很多時,同時畫「線」又畫「一堆密密麻麻的圓點」是浪費運算資源的——使用者根本看不清楚每一個點,不如二選一。
ticks.sampleSize:加速刻度標籤的尺寸計算scales: {
x: {
ticks: {
sampleSize: 20 // 只抽樣 20 個標籤來估算文字寬度,而不是量測全部標籤
}
}
}
Chart.js 在決定「這個座標軸要顯示幾條刻度線、要不要旋轉文字」之前,需要先量測標籤文字的寬度。標籤數量很多時,量測全部標籤本身就是一筆開銷;sampleSize 讓 Chart.js 只抽樣一部分標籤估算,適合標籤長度差異不大的情境。
scales 的 min / max:省去每次重新計算範圍scales: {
y: {
min: 0,
max: 200000 // 明確指定範圍,Chart.js 就不需要每次都重新掃描全部資料算最大最小值
}
}
Chart.js 預設會依照目前的資料自動算出座標軸的最大最小值,資料量大或更新頻繁時,這個「重新掃描」的過程會反覆發生。如果你的業務情境本來就有明確的合理範圍(例如財務儀表板的月支出很少超過某個上限),直接寫死 min / max,能省下這筆重複運算。
Chart.js 的線段元素(Line Element)有一項容易被忽略的內建優化:只要 tension、stepped、borderDash 都維持預設值(分別是 false、0、[]),繪製時就會自動略過畫面上看不見的線段,藉此加速渲染。這意味著:如果你為了美觀把 tension 設成 0.3(曲線平滑,Day 5 教過的技巧),就會失去這項自動優化——這是「視覺效果」與「效能」之間常見的取捨,資料量小的時候(例如目前的趨勢折線圖)完全不需要在意,但資料量真的很大時就要納入考量。
decimation 外掛實戰:處理上千個資料點的折線圖decimation(資料抽稀)外掛的用途,是在折線圖繪製之前,先把過多的資料點依照演算法縮減成「肉眼看起來幾乎一樣、但運算量小很多」的資料集。官方文件很直白地點出了它的核心理念:
一張只有幾百像素寬的圖表,畫上好幾萬個資料點是沒有意義的——使用者根本分辨不出來,不如先抽稀再畫。
decimation 是 Chart.js 核心內建的外掛之一(跟 Legend、Tooltip、Title 同一個等級),並不需要另外安裝套件;Day 2 教過的 Chart.register(...registerables) 已經把它一併註冊好了,只需要在 options.plugins.decimation 打開 enabled: true 即可使用。
decimation 外掛並不是所有折線圖都能套用,官方文件列出六項明確的先決條件:
indexAxis 必須是 'x'(也就是預設的直立折線圖,不是橫向的)。line。'linear' 或 'time'——'category' 軸不支援。parsing: false。dataset 物件必須是可變動的:外掛會把原始資料存成 dataset._data,再另外定義一個新的 data 屬性存放抽稀後的資料。threshold(門檻值)才會真的觸發抽稀,否則資料量不夠多,維持原樣即可。⚠️ 對照這份清單就會發現:Day 29 的收支趨勢折線圖(
trendChart.js)目前並不符合條件——它的labels是['2025-10', '2025-11', ...]這種月份字串陣列,形成的是'category'類型座標軸,而不是'linear'或'time'。再加上它最多也只有 12 個資料點,遠遠稱不上「大量資料」,硬套用 decimation 不會有任何效果,也沒有必要。真正適合示範 decimation 的情境,是下一節延伸出來的「每日資產淨值走勢」圖。
假設這個記帳 App 未來串接了銀行的 Open Banking API,開始累積「每天」的資產淨值快照——一年就有 365 筆資料,三年就破千筆。這正是 decimation 大展身手的場景。以下示範如何新增這張圖表模組 charts/netWorthChart.js:
// charts/netWorthChart.js
// 延伸範例:近一年每日資產淨值走勢,資料點數量遠多於現有四張圖表,
// 用來示範 Day 6 學過的 time 軸 + decimation 外掛的完整搭配。
import { Chart } from '../chartSetup.js';
import 'https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/+esm';
function buildConfig(dailyNetWorth) {
// dailyNetWorth 是已經依日期由舊到新排序好的 [{ x: '2026-01-01', y: 812000 }, ...]
return {
type: 'line',
data: {
datasets: [
{
label: '每日資產淨值',
data: dailyNetWorth,
borderColor: '#3b82f6',
borderWidth: 1.5,
pointRadius: 0, // 資料點太密集,圓圈幾乎重疊,只畫線即可
tension: 0 // 保留自動抽稀優化,不使用曲線平滑
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
parsing: false, // 資料已經是排序好的 {x, y},符合 decimation 的先決條件
normalized: true,
animation: false, // 資料點多,關閉動畫換取流暢的縮放/平移體驗
interaction: { mode: 'nearest', axis: 'x', intersect: false },
plugins: {
decimation: {
enabled: true,
algorithm: 'min-max', // 保留波峰波谷(例如某天有大額支出造成的淨值驟降)
samples: 500 // 若改用 'lttb' 演算法,這裡可指定輸出的取樣點數
}
},
scales: {
x: {
type: 'time',
time: { unit: 'month' },
ticks: { source: 'auto', maxRotation: 0, autoSkip: true } // 減少刻度計算成本
},
y: { ticks: { callback: (value) => `${(value / 10000).toLocaleString()}萬` } }
}
}
};
}
export function createNetWorthChart(canvas, dailyNetWorth) {
return new Chart(canvas, buildConfig(dailyNetWorth));
}
export function updateNetWorthChart(chart, dailyNetWorth) {
chart.data.datasets[0].data = dailyNetWorth;
chart.update();
}
這段程式碼一次整合了本篇前面幾節的技巧:type: 'time' 搭配 chartjs-adapter-date-fns(Day 6 學過)滿足 decimation 的座標軸限制、parsing: false 搭配已排序資料、animation: false 與 pointRadius: 0 減少繪製成本、最後才是 plugins.decimation 本身的設定。
| 演算法 | 特性 | 適合情境 |
|---|---|---|
'min-max' |
每個像素最多保留 4 個點(該區間的最大值、最小值等),能完整保留資料的波峰與波谷 | 想觀察「異常尖峰」的情境,例如本例的每日淨值——如果某天有一筆大額支出造成淨值驟降,min-max 能確保這個驟降依然會被畫出來,不會被抽稀抹平 |
'lttb'(Largest Triangle Three Buckets) |
大幅減少資料點數量,用固定的 samples 數量呈現資料的整體形狀與趨勢 |
只想呈現長期走勢、不特別在意單點細節的情境,例如「近三年資產成長趨勢」這種宏觀視角的圖表 |
threshold 選項則是決定「資料點數量超過多少才觸發抽稀」,預設是畫布寬度的 4 倍——換句話說,資料點數量沒有明顯超過畫布可以顯示的像素數量時,decimation 不會有任何動作,這也呼應了「沒必要抽稀就不抽稀」的設計理念。
innerHTML 整包字串重建回顧 Day 29 的 renderTransactionsTable():
transactionsTableBody.innerHTML = filtered
.slice()
.sort((a, b) => (a.date < b.date ? 1 : -1))
.map((tx) => `<tr>...</tr>`)
.join('');
每次篩選條件改變,這段程式碼都會把符合條件的全部交易轉成一大串 HTML 字串,整包塞進 innerHTML。交易筆數是 70 筆時完全感覺不出差異,但當資料量成長到幾千筆,瀏覽器需要一次性解析、建立、插入數千個 <tr> DOM 節點,畫面明顯會卡頓一下。
最直接、CP 值最高的解法是分頁:畫面上一次只渲染「一頁」的資料量(例如 20 筆),而不是把符合條件的全部資料一次塞進表格。
// aggregate.js 新增:純函式,只負責「切出某一頁的資料」
export function paginate(items, page, pageSize) {
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const safePage = Math.min(Math.max(1, page), totalPages);
const start = (safePage - 1) * pageSize;
return {
pageItems: items.slice(start, start + pageSize),
currentPage: safePage,
totalPages,
totalCount: items.length
};
}
// main.js:renderTransactionsTable 改為只渲染「目前頁次」的資料
const PAGE_SIZE = 20;
function renderTransactionsTable() {
const filtered = getFilteredTransactions().slice().sort((a, b) => (a.date < b.date ? 1 : -1));
const { pageItems, currentPage, totalPages, totalCount } = paginate(filtered, state.filters.page, PAGE_SIZE);
transactionCountEl.textContent = String(totalCount);
transactionsTableBody.innerHTML = pageItems.map(renderTransactionRow).join('');
renderPaginationControls(currentPage, totalPages);
}
💡 別忘了一個很容易漏掉的細節:每次篩選條件(時間區間、類別、關鍵字)改變時,都必須把
state.filters.page重設回1。否則使用者原本停留在第 5 頁,篩選之後總筆數變少、只剩 2 頁,畫面就會顯示一個空表格——這是分頁功能最常見的邊界情況錯誤,第九章會再次提醒。
資料量如果成長到數萬筆以上,分頁可能還是不夠,這時業界常見的做法是 虛擬捲動(Virtual Scrolling):畫面上永遠只實際渲染「使用者看得到的那幾十列」DOM 節點,捲動時動態抽換內容,其餘資料完全不建立 DOM。這已經超出 Chart.js 的範疇、進入表格元件庫的領域(例如 TanStack Virtual、react-window 等),這裡先點出概念,讓大家知道「分頁不是唯一解法,資料量夠大時還有更進階的技巧」,有興趣可以自行深入研究。
main.js 瘦身計畫Day 29 的 main.js 雖然已經把「彙整邏輯」(aggregate.js)與「畫圖邏輯」(charts/*.js)拆出去,但檔案本身依然同時扛了四種不同性質的責任:
state、charts 物件)document.getElementById(...))renderAll、renderSummaryCards、renderTrendChart 等)rangeSelect.addEventListener(...) 等)檔案不算長(不到 200 行)的時候還讀得下去,但可以想像:如果之後要加上淨值走勢圖、分頁控制、更多篩選器,這支檔案只會越來越肥。這正是**單一職責原則(Single Responsibility Principle)**要解決的問題——一個模組應該只有一個「改變的理由」。
public/js/
├── store.js # 全域狀態 + 簡單的訂閱/通知機制(Observer Pattern)
├── dom.js # 集中管理所有 DOM 節點參照
├── renderers.js # 純粹的「讀狀態 → 算資料 → 更新畫面」渲染函式
├── events.js # 統一綁定使用者互動事件,呼叫 store 的方法更新狀態
├── aggregate.js # 不變:彙整邏輯(Day 29 已經拆好)
├── chartSetup.js # 不變:Chart.js 註冊與全域預設值(Day 29 已經拆好)
├── charts/ # 不變:四張圖表模組
└── main.js # 瘦身後:只負責「串起以上模組、呼叫 init()」
store.js:狀態集中管理 + 訂閱通知// store.js
// 集中管理儀表板的全域狀態,並提供簡單的訂閱/通知機制(Observer Pattern):
// 任何模組只要呼叫 subscribe() 註冊回呼函式,state 一旦透過 setFilters() 改變,
// 就會自動通知所有訂閱者重新渲染,不需要在每個事件監聽器裡手動列出「這次要更新哪些畫面」。
const state = {
categories: [],
accounts: [],
transactions: [],
budgets: [],
filters: { range: 'month', categoryId: 'all', keyword: '', page: 1 }
};
const listeners = new Set();
export function getState() {
return state;
}
export function setData(data) {
Object.assign(state, data);
notify();
}
export function setFilters(patch) {
// 除了 page 之外的任何篩選條件改變,一律把頁碼重設回第 1 頁
const resetPage = 'page' in patch ? {} : { page: 1 };
Object.assign(state.filters, patch, resetPage);
notify();
}
export function subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener); // 回傳一個「取消訂閱」函式,方便日後擴充
}
function notify() {
listeners.forEach((listener) => listener(state));
}
renderers.js:只管「畫」,不管「怎麼被觸發」// renderers.js
import { getState } from './store.js';
import { filterByRange, aggregateCategoryBreakdown, /* ... */ } from './aggregate.js';
import { createCategoryChart, updateCategoryChart } from './charts/categoryChart.js';
import { categoryCanvas } from './dom.js';
const charts = { category: null /* ...其餘圖表 */ };
export function renderCategoryChart(onClickCategory) {
const state = getState();
const rangeFiltered = filterByRange(state.transactions, state.filters.range);
const breakdown = aggregateCategoryBreakdown(rangeFiltered, state.categories);
if (!charts.category) {
charts.category = createCategoryChart(categoryCanvas, breakdown, onClickCategory);
} else {
updateCategoryChart(charts.category, breakdown, state.filters.categoryId);
}
}
main.js:瘦身後只剩「組裝」的責任// main.js(重構後)
import { fetchDashboardData } from './api.js';
import { setData, subscribe } from './store.js';
import { renderAll } from './renderers.js';
import { bindEvents } from './events.js';
import { statusText } from './dom.js';
async function init() {
try {
const data = await fetchDashboardData();
setData(data);
statusText.textContent = `資料載入完成,共 ${data.transactions.length} 筆交易紀錄。`;
} catch (error) {
statusText.textContent = `資料載入失敗:${error.message}`;
}
}
subscribe(renderAll); // 狀態一改變,自動觸發重新渲染,事件綁定不需要知道「該重新畫哪些圖」
bindEvents();
init();
rangeSelect.addEventListener 內要手動列出「時間區間改變會影響卡片、分類圖、預算圖、表格」,重構後 events.js 只需要呼叫 setFilters({ range: ... }),剩下的交給 subscribe(renderAll) 統一處理,新增一張圖表時也不必回頭修改每一個事件監聽器。state.filters 的地方都必須經過 setFilters(),好處是可以在同一個地方統一處理「重設頁碼」這種跨欄位的邊界情況,不用擔心某個事件監聽器忘記重設。aggregate.js 本來就是純函式,現在 store.js 的狀態邏輯也不依賴 DOM,可以脫離瀏覽器環境單獨測試。這趟旅程從一個 <canvas> 元素開始,一路走到跨框架整合與效能優化。以下依照週次,把 30 天的學習重點濃縮成一張知識地圖,方便日後快速查找複習:
| Day | 主題 | 一句話重點 |
|---|---|---|
| 1 | 認識 Chart.js | Chart.js 的定位、三種安裝方式(CDN/npm/ESM),畫出第一張圖 |
| 2 | 基本結構 | new Chart(ctx, config) 三大設定 type/data/options,registerables 與 Tree-shaking 註冊機制 |
| 3 | 長條圖 | 單/多資料集、indexAxis 水平長條圖、顏色與圓角設定 |
| 4 | 圓餅/環狀圖 | Pie 與 Doughnut 的差異、cutout 屬性、圖例顯示 |
| 5 | 折線圖進階 | 多線比較、fill 區域圖、tension 曲線平滑 |
| 6 | 座標軸與時間序列 | linear/category/time 軸、雙 Y 軸、chartjs-adapter-date-fns、chartjs-plugin-zoom |
| 7 | 第一週總複習 | 綜合實作「天氣溫度折線圖」與「成績長條圖」 |
| Day | 主題 | 一句話重點 |
|---|---|---|
| 8 | 雷達圖 | 多維度能力值比較,angleLines/pointLabels 設定 |
| 9 | 極座標圖 | 與 Pie 的差異(角度相等、半徑代表數值),適合「比大小」而非「比佔比」 |
| 10 | 氣泡圖/散佈圖 | 三維資料(x, y, r)呈現,適合相關性與分布觀察 |
| 11 | 混合圖表 | 同畫布結合 Bar + Line,type 可設在 dataset 層級 |
| 12 | 圖例與 Tooltip | 自訂圖例位置/點擊事件、tooltip.callbacks 客製化內容 |
| 13 | 動畫效果 | animation/animations/transitions 設定、逐步顯示、update() 動態更新 |
| 14 | 第二週總複習 | 綜合實作「業績儀表板」混合圖表 |
| Day | 主題 | 一句話重點 |
|---|---|---|
| 15 | 串接靜態 JSON | fetch 讀本地 JSON,轉換成 Chart.js 資料格式 |
| 16 | 串接後端 API | RESTful 串接、async/await、loading 狀態處理 |
| 17 | 串接 CSV | PapaParse 解析、資料清洗(filter/map/reduce) |
| 18 | 動態資料更新 | setInterval + update()、滑動視窗(Sliding Window)、update('none') |
| 19 | 互動事件處理 | onClick/onHover、getElementsAtEventForMode、圖表連動 |
| 20 | 響應式設計 | responsive/maintainAspectRatio、專屬容器規則、min-width: 0 陷阱 |
| 21 | 第三週總複習 | 串接公開 API 製作即時互動圖表 |
| Day | 主題 | 一句話重點 |
|---|---|---|
| 22 | 外掛系統基礎 | Plugin 架構、生命週期 hook(beforeDraw/afterDraw 等)、chartjs-plugin-datalabels |
| 23 | 自訂外掛開發 | 撰寫 beforeDraw/afterDraw 外掛,實作浮水印與中央標籤 |
| 24 | 主題與樣式客製化 | Chart.defaults 全域設定、深色模式、Scriptable Options、漸層背景 |
| 25 | 與 React 整合 | react-chartjs-2、元件化、useState/useEffect |
| 26 | 與 Vue 整合 | vue-chartjs、Vue 3 Composition API、響應式資料綁定 |
| 27 | 與 Angular 整合 | ng2-charts、provideCharts(withDefaultRegisterables())、<canvas baseChart>、ChangeDetectorRef |
| Day | 主題 | 一句話重點 |
|---|---|---|
| 28 | 專案規劃與資料設計 | User Story、MoSCoW、Wireframe、Entity 設計、依目的挑圖表 |
| 29 | 專案實作 | 整合四張圖表、CSS Grid/Flexbox 響應式版面、三種互動功能、CSV 匯出 |
| 30 | 專案優化與總結 | 效能量測、decimation 外掛、表格分頁、模組化重構、全課程總複習 |
看著這張地圖回顧,可以發現一條清楚的學習脈絡:先學會「畫出一張圖」(第一週)→ 認識「不同資料形狀該用什麼圖表」(第二週)=> 讓圖表「動起來、connect 真實資料」(第三週)=> 讓圖表「融入真實專案與團隊技術棧」(第四週)=> 最後用一個完整專案把所有技能串起來(收尾週)。這也是為什麼 Day 28~30 刻意選擇「個人財務儀表板」這種綜合性題目——它幾乎用到了前 27 天教過的每一項技巧。
從 Day 1 那張最簡單的折線圖,到今天這個能篩選、能連動、能匯出、還懂得照顧效能與程式碼品質的個人財務儀表板,這趟旅程走過了 Chart.js 幾乎所有的核心能力:圖表類型的選擇、資料的清洗與彙整、互動與動畫的設計、外掛系統的擴充,以及與 React/Vue/Angular 三大框架的整合方式。
但更重要的,其實不是記住每一個 API 怎麼寫——那些永遠可以查官方文件;真正帶得走的,是面對一份資料時,知道該怎麼問對問題、選對圖表、想清楚每個篩選條件該套用在哪裡、以及在專案還小的時候就養成模組化與效能意識的習慣。這些思考方式,換到任何一個圖表庫、任何一個前端框架,都依然適用。
30 天的學習在這裡告一段落,但資料可視化這條路才剛開始——下一步,找一份你真正關心的資料(可能是工作上的報表、興趣相關的統計、或是任何讓你好奇的數字),把這 30 天學到的東西實際用上去,做出屬於你自己的作品。祝你在資料可視化的路上,走得又快又穩。