Архитектура расширений

Архитектура расширений определяет, как сторонний код встраивается в поведение timeago.js. Поскольку библиотека не предоставляет встроенных хуков, расширения реализуются через обёртки, прокси и паттерны дизайна.


Слои расширяемости

┌─────────────────────────────────────────────┐
│  Приложение (компоненты, страницы)           │
├─────────────────────────────────────────────┤
│  Расширения (кеш, логи, события, валидация) │
├─────────────────────────────────────────────┤
│  Обёртка (TimeagoCore / TimeagoService)     │
├─────────────────────────────────────────────┤
│  timeago.js (format, render, cancel, register)│
└─────────────────────────────────────────────┘

Приложение никогда не обращается к timeago.js напрямую — только через обёртку. Расширения регистрируются в обёртке.


Архитектура Middleware

import { format as baseFormat } from 'timeago.js';

type DateInput = Date | string | number;

interface Context {
  date:   DateInput;
  locale: string;
  opts?:  { relativeDate?: Date | number };
  result: string;
}

type Middleware = (ctx: Context, next: () => void) => void;

class TimeagoMiddleware {
  private stack: Middleware[] = [];

  use(fn: Middleware): this {
    this.stack.push(fn);
    return this;
  }

  format(date: DateInput, locale = 'ru'): string {
    const ctx: Context = { date, locale, result: '' };

    let index = 0;
    const next = () => {
      if (index < this.stack.length) {
        this.stack[index++](ctx, next);
      } else {
        ctx.result = baseFormat(ctx.date, ctx.locale, ctx.opts);
      }
    };

    next();
    return ctx.result;
  }
}

Использование Middleware

const timeago = new TimeagoMiddleware();

// Логирование
timeago.use((ctx, next) => {
  console.log(`[before] formatting ${ctx.date}`);
  next();
  console.log(`[after] result: ${ctx.result}`);
});

// Валидация
timeago.use((ctx, next) => {
  const d = new Date(ctx.date as any);
  if (isNaN(d.getTime())) {
    ctx.result = 'давно';
    return; // Не вызывать next — прервать цепочку
  }
  next();
});

// Кеширование
const cache = new Map<string, string>();
timeago.use((ctx, next) => {
  const key = `${ctx.date}:${ctx.locale}`;
  if (cache.has(key)) {
    ctx.result = cache.get(key)!;
    return;
  }
  next();
  cache.set(key, ctx.result);
});

const result = timeago.format('2025-01-01', 'ru');

Архитектура на основе Proxy

import * as timeagoLib from 'timeago.js';

type TimeagoModule = typeof timeagoLib;

function createExtendedTimeago(extensions: Partial<TimeagoModule>): TimeagoModule {
  return new Proxy(timeagoLib, {
    get(target, prop: keyof TimeagoModule) {
      if (prop in extensions) {
        return extensions[prop];
      }
      return target[prop];
    },
  });
}

// Расширенная версия с кастомным format
const extended = createExtendedTimeago({
  format: (date, locale, opts) => {
    if (!date) return 'N/A';
    return timeagoLib.format(date, locale, opts);
  },
});

extended.format(null, 'ru');   // 'N/A'
extended.render(el, 'ru');     // оригинальный render

Архитектура на основе декораторов (классовый стиль)

import { format, render, cancel } from 'timeago.js';

abstract class TimeagoDecorator {
  abstract format(date: Date | string | number, locale?: string): string;
  abstract render(nodes: Element | Element[], locale?: string): void;
  abstract cancel(nodes?: Element | Element[]): void;
}

class BaseTimeago extends TimeagoDecorator {
  format(date: Date | string | number, locale = 'ru') { return format(date, locale); }
  render(nodes: Element | Element[], locale = 'ru')   { render(nodes, locale); }
  cancel(nodes?: Element | Element[])                  { cancel(nodes); }
}

class LoggingTimeago extends TimeagoDecorator {
  constructor(private wrapped: TimeagoDecorator) { super(); }

  format(date: Date | string | number, locale = 'ru') {
    const result = this.wrapped.format(date, locale);
    console.log(`format → ${result}`);
    return result;
  }

  render(nodes: Element | Element[], locale = 'ru') {
    console.log('render called');
    this.wrapped.render(nodes, locale);
  }

  cancel(nodes?: Element | Element[]) {
    console.log('cancel called');
    this.wrapped.cancel(nodes);
  }
}

const base    = new BaseTimeago();
const logging = new LoggingTimeago(base);

Архитектура через Event Bus

import { format } from 'timeago.js';

type EventName = 'before:format' | 'after:format' | 'error:format';

class TimeagoEventBus {
  private handlers = new Map<EventName, Set<Function>>();

  on(event: EventName, handler: Function): () => void {
    if (!this.handlers.has(event)) this.handlers.set(event, new Set());
    this.handlers.get(event)!.add(handler);
    return () => this.handlers.get(event)!.delete(handler);
  }

  emit(event: EventName, data: unknown): void {
    (this.handlers.get(event) ?? new Set()).forEach(h => h(data));
  }

  format(date: Date | string | number, locale = 'ru'): string {
    this.emit('before:format', { date, locale });
    try {
      const result = format(date, locale);
      this.emit('after:format', { date, locale, result });
      return result;
    } catch (err) {
      this.emit('error:format', { date, locale, err });
      throw err;
    }
  }
}

export const bus = new TimeagoEventBus();

// Подключить расширение через события
bus.on('before:format', ({ date }) => {
  if (!date) throw new TypeError('date is required');
});

Сравнение архитектурных подходов

Подход Сложность Гибкость Порядок выполнения Когда использовать
Middleware Средняя Высокая Явный (стек) Несколько независимых расширений
Proxy Низкая Средняя Произвольный Быстрое добавление одного слоя
Decorator Высокая Высокая Цепочка OOP стиль, тестируемость
Event Bus Средняя Средняя Параллельный Слабосвязанные расширения