← amorgunov
Об автореПосты

История одной оптимизации React приложения

В настоящий момент я работаю над внутренним проектом для создания промо-страниц, который активно развивается и растет. Огромное количество собранных промо-страниц и целых сайтов, куча сэкономленных человеко-часов на разработку промок, которые собираются с помощью нашего конструктора, и огромная кодовая база. Несмотря на высокий уровень и профессиональный подход разработчиков, код ревью и современные технологии, в быстрорастущих проектах возникают проблемы из-за не всегда оптимальных архитектурных подходов, а порой из-за небольших локальных решений, которые изначально кажутся вполне логичными.

Недавно в работу мне попала несложная на первый взгляд задача: нужно было сделать поисковый инпут с возможностью фильтрации элементов без сложной логики, запросов к API - в общем ничего не предвещало беды. На реализацию фичи у меня ушло не больше одного дня и вот что получилось:

Результат работы поискового инпута

И нет, анимация не тормозит, как могло показаться изначально - после ввода символа в инпуте все зависало на несколько секунд, включая сам инпут и вообще весь интерфейс. Это точно не обрадует наших пользователей, нужно исправлять. Поехали.

Get started

Начать хочу со статьи из официальной документации React, в которой собраны полезные базовые советы по оптимизации приложений (в конце материла вы найдете еще парочку полезных ссылок по теме).

Основной инструмент, который мы сегодня будем использовать для анализа производительности, это вкладка «Performance» в DevTools браузера Chrome. В режиме разработки он позволяет отслеживать, как компоненты монтируются и обновляются, используя flame graphs. Flame graphs - это такой способ визуализации процессорного времени, потраченного на выполнение функций.

Чтобы воспользоваться инструментом, достаточно сделать пару действий:

Вкладка Performance

Работа на вкладке Performance

Так же полезная группа для анализа JavaScript - это группа Main (работа главного потока JavaScript страницы), ее можно использовать совместно с группой Timings и сравнивать рендеры компонент с реально выполняемым кодом. Это очень мощная тулза:

Результаты анализа приложения

Очень рекомендую к прочтению статью Бена Шварца, в которой он показывает по шагам, как анализировать компоненты, что в частности я и буду делать далее.

Redux и performance.mark

В состоянии редакса моего проекта хранится все выгруженное дерево промо-страниц и при поиске фильтруется часть нод (не DOM-нод). Первая же мысль у меня была следующая: операция обновления нод дорогая и из-за этого происходит такой лаг.

Заглянув во вкладку Performance, в группе Timings я увидел данные только по React компонентам, а в группе Main примерно следующее:

Непонятные вызовы функций в группе Main

Некоторые методы (dispatch, onStateChange или performWorkOnRoot) вызываются внутри React и для обычных пользователей не несут никакой объективной информации.

Возникает вопрос, а как React вообще строит красивые метки по компонентам? Как оказалось, в среде разработки React использует User timing API, который позволяет создавать временные метки, являющиеся частью временной шкалы производительности браузера. И с помощью этого API, мы можем создавать метки сами для Redux экшенов. Для этого достаточно написать middleware для редакса (gist с кодом):

export const userTimingMiddleware = () => next => action => {
  performance.mark(`${action.type}_start`);
  const result = next(action);

  performance.mark(`${action.type}_end`);
  performance.measure(
    `${action.type}`,
    `${action.type}_start`,
    `${action.type}_end`,
  );

  return result;
};

Так как это обычная миддлевара, то достаточно добавить ее при создании стора вместе с остальными:

applyMiddleware(/* thunkMiddleware */, userTimingMiddleware)

И во вкладке Timing появятся метки по редакс-экшенам!

Как выгдялит redux-action на вкладке Timings

Что касается моего кейса, то redux-экшен выполнялся какие-то доли миллисекунд, редакс в этой проблеме не причем, но теперь я четко мог понимать, после выполнения экшена какие именно компоненты перерисовываются, и какой код выполняется.

Избегайте формирование данных в render

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

Результат профилирования React приложения

Меня заинтересовало достаточно долгое обновление PagePreview (600ms), который не должен изменяться после ввода символов вообще. Я начал анализировать проблему, а алгоритм профилирования сводится к следующему:

Вкладка Performance

Процесс поиска проблемного места

Вкладка Performance

Процесс поиска проблемного места

_getLatestUpdatedNode оказался рекурсивным методом, который проходит всех потомков текущей ноды и ищет последнюю обновленную. И хоть один вызов и занимаем 3ms (из-за любимого нами moment, внутри метода происходило сравнение дат), то после сравнения сотни нод и выходило общее время в 600-800ms.

render() {
    const { selectedNode } = this.props
    const node = this._getLatestUpdatedNode(selectedNode);
    // ...
}

Этот метод постоянно отрабатывал напрасно. Он не должен был находиться в render-е компонента и после оптимизации был перенесен в redux. После устранения проблемного места обновление компонента стало занимать 22 миллисекунды:

Результат оптимизации

Сэкономили пол секунды зависания

Эта была маленькая (на самом деле большая) победа, но оптимизации на этом не закончены.

Помимо этой оптимизации в коде были почищены другие фрагменты с преобразованием в рендере. Но как и где делать такие преобразования, когда при изменении свойства нужно обновлять другую переменную и по различным причинам в Redux это не убрать?

Используйте мемоизацию

Хочу сразу отметить, что в правильных PureComponent-ах или в компонентах с shouldComponentUpdate метод рендер не будет вызываться без изменения пропсов, и в таком случае допустимо оставлять вычисляемую логику в самом методе render.

Рассмотрим пример из нашего кода:

class Component extends React.Component {
    render() {
        const { highlightedNodes } = this.props;
        const highlightedIds = highlightedNodes.map(node => node.id);
    
        return (<div>...</div>);
    }
}

В рендере на каждую перерисовку формируется новый массив с идентификаторами объектов пропа highlightedNodes.

Первое, что приходит в голову, использовать внутренний стейт компонента и метод жизненного цикла: getDerivedStateFromProps. Код будет выглядеть как-то так:

class Component extends React.Component {
    state = {
        highlightedIds: this.props.highlightedNodes.map(node => node.id)
    };
    
    static getDerivedStateFromProps(props, state) {
        const highlightedIds = props.highlightedNodes.map(node => node.id);
    
        // Если highlightedNodes был обновлен, возвращаем новое состояние
        if (!isEqual(highlightedIds, state.highlightedIds)) {
          return {
            highlightedIds
          };
        }
        return null;
    }

    render() {
        const { highlightedIds } = this.state;

        return (<div>...</div>);
    }
}

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

import memoize from 'memoize-one';

class Component extends React.Component {
    getHighlightedIds = memoize(
        (highlightedNodes) => highlightedNodes.map(node => node.id)
    );

    render() {
        const highlightedIds = this.getHighlightedIds(this.props.highlightedNodes);

        return (<div>...</div>);
    }
}

Метод getHighlightedIds будет заново итерироваться по массиву только в том случае, если проп highlightedNodes будет обновлен. По этой теме можете почитать топик из официальной документации React.

Избегайте dangerouslySetInnerHTML

У нас в проекте есть компонент Icon, который рендерит нужную иконку. Как он связан с задачей по фильтрации списка проектов? А связан он тем, что у каждой страницы есть своя иконка, а если это директория - то иконка папки:

Скриншот элементов списка

Иконки хранились в не оптимизированном svg (сжатие происходило в процессе билда продакшен сборки) и вставлялись в компоненте Icon через dangerouslySetInnerHTML:

render() {
    const props = {
        ...rest,
        dangerouslySetInnerHTML: { __html: icons[icon] },
    };

    return <span {...props} />;
}

Что можно и было оптимизировано:

  1. Сжать все svg (удобно делать через cli-утилиту svgo). До сжатия в dev-версии 1000 иконок директорий (одинаковых svg) занимало 2мб!! в HTML-разметке, после сжатия: 800кб.

  2. По опытам коллег, dangerouslySetInnerHTML замедляет время монтирования и демонтирования компонентов (ссылки в конце поста по этой теме приложу), поэтому svg рекомендуют переводить в простые stateless компоненты сразу в jsx. Но у нас не было патчинга свойств svg, поэтому я унес их в CSS Background. Время рендера списка уменьшилось на 30%, размер HTML снизился до 350кб.

Следите за слушателями событий

Все мы знаем, что если подписываемся на какое-то событие в componentDidMount, то и должны отписаться в componentWillUnmount. Но у меня был еще один кейс: после нажатие на кнопку в инпуте начинают перерисовываться компоненты, которые вообще не относятся ни к поисковой строчке, ни к списку с нодами.

Как оказалось, в рутовом компоненте было две подписки на keyUp и keyDown, которые обрабатывали разные shortcuts-ы в приложении. И когда я нажимал кнопку, вызывались эти обработчики, меняли свой стейт, перерисовывали свои под компоненты.

Чтобы избавиться от такого поведения, мне было достаточно вызвать stopPropagation для отмены всплытия события в своих обработчиках:

render() {
    return (
        <Input
            onKeyDown={this.onKeyDown}
            onKeyUp={this.onKeyUp} />
    );
}

onKeyDown = (event) => {
    event.stopPropagation();
};

onKeyUp = (event) => {
    event.stopPropagation();
    // ...
};

Не забывайте это делать и для своих компонент.

Декомпозируйте на компоненты

Следующим на очереди был компонент List, который отрисовывает список нод:

Скриншот элементов списка

Он постоянно перерисовывался и делал это не быстро, 314ms. Открыв компонент, я увидел примерно следующее:

class List extends React.Component {
    render() {
        return nodes.map(node => this._renderNode(node));    
    }
    
    _renderNode(node) {}
}

При изменении хотя бы одной ноды (а как вы помните, они там исчисляются тысячами) перерисовывалось абсолютно все! Проблема очевидна, нужно вынести элемент списка в отдельный компонент:

class List extends React.Component {
    render() {
        return nodes.map(node => (<ListNode node={node} />));
    }
}

class ListNode extends React.PureComponent {
    render() {
        return ();
    }
}

Сделав необходимый рефакторинг, время рендеринга уменьшилось до 30ms (в десять раз!).

Скриншот элементов списка

С простыми списками так же можно попробовать использовать библиотеки типа react-window, которые рендерят только видимую часть списка (никакой магии: простая математика и position: absolute). Они дают огромный прирост в производительности, но при сложной логике (у нас есть реализация массового выделения) их далеко не просто интегрировать.

И еще...

Декомпозиция на компоненты решило одну довольно частую ошибку: создание обработчиков в методе render.

_renderNode(node) {
    const handleDoubleClick = this._getNodeDoubleClickHandler(node);
    
    // ...
}

_getNodeDoubleClickHandler(node) {
    return function () {}
}

В методе отрисовки ноды на каждый рендер создавалась своя функция хэндлер (еще не забыли, 1000 нод - 1000 обработчиков, N перерисовок - 1000 * N обработчиков).

После создания компонента ListNode код стал выглядеть вот так:

class ListNode extends React.PureComponent {
    // ...
    
    _handleDoubleClick = () => {
        const { node } = this.props;
        // ...
    };
}

Один обработчик на все перерендеры и инстансы ListNode.

Вместо заключения

История подошла к концу. После оптимизаций код стал чище, поиск стал работать, как и ожидается, моя задача была решена. Но мест в коде, которые можно и нужно еще оптимизировать не мало (лайзи лоад компонент, иммутальные структуры данных, правильная работа с редаксом и т.д.), но об этом уже в другой раз.

Финальный результат

Спасибо за внимание, подписывайтесь на канал в телеграмме @amorgunov, чтобы не пропускать анонсы новых постов и до скорых встреч!

Ссылки по теме

Вам может быть интересно