Отображение списка (множества) элементов на странице — это стандартная задача для практически любого web-приложения. В этом посте я хотел бы поделиться некоторыми советами по повышению производительности.

Для тестового примера я создам небольшое приложение, которое рисует множество «целей» (кругов) на элементе 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мс.

image

Это время не включает в себя процесс рисования на 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):

image

Супер! 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):

image

0.25мс на одно обновление это уже намного лучше.

Бонусный совет



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

image

Работает примерно в 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')
);
Поделиться с друзьями
-->

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


  1. xGromMx
    29.06.2016 11:08

    MobX мутирует состояние


    1. Laney1
      29.06.2016 17:18
      +3

      совершенно верно, в корректной обработке мутаций и состоит основное преимущество этой библиотеки. По сравнению с Redux это позволяет сильно упростить код, обойтись без костылей вроде immutable.js, и попутно получить лучшую производительность.


  1. kanstantsin
    29.06.2016 11:20
    +1

    Для профайлинга рекомендую React Perf (недавно переписан) + расширение для Chrome.


  1. Miraage
    29.06.2016 11:25

    Вообще-то, для такой цели давно изобретен Reselect.
    И под шум волны можно использовать Virtualized, для рендеринга большого количества элементов в списке/гриде.


    1. lavrton
      29.06.2016 11:34
      +1

      А как reselect поможет?


    1. hell0w0rd
      29.06.2016 21:39

      virtualized — это рендер куска списка. Начать с оптимизации рендера вполне здравая идея, прежде чем тянуть дополнительную библиотеку в проект. Особенно если кол-во элементов в списке конечно и предсказуемо.


  1. Asgator
    29.06.2016 11:49
    -3

    Статья про то, что есть shouldComponentUpdate.


  1. justboris
    29.06.2016 12:28
    +1

    В коде редьюсера есть прекрасное:

    state = {
          targets: [
              ...state.targets.slice(0, i),
              updatedTarget,
              ...state.targets.slice(i + 1)
           ]
    }
    


    По сути, вам нужно вернуть копию массива с замененным одним элементом. Это делается через map в одну строку

    state.targets.map((target, index) => index === i ? updatedTarget : target)
    


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


    1. lavrton
      29.06.2016 12:48

      Согласен, map выглядет намного проще. Про производительность у меня есть сомнения (да-да, я прочитал дискуссию по ссылке), но всё же следует проверить самому. В любом случае, я не думаю что разница большая.


    1. stardust_kid
      29.06.2016 15:39
      +1

      К чему это увлечение модным синтаксисом, если можно проще?

      А стрелочную функцию еще деды использовали?


      1. justboris
        29.06.2016 15:46

        Я имел в виду чрезмерное увлечение.

        Там, где это оправдано, почему бы и нет.


        1. stardust_kid
          29.06.2016 16:03

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


          1. auine
            29.06.2016 16:39

            Понимаю, что не везде но .bind(this) значит не лень писать :)?


            1. stardust_kid
              29.06.2016 17:03
              -1

    1. xGromMx
      29.06.2016 22:14

      А если вместо нативного map взять lodash map то еще быстрее будет =)


    1. rageOfAxe
      30.06.2016 10:02

      Javascript-ninja же!


  1. everdimension
    29.06.2016 19:09
    +1

    shouldComponentUpdate(newProps, newState) {
        // проверяем что порядок и кол-во элементов остались прежними
        // то есть если id остались прежними, значит у нас нет "больших" изменений
        const changed = newState.targets.find((target, i) => {
            return this.state.targets[i].id !== target.id;
        });
        return changed;
    }

    здесь может вылететь ошибка, если в newState.targets больше элементов, чем в this.state.targets