rangeCache 快取機制

概述

在開發 Angular 圖表組件時,遇到了每次滑鼠 hover 都會觸發複雜查找邏輯的效能問題。透過實作 rangeCache 快取機制,將 O(n) 的查找時間降低到 O(1),大幅提升使用者體驗。

問題背景

原始問題

Chart.js 的 tooltip callbacks 在每次滑鼠移動時都會執行,原本的實作方式:

// ❌ 效能不佳的寫法
footer: (tooltipItems) => this.createTooltipFooter(tooltipItems)

private createTooltipFooter(tooltipItems: any[]): string {
  const item = tooltipItems[0];
  const datasetLabel = item.dataset.label || '';
  const labelWithoutPercent = item.label.slice(0, -1);
  
  // 每次 hover 都要執行這些 O(n) 操作
  return this.getRange(datasetLabel, labelWithoutPercent);
}

private getRange(columnName: string, label: string): string {
  const data = this.barChartData().data;
  
  // O(n) 查找標籤索引
  const index = data.labels.findIndex((item) => item.toString() === label);
  
  // O(n) 查找資料集
  const dataset = data.datasets.find((dataset) => dataset.label === columnName);
  
  return dataset.range[index];
}

效能問題

  • 頻繁的陣列查找:每次 hover 都要執行 findIndexfind

  • 重複的資料存取:每次都要呼叫 this.barChartData()

  • 時間複雜度高:每次查找都是 O(n),當資料量大時影響明顯

rangeCache 解決方案

核心概念

將原本需要「動態查找」的邏輯改為「預先建立快取」,把所有可能的查找結果預先計算並存在 Map 中。

資料結構設計

// 快取結構:Map<string, string>
// key: "資料集名稱-標籤值"
// value: "對應的範圍資訊"

// 範例:
Map {
  "feature_12_min-0~10" => "3040 ~ 3040",
  "feature_12_min-10~20" => "3040 ~ 3040",
  "label-0~10" => "2 ~ 14.1",
  "label-10~20" => "14.1 ~ 26.2"
}

實作程式碼

// 使用 computed signal 確保資料變更時自動更新快取
private rangeCache = computed(() => {
  const data = this.barChartData()?.data;
  if (!data?.datasets || !data?.labels) {
    return new Map<string, string>();
  }

  const cache = new Map<string, string>();
  
  // 預先建立所有可能的 key-value 對應
  data.datasets.forEach(dataset => {
    if (dataset.range && Array.isArray(dataset.range)) {
      data.labels.forEach((label, index) => {
        // 建立快取 key: "資料集名稱-標籤值"
        const key = `${dataset.label}-${label}`;
        cache.set(key, dataset.range[index] || '');
      });
    }
  });

  return cache;
});

使用方式

// ✅ 優化後的 tooltip callback
tooltip: {
  callbacks: {
    title: () => '',
    footer: (tooltipItems) => {
      if (!tooltipItems.length) return '';

      const item = tooltipItems[0];
      const datasetLabel = item.dataset.label || '';
      const labelWithoutPercent = item.label.slice(0, -1);
      
      // O(1) 快取查找,取代原本的 O(n) 查找
      const cacheKey = `${datasetLabel}-${labelWithoutPercent}`;
      const range = this.rangeCache().get(cacheKey) || '';
      
      return `${item.label}\n${range}`;
    },
  },
},

實際案例分析

原始資料結構

{
  "data": {
    "labels": ["0~10", "10~20", "20~30", "30~40", "40~50"],
    "datasets": [
      {
        "label": "feature_12_min",
        "data": [0, 0, 0, 0, 3],
        "range": ["3040 ~ 3040", "3040 ~ 3040", "3040 ~ 3040", "3040 ~ 3040", "3040 ~ 3040"]
      },
      {
        "label": "label",
        "data": [2, 0, 0, 0, 1],
        "range": ["2 ~ 14.1", "14.1 ~ 26.2", "26.2 ~ 38.3", "38.3 ~ 50.4", "50.4 ~ 62.5"]
      }
    ]
  }
}

快取建立過程

// Step 1: 處理第一個 dataset
dataset = {
  label: "feature_12_min",
  range: ["3040 ~ 3040", "3040 ~ 3040", "3040 ~ 3040", "3040 ~ 3040", "3040 ~ 3040"]
}

// 遍歷每個 label 建立快取項目
cache.set("feature_12_min-0~10", "3040 ~ 3040")
cache.set("feature_12_min-10~20", "3040 ~ 3040")
cache.set("feature_12_min-20~30", "3040 ~ 3040")
cache.set("feature_12_min-30~40", "3040 ~ 3040") 
cache.set("feature_12_min-40~50", "3040 ~ 3040")

// Step 2: 處理第二個 dataset
dataset = {
  label: "label",
  range: ["2 ~ 14.1", "14.1 ~ 26.2", "26.2 ~ 38.3", "38.3 ~ 50.4", "50.4 ~ 62.5"]
}

cache.set("label-0~10", "2 ~ 14.1")
cache.set("label-10~20", "14.1 ~ 26.2")
cache.set("label-20~30", "26.2 ~ 38.3")
cache.set("label-30~40", "38.3 ~ 50.4")
cache.set("label-40~50", "50.4 ~ 62.5")

使用者 hover 時的查找過程

// 使用者 hover 到 "feature_12_min" 的 "10~20%" 區間
tooltipItems = [{
  dataset: { label: "feature_12_min" },
  label: "10~20%"
}]

// 建立查找 key
const datasetLabel = "feature_12_min"
const labelWithoutPercent = "10~20" // 移除 %
const cacheKey = "feature_12_min-10~20"

// O(1) 快取查找
const range = rangeCache.get("feature_12_min-10~20") // "3040 ~ 3040"

// 返回結果
return "10~20%\n3040 ~ 3040"

效能對比

時間複雜度分析

操作
舊版本
新版本

建立快取

O(m × n) 一次性

每次查找

O(n)

O(1)

總查找成本

O(k × n)

O(k)

其中:

  • m = datasets 數量

  • n = labels 數量

  • k = hover 次數

實際效能提升

以一個擁有 13 個 datasets 和 10 個 labels 的圖表為例:

舊版本每次 hover

  • findIndex 遍歷 10 個 labels:10 次比較

  • find 遍歷 13 個 datasets:最多 13 次比較

  • 總計:最多 23 次操作

新版本每次 hover

  • Map.get() 雜湊查找:1 次操作

  • 效能提升:23 倍

記憶體使用分析

// 記憶體使用量估算
const entriesCount = datasetsCount × labelsCount // 13 × 10 = 130 個快取項目
const averageKeyLength = 20 // "feature_12_min-0~10" 約 20 字元
const averageValueLength = 15 // "3040 ~ 3040" 約 15 字元
const totalMemory = entriesCount × (averageKeyLength + averageValueLength) × 2 // 約 9KB

// 記憶體成本極低,效能提升顯著

適用場景

最適合的情況

  1. 頻繁查找:使用者會經常 hover 圖表

  2. 查找邏輯複雜:需要多次陣列遍歷

  3. 資料相對穩定:不會頻繁變更

  4. 查找結果有限:可預先列舉所有可能性

不適合的情況

  1. 資料頻繁變更:會導致快取頻繁重建

  2. 記憶體敏感:快取項目數量極大的情況

  3. 查找邏輯簡單:原本就是 O(1) 的查找

最佳實踐

1. 使用 Computed Signal

// ✅ 推薦:自動響應資料變更
private rangeCache = computed(() => {
  // 當 barChartData() 變更時自動重新計算
});

// ❌ 避免:手動管理快取更新
private rangeCache = new Map();

2. 錯誤處理

private rangeCache = computed(() => {
  const data = this.barChartData()?.data;
  
  // 邊界檢查
  if (!data?.datasets || !data?.labels) {
    return new Map<string, string>();
  }
  
  const cache = new Map<string, string>();
  
  data.datasets.forEach(dataset => {
    // 確保 range 屬性存在且為陣列
    if (dataset.range && Array.isArray(dataset.range)) {
      data.labels.forEach((label, index) => {
        const key = `${dataset.label}-${label}`;
        // 防止陣列越界
        cache.set(key, dataset.range[index] || '');
      });
    }
  });

  return cache;
});

3. 快取 Key 設計原則

// ✅ 好的 key 設計
const key = `${dataset.label}-${label}`; // "feature_1-0~10"

// ❌ 避免的 key 設計
const key = `${dataset.label}${label}`; // 可能產生衝突
const key = `${index}-${dataset.label}`; // 依賴索引,不穩定

4. 效能監控

private rangeCache = computed(() => {
  const startTime = performance.now();
  
  // 建立快取邏輯
  const cache = new Map<string, string>();
  // ...
  
  const endTime = performance.now();
  console.log(`Cache built in ${endTime - startTime}ms with ${cache.size} entries`);
  
  return cache;
});

擴展應用

多層快取

當查找邏輯更複雜時,可以建立多層快取:

// 範圍資訊快取
private rangeCache = computed(() => { /* ... */ });

// 統計資訊快取  
private statsCache = computed(() => { /* ... */ });

// 顏色快取
private colorCache = new Map<number, string[]>();

泛型快取工具

/**
 * 通用快取建立器
 */
private createLookupCache<T>(
  data: any[], 
  keyBuilder: (item: any, index: number) => string,
  valueBuilder: (item: any, index: number) => T
): Map<string, T> {
  const cache = new Map<string, T>();
  
  data.forEach((item, index) => {
    const key = keyBuilder(item, index);
    const value = valueBuilder(item, index);
    cache.set(key, value);
  });
  
  return cache;
}

小結

rangeCache 是一個典型的「以空間換時間」的最佳化策略。透過預先建立查找表,將複雜的 O(n) 查找降低到 O(1),在使用者體驗和系統效能上都有顯著提升。

關鍵收穫

  • 善用 Angular 的 computed signal 實現自動快取更新

  • 選擇合適的快取 key 設計避免衝突

  • 在記憶體使用和查找效能間找到平衡點

  • 透過效能分析驗證最佳化效果

這種模式可以應用到任何需要頻繁查找的場景中,是前端效能最佳化的重要技巧之一。

Last updated