Виртуализация списков с датами

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


Суть виртуализации

Виртуальный список отображает только N элементов, видимых пользователю (обычно 20–50). При прокрутке удалённые элементы заменяются новыми. Это снижает количество активных DOM-узлов с тысяч до десятков.

При использовании timeago.js:

  • Монтируемые элементы получают render.
  • Размонтируемые элементы получают cancel.

react-window + timeago.js

import { FixedSizeList } from 'react-window';
import { useEffect, useRef } from 'react';
import { render, cancel } from 'timeago.js';

interface Post {
  id:        number;
  title:     string;
  createdAt: string;
}

function Row({ index, style, data }: { index: number; style: any; dat a: Post[] }) {
  const ref = useRef<HTMLTimeElement>(null);

  useEffect(() => {
    if (!ref.current) return;
    render(ref.current, 'ru');
    return () => cancel(ref.current!);
  }, [data[index].createdAt]);

  return (
    <div style={style} className="post-row">
      <h3>{data[index].title}</h3>
      <time ref={ref} dateTime={data[index].createdAt} />
    </div>
  );
}

function VirtualPostList({ posts }: { posts: Post[] }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={posts.length}
      itemSize={72}
      itemData={posts}
    >
      {Row}
    </FixedSizeList>
  );
}

react-virtuoso + timeago.js

import { Virtuoso } from 'react-virtuoso';
import { format } from 'timeago.js';

function VirtualFeed({ posts }) {
  return (
    <Virtuoso
      totalCount={posts.length}
      itemContent={(index) => (
        <div className="feed-item">
          <h3>{posts[index].title}</h3>
          <time dateTime={posts[index].createdAt}>
            {format(posts[index].createdAt, 'ru')}
          </time>
        </div>
      )}
    />
  );
}

Здесь используется format вместо render — без автообновления. Для виртуальных списков это часто достаточно, так как элементы пересоздаются при прокрутке.


vue-virtual-scroller + timeago.js

<template>
  <RecycleScroller
    :items="posts"
    :item-size="72"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="post-row">
      <h3>{{ item.title }}</h3>
      <time :datetime="item.createdAt">
        {{ format(item.createdAt, 'ru') }}
      </time>
    </div>
  </RecycleScroller>
</template>

<script setup>
import { RecycleScroller } from 'vue-virtual-scroller';
import { format } from 'timeago.js';
</script>

IntersectionObserver как лёгкая альтернатива

Без специальной библиотеки виртуализации — обновлять только видимые:

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

function lazyTimeago(container, locale = 'ru') {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(({ target, isIntersecting }) => {
      if (isIntersecting) {
        render(target, locale);
      } else {
        cancel(target);
      }
    });
  }, {
    rootMargin: '200px', // предзагрузка за 200px до появления
  });

  container.querySelectorAll('[datetime]').forEach(el => observer.observe(el));

  return () => {
    observer.disconnect();
    cancel(container.querySelectorAll('[datetime]'));
  };
}

Стратегия при прокрутке без виртуализации

Для списков средней длины (100–300 элементов):

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

function renderVisible(locale = 'ru') {
  const all = Array.from(document.querySelectorAll('[datetime]'));

  all.forEach(el => {
    const rect = el.getBoundingClientRect();
    const inView = rect.top < window.innerHeight + 200 && rect.bottom > -200;

    if (inView) {
      render(el, locale);
    } else {
      cancel(el);
    }
  });
}

// При первой загрузке
renderVisible();

// При прокрутке
window.addEventListener('scroll', throttle(renderVisible, 300), { passive: true });

Счётчик активных таймеров

Для диагностики:

let activeCount = 0;

const originalRender = render;
const originalCancel = cancel;

function trackedRender(nodes, locale) {
  const count = nodes instanceof Element ? 1 : nodes.length;
  activeCount += count;
  originalRender(nodes, locale);
}

function trackedCancel(nodes) {
  if (!nodes) {
    activeCount = 0;
  } else {
    const count = nodes instanceof Element ? 1 : nodes.length;
    activeCount = Math.max(0, activeCount - count);
  }
  originalCancel(nodes);
}

console.log('Active timeago timers:', activeCount);

Рекомендации по выбору подхода

Размер списка Рекомендация
< 50 render напрямую
50–200 render + IntersectionObserver
200–1000 IntersectionObserver + throttled refresh
> 1000 Виртуализация + format без render