Отображение списка (множества) элементов на странице — это стандартная задача для практически любого web-приложения. В этом посте я хотел бы поделиться некоторыми советами по повышению производительности.
Для тестового примера я создам небольшое приложение, которое рисует множество «целей» (кругов) на элементе canvas. Я буду использовать redux как хранилище данных, но эти советы подойдут и для многих других способов хранения состояния.
Так же эти оптимизации можно применять с react-redux, но для простоты описания я не буду использовать эту библиотеку.
Данные советы могут повысить производительность приложения в 20 раз.
Начнем с описания состояния:
Теперь напишем отрисовку приложения. Я буду использовать react-konva для рисования на canvas.
Полное демо: http://codepen.io/lavrton/pen/GZXzGm
Теперь давайте напишем простой тест, который будет обновлять одну «цель».
Теперь запускаем тесты без каких-либо оптимизаций. На моей машине одно обновление занимает примерно 21мс.

Это время не включает в себя процесс рисования на canvas элемент. Только react и redux код, потому что react-konva будет рисовать на canvas только в следующем тике анимации (асинхронно). Сейчас я не буду рассматривать оптимизацию рисования на canvas. Это тема для другой статьи.
И так, 21мс для 1000 элеметнов это достаточно хорошая производительность. Если мы обновляем элементы достаточно редко мы может оставить этот код как есть.
Но у меня была ситуация когда обновлять элементы нужно было очень часто (при каждой движении мыши во время drag&drop). Для того, чтобы получить 60FPS нужно чтобы одно обновление занимало не больше 16мс. Так что 21мс это уже не так здорово (помните что еще потом будет происходить рисование на canvas).
И так что же можно сделать?
Собсвено это самое первое и очевидное правило для повышения производительности. Всё что нам нужно сделать это реализовать shouldComponentUpdate для компонента Target:
Результат такого дополнения (http://codepen.io/lavrton/pen/XdPGqj):

Супер! 4мс это уже намного лучше чем 21мс. Но можно ли лучше? В моём реальном приложении даже после такой оптимизации производительность была не очень.
Взгляните на функцию render компонента App. Штука, которая мне не очень нравится — это то, что код функции render будет выполняться при КАЖДОМ обновлении. То есть мы имеем 1000 вывозов React.createElement для каждой «цели». Для данного примера это работает быстро, но в реальном прилож��нии все может быть печально.
Почему мы должны перерисовывать весь список, если мы знаем, что обновился только один элемент? Можно ли напрямую обновить этот один элемент?
Идея очень проста:
1. Не обновлять компонент App если список имеет такое же количество элементов и их порядок не изменился.
2. Дочерние элементы должны обновить сами себя, если данные изменились.
Итак, компонент Target должен слушать изменения в состоянии и применять изменения:
Так же нам нужно реализовать shouldComponentUpdate для компонента App:
Результат после данных изменений (http://codepen.io/lavrton/pen/bpxZjy):

0.25мс на одно обновление это уже намного лучше.
Используйте https://github.com/mobxjs/mobx чтобы не писать код всех этих подписок на изменения и проверок. То же приложение, только написанное с помощью mobx (http://codepen.io/lavrton/pen/WwPaeV):

Работает примерно в 1.5 раза быстрее, чем предыдущий результат (разница будет более заметная для большего кол-ва элементов). И код намного проще:
Для тестового примера я создам небольшое приложение, которое рисует множество «целей» (кругов) на элементе canvas. Я буду использовать redux как хранилище данных, но эти советы подойдут и для многих других способов хранения состояния.
Так же эти оптимизации можно применять с react-redux, но для простоты описания я не буду использовать эту библиотеку.
Данные советы могут повысить производительность приложения в 20 раз.
Начнем с описания состояния:
function generateTargets() { return _.times(1000, (i) => { return { id: i, x: Math.random() * window.innerWidth, y: Math.random() * window.innerHeight, radius: 2 + Math.random() * 5, color: Konva.Util.getRandomColor() }; }); } // для теста логика будет очень простая // только одно действие "UPDATE", которое меняет радиус цели function appReducer(state, action) { if (action.type === 'UPDATE') { const i = _.findIndex(state.targets, (t) => t.id === action.id); const updatedTarget = { ...state.targets[i], radius: action.radius }; state = { targets: [ ...state.targets.slice(0, i), updatedTarget, ...state.targets.slice(i + 1) ] } } return state; } const initialState = { targets: generateTargets() }; // создаем хранилище const store = Redux.createStore(appReducer, initialState);
Теперь напишем отрисовку приложения. Я буду использовать react-konva для рисования на canvas.
function Target(props) { const {x, y, color, radius} = props.target; return ( <Group x={x} y={y}> <Circle radius={radius} fill={color} /> <Circle radius={radius * 1 / 2} fill="black" /> <Circle radius={radius * 1 / 4} fill="white" /> </Group> ); } // верхний компонент для отображения множества class App extends React.Component { constructor(...args) { super(...args); this.state = store.getState(); // subscibe to all state updates store.subscribe(() => { this.setState(store.getState()); }); } render() { const targets = this.state.targets.map((target) => { return <Target key={target.id} target={target}/>; }); const width = window.innerWidth; const height = window.innerHeight; return ( <Stage width={width} height={height}> <Layer hitGraphEnabled={false}> {targets} </Layer> </Stage> ); } }
Полное демо: http://codepen.io/lavrton/pen/GZXzGm
Теперь давайте напишем простой тест, который будет обновлять одну «цель».
const N_OF_RUNS = 500; const start = performance.now(); _.times(N_OF_RUNS, () => { const id = 1; let oldRadius = store.getState().targets[id].radius; // обновим redux хранилище store.dispatch({type: 'UPDATE', id, radius: oldRadius + 0.5}); }); const end = performance.now(); console.log('sum time', end - start); console.log('average time', (end - start) / N_OF_RUNS);
Теперь запускаем тесты без каких-либо оптимизаций. На моей машине одно обновление занимает примерно 21мс.

Это время не включает в себя процесс рисования на canvas элемент. Только react и redux код, потому что react-konva будет рисовать на canvas только в следующем тике анимации (асинхронно). Сейчас я не буду рассматривать оптимизацию рисования на canvas. Это тема для другой статьи.
И так, 21мс для 1000 элеметнов это достаточно хорошая производительность. Если мы обновляем элементы достаточно редко мы может оставить этот код как есть.
Но у меня была ситуация когда обновлять элементы нужно было очень часто (при каждой движении мыши во время drag&drop). Для того, чтобы получить 60FPS нужно чтобы одно обновление занимало не больше 16мс. Так что 21мс это уже не так здорово (помните что еще потом будет происходить рисование на canvas).
И так что же можно сделать?
1. Не обновлять элементы, которые не изменились
Собсвено это самое первое и очевидное правило для повышения производительности. Всё что нам нужно сделать это реализовать shouldComponentUpdate для компонента Target:
class Target extends React.Component { shouldComponentUpdate(newProps) { return this.props.target !== newProps.target; } render() { const {x, y, color, radius} = this.props.target; return ( <Group x={x} y={y}> <Circle radius={radius} fill={color} /> <Circle radius={radius * 1 / 2} fill="black" /> <Circle radius={radius * 1 / 4} fill="white" /> </Group> ); } }
Результат такого дополнения (http://codepen.io/lavrton/pen/XdPGqj):

Супер! 4мс это уже намного лучше чем 21мс. Но можно ли лучше? В моём реальном приложении даже после такой оптимизации производительность была не очень.
Взгляните на функцию render компонента App. Штука, которая мне не очень нравится — это то, что код функции render будет выполняться при КАЖДОМ обновлении. То есть мы имеем 1000 вывозов React.createElement для каждой «цели». Для данного примера это работает быстро, но в реальном прилож��нии все может быть печально.
Почему мы должны перерисовывать весь список, если мы знаем, что обновился только один элемент? Можно ли напрямую обновить этот один элемент?
2 Делаем дочерние элементы «умными»
Идея очень проста:
1. Не обновлять компонент App если список имеет такое же количество элементов и их порядок не изменился.
2. Дочерние элементы должны обновить сами себя, если данные изменились.
Итак, компонент Target должен слушать изменения в состоянии и применять изменения:
class Target extends React.Component { constructor(...args) { super(...args); this.state = { target: store.getState().targets[this.props.index] }; // subscibe to all state updates this.unsubscribe = store.subscribe(() => { const newTarget = store.getState().targets[this.props.index]; if (newTarget !== this.state.target) { this.setState({ target: newTarget }); } }); } shouldComponentUpdate(newProps, newState) { return this.state.target !== newState.target; } componentWillUnmount() { this.unsubscribe(); } render() { const {x, y, color, radius} = this.state.target; return ( <Group x={x} y={y}> <Circle radius={radius} fill={color} /> <Circle radius={radius * 1 / 2} fill="black" /> <Circle radius={radius * 1 / 4} fill="white" /> </Group> ); } }
Так же нам нужно реализовать shouldComponentUpdate для компонента App:
shouldComponentUpdate(newProps, newState) { // проверяем что порядок и кол-во элементов остались прежними // то есть если id остались прежними, значит у нас нет "больших" изменений const changed = newState.targets.find((target, i) => { return this.state.targets[i].id !== target.id; }); return changed; }
Результат после данных изменений (http://codepen.io/lavrton/pen/bpxZjy):

0.25мс на одно обновление это уже намного лучше.
Бонусный совет
Используйте https://github.com/mobxjs/mobx чтобы не писать код всех этих подписок на изменения и проверок. То же приложение, только написанное с помощью mobx (http://codepen.io/lavrton/pen/WwPaeV):

Работает примерно в 1.5 раза быстрее, чем предыдущий результат (разница будет более заметная для большего кол-ва элементов). И код намного проще:
const {Stage, Layer, Circle, Group} = ReactKonva; const {observable, computed} = mobx; const {observer} = mobxReact; class TargetModel { id = Math.random(); @observable x = 0; @observable y = 0; @observable radius = 0; @observable color = null; constructor(attrs) { _.assign(this, attrs); } } class State { @observable targets = []; } function generateTargets() { _.times(1000, (i) => { state.targets.push(new TargetModel({ id: i, x: Math.random() * window.innerWidth, y: Math.random() * window.innerHeight, radius: 2 + Math.random() * 5, color: Konva.Util.getRandomColor() })); }); } const state = new State(); generateTargets(); @observer class Target extends React.Component { render() { const {x, y, color, radius} = this.props.target; return ( <Group x={x} y={y}> <Circle radius={radius} fill={color} /> <Circle radius={radius * 1 / 2} fill="black" /> <Circle radius={radius * 1 / 4} fill="white" /> </Group> ); } } @observer class App extends React.Component { render() { const targets = state.targets.map((target) => { return <Target key={target.id} target={target}/>; }); const width = window.innerWidth; const height = window.innerHeight; return ( <Stage width={width} height={height}> <Layer hitGraphEnabled={false}> {targets} </Layer> </Stage> ); } } ReactDOM.render( <App/>, document.getElementById('container') );
