昨天(Day 25)我們用
react-chartjs-2把 Chart.js 帶進了 React 的世界,體驗了useState/useEffect如何驅動圖表更新。今天我們換一個舞台:Vue。Vue 3 的 Composition API 搭配官方生態圈推薦的vue-chartjs套件,會讓「資料驅動圖表」這件事變得更直覺——因為 Vue 的 Reactivity(響應式)系統本來就是為了「資料變、畫面自動變」而設計的。今天會示範如何用 Vite 建立 Vue 3 + TypeScript 專案、如何用ref/reactive/computed管理圖表資料,並再次搭配 Node.js 的 Express 架一支簡單的後端 API,體驗「前後端分離」下即時更新圖表的完整流程。
本日範例程式碼:
前端:example01-vue-dashboard
後端 API Server:example02-api-server
new Chart()?延續 Day1 ~ Day24 的寫法,我們一直都是這樣建立圖表的:
const ctx = document.getElementById('myChart');
const chart = new Chart(ctx, { type: 'bar', data, options });
如果把這段程式碼直接搬進 Vue 元件的 onMounted 裡,畫面確實「能動」,但很快就會遇到跟 React 類似、卻又帶有 Vue 特色的麻煩:
reactive() 會把資料包成 Proxy,如果直接把這個 Proxy 物件丟給 new Chart() 當作 data,Chart.js 內部某些運算(例如深拷貝、比較)可能會出現非預期的行為,因為 Chart.js 原本並不是為了跟 Proxy 物件打交道而設計的。destroy():Vue 元件被銷毀(例如切換路由)時,如果沒有在 onUnmounted 呼叫 chart.destroy() 釋放資源,就會造成記憶體洩漏(Memory Leak),這一點與 React 的 useEffect cleanup 概念相通,只是 Vue 用的是 onUnmounted 這個生命週期鉤子(Lifecycle Hook)。chart.update() 才會重繪,這件事很容易忘記寫、或寫在錯誤的時機點。watch 監聽資料變化 => update() => onUnmounted 時 destroy()」這一整套邏輯。vue-chartjs 就是為了解決上述問題而生的官方推薦套件:它內部已經用 Composition API 的 watch(..., { deep: true }) 監聽了 data 和 options 這兩個 props,資料一變就會自動局部更新(Patch)並呼叫 chart.update(),同時也用 onMounted 建立圖表、onUnmounted 銷毀圖表,把整套生命週期邏輯都封裝好了。我們只需要用 Vue 熟悉的方式傳入 data 和 options,其餘的事情都交給套件處理。
vue-chartjs內部仍然是呼叫 Chart.js 的原生 API,並沒有重新實作一套繪圖引擎,所以我們前面學過的data、options、Scale、Plugin 等觀念完全可以沿用,只是「建立與更新圖表」的方式换成了 Vue 元件與 Composition API。
我們一樣使用 Vite 這套現代前端建構工具來初始化專案(Vite 本身就是 Vue 團隊主導開發的,對 Vue 專案的支援非常完整):
npm create vite@latest chartjs-vue-demo -- --template vue-ts
cd chartjs-vue-demo
npm install
安裝完成後,再加裝 Chart.js 與 vue-chartjs:
npm install chart.js@4.5.1 vue-chartjs@5.3.4
chart.js是vue-chartjs的 peer dependency(同儕依賴),也就是說vue-chartjs本身不包含 Chart.js 的原始碼,兩者需要一起安裝,而且版本要互相搭配(vue-chartjsv5 對應chart.jsv4,同時支援 Vue 3 與 Vue 2.7)。
跟 React 篇一樣,Chart.js 4.x 為了減少打包後的檔案大小,把所有的圖表類型、Scale、Plugin 都拆成獨立的模組,需要我們手動 register() 註冊實際會用到的部分。建議把註冊的程式碼獨立成一個檔案,在應用程式進入點只 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.ts
import { createApp } from 'vue';
import './chartSetup'; // 確保進入點就完成註冊
import './style.css';
import App from './App.vue';
createApp(App).mount('#app');
一個
vue-chartjs特有的小細節:如果你是從vue-chartjsimport 具名的圖表元件(例如import { Bar } from 'vue-chartjs'),套件內部其實已經自動幫你ChartJS.register(BarController)註冊了「長條圖的 Controller」,這是createTypedChart()這個工廠函式的內建行為。但是CategoryScale、LinearScale、BarElement、Tooltip、Legend這些「座標軸」「圖形元素」「外掛」仍然需要自己手動register(),因為套件無法預測你會不會用到時間軸、多 Y 軸或其他外掛。所以最保險的做法,還是像上面一樣在chartSetup.ts統一註冊常用元件,不要依賴「元件會不會自動註冊」這件事。
npm run dev
Vite 預設會在 http://localhost:5173 啟動開發伺服器,並支援 HMR(Hot Module Replacement),修改 .vue 檔案後畫面會立即更新,不需要手動整頁重整,這一點與 React 篇的體驗完全一致。
vue-chartjs 的具名圖表元件vue-chartjs 針對每種圖表類型都提供了對應的具名元件,例如 Bar、Line、Pie、Doughnut、Radar、PolarArea、Bubble、Scatter,用法跟平常寫 Chart.js 的設定幾乎一樣,只是 data 和 options 變成了 props。這裡使用 Vue 3 推薦的 <script setup> 語法:
<!-- src/components/MonthlySalesChart.vue -->
<script setup lang="ts">
import { Bar } from 'vue-chartjs';
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: '每月銷售額' },
},
};
</script>
<template>
<div style="height: 360px">
<Bar :data="data" :options="options" />
</div>
</template>
maintainAspectRatio: false這行很重要:跟 React 篇提到的一樣,Chart.js 預設會依照固定的長寬比例縮放圖表,如果外層容器(例如上面的<div>)用 CSS 指定了高度,卻沒有關閉maintainAspectRatio,圖表可能不會乖乖填滿容器。養成「外層容器給高度 +maintainAspectRatio: false」的習慣,可以省下很多除錯時間。
上面的例子把 data 寫死在元件內部,沒有真正做到「元件化」。實務上我們會用 defineProps 把 data 需要的資料抽出來當作 props,讓同一顆圖表元件可以顯示不同的資料:
<!-- src/components/BarChart.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import type { ChartData, ChartOptions } from 'chart.js';
const props = withDefaults(
defineProps<{
title: string;
labels: string[];
values: number[];
color?: string;
}>(),
{
color: 'rgba(54, 162, 235, 0.6)',
},
);
// 用 computed 包裝,確保只有 props 真正改變時才重新建立 data 物件
const data = computed<ChartData<'bar'>>(() => ({
labels: props.labels,
datasets: [
{
label: props.title,
data: props.values,
backgroundColor: props.color,
},
],
}));
const options = computed<ChartOptions<'bar'>>(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
title: { display: true, text: props.title },
legend: { display: false },
},
}));
</script>
<template>
<div style="height: 320px">
<Bar :data="data" :options="options" />
</div>
</template>
使用時,只要傳入不同的 props,就能重複利用同一顆元件畫出不同的圖表:
<!-- src/App.vue -->
<script setup lang="ts">
import BarChart from './components/BarChart.vue';
</script>
<template>
<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>
</template>
這就是「元件化圖表設計」的核心精神:圖表元件只負責「怎麼畫」,資料則由外部透過 props 決定「畫什麼」,兩者職責分離,元件才能真正被重複使用在不同的頁面或情境。這跟昨天 React 篇的設計理念完全一致,只是把 props 的傳遞方式换成了 Vue 的模板語法。
vue-chartjs 內部有一個 datasetIdKey 的 props(預設值是 'label'),用來辨識「這是原本的第幾筆 dataset」。當 data 更新時,vue-chartjs 會依照 datasetIdKey 去比對新舊 dataset,找得到對應的舊資料就直接 Object.assign 進去做局部更新(保留動畫過渡效果),找不到就當作全新的 dataset 加入。
這代表:如果你的多個 dataset 沒有唯一的 label(或你指定的識別欄位),vue-chartjs 可能會誤判成同一筆資料,導致更新時發生資料錯亂或動畫跳動的狀況。解決方法跟 React 篇一樣:
label(vue-chartjs 預設就是用 label 當作識別 key)。label 不方便當作唯一識別(例如 label 是給圖例顯示用的中文說明,可能會重複),可以額外給每個 dataset 一個 id 欄位,並透過 datasetIdKey prop 告訴元件要用哪個欄位當 key:<Line
dataset-id-key="id"
:data="{
labels: ['六月', '七月', '八月'],
datasets: [
{ id: 1, label: '今年', data: [5, 6, 7] },
{ id: 2, label: '去年', data: [3, 2, 1] },
],
}"
/>
記住這個原則:只要圖表有兩個以上的 dataset,就要主動確認每個 dataset 有沒有唯一的識別值,否則等到資料動態更新時才發現圖表跑掉,會比較難排查問題根源。這一點無論是
react-chartjs-2還是vue-chartjs都是共通的設計,因為兩者都是圍繞著 Chart.js 原生 API 打造的 Wrapper。
vue-chartjs 內部用 watch([() => props.options, () => props.data], ..., { deep: true }) 監聽 data 和 options 這兩個 props 的深層變化,只要偵測到內容真的不一樣,就會自動呼叫 Chart.js 的 update()。這代表:在 Vue 裡,我們完全不需要手動呼叫任何「更新圖表」的方法,只要讓資料本身是響應式的(ref/reactive),並讓它的值真正改變,圖表就會自動重繪——這正是 Vue Reactivity 系統與 Chart.js 整合得最漂亮的地方。
ref + computed)例如做一個「切換時間區間」的下拉選單,選擇不同區間時圖表資料跟著換掉:
<!-- src/components/RangeSalesChart.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Line } from 'vue-chartjs';
import type { ChartData, ChartOptions } from 'chart.js';
type Range = 'week' | 'month' | 'year';
const DATASET_BY_RANGE: Record<Range, { 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] },
};
const range = ref<Range>('week'); // 響應式狀態:目前選擇的時間區間
// 只有 range 改變時才會重新計算,computed 會自動快取結果
const data = computed<ChartData<'line'>>(() => {
const source = DATASET_BY_RANGE[range.value];
return {
labels: source.labels,
datasets: [
{
label: '銷售趨勢',
data: source.values,
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.3)',
tension: 0.3,
},
],
};
});
const options: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
};
</script>
<template>
<div>
<select v-model="range">
<option value="week">本週</option>
<option value="month">本月</option>
<option value="year">今年</option>
</select>
<div style="height: 320px; margin-top: 12px">
<Line :data="data" :options="options" />
</div>
</div>
</template>
重點在於:只要 range 這個 ref 的值改變,computed 就會重新計算出新的 data,vue-chartjs 偵測到 data 的內容變了,就會自動更新圖表,開發者完全不需要自己手動操作 Chart.js 的 API。這跟 React 篇用 useState + useMemo 的思路幾乎一模一樣,只是 Vue 的 v-model 讓「表單元素綁定狀態」這件事變得更簡潔——不需要自己寫 onChange 事件處理函式。
onMounted + setInterval 定時向後端拉取即時資料接下來示範跟後端 API 整合、定時更新圖表的情境(後端 Express API 的部分會在下一節說明):
<!-- src/components/LiveStatsChart.vue -->
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Line } from 'vue-chartjs';
import type { ChartData, ChartOptions } from 'chart.js';
const MAX_POINTS = 10; // 圖表上最多保留幾個資料點(Sliding Window)
const API_URL = 'http://localhost:4000/api/stats';
const POLL_INTERVAL_MS = 3000;
interface StatsPayload {
time: string;
onlineUsers: number;
orders: number;
}
const labels = ref<string[]>([]);
const onlineUsers = ref<number[]>([]);
const errorMessage = ref<string | null>(null);
let intervalId: number | undefined;
async function fetchStats() {
try {
const res = await fetch(API_URL);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const stats: StatsPayload = await res.json();
// 直接對 ref.value 陣列做賦值,Vue 的響應式系統會自動偵測到變化
labels.value = [...labels.value, stats.time].slice(-MAX_POINTS);
onlineUsers.value = [...onlineUsers.value, stats.onlineUsers].slice(-MAX_POINTS);
errorMessage.value = null;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error('取得即時資料失敗', message);
errorMessage.value = `無法連線到後端 API(${API_URL}):${message}`;
}
}
onMounted(() => {
fetchStats(); // 先立即抓一次,不用等第一個 interval
intervalId = window.setInterval(fetchStats, POLL_INTERVAL_MS);
});
// 元件卸載時務必清除計時器,避免記憶體洩漏,觀念與 React 的 useEffect cleanup 相通
onUnmounted(() => {
window.clearInterval(intervalId);
});
const data = computed<ChartData<'line'>>(() => ({
labels: labels.value,
datasets: [
{
label: '線上人數',
data: onlineUsers.value,
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 },
},
};
</script>
<template>
<div>
<p v-if="errorMessage" style="color: #e03131; font-size: 0.85rem">
{{ errorMessage }}(請確認 examples/example02-api-server 是否已啟動)
</p>
<div style="height: 320px">
<Line :data="data" :options="options" />
</div>
</div>
</template>
這個範例展示了三個實務上很重要的細節,跟 React 篇的 useEffect 版本正好可以對照:
onUnmounted 一定要記得清除 setInterval:如果沒有在 onUnmounted 呼叫 clearInterval,即使元件已經卸載,計時器還是會持續在背景執行、持續嘗試修改 ref.value,這是 Vue 應用程式常見的記憶體洩漏來源。這個角色對應到 React 篇 useEffect 回傳的 cleanup function。ref.value 賦值就會觸發響應式更新:不像 React 的 useState 需要擔心「stale closure(過期閉包)」問題,Vue 的 ref 是透過 Proxy 追蹤依賴,setInterval 的 callback 裡直接讀取、修改 labels.value 都會拿到最新的值,不需要像 React 那樣特別寫成函式式更新 (prev) => ...。這是 Vue Reactivity 系統與 React 資料流哲學上一個很有趣的差異點。slice(-MAX_POINTS)):即時資料如果無限累加,陣列會越來越大,圖表也會越畫越擠,這裡一樣採用「移動視窗(Sliding Window)」的做法,只保留最近 N 筆資料。ref 取得 Chart.js 原生實例雖然大部分情境只需要 data 和 options 這兩個 props 就能滿足需求,但有時候還是需要直接呼叫 Chart.js 原生的 API(例如手動觸發 resize()、或是取得使用者點擊了圖表上的哪個元素)。vue-chartjs 的每個圖表元件都用 expose({ chart: chartRef }) 把底層的 Chart.js instance 暴露出來,我們可以透過 Vue 的 template ref 拿到它:
<!-- src/components/ClickableBarChart.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import { Bar, getElementAtEvent } from 'vue-chartjs';
import type { ChartData, ChartOptions } from 'chart.js';
const props = defineProps<{ data: ChartData<'bar'> }>();
// template ref:型別對應到 vue-chartjs 具名元件的 instance
const chartRef = ref<InstanceType<typeof Bar> | null>(null);
const message = ref('點擊長條可以看到對應的資料點資訊');
const options: ChartOptions<'bar'> = {
responsive: true,
maintainAspectRatio: false,
};
function handleClick(event: MouseEvent) {
if (!chartRef.value?.chart) return;
const [element] = getElementAtEvent(chartRef.value.chart, event);
if (element) {
const { datasetIndex, index } = element;
const dataset = props.data.datasets[datasetIndex];
const label = props.data.labels?.[index];
const value = dataset.data[index];
message.value = `點擊到「${dataset.label}」第 ${index + 1} 筆(${label}):${value}`;
}
}
</script>
<template>
<div>
<p style="font-size: 0.85rem; color: #666">{{ message }}</p>
<div style="height: 320px">
<Bar ref="chartRef" :data="data" :options="options" @click="handleClick" />
</div>
</div>
</template>
vue-chartjs 提供了三個常用的輔助函式,都是傳入「Chart.js instance」和「原生的滑鼠事件」,回傳使用者點擊到的資料,跟 react-chartjs-2 提供的輔助函式幾乎是同名同款(畢竟兩者都是圍繞著 Chart.js 原生 API 打造的):
| 輔助函式 | 說明 |
|---|---|
getDatasetAtEvent(chart, event) |
回傳點擊位置對應的整個 dataset |
getElementAtEvent(chart, event) |
回傳點擊位置對應的單一資料點(element) |
getElementsAtEvent(chart, event) |
回傳點擊位置對應的所有重疊的資料點 |
chartRef.value?.chart的chart是怎麼來的? 打開vue-chartjs原始碼會看到expose({ chart: chartRef })這一行,代表這個元件主動把內部的chartRef(Chart.js instance 的shallowRef)以chart這個名字暴露給外部使用。這是 Vue 3<script setup>元件想要對外公開內部狀態時的標準做法(defineExpose或setup()裡的expose()),如果元件沒有主動expose,外部即使拿到 template ref 也存取不到內部變數。
現在把前面學到的技巧整合起來,做一個完整的小型儀表板(Dashboard):分別顯示「固定資料的長條圖」「使用者互動切換的折線圖」「每 3 秒更新一次的即時折線圖」「可點擊的長條圖」,資料來源涵蓋「寫死的靜態資料」與「Express API」兩種情境。
先建立一個獨立的後端資料夾(與前端專案分開):
mkdir chartjs-api-server
cd chartjs-api-server
npm init -y
npm install express cors
撰寫一支最簡單的 API 伺服器(跟 Day 25 React 篇的寫法完全一樣,因為後端 API 本來就跟前端框架無關——這正是「前後端分離」架構的優勢,同一支 API 可以同時服務 React 版與 Vue 版的前端):
// 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
<!-- src/App.vue -->
<script setup lang="ts">
import BarChart from './components/BarChart.vue';
import RangeSalesChart from './components/RangeSalesChart.vue';
import LiveStatsChart from './components/LiveStatsChart.vue';
import ClickableBarChart from './components/ClickableBarChart.vue';
const clickableData = {
labels: ['北區', '中區', '南區', '東區'],
datasets: [
{
label: '本月訂單數',
data: [42, 35, 58, 20],
backgroundColor: 'rgba(76, 110, 245, 0.6)',
},
],
};
</script>
<template>
<div style="padding: 24px">
<h1>Day 26|Chart.js 與 Vue 整合:銷售儀表板</h1>
<div
style="
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 32px;
"
>
<section>
<h2>本季銷售額(元件化 + props)</h2>
<BarChart title="銷售額(萬元)" :labels="['一月', '二月', '三月']" :values="[65, 59, 80]" />
</section>
<section>
<h2>銷售趨勢(ref + computed 互動切換)</h2>
<RangeSalesChart />
</section>
<section>
<h2>即時線上人數(onMounted + Express API)</h2>
<LiveStatsChart />
</section>
<section>
<h2>各區訂單數(template ref + 點擊事件)</h2>
<ClickableBarChart :data="clickableData" />
</section>
</div>
</div>
</template>
開發時分別在兩個終端機視窗執行:
# 終端機 1:啟動後端 API
cd chartjs-api-server
node server.js
# 終端機 2:啟動前端開發伺服器
cd chartjs-vue-demo
npm run dev
打開瀏覽器就可以看到四個區塊分別呈現不同的資料流動方式,完整體驗了「Vue 元件化 + Composition API 響應式更新 + 後端 API」的整合流程。
register() 對應的元件:Chart.js 4.x 需要手動註冊 Scale、Element、Plugin,這件事和是否使用 Vue 無關,即使 import 了 vue-chartjs 的具名元件(會自動註冊 Controller),Scale/Element/Plugin 還是要自己補齊。data/options 包進 reactive(),卻沒注意深層監聽的效能成本:vue-chartjs 對 data/options 是 { deep: true } 監聽,如果圖表資料量很大(例如上千筆資料點),每次任何巢狀屬性改變都會觸發整個物件的深層比對,可能造成效能瓶頸。資料量大時,建議改成「整批替換陣列」(如同第 5.2 節的 labels.value = [...])而不是逐一 push 觸發多次巢狀變化。dataset-id-key 或唯一 label:如同第四章提到的,會造成資料更新時圖表資料錯亂或動畫跳動的問題。maintainAspectRatio:圖表可能顯示異常(太小、太大、或是不斷長高),這一點與 React 篇完全相同。onMounted 裡的 setInterval 忘記在 onUnmounted 清除:這是 Vue 應用程式最常見的記憶體洩漏來源之一,尤其在即時資料圖表、或是使用 <KeepAlive>/路由切換的情境中特別容易忽略。reactive():Chart.js instance 是一個複雜的物件,被 Vue 的 Proxy 包裹後容易出現非預期的行為(vue-chartjs 內部特地用 shallowRef 而非 ref 保存 instance 就是這個原因),如果需要保存 instance 的參照,應該用 shallowRef 或直接透過 template ref 存取。expose 卻期待外部能拿到內部狀態:如果你自己寫的 Vue 元件想像 vue-chartjs 一樣,讓外部透過 template ref 存取內部的某個變數,記得要用 defineExpose({ ... })(<script setup> 語法)或 setup() 裡的 expose() 明確公開,否則預設是拿不到的。明天(Day 27)我們會完成框架整合的最後一站:Angular。學習使用 Angular 官方推薦的 ng2-charts 套件,認識 provideCharts(withDefaultRegisterables()) 的模組設定方式、<canvas baseChart> 指令用法,並搭配 @Input()/ChangeDetectorRef 與 RxJS Observable 資料流,體驗第三種前端框架整合圖表的方式,與這兩天的 React/Vue 版本做個完整的三方對照。
ref / reactive / computed)
onMounted / onUnmounted)