
В данной статье мы расскажем про свой опыт реализации интерфейса редактирования расписания занятий. Расскажем о проблемах, с которыми мы столкнулись и о возможных путях решения.
В одном из последних проектов нам предстояло реализовать систему для управления учебным процессом образовательного учреждения. То, что у нас получилось в итоге, можно посмотреть здесь.
К интерфейсу редактирования расписания были предъявлены следующие требования:
- Возможность создания, редактирования и удаление занятий;
- В рамках одной пары занятие может проводиться сразу у двух групп;
- Возможность переноса занятия в сетке расписания.
С первыми двумя пунктами никаких проблем не возникло, а вот с третьим пришлось повозиться. На нем и остановимся поподробнее.
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, хотя совсем и не обязательно. Можно выбрать любой другой или написать свой.
Реализация
Для начала разобьем верстку сетки расписания на четыре основных компонента:
- Сетка расписания — ScheduleGrid (в этом компоненте мы будем инициализировать библиотеку React-DnD)
- Блок с расписанием на день — ScheduleColumn
- Секция с парами — SubjectSeciton (в нашем случае это будет DropTarget)
- Отдельное занятие — SubjectItem (в нашем случае это будет DragSource)

визуально разметили наши компоненты
Компонент 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.
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.
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 с аналогичным типом.
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.
Для удобства я разделил его на два, выделил отдельно контейнер и презентационную часть.
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);
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.
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
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 мс.
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.
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. Вариант рабочий, количество кода сократилось и не нужно дублировать разметку превью.
Листинг SubjectItemimport 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 будет полезен и другим разработчикам.
Будем благодарны за ваши комментарии и участие в мини-опросе!
Комментарии (26)
AstarothAst
11.12.2017 10:15Одно время была популярна библиотека Dragula, вроде бы. Она сейчас как? Уже не торт?
Odrin
11.12.2017 10:58ведь теперь мы полностью продублировали код нашего превью в функции generatePlaceholder
Почему бы не использовать «renderToStaticMarkup»? Сам не пробовал, но вроде как у renderToStaticMarkup нет ограничений в использовании на клиенте.dr_zorge Автор
11.12.2017 11:01Спасибо за комментарий! Обязательно попробую. Если заработает, то допишу в статье!)
RomanPokrovskij
11.12.2017 11:49Чтобы избавится от лишних перерисовок реализуем метод shouldComponentUpdate в GridDragLayer. Для плавности нам нужно обеспечить 60fps, т.е. одна перерисовка на 16 мс.
Возможные «залипания», когда компонент изменил свое состояние в интервале 16 мс, но не был перерисован, устраняются таймером и forceUpdate.
А что действительно нет возможности не перерисовывать весь GridDragLayer? Я правильно этому ужасаюсь, или «так и должно быть», «иначе не сделаешь»? Оно моргать не будет? А на планшетке? Я когда писал расписания с драгндропами с jquery-ui selectable/draggable/droppable (сотни объектов) никаких проблем с производительностью не имел и вообще не задумвывался о fps. Что-то я как то не готов морально к такому прогрессу. Ободрите, что все тут ok.dr_zorge Автор
11.12.2017 11:54GridDragLayer — маленькое прямоугольное превью, которое появляется только в момент перетаскивания, а не вся таблица с расписанием, как вы наверное подумали. Не моргает, скринкасты есть в статье.
mayorovp
11.12.2017 12:26А что если получить элемент через ref и при движении менять ему стиль напрямую?
Кстати, чем не устроили свойства left и top? Неужели трансформации работают быстрее?dr_zorge Автор
11.12.2017 12:331. Данный вариант оказался проще в плане реализации (если учесть, что используется React DnD)
2. Если верить этой статье — www.paulirish.com/2012/why-moving-elements-with-translate-is-better-than-posabs-topleft то да. Сам тесты не проводил.mayorovp
11.12.2017 12:55Хм, а что сложного? Третий параметр component у beginDrag — это ваш компонент. Пишем что-то вроде
<div ref={el => this.div = el}>
и можно обращаться к этому элементу какcomponent.div
вместоplaceholder
.dr_zorge Автор
11.12.2017 13:10А что если в «секции» находится две пары и мы перемещаем одну из них? Плейсхолдер получится в 1/2 от обычной высоты и будет выглядеть не так эстетично)
mayorovp
11.12.2017 13:30А как вы решаете эту проблему при создании плейсхолдера? Просто задаете ему начальную высоту при создании, которое происходит при начале перетаскивания, не так ли?
А ведь то же самое можно и с рефом сделать...
dr_zorge Автор
11.12.2017 13:45Да, все верно, при создании плейсхолдера мы задаем ему стили.
Можно проделать все это и c рефом. Таким образом получаем два возможных решения одной задачи, которые по сути отличаются не сильно. Перетаскиваем div-блок изменяя через стили его позицию.
mayorovp
11.12.2017 12:45Хм, а в чем преимущества рендера в строку с последующим парсингом перед однократным вызовом
ReactDOM.render
?
Не пробовали вместо
placeholder.innerHTML = ReactDOMServer.renderToStaticMarkup(<SubjectContent { ...item } />);
написать вот так:
ReactDOM.render(<SubjectContent { ...item } />, placeholder);
dr_zorge Автор
11.12.2017 13:07Реализовал по совету Odrin, тем самым решив проблему с дублированием кода. Можно попробовать и ваш подход, но не думаю что это даст нам существенный прирост по производительности. Сам метод вызывается всего один раз для создания превью и не является узким местом) В любом случае спасибо!)
gnv_cor
11.12.2017 13:31Недавно работал тоже была необходимость в Drag&Drop. Использовал вот это github.com/danielstocks/react-sortable (простенькая и ничего лишнего)
indestructable
11.12.2017 13:31Не совсем понял из статьи и из комментариев, почему нельзя оптимизировать перерисовку компонента до обновления только свойств
top
иleft
(илиtransform
)?dr_zorge Автор
11.12.2017 13:36В варианте с GridDragLayer так и было, но результат получился не очень. Без троттлинга работало вполне себе, а с CPU 4x slowdown пошли фризы.
indestructable
11.12.2017 13:41Не вижу там
shouldComponentUpdate
. Мне кажется, можно было бы реализовать его и проверять, изменились ли данные, возвращаемыеmonitor
(а предыдущие сохранять в стейте, например).
Или, как вариант, возвращать
true
изshouldComponentUpdate
каждые 16мс.
forceUpdate
, как по мне, не выглядит правильным решением.
Nikulio
Спасибо! Как раз сейчас делаю «убийцу трелло» и думал над днд