Паттерн кеширования

Кеширование результатов format позволяет избежать повторных вычислений для одних и тех же дат и локалей. Особенно полезно при рендеринге длинных списков, где одна дата может встречаться несколько раз.


Когда кеширование полезно

Без кеша каждый вызов format вычисляет разницу заново. Для статичного списка из 100 постов с разными датами — 100 вычислений при каждом рендере. Для длинных лент с частичным совпадением дат кеш даёт ощутимый выигрыш.


Простой кеш с Map

import { format } from 'timeago.js';

const cache = new Map();

function cachedFormat(date, locale = 'ru') {
  const key = `${date}:${locale}`;

  if (cache.has(key)) {
    return cache.get(key);
  }

  const result = format(date, locale);
  cache.set(key, result);
  return result;
}

Проблема: кеш не инвалидируется. Через час format вернёт “5 минут назад” для значения, которое уже должно быть “час назад”.


Кеш с TTL

import { format } from 'timeago.js';

interface CacheEntry {
  value: string;
  cachedAt: number;
}

const cache = new Map<string, CacheEntry>();

function cachedFormatWithTTL(
  date: Date | string | number,
  locale = 'ru',
  ttlMs = 30_000
): string {
  const key     = `${date}:${locale}`;
  const entry   = cache.get(key);
  const now     = Date.now();

  if (entry && now - entry.cachedAt < ttlMs) {
    return entry.value;
  }

  const value = format(date, locale);
  cache.set(key, { value, cachedAt: now });
  return value;
}

Адаптивный TTL в зависимости от возраста даты

Дата 5-минутной давности меняет своё отображение каждые 5-10 секунд. Дата двухлетней давности — раз в год. Разные TTL снижают частоту обновлений кеша:

function getTTL(date: Date | string | number): number {
  const ageMs = Date.now() - new Date(date as any).getTime();

  if (ageMs < 60_000)      return 10_000;    // < 1 минуты: кеш 10 сек
  if (ageMs < 3600_000)    return 60_000;    // < 1 часа: кеш 1 мин
  if (ageMs < 86400_000)   return 1800_000;  // < 1 дня: кеш 30 мин
  if (ageMs < 2592000_000) return 86400_000; // < 30 дней: кеш 1 день
  return 7 * 86400_000;                       // Старше: кеш 1 неделя
}

function adaptiveCachedFormat(date: Date | string | number, locale = 'ru'): string {
  const key   = `${date}:${locale}`;
  const entry = cache.get(key);
  const now   = Date.now();
  const ttl   = getTTL(date);

  if (entry && now - entry.cachedAt < ttl) {
    return entry.value;
  }

  const value = format(date, locale);
  cache.set(key, { value, cachedAt: now });
  return value;
}

LRU кеш (ограничение по размеру)

class LRUCache<K, V> {
  private map = new Map<K, V>();
  private max: number;

  constructor(max: number) {
    this.max = max;
  }

  get(key: K): V | undefined {
    if (!this.map.has(key)) return undefined;
    // Переместить в конец (самый новый)
    const val = this.map.get(key)!;
    this.map.delete(key);
    this.map.set(key, val);
    return val;
  }

  set(key: K, value: V): void {
    if (this.map.has(key)) this.map.delete(key);
    else if (this.map.size >= this.max) {
      // Удалить самый старый
      this.map.delete(this.map.keys().next().value);
    }
    this.map.set(key, value);
  }
}

const lruCache = new LRUCache<string, string>(500);

Кеш с WeakMap для элементов

Кешировать результат по DOM-элементу, а не по строке ключа:

import { format } from 'timeago.js';

const elementCache = new WeakMap<HTMLTimeElement, { value: string; ts: number }>();

function formatElement(el: HTMLTimeElement, locale = 'ru'): string {
  const datetime = el.getAttribute('datetime');
  if (!datetime) return '';

  const cached = elementCache.get(el);
  const now    = Date.now();
  const ttl    = 60_000;

  if (cached && now - cached.ts < ttl) {
    return cached.value;
  }

  const value = format(datetime, locale);
  elementCache.set(el, { value, ts: now });
  return value;
}

WeakMap автоматически освобождает память при удалении элемента из DOM.


Инвалидация кеша при смене локали

const cacheByLocale = new Map<string, Map<string, string>>();

function getLocaleCache(locale: string): Map<string, string> {
  if (!cacheByLocale.has(locale)) {
    cacheByLocale.set(locale, new Map());
  }
  return cacheByLocale.get(locale)!;
}

function clearLocaleCache(locale: string): void {
  cacheByLocale.delete(locale);
}

// При смене языка в приложении
function onLocaleChange(newLocale: string): void {
  // Кеш для новой локали будет строиться заново
  // Старый кеш можно оставить (не удалять) — он перестанет использоваться
}

Глобальный сброс кеша

function clearFormatCache(): void {
  cache.clear();
  console.debug('[timeago] Cache cleared');
}

// Очищать при фокусе вкладки после долгого отсутствия
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    clearFormatCache(); // Принудительно обновить все значения
  }
});

Метрики кеша

let hits = 0;
let misses = 0;

function trackedCachedFormat(date, locale = 'ru') {
  const key = `${date}:${locale}`;

  if (cache.has(key)) {
    hits++;
    return cache.get(key);
  }

  misses++;
  const value = format(date, locale);
  cache.set(key, value);
  return value;
}

// В DevTools
console.log(`Cache hits: ${hits}, misses: ${misses}, ratio: ${(hits / (hits + misses) * 100).toFixed(1)}%`);