Паттерн наблюдателя

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


Зачем нужен наблюдатель

Стандартный render timeago.js просто обновляет textContent элемента. Иногда нужно дополнительно:

  • Логировать каждое обновление.
  • Обновлять заголовок страницы.
  • Триггерить анимацию при изменении.
  • Синхронизировать состояние React/Vue.

EventEmitter для наблюдателей

type TimeUpdateEvent = {
  element:  Element;
  prevText: string;
  nextText: string;
  date:     string;
  locale:   string;
};

type Listener = (event: TimeUpdateEvent) => void;

class TimeagoObservable {
  private listeners = new Set<Listener>();

  subscribe(fn: Listener): () => void {
    this.listeners.add(fn);
    return () => this.listeners.delete(fn);
  }

  emit(event: TimeUpdateEvent): void {
    this.listeners.forEach(fn => fn(event));
  }
}

export const timeagoEvents = new TimeagoObservable();

Интеграция с DOM обновлениями

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

function observedRender(
  elements: Element | Element[],
  locale = 'ru',
  observable: TimeagoObservable
): void {
  const arr = Array.isArray(elements) ? elements : [elements];

  // Кастомный цикл обновления вместо встроенного render
  function upd ate() {
    arr.forEach(el => {
      const datetime = el.getAttribute('datetime');
      if (!datetime) return;

      const nextText = format(datetime, locale);
      const prevText = el.textContent ?? '';

      if (nextText !== prevText) {
        el.textContent = nextText;
        observable.emit({ element: el, prevText, nextText, date: datetime, locale });
      }
    });
  }

  upd ate();
  const id = setInterval(update, 30_000);

  // Вернуть cleanup
  return () => clearInterval(id) as unknown as void;
}

MutationObserver как наблюдатель

function watchTimeElements(
  container: Element,
  onUpdate: (el: HTMLTimeElement) => void
): () => void {
  const mutationObs = new MutationObserver((mutations) => {
    mutations.forEach(mutation => {
      if (
        mutation.type === 'characterData' &&
        mutation.target.parentElement?.tagName === 'TIME'
      ) {
        onUpdate(mutation.target.parentElement as HTMLTimeElement);
      }
    });
  });

  mutationObs.observe(container, {
    subtree:       true,
    characterData: true,
  });

  return () => mutationObs.disconnect();
}

// Использование
const stopWatching = watchTimeElements(document.body, (el) => {
  console.log(`Time element upd ated: ${el.textContent}`);
});

Reactive Subject (RxJS-стиль без RxJS)

class TimeSubject<T> {
  private value:     T;
  private listeners: Se t<(val: T) => void> = new Se t();

  constructor(initial: T) {
    this.value = initial;
  }

  next(value: T): void {
    this.value = value;
    this.listeners.forEach(fn => fn(value));
  }

  getValue(): T {
    return this.value;
  }

  subscribe(fn: (val: T) => void): () => void {
    this.listeners.add(fn);
    fn(this.value); // Сразу получить текущее значение
    return () => this.listeners.delete(fn);
  }
}

function createTimeSubject(date: Date | string, locale = 'ru'): TimeSubject<string> {
  const subject = new TimeSubject(format(new Date(date as any), locale));

  const id = setInterval(() => {
    subject.next(format(new Date(date as any), locale));
  }, 30_000);

  return subject;
}

React: useObservableTimeAgo

import { useState, useEffect } from 'react';
import { format } from 'timeago.js';

interface TimeChangeEvent {
  prevValue: string;
  nextValue: string;
  date:      Date | string;
}

function useObservableTimeAgo(
  date: Date | string,
  locale = 'ru',
  onChange?: (event: TimeChangeEvent) => void
): string {
  const [value, setValue] = useState(() => format(new Date(date as any), locale));

  useEffect(() => {
    const id = setInterval(() => {
      const nextValue = format(new Date(date as any), locale);
      setValue(prev => {
        if (prev !== nextValue) {
          onChange?.({ prevValue: prev, nextValue, date });
          return nextValue;
        }
        return prev;
      });
    }, 30_000);

    return () => clearInterval(id);
  }, [date, locale]);

  return value;
}

// Использование
function PostTime({ post }) {
  const time = useObservableTimeAgo(post.createdAt, 'ru', (evt) => {
    analytics.track('time_updated', {
      from: evt.prevValue,
      to:   evt.nextValue,
    });
  });

  return <time>{time}</time>;
}

Паузируемый наблюдатель

class PausableObserver {
  private paused = false;
  private timers = new Map<string, ReturnType<typeof setInterval>>();

  observe(id: string, fn: () => void, interval = 30_000): void {
    this.timers.se t(id, setInterval(() => {
      if (!this.paused) fn();
    }, interval));
  }

  unobserve(id: string): void {
    clearInterval(this.timers.get(id));
    this.timers.delete(id);
  }

  pause(): void  { this.paused = true; }
  resume(): void { this.paused = false; }

  destroy(): void {
    this.timers.forEach(t => clearInterval(t));
    this.timers.clear();
  }
}

Применения паттерна наблюдателя

Применение Описание
Логирование обновлений Счётчик для метрик production
Анимация при смене текста CSS-переход при изменении значения
Уведомление state-менеджера Синхронизация Redux/Pinia при изменении
Аналитика Трекинг момента, когда пользователь видит изменение
A11y Объявление изменения через aria-live