Оптимизируем React приложение для отображения списка элементов

    Отображение списка (множества) элементов на странице — это стандартная задача для практически любого 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')
    );
    
    Поделиться публикацией
    Комментарии 18
      0
      MobX мутирует состояние
        +3
        совершенно верно, в корректной обработке мутаций и состоит основное преимущество этой библиотеки. По сравнению с Redux это позволяет сильно упростить код, обойтись без костылей вроде immutable.js, и попутно получить лучшую производительность.
        +1
        Для профайлинга рекомендую React Perf (недавно переписан) + расширение для Chrome.
          0
          Вообще-то, для такой цели давно изобретен Reselect.
          И под шум волны можно использовать Virtualized, для рендеринга большого количества элементов в списке/гриде.
            +1
            А как reselect поможет?
              0
              virtualized — это рендер куска списка. Начать с оптимизации рендера вполне здравая идея, прежде чем тянуть дополнительную библиотеку в проект. Особенно если кол-во элементов в списке конечно и предсказуемо.
              –3
              Статья про то, что есть shouldComponentUpdate.
                +1
                В коде редьюсера есть прекрасное:

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


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

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


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

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

                      Там, где это оправдано, почему бы и нет.
                        0
                        Это моя заморочка, конечно, но читать их намного труднее. Стараюсь избегать, мне не лень лишние буквы набрать.
                    0
                    А если вместо нативного map взять lodash map то еще быстрее будет =)
                      0
                      Javascript-ninja же!
                      +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

                        0
                        Статью можно сократить до предложения «Используйте mobx для менеджмента стейта приложения.». Полностью согласен, кстати.

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

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