React, Drag&Drop и performance

  • Tutorial


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

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

К интерфейсу редактирования расписания были предъявлены следующие требования:

  1. Возможность создания, редактирования и удаление занятий;
  2. В рамках одной пары занятие может проводиться сразу у двух групп;
  3. Возможность переноса занятия в сетке расписания.

С первыми двумя пунктами никаких проблем не возникло, а вот с третьим пришлось повозиться. На нем и остановимся поподробнее.

React и Drag&Drop


Для начала нам необходимо выбрать Drag&Drop библиотеку. На просторе интернета их великое множество: DraggableJS, dragula, interactjs.io и пр.  А библиотек, заточенных для использования вместе с React, всего две: React-DnD и react-beautiful-dnd.

Библиотека react-beautiful-dnd отлично выглядит на демках, но, к сожалению, вышла уже после реализации проекта. Поэтому мы использовали React-DnD.

Про react-beautiful-dnd Alex Reardon написал статью —  «Rethinking drag and drop», которую можно почитать в переводе на Хабре

React DnD

Данная библиотека предоставляет нам набор из компонентов высшего порядка (HOC). Если говорить простым языком то:

  • DragSource — делает компонент перетаскиваемым;
  • DropTarget — добавляет компоненту возможность взаимодействовать с перетаскиваемыми компонентами;
  • DragLayer — позволяет реализовать собственное превью для перетаскиваемого элемента;
  • DragDropContext — предназначен для инициализации библиотеки.

Еще одна важная составляющая без которой React DnD не заработает — это drag&drop backend. Библиотека для обеспечения кроссбраузерности, абстракция над стандартным браузерным API.

Авторы React DnD советуют использовать HTML5-Backend, хотя совсем и не обязательно. Можно выбрать любой другой или написать свой.

Реализация


Для начала разобьем верстку сетки расписания на четыре основных компонента:

  1. Сетка расписания — ScheduleGrid (в этом компоненте мы будем инициализировать библиотеку React-DnD)
  2. Блок с расписанием на день — ScheduleColumn
  3. Секция с парами — SubjectSeciton (в нашем случае это будет DropTarget)
  4. Отдельное занятие — SubjectItem (в нашем случае это будет DragSource)



визуально разметили наши компоненты

Компонент App

Реализуем базовый компонент-контейнер App, который будет хранить информацию о недельном расписании и рендерить описанные выше компоненты.

Листинг компонента App
import React, { Component } from 'react';
import ScrollArea from 'react-scrollbar';

import ScheduleGrid from './ScheduleGrid';

import subjectsArray from './schedule-data';

class App extends Component {

  state = {
    subjectsArray: []
  }

  moveSubject = (movedSubjectId, newPosition) => {
    this.setState({
      subjectsArray: this.state.subjectsArray.map(subject => {
        if (subject.ID == movedSubjectId) {
          return {
            ...subject,
            DAY_OF_WEEK: newPosition.day,
            PERIOD: newPosition.period,
          }
        }
        
        return subject;
      }),
    });
  }

  render() {
    return (
      <div className="app-container">
        <div className="app-container-header">
          <h3>Редактирование расписания</h3>
          <h4>Версия React: {React.version}</h4>
        </div>
        <div className="posit-unit--middle">
          <ScrollArea
            speed={0.8}
            className="area"
            smoothScrolling={true}
            contentClassName="schedule-grid"
            horizontal={true}
            vertical={false}
          >
            <ScheduleGrid
              subjectsArray={this.state.subjectsArray}
              columns={6}
              itemsInColumn={6}
              moveSubject={this.moveSubject}
            />
          </ScrollArea>
        </div>
      </div>
    );
  }
}

export default App;


Формат ответа сервера
[
  {
    "ID": "2833",
    "NAME": "КР-101 (1 пара)",
    "DAY_OF_WEEK": 5,
    "GROUP": "834",
    "NOTICE": null,
    "SHEDULE_TYPE_ID": "1956",
    "SHEDULE_TYPE_NAME": "Практика",
    "SHEDULE_TYPE_CODE": "practice",
    "SUBJECT_ID": "868",
    "SUBJECT_NAME": "информатика",
    "CLASSROOM_ID": "883",
    "CLASSROOM_NAME": "55а-ПМК",
    "EDUCATION": "1",
    "PERIOD": "1",
    "TEACHER_ID": "1732",
    "TEACHER_FIRST_NAME": "Диана",
    "TEACHER_MIDDLE_NAME": "Юрьевна",
    "TEACHER_LAST_NAME": "Матвеева",
    "TEACHER_SHORT_NAME": "Д. Ю. Матвеева"
  },
  {
    "ID": "2832",
    "NAME": "КР-101 (1 пара)",
    "DAY_OF_WEEK": 5,
    "GROUP": "834",
    "NOTICE": null,
    "SHEDULE_TYPE_ID": "1957",
    "SHEDULE_TYPE_NAME": "Занятие",
    "SHEDULE_TYPE_CODE": "lesson",
    "SUBJECT_ID": "1491",
    "SUBJECT_NAME": "информационные сервисы",
    "CLASSROOM_ID": "883",
    "CLASSROOM_NAME": "55а-ПМК",
    "EDUCATION": "1",
    "PERIOD": "1",
    "TEACHER_ID": "1732",
    "TEACHER_FIRST_NAME": "Диана",
    "TEACHER_MIDDLE_NAME": "Юрьевна",
    "TEACHER_LAST_NAME": "Матвеева",
    "TEACHER_SHORT_NAME": "Д. Ю. Матвеева"
  }
]

Для построения сетки занятий нас интересуют поля:

  • PERIOD — номер занятий
  • DAY_OF_WEEK — день недели
  • TEACHER_SHORT_NAME — ФИО преподавателя
  • CLASSROOM_NAME — номер аудитории
  • SUBJECT_NAME — название предмета


ScheduleGrid

Далее приступим к реализации сетки занятий, генерируем колонки  ScheduleColumn.
В этом компоненте инициализируем React DnD. Для этого оборачиваем наш компонент в DragDropContext и передаем ему HTML5-backend.

Листинг ScheduleGrid
import React, { Component } from 'react';
import propTypes from 'prop-types';

import { DragDropContext } from 'react-dnd';
import HTML5Backend from 'react-dnd-html5-backend';

import ScheduleColumn from './Grid/ScheduleColumn';
import ScrollButton from './Grid/ScrollButton';

import throttle from './utils/throttle.js';

class ScheduleGrid extends Component {

  static contextTypes = {
    scrollArea: propTypes.object,
  };

  constructor(props, context) {
    super(props);
    this.weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
    this.scrollLeft = throttle(context.scrollArea.scrollLeft, 1500);
    this.scrollRight = throttle(context.scrollArea.scrollRight, 1500);
  } 
  
  printColumns = () => {
    return Array.from({ length: this.props.columns }, (el, index) => (
      <ScheduleColumn 
        itemsInColumn={this.props.itemsInColumn}
        moveSubject={this.props.moveSubject}
        subjectsArray={this.props.subjectsArray}
        xPos={index + 1}
        key={index}
        weekDay={this.weekDays[index]} />
    ));
  }

  render() {
    return (
      <div>
        <div className="table-schedule table-schedule--days clearfix">
          {this.printColumns()}          
        </div>
        <div className="navigation-table-day">
          <ScrollButton type={'prev'} handleClick={this.scrollLeft} />
          <ScrollButton type={'next'} handleClick={this.scrollRight} />
        </div>         
      </div>
    );
  }
}

export default DragDropContext(HTML5Backend)(ScheduleGrid);


ScheduleColumn

Каждая из колонок состоит из нескольких секций. Секции имеет координаты xPos и yPos, которые соответствуют полям PERIOD и DAY_OF_WEEK из API.

Листинг ScheduleColumn
import React, { Component } from 'react';

import SubjectSection from './SubjectSection';

class ScheduleColumn extends Component {

  getSectionData = (x, y) => {
    return this.props.subjectsArray.filter(subject => {
      return subject.DAY_OF_WEEK == x && subject.PERIOD == y;
    });
  }

  generateSection = yPos => {    
    const { xPos, emptyColumnItemClick, onColumnItemClick } = this.props;
    const sectionData = this.getSectionData(xPos, yPos).slice(0, 2);

    return (
      <SubjectSection        
        key={`${yPos}_${xPos}`}
        yPos={yPos}
        xPos={xPos}
        sectionData={sectionData}
        moveSubject={this.props.moveSubject}
      />
    );
  }

  render() {
    const { weekDay, itemsInColumn } = this.props;

    return (
      <div className="table-schedule--column">
        <div className="day">{weekDay}</div>
        <div className="technical-data">
          <div className="items">     
            <div className="item subject">Предмет:</div>
            <div className="item teacher">Преподаватель:</div>
            <div className="item lecture">Аудитория:</div>
          </div>
        </div>
        <div className="couples-description">
          <div className="sections">
            {Array.from({ length: this.props.itemsInColumn }, (el, index) => this.generateSection(index + 1))}
          </div>
        </div>
      </div>
    ); 
  }  
}

export default ScheduleColumn;


SubjectSection

В терминологии React DnD, данный компонент является DropTarget, т.е. предназначен для взаимодействия с другими перетаскиваемыми компонентами.

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

Необходимо также задать ему тип. В нашем случае это — subjectItem. Теперь в него можно перетаскивать компоненты DragSource с аналогичным типом.

Листинг SubjectSection
import React, { Component } from 'react';
import { DropTarget } from 'react-dnd';
import className from 'classnames';

import SubjectItem from './SubjectItem';

const SubjectSectionTarget = {
  drop(props) {
    return props;
  },
  canDrop(props) {
    return props.sectionData.length <= 1;
  },
};

const collect = (connect, monitor) => {
  return {
    connectDropTarget: connect.dropTarget(),
    isOver: monitor.isOver(),
  };
}

class SubjectSection extends Component {

  itemTemplate(xPos, yPos, styles, isOver) {
    const itemClass = className({
      'section': true,
      'section--2-elements': this.props.sectionData.length > 1 || isOver, 
      'section--drag-here': this.props.sectionData.length <= 1 && isOver,
    });

    return (
      <div
        key={`${xPos}_${yPos}`}
        style={styles}
        className={itemClass}       
      >
        {this.props.sectionData.map((data, index, sectionData) => {
          return (
            <SubjectItem
              key={data.ID}
              moveSubject={this.props.moveSubject}
              data={data}
              index={index}
              sectionData={sectionData}
            />
          );
        })}
      </div>
    );
  }

  emptyItemTemplate(xPos, yPos, styles) {
    return (
      <div
        key={`${xPos}_${yPos}`}
        style={styles}
        className="section section--is-dragging"
      >
        <div className="technical-data" />
      </div>
    );
  }

  render() {
    const { connectDropTarget, isOver, sectionData, xPos, yPos } = this.props;
    
    const styles = isOver ? { opacity: 0.7 } : null;
    const sectionIsEmpty = sectionData.length === 0;

    const subjectSection =
      sectionIsEmpty
        ? this.emptyItemTemplate(xPos, yPos, styles)
        : this.itemTemplate(xPos, yPos, styles, isOver);

    return connectDropTarget(subjectSection);
  }
}

export default DropTarget('subjectItem', SubjectSectionTarget, collect)(SubjectSection);


Почти закончили, осталось только реализовать компонент для отображения информации о занятии SubjectItem

SubjectItem

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

Листинг SubjectItem
import React, { Component } from 'react';
import { DragSource } from 'react-dnd';
import classNames from 'classnames';
import equals from 'shallow-equals';

import SubjectContent from './SubjectContent';

const subjectSource = {
  beginDrag(props, monitor, component) {
    return props;
  },
  endDrag(props, monitor, component) {
    
    if (!monitor.didDrop()) {
      return;
    }
    const item = monitor.getItem();
    const dropResult = monitor.getDropResult();

    props.moveSubject(item.data.ID, {
      day: dropResult.xPos,
      period: dropResult.yPos,
    });
  },
};

function collect(connect, monitor) {
  return {
    connectDragSource: connect.dragSource(),
    isDragging: monitor.isDragging(),
    connectDragPreview: connect.dragPreview(),
  };
}

class SubjectItem extends Component {
 
  onTooltipClick(event) {
    event.preventDefault();
    return false;
  }    

  render() {
    const { connectDragSource, isDragging, index, sectionData, data } = this.props;

    const itemClass = classNames({
      'technical-data': true,
      'l-separation': index === 0 && sectionData.length > 1,
    });

    return connectDragSource(
      <a
        href="#"
        style={{ opacity: isDragging ? 0 : 1 }}
        onClick={this.onTooltipClick}
        className={itemClass}
      >
        <div style={{ height: '100%' }}>
          <SubjectContent 
            data={data}
            isDragging={isDragging}
          />          
        </div>
      </a>
    );
  }
}

export default DragSource('subjectItem', subjectSource, collect)(SubjectItem);


Листинг SubjectContent
import React from 'react';
import ReactTooltip from 'react-tooltip';

const printSubjectType = ({ SHEDULE_TYPE_CODE, ID, SHEDULE_TYPE_NAME, NOTICE }) => (
  <span className="types-classes types-classes--vertical">
    <ReactTooltip
      delayShow={250}
      id={`tolltip${ID}`}
      place="bottom"
      class="customeTheme"
      effect="solid"
    />
    <span className="types-classes__items">
      {
        SHEDULE_TYPE_CODE != 'lesson' ? (
          <span
            className="types-classes__item"
            data-tip={SHEDULE_TYPE_NAME}
            data-for={`tolltip${ID}`}
          >
            <span
              className={
                'type-occupation type-occupation--' +
                SHEDULE_TYPE_CODE +
                ' icon'
              }
            />
          </span>
      ) : null }
      {NOTICE ? (
        <span
          className="types-classes__item"
          data-tip={NOTICE}
          data-for={`tolltip${ID}`}
        >
          <span className="type-occupation type-occupation--note icon" />
        </span>
      ) : null}
    </span>
  </span>
);


const SubjectContent = props => {
  const { isDragging } = props;
  const { SUBJECT_NAME = '(нет)', TEACHER_SHORT_NAME = '(нет)', CLASSROOM_NAME = '(нет)' } = props.data;

  return (
    <span className="items">
      <span className="item subject">
        <span className="imit-table">
          <span className="imit-table--column">
            {SUBJECT_NAME}
          </span>
        </span>
      </span>
      <span className="item teacher">
        <span className="imit-table">
          <span className="imit-table--column">
            {TEACHER_SHORT_NAME}
          </span>
        </span>
      </span>
      <span className="item lecture">
        <span className="imit-table">
          <span className="imit-table--column">
            {CLASSROOM_NAME}
          </span>
          {printSubjectType(props.data)}
        </span>
      </span>
    </span> 
  )
};

export default SubjectContent;


Ну что, вроде бы все готово. Можно приступать к тестам.

Тестируем


Запускаем наш чудо-интерфейс и пробуем переместить предмет.



«Вот, блин!» — сказал мне Google Chrome 62, а в Firefox 57 все отработало нормально.

Позже выяснилось, что React-DnD конфликтует с некоторыми библиотеками, например ReactTooltip. Есть даже открытый issue на github.

Ну что, tooltip нам нужен. Придется как-то фиксить. Попробуем задать свое превью изображение, для этого добавим буквально пару строк.

Фиксим вылет браузера
function collect(connect, monitor) {
  return {
    connectDragSource: connect.dragSource(),
    isDragging: monitor.isDragging(),
    connectDragPreview: connect.dragPreview(),
  };
}

class SubjectItem extends Component {

  componentDidMount() {
    const img = new Image();
    img.src = 'data:image/png;base64,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';
    img.onload = () => this.props.connectDragPreview(img);
  }


Обновляем страницу и проверяем.



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

Убираем коня
componentDidMount() {
    const img = new Image();
    img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAMAAAAoyzS7AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6REY0OTM1NzczOTYzMTFFN0E3MDBBMUQ5N0U1QzgxMkIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6REY0OTM1NzYzOTYzMTFFN0E3MDBBMUQ5N0U1QzgxMkIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmRpZDpGQkE5MURDNjYzMzlFNzExOTBGM0IxRjlDRDFERkM0RCIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpGQkE5MURDNjYzMzlFNzExOTBGM0IxRjlDRDFERkM0RCIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PrL7xCIAAAAGUExURf///wAAAFXC034AAAABdFJOUwBA5thmAAAADElEQVR42mJgAAgwAAACAAFPbVnhAAAAAElFTkSuQmCC';
    img.onload = () => this.props.connectDragPreview(img);
  }


Интересное замечание. Если в Windows выбрать упрощенную цветовую схему «Windows Classic», то в браузере не будет отображаться тень (preview) при перемещении от drag&drop.



после установки прозрачного превью

Уже лучше, однако, все равно похоже на дешевую подделку. Что ж, будем реализовывать свое превью, чтобы не зависеть от особенностей браузеров и ОС.

В React-DnD для этого предусмотрен DragLayer — компонент, который будет отображаться при перемещении DragSource.

Листинг GridDragLayer
import React, { Component } from 'react';
import { DragLayer } from 'react-dnd';
import SubjectContent from './SubjectContent';

function collect(monitor) {
  return {
    item: monitor.getItem(),
    currentOffset: monitor.getSourceClientOffset(),
    isDragging: monitor.isDragging(),
  };
}

function getItemTransform(props) {
  const { currentOffset } = props;
  if (!currentOffset) {
    return {
      display: 'none',
    };
  }

  const { x, y } = currentOffset;
  const transform = `translate(${x}px, ${y}px) rotate(3deg)`;
  return {  
    position: 'fixed', 
    display: 'block',
    zIndex: 10000,
    transform: transform,
    WebkitTransform: transform,
    cursor: 'move',
  };
}

class GridDragLayer extends Component {
  constructor(props) {
    super(props);
    this.lastUpdate = +new Date();
  }

render() {
    const { item, isDragging } = this.props;

    if (!isDragging) {
      return null;
    }

    return (
      <div
        id="drag-placeholder"
        style={getItemTransform(this.props)}
      >
        <SubjectContent
          data={item.data}
          isDragging={isDragging}
        />
      </div>
    );
  }
}

export default DragLayer(collect)(GridDragLayer);


Помещаем наш GridDragLayer в метод ScheduleGrid.render

Листинг ScheduleGrid
render() {
    return (
      <div>
        <GridDragLayer />
        <div className="table-schedule table-schedule--days clearfix">
          {this.printColumns()}          
        </div>
        <div className="navigation-table-day">
          <ScrollButton type={'prev'} handleClick={this.scrollLeft} />
          <ScrollButton type={'next'} handleClick={this.scrollRight} />
        </div>         
      </div>
    );
  }


В очередной раз проверяем.



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

В Chrome DevTools переходим на вкладку Performance и включаем CPU 4x slowdown. Видим примерную картину того как будет работать у обычного пользователя.



Решаем проблему производительности


shouldComponentUpdate

Основная проблема заключается в том, что на каждое событие drag (а триггерится оно очень часто) React перерисовывает компонент GridDragLayer. Выполняется большое число ненужных операций.

Чтобы избавится от лишних перерисовок реализуем метод shouldComponentUpdate в GridDragLayer. Для плавности нам нужно обеспечить 60fps, т.е. одна перерисовка на 16 мс.

Листинг shouldComponentUpdate
constructor(props) {
    super(props);
    this.lastUpdate = +new Date();
    this.updateTimer = null;
  }

  shouldComponentUpdate(nextProps, nextState) {
    if (+new Date() - this.lastUpdate > 16) {
      this.lastUpdate = +new Date();
      clearTimeout(this.updateTimer);
      return true;
    } else {
      this.updateTimer = setTimeout(() => {
        this.forceUpdate();
      }, 100);
    }
    return false;
  }


Возможные «залипания», когда компонент изменил свое состояние в интервале 16 мс, но не был перерисован, устраняются таймером и forceUpdate.

Проверяем при CPU 4x slowdown. Стало намного шустрее, но все равно недостаточно.



Реализуем drag placeholder на vanilla js

Для этого немного допишем наш SubjectItem. Реализуем функцию generatePlaceholder, которая возвращает нам разметку элемента. Также напишем обработчик createMouseMoveHandler, который будет изменять положение placeholder. В объект subjectSource добавим event listeners, которые будут реагировать на событие dragover.

Листинг SubjectItem
import React, { Component } from 'react';
import { DragSource } from 'react-dnd';
import classNames from 'classnames';
import equals from 'shallow-equals';
import SubjectContent from './SubjectContent';

import throttle from '../utils/throttle.js';

function generatePlaceholder(item) {
  const placeholder = document.createElement('div');
  placeholder.id = 'drag-placeholder';
  placeholder.style.cssText =
    'display:none;position:fixed;z-index:100000;pointer-events:none;';
  
  placeholder.innerHTML = `<span class="items">
					                    <span class="item subject">			                        
					                        ${item.data.SUBJECT_NAME || '(нет)'}
					                    </span>
					                    <span class="item teacher">
					                        ${item.data.TEACHER_SHORT_NAME || '(нет)'}
					                    </span>
					                    <span class="item lecture">
					                        ${item.data.CLASSROOM_NAME || '(нет)'}
					                    </span>
					                </span>`;
  return placeholder;
}

function createMouseMoveHandler() {
  let currentX = -1;
  let currentY = -1;

  return function(event) {
    let newX = event.clientX - 8;
    let newY = event.clientY - 2;

    if (currentX === newX && currentY === newY) {
      return;
    }

    const dragPlaceholder = document.getElementById('drag-placeholder');
    const transform = 'translate(' + newX + 'px, ' + newY + 'px) rotate(3deg)';

    dragPlaceholder.style.transform = transform;
    dragPlaceholder.style.display = 'block';
  };
}

const mouseMoveHandler = createMouseMoveHandler();

const throttledMoveHandler = throttle(createMouseMoveHandler(), 16);

const subjectSource = {
  beginDrag(props, monitor, component) {
    document.addEventListener('dragover', throttledMoveHandler);
    document.body.insertBefore(
      generatePlaceholder(props),
      document.body.firstChild
    );
    return props;
  },
  endDrag(props, monitor, component) {
    document.removeEventListener('dragover', throttledMoveHandler);
    let child = document.getElementById('drag-placeholder');
    child.parentNode.removeChild(child);

    if (!monitor.didDrop()) {
      return;
    }
    const item = monitor.getItem();
    const dropResult = monitor.getDropResult();

    props.moveSubject(item.data.ID, {
      day: dropResult.xPos,
      period: dropResult.yPos,
    });
  },
};

function collect(connect, monitor) {
  return {
    connectDragSource: connect.dragSource(),
    isDragging: monitor.isDragging(),
    connectDragPreview: connect.dragPreview(),
  };
}

class SubjectItem extends Component {

  componentDidMount() {
    const img = new Image();
    img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAMAAAAoyzS7AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6REY0OTM1NzczOTYzMTFFN0E3MDBBMUQ5N0U1QzgxMkIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6REY0OTM1NzYzOTYzMTFFN0E3MDBBMUQ5N0U1QzgxMkIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmRpZDpGQkE5MURDNjYzMzlFNzExOTBGM0IxRjlDRDFERkM0RCIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpGQkE5MURDNjYzMzlFNzExOTBGM0IxRjlDRDFERkM0RCIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PrL7xCIAAAAGUExURf///wAAAFXC034AAAABdFJOUwBA5thmAAAADElEQVR42mJgAAgwAAACAAFPbVnhAAAAAElFTkSuQmCC';
    img.onload = () => this.props.connectDragPreview(img);
  }
  
  onTooltipClick(event) {
    event.preventDefault();
    return false;
  }    

  render() {
    const { connectDragSource, isDragging, index, sectionData, data } = this.props;

    const itemClass = classNames({
      'technical-data': true,
      'l-separation': index === 0 && sectionData.length > 1,
    });

    return connectDragSource(
      <a
        href="#"
        style={{ opacity: isDragging ? 0 : 1 }}
        onClick={this.onTooltipClick}
        className={itemClass}
      >
        <div style={{ height: '100%' }}>
          <SubjectContent 
            data={data}
            isDragging={isDragging}
          />          
        </div>
      </a>
    );
  }
}

export default DragSource('subjectItem', subjectSource, collect)(SubjectItem);


Также проверяем все при CPU 4x slowdown.



Теперь все работает как надо!

Конечно, это отступление от good practices, ведь теперь мы полностью продублировали код нашего превью в функции generatePlaceholder. Зато интерфейсом стало пользоваться намного удобней и приятней.

#update
Odrin предложил использовать «renderToStaticMarkup», чтобы избежать дублирования кода в функции generatePlaceholder. Вариант рабочий, количество кода сократилось и не нужно дублировать разметку превью.

Листинг SubjectItem
import React, { Component } from 'react';
import ReactDOMServer from 'react-dom/server';
import { DragSource } from 'react-dnd';
import classNames from 'classnames';
import equals from 'shallow-equals';

import SubjectContent from './SubjectContent';

import throttle from '../utils/throttle.js';

function generatePlaceholder(item) {
  const placeholder = document.createElement('div');
  placeholder.id = 'drag-placeholder';
  placeholder.style.cssText =
    'display:none;position:fixed;z-index:100000;pointer-events:none;';
  
  placeholder.innerHTML = ReactDOMServer.renderToStaticMarkup(<SubjectContent { ...item } />);
  return placeholder;
}

function createMouseMoveHandler() {
  let currentX = -1;
  let currentY = -1;

  return function(event) {
    let newX = event.clientX - 8;
    let newY = event.clientY - 2;

    if (currentX === newX && currentY === newY) {
      return;
    }

    const dragPlaceholder = document.getElementById('drag-placeholder');
    const transform = 'translate(' + newX + 'px, ' + newY + 'px) rotate(3deg)';

    dragPlaceholder.style.transform = transform;
    dragPlaceholder.style.display = 'block';
  };
}

const mouseMoveHandler = createMouseMoveHandler();

const throttledMoveHandler = throttle(createMouseMoveHandler(), 16);

const subjectSource = {
  beginDrag(props, monitor, component) {
    document.addEventListener('dragover', throttledMoveHandler);
    document.body.insertBefore(
      generatePlaceholder(props),
      document.body.firstChild
    );
    return props;
  },
  endDrag(props, monitor, component) {
    document.removeEventListener('dragover', throttledMoveHandler);
    let child = document.getElementById('drag-placeholder');
    child.parentNode.removeChild(child);

    if (!monitor.didDrop()) {
      return;
    }
    const item = monitor.getItem();
    const dropResult = monitor.getDropResult();

    props.moveSubject(item.data.ID, {
      day: dropResult.xPos,
      period: dropResult.yPos,
    });
  },
};

function collect(connect, monitor) {
  return {
    connectDragSource: connect.dragSource(),
    isDragging: monitor.isDragging(),
    connectDragPreview: connect.dragPreview(),
  };
}

class SubjectItem extends Component {

  componentDidMount() {
    const img = new Image();
    img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAMAAAAoyzS7AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6REY0OTM1NzczOTYzMTFFN0E3MDBBMUQ5N0U1QzgxMkIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6REY0OTM1NzYzOTYzMTFFN0E3MDBBMUQ5N0U1QzgxMkIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmRpZDpGQkE5MURDNjYzMzlFNzExOTBGM0IxRjlDRDFERkM0RCIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpGQkE5MURDNjYzMzlFNzExOTBGM0IxRjlDRDFERkM0RCIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PrL7xCIAAAAGUExURf///wAAAFXC034AAAABdFJOUwBA5thmAAAADElEQVR42mJgAAgwAAACAAFPbVnhAAAAAElFTkSuQmCC';
    img.onload = () => this.props.connectDragPreview(img);
  }
  
  onTooltipClick(event) {
    event.preventDefault();
    return false;
  }    

  render() {
    const { connectDragSource, isDragging, index, sectionData, data } = this.props;

    const itemClass = classNames({
      'technical-data': true,
      'l-separation': index === 0 && sectionData.length > 1,
    });

    return connectDragSource(
      <a
        href="#"
        style={{ opacity: isDragging ? 0 : 1 }}
        onClick={this.onTooltipClick}
        className={itemClass}
      >
        <div style={{ height: '100%' }}>
          <SubjectContent 
            data={data}
            isDragging={isDragging}
          />          
        </div>
      </a>
    );
  }
}

export default DragSource('subjectItem', subjectSource, collect)(SubjectItem);


Заключение


После обновления React до 16 версии наш интерфейс не стал работать быстрее. Поэтому мы остановились на варианте с placeholder на vanilla js, поскольку он заметно выигрывает по производительности.

Вот так все работает на production:



Надеемся, что наш опыт разработки интерфейсов с использованием drag&drop будет полезен и другим разработчикам.

Будем благодарны за ваши комментарии и участие в мини-опросе!

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

А какую библиотеку используете Вы?

Macte

11,97

Сервисы и интеграции

Поделиться публикацией
Комментарии 26
    0
    Спасибо! Как раз сейчас делаю «убийцу трелло» и думал над днд
      0
      Одно время была популярна библиотека Dragula, вроде бы. Она сейчас как? Уже не торт?
        0
        ведь теперь мы полностью продублировали код нашего превью в функции generatePlaceholder

        Почему бы не использовать «renderToStaticMarkup»? Сам не пробовал, но вроде как у renderToStaticMarkup нет ограничений в использовании на клиенте.
          0
          Спасибо за комментарий! Обязательно попробую. Если заработает, то допишу в статье!)
          –1
          Perfomance? Ну нельзя же так, в заголовке-то…
            0
            Спасибо!) Поправил.
            0
            Чтобы избавится от лишних перерисовок реализуем метод shouldComponentUpdate в GridDragLayer. Для плавности нам нужно обеспечить 60fps, т.е. одна перерисовка на 16 мс.
            Возможные «залипания», когда компонент изменил свое состояние в интервале 16 мс, но не был перерисован, устраняются таймером и forceUpdate.


            А что действительно нет возможности не перерисовывать весь GridDragLayer? Я правильно этому ужасаюсь, или «так и должно быть», «иначе не сделаешь»? Оно моргать не будет? А на планшетке? Я когда писал расписания с драгндропами с jquery-ui selectable/draggable/droppable (сотни объектов) никаких проблем с производительностью не имел и вообще не задумвывался о fps. Что-то я как то не готов морально к такому прогрессу. Ободрите, что все тут ok.
              0
              GridDragLayer — маленькое прямоугольное превью, которое появляется только в момент перетаскивания, а не вся таблица с расписанием, как вы наверное подумали. Не моргает, скринкасты есть в статье.
              0
              А такие замечательные гифки ловящие курсор и ДнД чем делаются?
                0
                Можно в ScreenToGif, к примеру.
                0
                А что если получить элемент через ref и при движении менять ему стиль напрямую?

                Кстати, чем не устроили свойства left и top? Неужели трансформации работают быстрее?
                  0
                  1. Данный вариант оказался проще в плане реализации (если учесть, что используется React DnD)
                  2. Если верить этой статье — www.paulirish.com/2012/why-moving-elements-with-translate-is-better-than-posabs-topleft то да. Сам тесты не проводил.
                    0

                    Хм, а что сложного? Третий параметр component у beginDrag — это ваш компонент. Пишем что-то вроде <div ref={el => this.div = el}> и можно обращаться к этому элементу как component.div вместо placeholder.

                      0
                      А что если в «секции» находится две пары и мы перемещаем одну из них? Плейсхолдер получится в 1/2 от обычной высоты и будет выглядеть не так эстетично)
                        0
                        Это вы вообще о чем?
                        0

                        А как вы решаете эту проблему при создании плейсхолдера? Просто задаете ему начальную высоту при создании, которое происходит при начале перетаскивания, не так ли?


                        А ведь то же самое можно и с рефом сделать...

                          0
                          Да, все верно, при создании плейсхолдера мы задаем ему стили.
                          Можно проделать все это и c рефом. Таким образом получаем два возможных решения одной задачи, которые по сути отличаются не сильно. Перетаскиваем div-блок изменяя через стили его позицию.
                  0

                  Хм, а в чем преимущества рендера в строку с последующим парсингом перед однократным вызовом ReactDOM.render?


                  Не пробовали вместо


                    placeholder.innerHTML = ReactDOMServer.renderToStaticMarkup(<SubjectContent { ...item } />);

                  написать вот так:


                    ReactDOM.render(<SubjectContent { ...item } />, placeholder);
                    0
                    Реализовал по совету Odrin, тем самым решив проблему с дублированием кода. Можно попробовать и ваш подход, но не думаю что это даст нам существенный прирост по производительности. Сам метод вызывается всего один раз для создания превью и не является узким местом) В любом случае спасибо!)
                    0
                    Недавно работал тоже была необходимость в Drag&Drop. Использовал вот это github.com/danielstocks/react-sortable (простенькая и ничего лишнего)
                      0

                      Не совсем понял из статьи и из комментариев, почему нельзя оптимизировать перерисовку компонента до обновления только свойств top и left (или transform)?

                        0
                        В варианте с GridDragLayer так и было, но результат получился не очень. Без троттлинга работало вполне себе, а с CPU 4x slowdown пошли фризы.
                          0

                          Не вижу там shouldComponentUpdate. Мне кажется, можно было бы реализовать его и проверять, изменились ли данные, возвращаемые monitor (а предыдущие сохранять в стейте, например).


                          Или, как вариант, возвращать true из shouldComponentUpdate каждые 16мс.
                          forceUpdate, как по мне, не выглядит правильным решением.

                            0
                            Он в «Листинг shouldComponentUpdate»
                        0
                        Спасибо! Можно забыть про jQuery UI Draggable и Droppable

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

                        Самое читаемое