Модуль 9. Реакции, эмодзи, стикеры и виртуализация списков
Цель модуля: реализовать реакции и пикеры эмодзи/стикеров, освоить виртуализацию списков (единая техника для сетки эмодзи и списка сообщений).
К концу модуля у вас:
Reaction,ReactionBar,EmojiPickerс анимацией пульсации.- Утилита виртуализации списков (работает и для 1000 эмодзи, и для 10k сообщений).
- Route Handler
/api/emojisс mock-данными.
Перед началом: Модули 1-8. Инструменты: react-window, CSS animation. Время: ~5 ч.
🔥 Инцидент. Чат на 10 000 сообщений. React рендерит все 10 000 DOM-узлов одновременно — страница зависает на 8 секунд. Профайлер показывает: 99% времени уходит на layout и paint невидимых элементов. Решение — виртуализация: рендерим только ~20 видимых строк, остальное подменяется при скролле.
📖 Теория
9.1 Reaction — кнопка реакции с анимацией
tsx// ui-kit/src/components/Reaction/Reaction.tsx import styles from './Reaction.module.css' interface ReactionProps { emoji: string count: number selected?: boolean onClick?: () => void } export function Reaction({ emoji, count, selected, onClick }: ReactionProps) { return ( <button className={[styles.reaction, selected && styles.selected].filter(Boolean).join(' ')} onClick={onClick} aria-pressed={selected} > <span className={styles.emoji}>{emoji}</span> <span className={styles.count}>{count}</span> </button> ) }
css/* ui-kit/src/components/Reaction/Reaction.module.css */ .reaction { display: inline-flex; align-items: center; gap: var(--s-xs); padding: var(--s-xs) var(--s-sm); background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-full); cursor: pointer; transition: all 0.15s; } .reaction:hover { background: var(--c-surface-2); } .reaction.selected { background: var(--c-surface-accent, rgba(37, 99, 235, 0.1)); border-color: var(--c-primary); animation: pulse 0.3s ease-out; } .emoji { font-size: 1rem; } .count { font-size: var(--font-size-sm); color: var(--c-text-muted); } .selected .count { color: var(--c-primary); font-weight: 600; } @keyframes pulse { 0% { transform: scale(1); } 50% { transform: scale(1.15); } 100% { transform: scale(1); } } @media (prefers-reduced-motion: reduce) { .reaction.selected { animation: none; } }
ReactionBar — панель реакций под сообщением
tsx// ui-kit/src/components/ReactionBar/ReactionBar.tsx import { Reaction } from '../Reaction' import { Box } from '../Box' import styles from './ReactionBar.module.css' interface ReactionItem { emoji: string; count: number; selected?: boolean } interface ReactionBarProps { reactions: ReactionItem[] onReact?: (emoji: string) => void maxReactions?: number } export function ReactionBar({ reactions, onReact, maxReactions = 5 }: ReactionBarProps) { const visible = reactions.slice(0, maxReactions) return ( <Box gap="xs" className={styles.bar}> {visible.map((r) => ( <Reaction key={r.emoji} {...r} onClick={() => onReact?.(r.emoji)} /> ))} </Box> ) }
9.2 Виртуализация списков — обобщённо
И сетка эмодзи, и список сообщений — одна и та же задача: рендерить только видимую часть.
tsx// ui-kit/src/components/VirtualList/VirtualList.tsx /* eslint-disable react-hooks/incompatible-library -- TanStack Virtual uses non-memoizable API by design */ import { useRef, type ReactNode, type UIEvent } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import styles from './VirtualList.module.css' interface VirtualListProps<T> { items: T[] renderItem: (item: T, index: number) => ReactNode estimateSize?: number overscan?: number height?: number | string onScroll?: (e: UIEvent<HTMLDivElement>) => void } export function VirtualList<T>({ items, renderItem, estimateSize = 48, overscan = 5, height = 400, onScroll, }: VirtualListProps<T>) { const parentRef = useRef<HTMLDivElement>(null) const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => estimateSize, overscan, }) return ( <div ref={parentRef} className={styles.container} style={{ height, overflow: 'auto' }} onScroll={onScroll} > <div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}> {virtualizer.getVirtualItems().map((virtualItem) => ( <div key={virtualItem.key} style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${virtualItem.start}px)`, }} > {renderItem(items[virtualItem.index], virtualItem.index)} </div> ))} </div> </div> ) }
Установите
@tanstack/react-virtual:npm install @tanstack/react-virtualвapp/package.json.
Использование для EmojiPicker
tsx// ui-kit/src/components/EmojiPicker/EmojiPicker.tsx import { useState } from 'react' import { VirtualList } from '../VirtualList' import { Input } from '../Input' import styles from './EmojiPicker.module.css' const EMOJIS = ['😀', '😂', '😍', '🤔', '👍', '👎', '❤️', '🔥', '🎉', '😢', /* ... 1000+ */] export function EmojiPicker({ onSelect }: { onSelect: (emoji: string) => void }) { const [search, setSearch] = useState('') const filtered = EMOJIS.filter((e) => e.includes(search)) return ( <div className={styles.picker}> <Input id="emoji-search" placeholder="Search emoji..." value={search} onChange={(e) => setSearch(e.target.value)} /> <VirtualList items={filtered} renderItem={(emoji) => ( <button className={styles.emojiBtn} onClick={() => onSelect(emoji)}> <span style={{ fontSize: '1.5rem' }}>{emoji}</span> </button> )} estimateSize={44} height={300} /> </div> ) }
Story «1000 эмодзи»: в Storybook добавьте story с 1000 элементами — без виртуализации это лагает, с
VirtualList— плавный скролл.
9.3 Route Handler /api/emojis
ts// app/app/api/emojis/route.ts import { NextResponse } from 'next/server' const EMOJI_CATEGORIES = { Smileys: ['😀', '😂', '😍', '🤔', '😎', '😴'], Gestures: ['👍', '👎', '👏', '🙏', '💪', '🤝'], Hearts: ['❤️', '🧡', '💛', '💚', '💙', '💜'], Objects: ['🔥', '🎉', '⭐', '💎', '🎁', '🏆'], } export async function GET() { const emojis = Object.entries(EMOJI_CATEGORIES).map(([category, items]) => ({ category, items })) return NextResponse.json({ emojis }) }
Компромиссы (Trade-offs)
| Решение | Плюсы | Минусы |
|---|---|---|
VirtualList (windowing) | O(visible) DOM-узлов, плавный скролл на 10k+ | Сложнее sticky-headers и динамические высоты |
Прямой .map() | Простота, достаточно для <100 элементов | Тормозит на больших списках, блокирует скролл |
FixedSizeList | O(1) расчёт позиции — быстро | Только строки одинаковой высоты |
VariableSizeList | Поддержка разных высот сообщений | Нужен measure, медленнее и сложнее |
CSS-анимация (pulse) | Дёшево, GPU-ускорено | Отключается при prefers-reduced-motion |
💻 9.4 Практика
- Установите
@tanstack/react-virtual. - Реализуйте
Reaction,ReactionBar,VirtualList,EmojiPicker. - Добавьте stories: Reaction с states, EmojiPicker с story «1000 эмодзи».
- Добавьте
/api/emojisRoute Handler. - Добавьте ReactionBar под
MessageBubbleвMessageRow.
bashnpm run verify
9.5 Артефакты сдачи М9
| Артефакт | Критерий |
|---|---|
verify зелёный | Все тесты проходят |
| Скриншот Storybook | Primitives/Reaction (default + selected + pulsation), Primitives/EmojiPicker |
| Профайлер React | Скриншот Profiler: 100 эмодзи с VirtualList — < 16ms на рендер |
GET /api/emojis | curl /api/emojis → категории с эмодзи |
Вперёд → Модуль 10: Core Web Vitals.
🧩 Проверь себя
🔗 Мостик к Модулю 10
Списки виртуализированы — профайлер доволен. Но Lighthouse всё ещё красный: Core Web Vitals (LCP, CLS, INP) плохие. В Модуле 10 измерим метрики и приведём их в норму.