При отображении сотен или тысяч записей с датами создание таймера для каждого элемента нецелесообразно. Виртуализация решает эту проблему, рендеря только элементы, которые находятся в видимой области viewport.
Виртуальный список отображает только N элементов, видимых пользователю (обычно 20–50). При прокрутке удалённые элементы заменяются новыми. Это снижает количество активных DOM-узлов с тысяч до десятков.
При использовании timeago.js:
render.cancel.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>
);
}
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 —
без автообновления. Для виртуальных списков это часто достаточно, так
как элементы пересоздаются при прокрутке.
<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>
Без специальной библиотеки виртуализации — обновлять только видимые:
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 |