Как управлять React Data Grid через Redux

    Это продолжение предыдущей статьи: Зачем писать свой React Data Grid в 2019


    Для чего нужен Redux? Ответов много. Например, чтобы работать с общими данными в разных React-компонентах. Но можно воспринимать Redux еще как способ манипулирования компонентой. Сам взгляд интересный: любой React-компонент может управлять другим React-компонентом через Redux.


    Возьмём React-компоненту, которая отображает данные в виде строк и колонок (Data Grid, грид). Каким функционалом у нее можно управлять? Составом колонок и строк. Выделением. Хорошо бы и прокруткой данных.


    image


    Например, некая React-компонента (Some Сomponent) могла бы управлять гридом так:


    • отобрази такие-то строки и колонки;
    • подсвети вхождение такого-то слова;
    • выдели такую-то строку;
    • выполни прокрутку к такой-то строке.

    Управлять колонками не сложно. Достаточно положить в Redux настройки колонок: имена, порядок, ширины, маппинг на данные. Грид возьмет эти настройки и применит. С данными подход тот же.


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


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


    Это не абстрактная задача, а реальная задача из разрабатываемой нами ECM-системы:
    image


    Упорядочим требования. Что хотим получить?


    • чтобы при скроллинге загружались новые порции данных;
    • чтобы загруженные порции данных лежали в Redux;
    • чтобы загруженными порциями можно было манипулировать из других компонент. Через Redux добавлять-удалять-изменять строки, и грид подхватывал эти изменения;
    • чтобы позицией скроллинга можно было управлять из других компонент. Через Redux выполнить прокрутку к нужной строке.

    Эти задачи мы и рассмотрим.


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


    Мы выбрали следующую схему по загрузке-хранению данных:
    image


    Грид в этой схеме делится на две части – компоненты Presentational и Container. Presentational занимается только отображением данных – это view. Данные показываются страницами (про это было рассказано в предыдущей статье). Container отвечает за загрузку данных и взаимодействие с Redux.


    Пройдёмся по стрелкам схемы:


    1. Presentational не занимается загрузкой данных, он только сообщает через callback, каких данных ему не хватает для отображения. Presentational не знает про Redux, он не выполняет dispatch действий и не коннектится к хранилищу Redux.
    2. За загрузку данных отвечает Container. Эта компонента отправляет запрос на сервер при вызове callback. Container может запросить больше данных, чем требуется для отображения, чтобы минимизировать число запросов к серверу.
    3. Сервер присылает данные.
    4. Полученные данные Container отправляет в Redux. В Redux хранятся все загруженные порции данных, а не только последняя загруженная порция.
    5. Как только очередная порция данных попадёт в Redux, Container вытащит из Redux все порции.
    6. И отдаст их Presentational. Presentational не обязан отрисовать все полученные данные, он отображает только то, что попадает во viewport. При этом загруженные данные и отрисованные страницы – это не одно и тоже. Может быть загружено 1000 записей одним блоком, а отображено 50 записей двумя страницами.

    Приведу псевдокод этой схемы:


    class GridContainer extends React.Component<Props> {
      props: Props;
    
      render(): React.Element<any> {
        return <Grid
          // Отдаем гриду все загруженные данные.
          dataSource={this.props.data}
          // Callback на загрузку недостающих данных.
          loadData={this.props.loadData} />;
      }
    }

    const mapStateToProps = (state) => {
      return { data: state.data };
    };
    
    const mapDispatchToProps = (dispatch) => {
      return {
        loadData: async (skip: number, take: number) => {
          // Загружаем данные с сервера.
          const page: Page = await load(skip, take);
          // Добавляем загруженные данные в Redux.
          dispatch({ type: ADD_PAGE, page });
        }
      };
    };
    
    export default connect(mapStateToProps, mapDispatchToProps)(GridContainer);

    Используемые типы в псевдокоде:


    type Props = {
      data: DataSource,
      loadData: (skip: number, take: number) => void
    };
    
    type DataSource = {
      // Загруженные порции данных.
      pages: Array<Page>,
      // Количество строк в гриде.
      totalRowsCount: number
    };
    
    type Page = {
      // Индекс, с которого загружены строки.
      startIndex: number,
      // Строки.
      rows: Array<Object>
    };

    С первой задачей справились — порционно загружать и хранить данные в Redux. Теперь перейдем к манипулированию. Самая частая задача — добавлять-удалять-изменять строки. Мы хотим, чтобы любая компонента веб-приложения могла это делать. Схема проста:


    image


    Some Component – это некоторая компонента веб-приложения, которая хочет управлять данными грида.


    Пройдёмся по схеме:


    1. Все манипуляции с данными выполняются через редьюсеры Redux. Для добавления-удаления-изменения строки достаточно задиспатчить соответствующее действие (ADD_ROW, DELETE_ROW, UPDATE_ROW). Редьюсеры скорректируют данные в хранилище Redux.
    2. Как только данные изменяться в Redux, Grid Container вытащит актуальные данные из Redux.
    3. И отдаст их Presentational. Presentational актуализирует отрисованные страницы.

    Управление скроллингом через Redux


    Управлять скроллингом программно — это необходимый функционал. Самая распространенная ситуация — проскроллиться к выделенной записи. Например, пользователь создает новую запись в списке. Запись с учетом сортировки попадает в середину списка. Нужно программно выделить ее и проскроллиться к ней. И хорошо бы сделать это через Redux.


    image


    Управлять выделением через Redux не сложно, но как управлять скроллингом?
    Для этого в Redux Store мы положим два поля:


      // Индекс строки, к которой нужно проскроллиться.
      scrollToIndex: ?number,
      // Сигнал, что нужно выполнить скроллинг.
      scrollSignal: number

    Поле scrollToIndex понятное. Хочешь выполнить скроллинг, тогда установи в scrollToIndex номер нужной строки. Этот номер будет передан гриду, и грид тут же выполнит скроллинг к ней:


    image


    Для чего поле scrollSignal? Оно решает проблему повторного скроллинга к тому же индексу. Если мы уже выполнили программный скроллинг к индексу 100, то повторно выполнить скроллинг к этому же индексу не получится. Поэтому используется поле scrollSignal, при изменении которого грид повторно выполнит скроллинг к scrollToIndex. ScrollSignal инкрементируется автоматически в редьюсере при обработке действия SCROLL:


    image


    Псевдокод управления скроллингом:


    class GridContainer extends React.Component<Props> {
      props: Props;
    
      render(): React.Element<any> {
        return <Grid
          // Отдаем гриду все загруженные данные.
          dataSource={this.props.data}
          // Индекс строки, к которой нужно проскроллиться..
          scrollToIndex={this.props.scrollToIndex}
          // Сигнал, что нужно выполнить скроллинг.
          scrollSignal={this.props.scrollSignal} />;
      }
    }

    const mapStateToProps = (state) => {
      return {
        data: state.data,
        scrollToIndex: state.scrollToIndex,
        scrollSignal: state.scrollSignal
       };
    };
    
    export default connect(mapStateToProps)(GridContainer);

    Используемые типы в псевдокоде:


    type Props = {
      data: DataSource,
      scrollToIndex: ?number,
      scrollSignal: number
    };

    Заключение (по Redux)


    Предложенные схемы взаимодействия с Redux, конечно же, не универсальны. Они подходят для разработанного нами грида, потому что мы оптимизировали грид под эти схемы, сделали у него соответствующее АПИ. Эти схемы не заработают для любого стороннего грида, поэтому воспринимайте статью, как один из примеров реализации взаимодействия с Redux.


    Итоговое заключение (по 1 и 2 статье)


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


    Писать свой грид — это было правильное для нас решение. У нас было достаточно времени, чтобы реализовать все, что хотели (виртуализацию, работу с redux, порционную загрузку, работу с клавиатуры, работу с колонками, like-поиск с подсветкой и многое другое). Изначально мы сильно вложились в сторонний грид, в надежде, что он взлетит на наших ситуациях. Используя его, мы поняли, как вообще работают гриды, какие существуют проблемы, как их надо решать, и что мы в итоге хотим получить. И сделали свое решение.

    Directum
    Цифровизация процессов и документов

    Comments 3

      0

      По первой я тоже пытался такие вещи как scrollToIndex и разного другого рода эффекты (например всякие swap-анимации) держать в redux. Ничего кроме боли и агонии это не принесло. У вас в store в итоге лежат ненужные данные временного характера (можно это назвать эффектом). Скроллить ничего никуда не нужно уже, а данные лежат. Оттого и всякие костыли вроде scrollSignal-а. Плюс отдельно нужно обрабатывать логику когда начинать скроллить, когда не нужно сколлить.


      ИМХО, намного проще живётся, когда в store лежат только те данные, которые отвечают на вопрос что нужно отрендерить. Любые анимации, скроллы, фокусы-блюры и другие вещи просто обрабатывать другими каналами обычным императивным образом. И не передавать номер строки через props, т.к. props имхо не для этого.


      Вариантов решения тут много, все они со своими нюансами. Но даже если вы несмотря ни на что твёрдо убеждены что лучше решать такие вещи через redux + props, то лучше исходить из другой схемы:


      • ну нужен никакой signal
      • по окончанию действия scroll-а вызывать action который очищает поле scrollIndex в store посредством reducer-а
        0
        Не понятно как использовать императивное управление. У нас скроллингом в гриде управляют вообще из других компонент, которые не в курсе, где этот грид находится. Может он даже не отрисован. Да, и не держать же ссылку на грид в Store.

        Можно, конечно, пойти от кейсов. И убедить, что наши кейсы можно закрыть и без управления скроллингом. Например, один кейс — это скроллиться к выделению при смене выделения (но не всегда, есть исключения). Намного проще разрешить управление и все кейсы закрыть через явное управление)

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

        По поводу чистить scrollToIndex после скроллинга: можно попробовать. В теории между установкой scrollToIndex и сбросом, может еще кто-то вклинится с желанием установить свое значение scrollToIndex, и важное не сбросить его, случайно. Надо потестировать, может и нет проблем. А вообще, хорошая идея, попробую как-нибудь на другом функционале.
          +1

          Как вариант:


          • иметь где-нибудь на верхнем уровне контекст предоставляющий API вида { scrollTo: fn, ...otherEffect }
          • на нижних уровнях использовать его за счёт useContext и вызываеть в нужных callback-ах
          • само API обрабатывать в <Grid/>-е, и пробрасывать наверх за счёт useImperativeAPI или вызвав setActiveGrid-метод из другого контекста, проброшенного из того же метода сверху.

          Варианто действительно много. Все какие-то плохоие. Ни одного хорошего и идиоматического придумать не могу. Но в redux я бы постарался не хранить ничего временного вроде isFetching, isAnimating, scrollTo и пр…

      Only users with full accounts can post comments. Log in, please.