Хуки и middleware

Хуки и middleware позволяют встраивать логику до и после вызовов timeago.js без изменения исходного кода. Это полезно для логирования, кеширования, телеметрии и валидации.


Хуки жизненного цикла

Хук — функция, вызываемая в определённый момент. Для timeago.js имеет смысл четыре момента:

  • beforeFormat — перед вызовом format;
  • afterFormat — после получения результата;
  • beforeRender — перед вызовом render;
  • afterCancel — после вызова cancel.
import { format as baseFormat, render as baseRender, cancel as baseCancel } from 'timeago.js';

type HookName = 'beforeFormat' | 'afterFormat' | 'beforeRender' | 'afterCancel';

type HookPayload = {
  beforeFormat: { date: unknown; locale?: string };
  afterFormat:  { date: unknown; locale?: string; result: string };
  beforeRender: { nodes: unknown; locale?: string };
  afterCancel:  { nodes?: unknown };
};

type HookFn<K extends HookName> = (payload: HookPayload[K]) => void;

Реализация системы хуков

class TimeagoHooks {
  private hooks: { [K in HookName]?: HookFn<K>[] } = {};

  hook<K extends HookName>(name: K, fn: HookFn<K>): void {
    if (!this.hooks[name]) this.hooks[name] = [];
    (this.hooks[name] as HookFn<K>[]).push(fn);
  }

  private fire<K extends HookName>(name: K, payload: HookPayload[K]): void {
    ((this.hooks[name] ?? []) as HookFn<K>[]).forEach(fn => fn(payload));
  }

  format(date: unknown, locale = 'ru'): string {
    this.fire('beforeFormat', { date, locale });

    const d = date instanceof Date ? date : new Date(date as any);
    const result = baseFormat(d, locale);

    this.fire('afterFormat', { date, locale, result });
    return result;
  }

  render(nodes: Element | Element[], locale = 'ru'): void {
    this.fire('beforeRender', { nodes, locale });
    baseRender(nodes, locale);
  }

  cancel(nodes?: Element | Element[]): void {
    baseCancel(nodes);
    this.fire('afterCancel', { nodes });
  }
}

export const timeago = new TimeagoHooks();

Регистрация хуков

// Логирование перед форматированием
timeago.hook('beforeFormat', ({ date, locale }) => {
  console.log(`[format] date=${date}, locale=${locale}`);
});

// Телеметрия после форматирования
timeago.hook('afterFormat', ({ locale, result }) => {
  metrics.increment('timeago.format', { locale });
});

// Предупреждение о рендере большого количества элементов
timeago.hook('beforeRender', ({ nodes }) => {
  const arr = Array.isArray(nodes) ? nodes : [nodes];
  if (arr.length > 100) {
    console.warn(`[timeago] Rendering ${arr.length} elements — consider batching`);
  }
});

Middleware с прерыванием цепочки

В отличие от хуков, middleware может прервать цепочку и вернуть собственный результат:

type NextFn     = () => string;
type FormatMiddleware = (date: unknown, locale: string, next: NextFn) => string;

class TimeagoMiddlewareChain {
  private middlewares: FormatMiddleware[] = [];

  use(mw: FormatMiddleware): this {
    this.middlewares.push(mw);
    return this;
  }

  format(date: unknown, locale = 'ru'): string {
    let index = 0;

    const next: NextFn = () => {
      if (index < this.middlewares.length) {
        return this.middlewares[index++](date, locale, next);
      }
      return baseFormat(date as any, locale);
    };

    return next();
  }
}

Middleware для валидации

const validationMiddleware: FormatMiddleware = (date, locale, next) => {
  if (date == null) {
    return '';
  }

  const d = new Date(date as any);
  if (isNaN(d.getTime())) {
    console.error(`[timeago] Invalid date: ${date}`);
    return '';
  }

  return next();
};

Middleware для кеширования

const CACHE_TTL = 30_000;
const cache = new Map<string, { value: string; ts: number }>();

const cacheMiddleware: FormatMiddleware = (date, locale, next) => {
  const key    = `${date}:${locale}`;
  const cached = cache.get(key);
  const now    = Date.now();

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

  const result = next();
  cache.set(key, { value: result, ts: now });
  return result;
};

Middleware для fallback

const fallbackMiddleware = (fallback: string): FormatMiddleware =>
  (date, locale, next) => {
    try {
      return next();
    } catch {
      return fallback;
    }
  };

Составная цепочка middleware

const chain = new TimeagoMiddlewareChain()
  .use(validationMiddleware)
  .use(fallbackMiddleware('давно'))
  .use(cacheMiddleware);

// Вызовы проходят через все три middleware по очереди
chain.format(null, 'ru');           // '' — прерван validationMiddleware
chain.format('invalid', 'ru');      // '' — прерван validationMiddleware
chain.format('2025-01-01', 'ru');   // Кешируется после первого вызова

Разница между хуками и middleware

Характеристика Хук Middleware
Порядок Все вызываются Может прервать цепочку
Влияние на результат Только наблюдение Может изменить результат
Сложность Простые функции-слушатели Требует передачи next
Типичное применение Логи, метрики, side effects Кеш, валидация, fallback

Комбинирование хуков и middleware

// Хуки — для побочных эффектов (логи, метрики)
// Middleware — для изменения поведения (кеш, валидация)

class TimeagoExtensible {
  private chain   = new TimeagoMiddlewareChain();
  private hookSys = new TimeagoHooks();

  useMiddleware(mw: FormatMiddleware): this {
    this.chain.use(mw);
    return this;
  }

  useHook<K extends HookName>(name: K, fn: HookFn<K>): this {
    this.hookSys.hook(name, fn);
    return this;
  }

  format(date: unknown, locale = 'ru'): string {
    this.hookSys['fire']('beforeFormat', { date, locale });
    const result = this.chain.format(date, locale);
    this.hookSys['fire']('afterFormat', { date, locale, result });
    return result;
  }
}