open atlas
← Все проекты

frontend · advanced · 5d

Виртуальная таблица данных

Отрисуй и плавно прокручивай 100 тыс. строк на 60fps с windowing-виртуализацией, залипающими заголовками и полной клавиатурной навигацией — без библиотек, только математика.

Большинство таблиц берут библиотеку и прячут сложное: переиспользование DOM, математику позиций прокрутки и доступность. Ты соберёшь всё из примитивов — цикл виртуализации, overscan, залипающие заголовки и правильный ARIA role grid с полным клавиатурным управлением. Растяжка с деревом Фенвика превращает демо в production-компонент.

Результат

Таблица, загружающая 100 тыс. строк менее чем за 200ms, прокручивающаяся на стабильных 60fps по данным DevTools, с навигацией по ячейкам стрелками и выделением строк.

Этапы

0/6 · 0%
  1. 01Окно по строкам: рендерь только видимое

    Наивная таблица монтирует по DOM-узлу на ячейку, поэтому 100 тыс. строк × 8 столбцов — это 800 тыс.+ узлов; один только layout вылетает за бюджет кадра 16ms, и вкладка зависает до первой отрисовки. Windowing это решает: высокий scroll-спейсер резервирует полную высоту (rowCount × rowHeight), абсолютно позиционированный viewport рендерит только срез, который пересекает scrollTop, а startIndex = floor(scrollTop / rowHeight) ты пересчитываешь на каждый скролл. При viewport 600px и строках 32px это ~19 видимых строк; добавь буфер overscan 4–8 строк сверху и снизу, чтобы быстрый флик не обнажал пустые промежутки до отрисовки следующего кадра. Весь смысл — держать число живых узлов около 30 вместо 800 тыс., чтобы style+layout+paint каждого кадра укладывались в 16ms.

    Критерии готовности
    • При любой позиции скролла в DOM есть только видимые строки плюс буфер overscan (проверь число узлов в DevTools — оно остаётся плоским, а не 100 тыс.).
    • Скроллбар отражает полную высоту в 100 тыс. строк, а startIndex отображает scrollTop в правильную первую строку без дрейфа после долгого скролла.
    Самопроверка

    Senior-ревьюер прокручивает к строке 90 000, открывает счётчик элементов в DevTools и подтверждает, что это всё ещё ~30 узлов; проверяет, что overscan симметричен и быстрый флик никогда не отрисовывает пустую полосу.

  2. 02Строки переменной высоты с кэшем измерений

    Реальные строки переносят текст и растут, поэтому математика с фиксированной rowHeight ломается: floor(scrollTop / rowHeight) больше не указывает на нужную строку, а полная высота — догадка. Нужен кэш измерений: измерь реальную высоту каждой строки однократно (после монтирования, через ResizeObserver или offsetHeight), сохрани её и используй оценочную высоту для ещё не увиденных строк. Отображай scrollTop → startIndex через префиксную сумму/бинарный поиск вместо деления; дерево Фенвика даёт O(log n) на запрос смещения и O(log n) на обновление при переизмерении одной строки против O(n) для плоского префиксного массива, перестраиваемого на каждое изменение. Компромисс: из-за оценок скроллбар «дышит», пока приземляются реальные высоты, поэтому при переизмерении заякори видимую строку, чтобы избежать прыжков скролла, и ограничь кэш (100 тыс. записей × несколько байт — нормально; не удерживай заодно отсоединённый DOM).

    Критерии готовности
    • Строки разной высоты позиционируются верно, а запрос смещения для любого индекса — O(log n) (дерево Фенвика или эквивалент), а не полная перестройка префиксного массива.
    • Переизмерение видимой строки оставляет её заякоренной под viewport (без прыжка скролла), а кэш измерений хранит только высоты — без удержанных отсоединённых узлов.
    Самопроверка

    Senior-ревьюер подбрасывает строки смешанной высоты, прокручивает глубоко и проверяет, что скроллбар не прыгает при переизмерении; смотрит сложность запроса смещения и подтверждает, что кэш не удерживает отсоединённый DOM (heap snapshot).

  3. 03Виртуализация столбцов и закреплённые столбцы

    Широкая таблица (скажем, 60 столбцов) встречает тот же взрыв узлов по горизонтали: 30 видимых строк × 60 столбцов — это 1 800 ячеек, но на экране только ~12 столбцов. Виртуализируй ось X так же: отслеживай scrollLeft, вычисляй окно видимых столбцов и сдвигай каждую ячейку на накопленный left её столбца. Закреплённые (pinned) столбцы — сложная часть: первые один-два столбца должны оставаться на месте, пока остальные скроллятся, поэтому они живут в sticky-слое с большим z-index, а левый паддинг скроллящегося тела учитывает их ширину. Компромисс — композируемость: pinned + горизонтальная виртуализация означают две системы координат (закреплённая и скроллящаяся), которые должны сходиться по высоте строки и выделению, а неверный z-index или transform заставляет закреплённые ячейки просвечивать под телом при быстром скролле.

    Критерии готовности
    • Рендерятся только горизонтально видимые столбцы (плюс overscan), а скролл влево/вправо переиспользует ячейки так же, как строки.
    • Один-два закреплённых столбца остаются на месте при вертикальном и горизонтальном скролле, без просвечивания z-index и с корректным выравниванием по скроллящимся строкам.
    Самопроверка

    Senior-ревьюер скроллит по диагонали на скорости и проверяет, что закреплённые столбцы не рвутся и не просвечивают под телом, что горизонтальное переиспользование совпадает с вертикальным, а закреплённый и скроллящийся слои сходятся по высоте строки и выделению.

  4. 04Данные с сервера: пагинация, сортировка, фильтр

    100 тыс. строк в браузере годятся для демо; в проде данные живут на сервере, и переслать их все нельзя. Перейди на оконную загрузку: по мере движения видимого окна запрашивай нужный срез (offset/limit или, лучше, keyset/cursor-пагинацию, чтобы глубокие страницы не деградировали), держи кэш страниц по диапазону и рендерь skeleton-строки для диапазонов в полёте, чтобы скролл никогда не блокировался на сети. Сортировку и фильтр вынеси на сервер — сортировка 100 тыс. строк на клиенте это O(n log n) затык главного потока, а сервер может опереться на индекс — а значит смена сортировки/фильтра инвалидирует кэшированные страницы и сбрасывает окно. Компромисс — латентность против корректности: префетчи следующее окно, чтобы скрыть round-trip'ы, но дебаунси ввод фильтра и отменяй устаревшие запросы, чтобы быстрый набор не отрисовал результаты заброшенного запроса.

    Критерии готовности
    • Скролл загружает только видимый диапазон (cursor/keyset-пагинация), показывает skeleton для диапазонов в полёте и отдаёт повторные диапазоны из кэша страниц.
    • Сортировка и фильтр работают на сервере; их смена инвалидирует кэш, сбрасывает окно, а устаревшие ответы в полёте отменяются (без мигания заброшенных результатов).
    Самопроверка

    Senior-ревьюер быстро печатает в фильтре и меняет сортировку посреди скролла, затем проверяет в network-панели отменённые устаревшие запросы, отсутствие мигания заброшенных результатов и что кэшированные диапазоны не перезапрашиваются.

  5. 05Редактирование, выделение и доступный ARIA-grid

    Виртуализированная таблица по умолчанию враждебна скринридерам и клавиатуре: строки, которых нет в DOM, не существуют для вспомогательных технологий, а фокус исчезает, когда сфокусированная ячейка уезжает за экран и размонтируется. Построй настоящий ARIA-grid-паттерн — role="grid" / "row" / "gridcell", aria-rowcount/aria-colcount, выставленные на полные 100 тыс. (не на отрисованный срез), и aria-rowindex на каждой отрисованной строке, чтобы ридер объявлял «строка 90 000 из 100 000». Реализуй roving tabindex: ровно одна ячейка tabbable, стрелки / Page Up-Down / Home-End двигают фокус, а когда сфокусированная ячейка уезжает за экран, ты обязан восстановить фокус на её элементе при ремонтировании (отслеживай фокус по индексу строки/столбца, а не по DOM-узлу). Сверху положи диапазонное/мультистрочное выделение (Shift+клик, Shift+стрелка, Ctrl/Cmd-переключение) и инлайн-редактирование ячейки (Enter — редактировать, Escape — отменить, commit на blur). Компромисс, который упускают senior-инженеры: виртуализация и доступность воюют друг с другом, и фикс — держать фокус и выделение в состоянии данных, а DOM считать его одноразовой проекцией.

    Критерии готовности
    • Таблица отдаёт role/aria-rowcount/aria-colcount/aria-rowindex для полного набора данных, и скринридер объявляет абсолютный номер строки, а не отрисованный индекс.
    • Фокус клавиатуры переживает скролл: сфокусировал ячейку, уехал и вернулся — фокус восстановлен на неё через roving tabindex (фокус отслеживается по индексу, а не узлу).
    • Диапазонное/мультистрочное выделение и инлайн-редактирование (Enter/Escape/commit-на-blur) работают целиком с клавиатуры и переживают размонтирования виртуализации.
    Самопроверка

    Senior-ревьюер ходит по всей таблице только с клавиатуры и включённым скринридером, проверяя объявление абсолютных номеров строк, восстановление фокуса после ухода за экран и что состояние выделения/редактирования живёт в данных — а не теряется при размонтировании строки.

  6. 06Бюджет на 100 тыс. строк: профиль, INP и инцидент со стуттером скролла

    Теперь докажи, что быстро, а потом сломай нарочно. Задай бюджет: первичный монтаж 100 тыс. строк под 200ms, стабильные 60fps (кадры ≤16ms) при длительном скролле и INP под 200ms для взаимодействий фокуса/редактирования ячейки. Профилируй в Chrome Performance — flamechart скролла не должен показывать долгих задач (>50ms); обычные виновники — синхронные чтения layout внутри обработчика скролла (layout thrash), аллокации на кадр, кормящие паузы GC, и небатченные обновления состояния. Почини обработку скролла, читая состояние скролла и записывая DOM в правильной фазе (requestAnimationFrame, а не на каждое событие скролла), и батчи пересчёт окна. Затем инцидент: «безобидное» изменение коллеги — добавленный getBoundingClientRect() внутри рендера строки или выброшенный rAF-батч — возвращает регрессию стуттера скролла, которая проявляется только при длительном быстром скролле на 100 тыс. строк. Воспроизведи её, поймай в профиле (forced reflow / long task), локализуй виновный span, почини и напиши короткую заметку о регрессии, чтобы она не вернулась тихо.

    Критерии готовности
    • Трейс Chrome Performance длительного скролла по 100 тыс. строк показывает стабильные кадры ≤16ms без долгих задач, и ты записал время монтажа (<200ms) и INP взаимодействий (<200ms).
    • Ты воспроизвёл регрессию стуттера скролла, нашёл span с forced-reflow/long-task в профиле, починил его (rAF-батчинг / без чтения layout в рендере) и подтвердил возврат кадров в бюджет.
    • Короткая заметка о регрессии называет триггер (чтение layout / отсутствие rAF-батча), симптом (кадры свыше 16ms при быстром скролле) и защиту, которая не даёт ей повториться.
    Самопроверка

    Вставь профиль скролла и заметку о регрессии; senior-ревьюер проверяет, что flamechart действительно показывает кадры ≤16ms без долгих задач, что корневая причина называет механизм чтения-layout/rAF (а не просто «было медленно»), и что защита реально поймает регрессию снова.

Стартер

  • README.md
  • src/grid.ts
  • test/grid.test.ts
Скачать стартер (.zip)

Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test

Рубрика

Джуниор Миддл Сеньор
Корректность вычисления диапазона Верно вычисляет first и visible count для простого случая при scrollTop=0 и равных высотах строк. Overscan захардкожен или отсутствует. Реализует first=floor(scrollTop/rowHeight), start=max(0,first-overscan), visible=ceil(viewportH/rowHeight), end=min(total,first+visible+overscan), padTop/padBottom из числа строк. Проходят все пять проверочных случаев, включая инвариант спейсеров. Обосновывает выбор ceil вместо floor для viewportH/rowHeight (ceil предотвращает пустой зазор внизу viewport при нехратном rowHeight scrollTop), называет максимальное число отрисованных строк (visible+2*overscan) и доказывает инвариант спейсеров алгебраически.
Зажим на границах Обрабатывает scrollTop=0 без отрицательного start. Нижнюю границу не обрабатывает — end может превысить total. Оба зажима верны: start=max(0,…) исключает отрицательный индекс, end=min(total,…) — выход за границы. padBottom равен ровно 0, когда end===total. Защита rowHeight<=0 возвращает нулевой диапазон без исключения. Обосновывает выбор защиты (вернуть нулевой диапазон, а не бросить исключение) для использования в React render path, где исключения обходят error boundaries неожиданно, и расширяет анализ на случай scrollTop>total*rowHeight, показывая одновременную активацию обоих зажимов.
Устранение тормозов и подбор overscan Добавляет фиксированный overscan (например, 2 строки) сверху и снизу без учёта скорости прокрутки. Пересчитывает диапазон только при изменении start или end (а не на каждый пиксель скролла), удерживая работу кадра пропорциональной изменениям диапазона, а не расстоянию прокрутки. Объясняет компромисс: больший overscan — больше живых DOM-узлов, но меньше пустых вспышек при быстром скролле. Выводит формулу overscan из скорости прокрутки и бюджета кадра: overscan_rows >= scrollVelocity_px_per_frame / rowHeight, где scrollVelocity измеряется скользящим средним по последним событиям скролла. Показывает, что при 60fps и 30px/кадр достаточно ≥2 строк; при скорости ×5 нужно ≥10. Отмечает, что пересчёт диапазона внутри requestAnimationFrame (а не обработчика scroll) убирает вычисление с критического пути событий.
Эталонный разбор (спойлер)

Почему спейсеры-паддинги лучше абсолютного позиционирования: абсолютное позиционирование каждой из 100 тыс. строк требует 100 тыс. вычислений стилей при каждом layout-проходе. Два спейсера (padTop над срезом, padBottom под ним) резервируют ту же полную высоту прокрутки с постоянной стоимостью CSS — браузер видит один высокий блок, а не 100 тыс. позиционированных потомков.

Инвариант спейсеров как контракт корректности: padTop + (end-start)*rowHeight + padBottom === total*rowHeight всегда выполняется. Если он нарушается, скроллбар врёт о высоте документа и пользователь видит прыжок при пересчёте спейсеров. Тестирование этого инварианта дешевле визуального регрессионного теста и одновременно ловит все граничные баги.

Расширение на строки переменной высоты: когда строки переносят текст, их высоты различаются, и floor(scrollTop/rowHeight) даёт неверную первую строку. Замени деление на бинарный поиск по префиксной сумме измеренных высот. Дерево Фенвика поддерживает запросы префиксной суммы за O(log n) и точечные обновления при переизмерении строки за O(log n) — против O(n) для плоского массива, перестраиваемого на каждое изменение.

Подбор overscan под скорость прокрутки: пользователь, прокручивающий на 600px/кадр при строках 32px, нуждается в ceil(600/32) = 19 предварительно отрисованных строках overscan, чтобы никогда не обнажать пустую полосу. Измерение скорости прокрутки коротким скользящим средним и установка overscan = ceil(velocity/rowHeight) + 1 адаптируется к устройству и скорости указателя без лишних DOM-узлов при медленном, намеренном скролле.

Фокус и выделение должны жить в данных, а не в DOM: виртуализация размонтирует строки, уехавшие за экран. Если фокус или выделение хранится как DOM-атрибут или ref, они исчезают при размонтировании. Храни сфокусированную ячейку как {rowIndex, colIndex} в состоянии компонента и восстанавливай фокус в useEffect, когда эта строка снова входит в отрисованный срез — DOM является одноразовой проекцией состояния данных, а не источником истины.

Сделай по-сеньорски

  • Отрисуй таблицу на canvas (или WebGL) слое вместо DOM-ячеек: ты меняешь ARIA-семантику на единую композитную поверхность, которая никогда не дёргает layout, — а затем верни доступность через off-screen DOM-зеркало.
  • Добавь режим виртуализированного дерева/группировки: сворачиваемые группы строк, где разворачивание/сворачивание меняет индекс смещений за O(log n), а кэш измерений переживает переключения групп.
  • Поддержи RTL и i18n: отзеркаль математику горизонтального скролла и закреплённых столбцов под right-to-left и обработай локале-зависимую сортировку/форматирование, не ломая смещения виртуализации столбцов.

Навыки

DOM virtualizationoverscan tuningscroll event batchingkeyboard accessibility (ARIA grid)

Рекомендуемый стек

preacttypescript