昨天(Day 23)我們學會了自訂外掛(Plugin)開發,也搞懂了外掛
options如何在plugin.defaults=>Chart.defaults.plugins.{id}=>chart.options.plugins.{id}這三層之間合併。今天要把視角從「單一外掛的行為」拉高到「整個專案的視覺風格管理」:學習如何透過Chart.defaults一次設定所有圖表的全域樣式、打造深色模式(Dark Mode)與品牌色彩系統,並實作漸層色(Gradient)背景,讓圖表風格能夠有系統地被管理,而不是每張圖表都重複寫一樣的樣式設定。
在動手客製化樣式之前,必須先搞懂一件事:當 Chart.js 要決定「這個長條要用什麼顏色畫」時,它到底參考了哪些設定、以什麼順序參考? 這個機制叫做 Option Resolution(選項解析)。
以「Chart 層級的選項」為例,Chart.js 會依照下面的順序,由上而下尋找第一個「有定義值」的設定:
config.options(你在 new Chart() 時傳入的 options)
↓ 找不到就往下找
overrides[config.type](該圖表類型的預設覆寫,例如 bar、line 各自的預設)
↓ 找不到就往下找
Chart.defaults(全域預設值,優先權最低)
也就是說,Chart.defaults 是所有設定的「最後一道防線」:只要你在建立圖表時沒有明確指定某個選項,Chart.js 最終就會回頭讀取 Chart.defaults 裡的值。這也是為什麼修改 Chart.defaults 可以「一次影響專案內所有還沒有明確覆蓋該選項的圖表」——這正是今天要善用的核心機制。
反過來說,如果某張圖表的 options 裡已經明確寫了顏色,那麼不管 Chart.defaults 怎麼改,這張圖表都不會受影響,因為它的優先權比 Chart.defaults 高。記住這個原則:Chart.defaults 負責「兜底」,個別圖表的 options 負責「例外」。
Chart.defaultsChart.defaults 是一個巨大的物件,包含了 Chart.js 內部所有選項的預設值。實務上最常客製化的幾個項目整理如下:
| 設定路徑 | 說明 | 預設值 |
|---|---|---|
Chart.defaults.font.family |
全域字型 | "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif" |
Chart.defaults.font.size |
全域字型大小(px) | 12 |
Chart.defaults.color |
全域文字顏色(座標軸文字、圖例文字等) | #666 |
Chart.defaults.backgroundColor |
幾何元素(長條、扇形等)未指定時的背景色 | rgba(0, 0, 0, 0.1) |
Chart.defaults.borderColor |
幾何元素未指定時的邊框色,也影響格線顏色 | rgba(0, 0, 0, 0.1) |
Chart.defaults.plugins.legend.display |
是否顯示圖例 | true |
Chart.defaults.plugins.tooltip.enabled |
是否啟用提示框 | true |
Chart.defaults.elements.line.tension |
折線圖預設曲線張力 | 0(Chart.js 4.x 起) |
假設專案要求所有圖表統一使用「微軟正黑體」字型、文字顏色改為深灰色,可以在應用程式**最一開始(建立任何圖表之前)**寫一次:
// main.js(或任何整個應用程式的進入點)
Chart.defaults.font.family = "'Microsoft JhengHei', 'Segoe UI', sans-serif";
Chart.defaults.font.size = 14;
Chart.defaults.color = '#333333';
// 之後在專案裡任何地方建立的圖表,都會自動套用上面這三項設定
const chart1 = new Chart(ctx1, { type: 'line', data, options: {} });
const chart2 = new Chart(ctx2, { type: 'bar', data, options: {} });
程式碼重點說明:
Chart.defaults 的修改必須發生在 new Chart() 之前才有意義。因為 Chart.js 在建立圖表當下就會把 Chart.defaults 的值解析、套用進去;圖表建立完成後才修改 Chart.defaults,並不會讓「已經存在」的圖表自動套用新值(需要呼叫 chart.update() 才會重新解析)。Chart.defaults.font 本身是一個物件,如果直接整個覆蓋(Chart.defaults.font = { size: 14 }),會把 family、weight 等其他欄位一併蓋掉。建議像範例一樣,逐一設定巢狀物件裡的單一欄位,避免不小心清空其他預設值。Chart.overrides.line.xxx = ...,這是專屬於「該圖表類型」的預設層(優先權比 Chart.defaults 高,比個別圖表的 options 低),適合「某一類圖表要有共同風格,但又不想全域套用」的情境。Chart.defaults 不只能設定核心選項,也能設定外掛(Plugin)的全域預設值,這正好呼應昨天學到的三層合併機制:
// 讓所有圖表的圖例文字統一改成 16px、置底顯示
Chart.defaults.plugins.legend.position = 'bottom';
Chart.defaults.plugins.legend.labels.font = { size: 16 };
// 讓昨天寫的 watermark 外掛,全域預設文字改成公司名稱
Chart.defaults.plugins.watermark = { text: 'MyCompany Inc.' };
切換深色模式時,「資料本身的顏色」(例如長條的 backgroundColor)通常不需要變動,真正需要跟著切換的是背景與文字類的選項,整理如下:
| 選項 | 淺色模式建議值 | 深色模式建議值 |
|---|---|---|
Chart.defaults.color(文字顏色) |
#333333 |
#e0e0e0 |
Chart.defaults.borderColor(格線顏色) |
rgba(0, 0, 0, 0.1) |
rgba(255, 255, 255, 0.15) |
| 畫布背景色(需搭配外掛或 CSS) | #ffffff |
#1e1e2e |
plugins.legend.labels.color |
通常跟隨 Chart.defaults.color |
同上 |
plugins.tooltip.backgroundColor |
rgba(0, 0, 0, 0.8)(預設已偏深,通常不需大改) |
可視需求微調 |
最推薦的做法是把「淺色」與「深色」兩套設定整理成物件,切換時統一寫回 Chart.defaults,再對所有現存圖表呼叫 update():
const themes = {
light: {
color: '#333333',
borderColor: 'rgba(0, 0, 0, 0.1)',
background: '#ffffff'
},
dark: {
color: '#e0e0e0',
borderColor: 'rgba(255, 255, 255, 0.15)',
background: '#1e1e2e'
}
};
// 用來記錄目前所有已建立的圖表,切換主題時才能逐一更新
const allCharts = [];
function applyTheme(themeName) {
const theme = themes[themeName];
Chart.defaults.color = theme.color;
Chart.defaults.borderColor = theme.borderColor;
// 畫布本身的底色不屬於 Chart.js 選項,改用 CSS 或 body 的 class 控制
document.body.style.backgroundColor = theme.background;
document.body.dataset.theme = themeName;
// 已經存在的圖表不會自動套用新的 Chart.defaults,
// 必須逐一呼叫 update() 強迫它們重新解析選項
allCharts.forEach(chart => chart.update());
}
// 建立圖表時記得推進 allCharts 陣列
const chart = new Chart(ctx, { type: 'line', data, options: {} });
allCharts.push(chart);
// 切換按鈕
document.getElementById('themeToggleBtn').addEventListener('click', () => {
const next = document.body.dataset.theme === 'dark' ? 'light' : 'dark';
applyTheme(next);
});
applyTheme('light'); // 頁面載入時套用預設主題
allCharts 陣列是關鍵:因為修改 Chart.defaults 不會自動讓「已經畫出來」的圖表重繪,一定要手動保存每個圖表的實例,並在切換主題時逐一呼叫 chart.update(),讓 Chart.js 重新跑一次 Option Resolution 流程。beforeDraw 外掛手動填色)。因此深色模式的畫布底色,建議直接透過 CSS(background-color)處理,會比每次都用外掛填色更簡單、效能也更好。datasets 顏色的可讀性:如果資料本身用的是很淺的顏色(例如淡黃色的長條),在深色背景下可能會看不清楚邊框。建議品牌色票(見下一節)本身就設計成「在深色背景下也有一定對比度」的顏色,或是針對 borderColor(長條外框)在深色模式下加上一圈稍亮的邊框以增加辨識度。實務上,公司或產品通常有自己的一套「品牌識別色」(例如主色、輔助色、警示色)。如果每次建立圖表都手動輸入色碼(#FF6384、#36A2EB...),不但容易複製貼上打錯字,未來設計團隊更換色票時,也得一個一個檔案去改。更好的做法是把顏色整理成一份共用的調色盤模組,所有圖表統一從這裡取色。
// palette.js:集中管理品牌色彩,之後設計團隊換色只需要改這一個檔案
export const BrandColors = {
primary: '#4c6ef5',
secondary: '#12b886',
warning: '#f59f00',
danger: '#e03131',
neutral: '#868e96',
// 提供半透明版本,方便當作 backgroundColor 使用
primaryAlpha: 'rgba(76, 110, 245, 0.5)',
secondaryAlpha: 'rgba(18, 184, 134, 0.5)',
// 依序取用的調色盤陣列,適合多個 dataset 或圓餅圖各區塊使用
palette: ['#4c6ef5', '#12b886', '#f59f00', '#e03131', '#868e96', '#7048e8']
};
// 提供一個小工具函式:依索引取色,並自動循環(資料筆數超過色票長度時)
export function getBrandColor(index) {
return BrandColors.palette[index % BrandColors.palette.length];
}
使用時搭配 Chart.js 的 Scriptable Options(可以傳入一個函式,依 context 動態回傳值),就能讓多筆資料自動套用品牌色,不需要手動排列每個顏色:
import { getBrandColor } from './palette.js';
const chart = new Chart(ctx, {
type: 'bar',
data: {
labels: ['台北', '台中', '台南', '高雄'],
datasets: [{
label: '各分店營收',
data: [120, 95, 88, 102],
backgroundColor: (context) => getBrandColor(context.dataIndex)
}]
}
});
backgroundColor 傳入函式:這就是 Chart.js 的 Scriptable Options,函式會針對每一筆資料被呼叫一次,並收到一個 context 物件(內含 dataIndex、dataset、chart 等資訊),讓顏色可以依據資料索引、數值大小等條件動態決定,而不是寫死一組固定陣列。getBrandColor 自動循環:使用 index % palette.length 取餘數,確保即使資料筆數超過調色盤顏色數量(例如 10 個分店但只有 6 種品牌色),也不會出現 undefined 顏色,而是從頭開始循環使用。palette.js 這一個檔案,所有引用 BrandColors 的圖表都會自動套用新色票,不需要在每個頁面裡逐一搜尋、取代色碼。Colors 外掛如果專案沒有嚴格的品牌色票需求,Chart.js 內建的 Colors 外掛可以直接幫每個 dataset 自動套用七種預設色,快速做出「至少每個資料集顏色都不同」的效果:
import { Colors } from 'chart.js';
Chart.register(Colors);
但若有明確的品牌識別色,仍建議採用 5.2 節的自訂調色盤做法,才能確保視覺風格與品牌完全一致。
漸層色在 Canvas 裡是透過 CanvasRenderingContext2D.createLinearGradient() 建立的一個 CanvasGradient 物件,而不是單純的色碼字串。而建立這個物件時,需要用到圖表的實際繪圖區座標(chart.chartArea)——但問題是,圖表在第一次渲染之前,chartArea 的寬高都還不存在(因為畫布還沒被量測、佈局)。這就是為什麼漸層色沒辦法像 backgroundColor: '#4c6ef5' 這樣直接寫死一個值,而必須透過 Scriptable Options 動態產生。
// 快取已經算好的漸層物件,避免每一幀都重新建立造成效能浪費
let cachedGradient;
let cachedWidth, cachedHeight;
function getGradient(ctx, chartArea) {
const chartWidth = chartArea.right - chartArea.left;
const chartHeight = chartArea.bottom - chartArea.top;
// 只有第一次渲染、或圖表尺寸改變(例如視窗縮放)時,才重新建立漸層
if (!cachedGradient || cachedWidth !== chartWidth || cachedHeight !== chartHeight) {
cachedWidth = chartWidth;
cachedHeight = chartHeight;
cachedGradient = ctx.createLinearGradient(0, chartArea.bottom, 0, chartArea.top);
cachedGradient.addColorStop(0, 'rgba(76, 110, 245, 0.05)'); // 底部:接近透明
cachedGradient.addColorStop(1, 'rgba(76, 110, 245, 0.6)'); // 頂部:較深的品牌色
}
return cachedGradient;
}
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: ['一月', '二月', '三月', '四月', '五月'],
datasets: [{
label: '月營收趨勢',
data: [30, 45, 42, 58, 66],
borderColor: '#4c6ef5',
backgroundColor: (context) => {
const { chart } = context;
const { ctx, chartArea } = chart;
if (!chartArea) {
// 圖表第一次載入、chartArea 尚未計算出來時,先回傳 undefined
// Chart.js 會在下一輪重繪(chartArea 就緒後)自動再呼叫一次這個函式
return;
}
return getGradient(ctx, chartArea);
},
fill: true, // 折線圖預設不會填色,需要設定 fill: true 才會套用 backgroundColor
tension: 0.3
}]
}
});
if (!chartArea) return; 是必要的防呆:圖表在初次載入時,Chart.js 會先用「假的資料」跑一次選項解析來決定版面配置,這時候 chartArea 還是 undefined。如果沒有做這個判斷,直接對 undefined 呼叫方法會直接噴錯,導致圖表整個渲染失敗。回傳 undefined 之後,Chart.js 會在 chartArea 準備好的下一輪繪製中自動重新呼叫這個函式。createLinearGradient(x0, y0, x1, y1) 的方向:這四個參數定義了漸層的「起點」與「終點」座標。範例中 (0, chartArea.bottom, 0, chartArea.top) 表示 x 座標不變(垂直方向漸層),從圖表底部(bottom,色標 0)漸變到圖表頂部(top,色標 1),做出「越往上顏色越深」的效果,很適合搭配折線圖下方填色,營造出資料越高、色彩越飽和的視覺重點。addColorStop(offset, color):offset 是 0 到 1 之間的數值,代表這個顏色出現在漸層的哪個位置。可以呼叫多次 addColorStop 做出三段以上的多色漸層(例如:藍 → 黃 → 紅,常用來表示數值從低到高的警示程度)。getGradient() 函式會在圖表每一次重繪時被呼叫(包含滑鼠移過去觸發的 hover 動畫),如果每次都重新呼叫 createLinearGradient(),會造成不必要的效能負擔。範例中用 cachedGradient 搭配「尺寸是否改變」的判斷式,只有在真正需要時(第一次渲染、或圖表尺寸改變)才重新建立漸層物件,其餘情況直接複用快取結果。fill: true 別忘記設定:折線圖(Line Chart)預設是「只畫線、不填色」,即使設定了 backgroundColor,如果沒有搭配 fill: true(或 fill: 'origin' 等填色模式),漸層依然不會顯示出來。長條圖的做法大同小異,差別在於漸層方向通常會設計成「沿著長條的高度」,且因為長條圖沒有 fill 選項的限制,backgroundColor 直接套用即可:
datasets: [{
data: [65, 59, 80, 81],
backgroundColor: (context) => {
const { chart } = context;
const { ctx, chartArea } = chart;
if (!chartArea) return;
const gradient = ctx.createLinearGradient(0, chartArea.bottom, 0, chartArea.top);
gradient.addColorStop(0, '#4c6ef5');
gradient.addColorStop(1, '#a5b4fc');
return gradient;
}
}]
CSS 樣式內容如下:
body { font-family: 'Segoe UI', 'Microsoft JhengHei', sans-serif; max-width: 960px; margin: 40px auto; padding: 0 16px; transition: background-color 0.2s ease; }
h1 { font-size: 1.4rem; }
button { padding: 8px 16px; margin: 12px 8px 12px 0; cursor: pointer; }
.charts { display: flex; gap: 24px; flex-wrap: wrap; }
.chart-box { flex: 1 1 380px; position: relative; height: 380px; }
p.tip { color: #666; font-size: 0.9rem; }
body[data-theme='dark'] p.tip { color: #aaa; }
HTML 版面內容如下:
<button id="themeToggleBtn">切換深色模式</button>
<div class="charts">
<div class="chart-box"><canvas id="lineChart"></canvas></div>
<div class="chart-box"><canvas id="barChart"></canvas></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1"></script>
JavaScript 程式碼內容如下:
// 全域預設值設定:在建立任何圖表之前先統一設定字型
Chart.defaults.font.family = "'Microsoft JhengHei', 'Segoe UI', sans-serif";
Chart.defaults.font.size = 13;
// 品牌色彩系統:集中管理色票,並提供依索引循環取色的工具函式
const BrandColors = {
palette: ['#4c6ef5', '#12b886', '#f59f00', '#e03131']
};
function getBrandColor(index) {
return BrandColors.palette[index % BrandColors.palette.length];
}
// 深色模式:把淺色/深色兩套設定整理成主題物件
const themes = {
light: { color: '#333333', borderColor: 'rgba(0, 0, 0, 0.1)', background: '#ffffff' },
dark: { color: '#e0e0e0', borderColor: 'rgba(255, 255, 255, 0.15)', background: '#1e1e2e' }
};
// 保存所有已建立的圖表,切換主題時才能逐一呼叫 update()
const allCharts = [];
function applyTheme(themeName) {
const theme = themes[themeName];
Chart.defaults.color = theme.color;
Chart.defaults.borderColor = theme.borderColor;
document.body.style.backgroundColor = theme.background;
document.body.style.color = theme.color;
document.body.dataset.theme = themeName;
// Chart.defaults 修改後,已存在的圖表要手動 update() 才會重新套用
allCharts.forEach(chart => chart.update());
}
// 漸層色背景:折線圖使用 Scriptable Options 動態產生垂直漸層
let cachedGradient, cachedWidth, cachedHeight;
function getGradient(ctx, chartArea) {
const chartWidth = chartArea.right - chartArea.left;
const chartHeight = chartArea.bottom - chartArea.top;
if (!cachedGradient || cachedWidth !== chartWidth || cachedHeight !== chartHeight) {
cachedWidth = chartWidth;
cachedHeight = chartHeight;
cachedGradient = ctx.createLinearGradient(0, chartArea.bottom, 0, chartArea.top);
cachedGradient.addColorStop(0, 'rgba(76, 110, 245, 0.05)');
cachedGradient.addColorStop(1, 'rgba(76, 110, 245, 0.6)');
}
return cachedGradient;
}
// 圖表 1:折線圖 + 漸層背景
const lineChart = new Chart(document.getElementById('lineChart'), {
type: 'line',
data: {
labels: ['一月', '二月', '三月', '四月', '五月'],
datasets: [{
label: '月營收趨勢(萬元)',
data: [30, 45, 42, 58, 66],
borderColor: '#4c6ef5',
backgroundColor: (context) => {
const { chart } = context;
const { ctx, chartArea } = chart;
if (!chartArea) return; // 圖表尚未完成版面配置,先不回傳漸層
return getGradient(ctx, chartArea);
},
fill: true, // 折線圖需要 fill: true 才會套用 backgroundColor
tension: 0.3
}]
},
options: { responsive: true, maintainAspectRatio: false }
});
// 圖表 2:長條圖 + 品牌色票(依索引循環取色)
const barChart = new Chart(document.getElementById('barChart'), {
type: 'bar',
data: {
labels: ['台北', '台中', '台南', '高雄'],
datasets: [{
label: '各分店營收(萬元)',
data: [120, 95, 88, 102],
backgroundColor: (context) => getBrandColor(context.dataIndex)
}]
},
options: { responsive: true, maintainAspectRatio: false }
});
allCharts.push(lineChart, barChart);
applyTheme('light'); // 頁面載入時套用預設主題
document.getElementById('themeToggleBtn').addEventListener('click', () => {
const next = document.body.dataset.theme === 'dark' ? 'light' : 'dark';
applyTheme(next);
});

Chart.defaults 統一設定字型與文字顏色。點擊「切換深色模式」按鈕,觀察畫面背景、格線、文字顏色是否同步切換;同時留意折線圖下方的漸層填色在深色模式下是否依然清晰可辨識。
明天(Day 25)我們會離開純 JavaScript 的世界,開始把 Chart.js 與現代前端框架整合:學習使用 react-chartjs-2 套件,把圖表拆解成可重複使用的 React 元件,並透過 props 傳遞資料、搭配 useState / useEffect 實現資料的動態更新。