Недавно я столкнулся с задачей, известной как one billion row challenge. Два аспекта этого вызова меня заинтриговали:

  1. Каковы будут последствия, если я попробую решить этот вызов на фронтенде?

  2. Удастся ли мне это?

Хотя я не уверен в возможности визуализировать миллиард строк в таблице, цифра в миллион кажется вполне достижимой. Узнав о таком интересном вызове, я решил заняться маленьким проектом, целью которого было отображение миллиона строк в React.

Давайте я вас подробно ознакомлю с тем, что именно произошло, как это было сделано и по каким причинам.

Рекомендация

Для более глубокого понимания статьи, я рекомендую вам изучить следующие темы:

Что мы делаем?

Мы предпримем попытку разработать компонент, способный эффективно визуализировать миллион строк в ReactJS. Для этой цели мы воспользуемся рядом методов, схожих с теми, что используются в таких продуктах, как Google Sheets и Glide Data Grid.

Почему мы это делаем?

Как я уже упоминал в начале, есть веские причины для рассмотрения этой темы.

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

Тем не менее, эффективность такого метода может снижаться при больших размерах области просмотра, например, когда требуется одновременно просматривать от 150 до 250 строк. В результате алгоритм виртуализации должен:

  1. Удалять все DOM-элементы в области видимости.

  2. Добавлять новый набор из 150+ DOM-элементов.

Этот процесс может существенно снизить скорость и плавность прокрутки, ухудшая тем самым пользовательский опыт.

Кроме того, я изучил другие инструменты и библиотеки, такие как:

Они эффективно справляются с задачей отображения миллиона строк.

Теперь, когда мы понимаем причины, давайте рассмотрим нестандартный подход к решению этой распространенной проблемы.

? ПРИМЕЧАНИЕ: Этот блог объясняет все основные концепции, необходимые для создания проекта. Вы можете ознакомиться с полным кодом в репозитории.

Как мы это сделаем?

Для осуществления данной методики мы намерены использовать технику рисования, а не традиционное отображение таблицы. Другими словами, мы будем визуализировать каждую строку данных с применением элемента canvas.

HTML Canvas является основным инструментом для графического рендеринга. Его контекстное API открывает широкие возможности для создания различных форм и изображений.

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

  1. Загрузка данных.

  2. Инициализация холстов.

  3. Рисование данных во время прокрутки.

Мы изучим каждый из этих этапов в деталях. Чтобы облегчить понимание, я постараюсь наглядно демонстрировать каждый этап, сопровождая его минимально необходимым объемом кода.

Таким образом, вы получите возможность ознакомиться с представленным кодом и анализировать его, следуя указанным ранее рекомендациям.

Инициализация проекта

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

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

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

Загрузка данных

UI для загрузки данных
UI для загрузки данных

Этот этап подразумевает создание ряда кнопок для получения данных из внешнего источника. В результате в пользовательском интерфейсе появятся четыре кнопки, каждая из которых инициирует загрузку одних и тех же данных, но в различных объемах: 100 строк, 500 тысяч строк, 1 миллион строк и 2 миллиона строк.

Когда пользователь нажимает на одну из кнопок, начинается загрузка данных, которые затем преобразуются в массив объектов с помощью библиотеки papa-parse.

Понимание структуры DOM

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

Она будет представлена в виде обычной таблицы, как показано ниже:

Таблица UI
Таблица UI

Это просто обычная таблица с заголовком, строками и полосами прокрутки.

Структура этой таблицы разделена на следующие компоненты, каждый из которых представляет элемент DOM в проекте:

  • header-canvas: Это элемент canvas, на котором отображается заголовок таблицы.

  • target-canvas: Это элемент canvas, где рисуются фактические строки таблицы.

  • scrollbar-container: Это контейнер div, который предоставляет фиктивную полосу прокрутки для основного контейнера.

  • main-container: Это контейнер div, который оборачивает как header-canvas, так и target-canvas, а также scrollbar-container.

Для более точного представления об этих элементах смотрите gif:

Вы можете рассмотреть структуру DOM c соответствующим кодом здесь.

Инициализация холстов

Теперь, когда мы разобрались в структуре, давайте перейдем к инициализации обоих холстов. Для этого мы выполняем следующие шаги:

При монтировании компонента мы инициализируем веб-воркер:

/**
 * При монтировании компонента инициализируем воркер.
 */
useEffect(() => {
    if (window.Worker) {
        // Обратитесь к синтаксису Vite's Query Suffix для загрузки вашего пользовательского воркера: https://vitejs.dev/guide/features.html#import-with-query-suffixes
        const worker = new CustomWorker();
        workerRef.current = worker;
    }
}, []);

Затем, когда данные CSV доступны, мы запускаем эффект, который обновляет header-canvas и передает target-canvas в веб-воркер:

/**
 * Этот эффект запускается, когда загруженные данные становятся доступными.
 * Его цель заключается в следующем:
 * 1. Нарисовать заголовок таблицы на #header-canvas
 * 2. Передать управление воркеру
 */
useEffect(() => {
    const canvas = canvasRef.current;
    const headerCanvas = headerCanvasRef.current;

    if (headerCanvas) {
        const headerContext = headerCanvas.getContext("2d");
        const { width, height } = DEFAULT_CELL_DIMS;
        const colNames = CustomerDataColumns;

        if (headerContext) {
            headerContext.strokeStyle = "white";
            headerContext.font = "bold 18px serif";

            for (let i = 0; i < DEFAULT_COLUMN_LENGTH; i++) {
                headerContext.fillStyle = "#242424";
                headerContext.fillRect(i * width, 0, width, height);
                headerContext.fillStyle = "white";
                headerContext.strokeRect(i * width, 0, width, height);
                headerContext.fillText(colNames[i], i * width + 20, height - 10);
            }
        }
    }

    /**
     * Мы передаем две вещи здесь:
     * 1. Мы преобразуем наш #canvas, который рисует фактическую таблицу, в отключенный холст
     * 2. Мы используем передачу вышеуказанного холста в воркер через postMessage
     */
    if (workerRef.current && csvData && canvas) {
        const mainOffscreenCanvas = canvas.transferControlToOffscreen();
        workerRef.current.postMessage(
            {
                type: "generate-data-draw",
                targetCanvas: mainOffscreenCanvas,
                csvData,
            },
            [mainOffscreenCanvas]
        );
    }
}, [csvData]);

Следует отметить, что мы преобразовали target-canvas в независимый холст. Независимый холст работает подобно обычному элементу canvas, но он не связан с DOM-деревом. Вы даже можете создать такой холст при помощи оператора new и перенести его в веб-воркер.

Интересной особенностью независимого холста является то, что он может быть использован в контексте веб-воркера, что дает возможность применять API холста непосредственно из воркера. Мы превратили target-canvas в независимый холст с использованием функции transferControlToOffscreen, что дало нам возможность манипулировать им внутри воркера.

Для дополнительной информации об offscreen canvas API вы можете обратиться к соответствующему ресурсу.

Иллюстрирование нашего шага
Иллюстрирование нашего шага

Эпоха познания

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

Мы изучим процесс визуализации данных на целевом холсте в моменты прокрутки страницы.

Понимание контейнера с полосой прокрутки

Начнем с освоения функций нашего особенного контейнера со скроллбаром, который мы упомянули ранее.

Стандартный элемент div оснащается скроллбарами, когда его размеры по ширине и высоте установлены в фиксированное количество пикселей и содержимое превышает эти границы.

Но что, если в div недостаточно контента для появления скроллбаров, а вы хотите их видеть?

Имеется несколько способов решения этой задачи, включая применение таких сторонних скроллинг библиотек, как simplebar или OverlayScrollbars. Однако, даже при использовании этих инструментов, может возникнуть необходимость вручную настроить скроллинг по высоте для вашего элемента div.

Как же достигнуть этого эффекта? Оказывается, это не так сложно, как может показаться. Я открыл для себя этот прием, исследуя Google Sheets, где встретил пример с полумиллионом строк.

В Google Sheets используется div шириной в один пиксель, но высота которого составляет произведение количества строк на высоту одной строки. Этот div располагается внутри родительского контейнера и обеспечивает настройку скроллинга по высоте в случае превышения контентом заданных границ.

Я применил аналогичный подход, создав макетный div шириной 1 пиксель и высотой, равной произведению количества строк на высоту одной строки, что и создает нужный эффект скроллбара.

Вот визуальный пример такого контейнера со скроллбаром:

Следует отметить, что этот div скрыт, используя свойство visibility: hidden.

Вы можете ознакомиться с этим контейнером со скроллбаром в исходном коде по этой ссылке.

Освоение принципов работы механизма рисования

Переходим к изучению ключевой темы – механизма рисования. Прежде чем мы углубимся в этот вопрос, важно отметить, что весь процесс рендеринга на target-canvas выполняется с помощью рабочего потока. Ранее мы говорили о запуске воркера во время инициализации компонента, поскольку именно этот воркер и занимается рисованием. Инициализацию воркера можно просмотреть в исходном коде по этой ссылке.

Код, управляющий механизмом рисования и называемый кодом воркера, доступен здесь.

Теперь нам ясно, что рисование строк на холсте — это задача воркера. Давайте разберемся, как работает этот механизм.

Я хотел бы проиллюстрировать принципы работы механизма, опираясь на три разных метода:

Метод 1

В этом методе, после того как вся необходимая информация загружена в память, мы немедленно приступаем к её отображению на target-canvas.

Как вы думаете, что произойдет в таком случае?

Вы правильно угадали: весь миллион строк будет нарисован на target-canvas холсте, заменяя ранее нарисованные пиксели и давая нам перезаписанное изображение, как показано ниже:

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

Метод 2

В этой технике мы отказываемся от идеи рисования всех данных одновременно. Вместо этого мы отображаем лишь сегмент данных на target-canvas, размер которого соответствует количеству строк, умещающихся на нем.

Ниже представлена визуализация процесса рендеринга части данных на холсте:

Этот метод реализуется путем динамического отрисовывания отдельных сегментов строк во время прокрутки данных. Однако, прежде чем выбрать этот подход, следует принять во внимание несколько существенных факторов:

Для визуализации строки требуется отрисовать каждую из её ячеек, причём их количество должно быть равно количеству столбцов.

В ходе работы над каждой ячейкой выполняются определённые операции:

  • Очищение соответствующего участка холста для ячейки с использованием метода clearRect.

  • Создание границы ячейки с помощью метода strokeRect.

  • Заполнение ячейки данными при помощи метода fillRect.

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

Такой процесс требует значительных вычислительных ресурсов.

Тем не менее, я применил другую стратегию, которая позволяет достичь тех же результатов, но более эффективно с точки зрения производительности. Давайте рассмотрим её.

Метод 3

В этом методе применяется API для работы с независимыми холстами. Процесс выглядит следующим образом:

  • Каждый отдельный холст отображает 100 строк, которые рендерятся из данных в формате CSV.

  • Создание таких холстов происходит на основе текущей позиции скролла. Исходя из значения scrollTop, мы вычисляем количество уже прокрученных строк.

  • На основе этого подсчёта мы определяем, какой диапазон строк необходимо отобразить на холсте. К примеру, если scrollTop равно 150, мы выбираем для отображения строки с 100 по 200 и рисуем их на вновь созданном отдельном холсте.

  • Затем создаётся ещё один отдельный холст для рендеринга следующей сотни строк. Исходя из предыдущего примера, на этом холсте будут строки с 200 по 300.

  • Все эти холсты хранятся в глобальном состоянии в рабочем потоке во время скроллинга, что можно просмотреть здесь.

  • Когда холсты подготовлены, мы переносим соответствующий сегмент из текущего отдельного холста и отображаем его на целевом холсте как изображение. Размер этого сегмента соизмерим с количеством строк, способных поместиться на целевом холсте.

  • Под "сегментом с текущего отдельного холста" подразумевается копирование этой части в виде изображения и её отрисовка на target-canvas с использованием функции drawImage. Эта операция известна как "блиц-перенос". Больше информации об этом можно найти здесь.

Для большей наглядности вот пример анимации, демонстрирующей этот процесс:

Представленная синяя анимация иллюстрирует момент, когда ползунок прокрутки оказывается между двумя неактивными холстами. Как пример: если контент с первого неактивного холста уже в полном объеме визуализирован на основном холсте, то следует дорисовать недостающие строки на основном холсте, используя данные со следующего неактивного холста.

Данный подход гарантирует бесперебойное отображение информации в процессе прокрутки, создавая непрерывный и плавный пользовательский опыт.

Так, с применением этой методики, прокрутка данных становится более плавной и интуитивно понятной, будь то использование колёсика мыши, тачпада или же вручную перемещая ползунок прокрутки.


Подводя итог, в этой статье мы обсудили следующие темы:

  • Как нетрадиционный метод, вроде рисования на холсте, может решить распространённую задачу.

  • Принцип работы контейнера с бесконечной прокруткой.

  • Запуск и структуру DOM для компонента холста.

  • Методы инициализации компонента и делегирования управления воркеру.

  • Разнообразные стратегии визуализации на холсте при активной прокрутке.

Код проекта доступен по представленной ссылке.

Благодарю за прочтение!

Комментарии (6)


  1. navrotski
    28.03.2024 20:37
    +1

    Отсылка к 1brc понятна, но там все-таки был миллиард строк))


  1. inko
    28.03.2024 20:37
    +2

    Много допущений. В реальной ситуации данные в ячейках могут быть разной длинны, и ширина колонки не будет фиксированной. Так же может быть задействован режим с отображением текста в несколько строк. И самое главное канвас не даёт возможности взаимодействовать с выводимой информацией, нельзя даже копировать текс.


    1. nin-jin
      28.03.2024 20:37
      +1

      Тут не так уж сложно сделать свой лейаутер с поддержкой переносов текста, таблиц и тд. Для взаимодействия с текстом придётся реализовать свою каретку/селекшен. Это уже чуть сложнее, но тоже выполнимо. Но вот подход из статьи с ререндером всего холста - это весьма не быстро. Куда оптимальнее сделать виртуальный рендеринг холстов фиксированного размера. Тогда при скроллинге браузер сможет бесплатно перемещать холсты без ререндера на каждом кадре.


      1. dizel3d
        28.03.2024 20:37
        +1

        А можно просто использовать $mol


        1. nin-jin
          28.03.2024 20:37

          Можно, конечно, но это слишком просто, да и инициализация миллиона строк слишком долгая.


  1. domix32
    28.03.2024 20:37
    +1

    Ох, уж эти фронтедеры. Решали 1brc, в итоге вместо миллиарда задачу уменьшили в 500 раз до пары миллионов, решали руками соседа, подключив внешний парсер и по итогу так и не решили главную задачу даже для тех миллионов.