Паттерн стратегии

Паттерн стратегии позволяет выбирать алгоритм форматирования в runtime. Это полезно, когда логика форматирования зависит от контекста: типа пользователя, настроек приложения или типа контента.


Базовая реализация

import { format } from 'timeago.js';
import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale';

// Интерфейс стратегии
interface TimeFormatStrategy {
  format(date: Date | string | number): string;
}

// Стратегия timeago.js
class TimeagoStrategy implements TimeFormatStrategy {
  constructor(private locale: string = 'ru') {}

  format(date: Date | string | number): string {
    return format(date, this.locale);
  }
}

// Стратегия date-fns
class DateFnsStrategy implements TimeFormatStrategy {
  format(date: Date | string | number): string {
    return formatDistanceToNow(new Date(date as any), {
      locale:    ru,
      addSuffix: true,
    });
  }
}

// Стратегия Intl.RelativeTimeFormat
class IntlStrategy implements TimeFormatStrategy {
  private rtf: Intl.RelativeTimeFormat;

  constructor(locale = 'ru') {
    this.rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
  }

  format(date: Date | string | number): string {
    const diffSec = (new Date(date as any).getTime() - Date.now()) / 1000;
    const absSec  = Math.abs(diffSec);
    const sign    = diffSec < 0 ? -1 : 1;

    if (absSec < 60)   return this.rtf.format(sign * Math.round(absSec), 'second');
    if (absSec < 3600) return this.rtf.format(sign * Math.round(absSec / 60), 'minute');
    return                    this.rtf.format(sign * Math.round(absSec / 3600), 'hour');
  }
}

// Стратегия абсолютного времени
class AbsoluteStrategy implements TimeFormatStrategy {
  constructor(private locale = 'ru-RU') {}

  format(date: Date | string | number): string {
    return new Date(date as any).toLocaleDateString(this.locale, {
      year:  'numeric',
      month: 'long',
      day:   'numeric',
    });
  }
}

Контекст, использующий стратегию

class TimeFormatter {
  private strategy: TimeFormatStrategy;

  constructor(strategy: TimeFormatStrategy) {
    this.strategy = strategy;
  }

  setStrategy(strategy: TimeFormatStrategy): void {
    this.strategy = strategy;
  }

  format(date: Date | string | number): string {
    return this.strategy.format(date);
  }
}

Выбор стратегии по контексту

type DisplayContext = 'feed' | 'notification' | 'admin' | 'email';

function getStrategy(context: DisplayContext, locale = 'ru'): TimeFormatStrategy {
  switch (context) {
    case 'feed':
      return new TimeagoStrategy(locale);         // Относительное, живое
    case 'notification':
      return new IntlStrategy(locale);            // Нативный браузерный
    case 'admin':
      return new AbsoluteStrategy('ru-RU');       // Точная дата
    case 'email':
      return new DateFnsStrategy();               // Без автообновления
    default:
      return new TimeagoStrategy(locale);
  }
}

const formatter = new TimeFormatter(getStrategy('feed'));
console.log(formatter.format(new Date()));

Стратегия на основе возраста даты

class AgeBasedStrategy implements TimeFormatStrategy {
  private recentStrategy: TimeFormatStrategy;
  private oldStrategy:    TimeFormatStrategy;
  private thresholdMs:    number;

  constructor(thresholdMs = 7 * 86400_000) {
    this.recentStrategy = new TimeagoStrategy();       // Свежие — относительное
    this.oldStrategy    = new AbsoluteStrategy();      // Старые — абсолютное
    this.thresholdMs    = thresholdMs;
  }

  format(date: Date | string | number): string {
    const age = Date.now() - new Date(date as any).getTime();

    if (age < this.thresholdMs) {
      return this.recentStrategy.format(date);
    }

    return this.oldStrategy.format(date);
  }
}

const smart = new TimeFormatter(new AgeBasedStrategy(7 * 86400_000));
smart.format(new Date(Date.now() - 3600_000));   // "час назад"
smart.format(new Date('2020-01-01'));              // "1 января 2020 г."

Стратегия с fallback

class FallbackStrategy implements TimeFormatStrategy {
  private primary:   TimeFormatStrategy;
  private secondary: TimeFormatStrategy;

  constructor(primary: TimeFormatStrategy, secondary: TimeFormatStrategy) {
    this.primary   = primary;
    this.secondary = secondary;
  }

  format(date: Date | string | number): string {
    try {
      const result = this.primary.format(date);
      if (!result) throw new Error('Empty result');
      return result;
    } catch {
      return this.secondary.format(date);
    }
  }
}

const resilient = new FallbackStrategy(
  new TimeagoStrategy('ru'),
  new AbsoluteStrategy('ru-RU')
);

React: стратегия через Context

const TimeFormatContext = React.createContext<TimeFormatStrategy>(
  new TimeagoStrategy('ru')
);

function TimeFormatProvider({
  strategy,
  children,
}: {
  strategy: TimeFormatStrategy;
  children: React.ReactNode;
}) {
  return (
    <TimeFormatContext.Provider value={strategy}>
      {children}
    </TimeFormatContext.Provider>
  );
}

function TimeDisplay({ date }: { date: Date }) {
  const strategy = useContext(TimeFormatContext);
  return <time>{strategy.format(date)}</time>;
}

// Использование
<TimeFormatProvider strategy={new AgeBasedStrategy()}>
  <TimeDisplay date={new Date('2020-01-01')} /> {/* Абсолютная */}
  <TimeDisplay date={new Date(Date.now() - 3600_000)} /> {/* Относительная */}
</TimeFormatProvider>

Таблица стратегий

Стратегия Когда использовать
TimeagoStrategy Живые ленты, комментарии, чаты
IntlStrategy Один язык, только современные браузеры
DateFnsStrategy date-fns уже в проекте
AbsoluteStrategy Admin-интерфейсы, логи, email
AgeBasedStrategy Смешанный контент разного возраста
FallbackStrategy Отказоустойчивость