Как сделать React приложение быстрее при помощи совместного размещения состояний +5


В статье речь идет о state colocation, то есть о совместном размещении состояний, этот термин можно было бы еще перевести как стейт колокейшн или стейт колокация.


Одной из основных причин замедления работы React приложения является его глобальное состояние (global state). Я покажу это на примере очень простого приложения, после чего приведу более близкий к реальной жизни пример.


Вот простенькое приложение в котором можно ввести имя для собаки (если окно не работает, вот ссылка):



Если вы поиграете с этим приложением то вскоре обнаружите что оно очень медленно работает. При взаимодействии с любым полем ввода возникают заметные проблемы с производительностью. В такой ситуации можно было бы использовать спасательный круг в виде React.memo и обернуть им все компоненты с медленным рендером. Но давайте попробуем решить эту проблему по другому.


Вот код этого приложения:


function sleep(time) {
  const done = Date.now() + time
  while (done > Date.now()) {
    // спим...
  }
}

// представим что этот компонент медленный
// из-за того что он рендерит кучу информации
function SlowComponent({time, onChange}) {
  sleep(time)
  return (
    <div>
      Wow, that was{' '}
      <input
        value={time}
        type="number"
        onChange={e => onChange(Number(e.target.value))}
      />
      ms slow
    </div>
  )
}

function DogName({time, dog, onChange}) {
  return (
    <div>
      <label htmlFor="dog">Dog Name</label>
      <br />
      <input id="dog" value={dog} onChange={e => onChange(e.target.value)} />
      <p>
        {dog ? `${dog}'s favorite number is ${time}.` : 'enter a dog name'}
      </p>
    </div>
  )
}

function App() {
  // это наше "глобальное состояние" (global state)
  const [dog, setDog] = React.useState('')
  const [time, setTime] = React.useState(200)
  return (
    <div>
      <DogName time={time} dog={dog} onChange={setDog} />
      <SlowComponent time={time} onChange={setTime} />
    </div>
  )
}

Если вы еще не читали статью про Colocation (совместное размещение), предлагаю вам ее прочитать. Зная что совместное размещение может упростить работу с нашим приложением, давайте используем этот принцип при работе с состояниями.


Обратите внимание на код нашего приложения, а именно на состояние time — оно используется каждым компонентом нашего приложения, поэтому оно и было поднято (lifting — поднятие состояния) в компонент App (компонент который оборачивает все наше приложение). Однако "собачье" состояние (dog и setDog) используется только одним компонентом, оно не нужно в компоненте App, так что давайте переместим его в компонент DogName:


function DogName({time}) { // <- убрали передачу состояний
  const [dog, setDog] = React.useState('') // <- добавили хук
  return (
    <div>
      <label htmlFor="dog">Dog Name</label>
      <br />
      <input id="dog" value={dog} onChange={e => setDog(e.target.value)} />
      <p>
        {dog ? `${dog}'s favorite number is ${time}.` : 'enter a dog name'}
      </p>
    </div>
  )
}

function App() {
  // это наше "глобальное состояние" (global state)
  const [time, setTime] = React.useState(200)
  return (
    <div>
      <DogName time={time} /> // <- убрали передачу состояний
      <SlowComponent time={time} onChange={setTime} />
    </div>
  )
}

И вот наш результат (если окно не работает, ссылка):



Вот это да! Ввод имени теперь работает ЗАМЕТНО быстрее. Более того, компонент стало легче поддерживать благодаря совместному размещению. Но почему оно стало работать быстрее?


Говорят что лучший способ сделать что-то быстро — это делать как можно меньше вещей. Это именно то, что здесь происходит. Когда мы управляем состоянием расположенным в самом верху дерева компонентов, каждое обновление этого состояния приводит к аннулированию всего дерева. Реакт не знает что изменилось, из-за этого ему приходиться проверять все компоненты чтобы понять нужны ли им обновления DOM. Этот процесс не бесплатен и потребляет ресурсы (особенно если у вас есть преднамеренно медленные компоненты). Но если вы переместите состояние как можно ниже в дереве компонентов, как мы это сделали с состоянием dog и компонентом DogName, то Реакт будет делать меньше проверок. Реакт не будет проверять компонент SlowComponent (который мы сделали преднамеренно медленным), так как Реакт знает что этот компонент все равно не может повлиять на вывод.


Короче говоря, раньше, при изменении имени собаки, каждый компонент проверялся на изменения (ре-рендерился). А после изменений которые мы внесли в код, Реакт стал проверять только компонент DogName. Это привело к заметному увеличению производительности!


В реальной жизни


Я вижу что разработчики помещают в глобальное хранилище Redux или в глобальный контекст то, что на самом деле не должно быть глобальным. Такие входные данные, как DogName из примера выше, часто являются местами, где проявляется проблема производительности. Еще я часто вижу что эта проблема проявляется при взаимодействии с мышью (например, при отображении всплывающей подсказки над графиком или над таблицей данных).


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


Еще одним решением, которое часто применяют разработчики — это применение одного из спасательных рендеров Реакта, например React.memo. Это будет очень хорошо работать в нашем надуманном примере, так как позволяет Реакту пропустить повторную визуализацию SlowComponent, но на практике приложение может пострадать из-за «смерти от тысячи порезов», так как в реальном приложении замедление работы обычно происходит не из-за одного медленного компонента, а из-за недостаточно быстрой работы множества компонентов, так что вам придется применять React.memo повсюду. Сделав это, вы будете должны начать использовать useMemo и useCallback, иначе вся работа, которую вы вложили в React.memo, окажется напрасной. Эти действия могут решить проблему, но они заметно увеличивают сложность вашего кода и, фактически, они все равно менее эффективны чем совместное размещение состояний, так как Реакту нужно пройти через каждый компонент (начиная с верхнего), чтобы определить нужно ли рендерить его снова.


Если хотите поиграть с немного менее надуманным примером, переходите здесь на codesandbox.


Что такое совместное размещение состояний?


Принцип совместного размещения гласит:


Код должен быть расположен как можно ближе к тому месту к которому он имеет отношение.

Итак, что бы соответствовать этому принципу, наше dog состояние должно находиться внутри компонента DogName:


function DogName({time}) {
  const [dog, setDog] = React.useState('')
  return (
    <div>
      <label htmlFor="dog">Dog Name</label>
      <br />
      <input id="dog" value={dog} onChange={e => setDog(e.target.value)} />
      <p>
        {dog ? `${dog}'s favorite number is ${time}.` : 'enter a dog name'}
      </p>
    </div>
  )
}

Но что делать, если мы разобьем этот компонент на несколько компонентов? Куда должно передаваться состояние? Ответ все тот же: "как можно ближе к тому месту к которому он имеет отношение", а таковым будет ближайший общий родительский компонент. В качестве примера давайте разобьем компонент DogName, чтобы input и p отображались в разных компонентах:


function DogName({time}) {
  const [dog, setDog] = React.useState('')
  return (
    <div>
      <DogInput dog={dog} onChange={setDog} />
      <DogFavoriteNumberDisplay time={time} dog={dog} />
    </div>
  )
}

function DogInput({dog, onChange}) {
  return (
    <>
      <label htmlFor="dog">Dog Name</label>
      <br />
      <input id="dog" value={dog} onChange={e => onChange(e.target.value)} />
    </>
  )
}

function DogFavoriteNumberDisplay({time, dog}) {
  return (
    <p>
      {dog ? `${dog}'s favorite number is ${time}.` : 'enter a dog name'}
    </p>
  )
}

Мы не можем переместить состояние в компонент DogInput, так как компоненту DogFavoriteNumberDisplay тоже нужен доступ к состоянию, поэтому мы перемещаемся с низу в вверх по дереву компонентов, пока не найдем общий родительский элемент этих двух компонентов, и именно в нем организуем управление состоянием.


Все это относится и к ситуациям когда вам нужно управлять состоянием десятков компонентов на определенном экране вашего приложения. Вы даже можете переместить это в контекст (context), чтобы избежать prop drilling, если хотите. Но держите этот контекст как можно ближе к тому месту к которому он относится, и тогда вы продолжите поддерживать хорошую производительность (и удобство работы с кодом) которую дает совместное размещение. Помните, что вам не нужно размещать все контексты на верхнем уровне вашего Реакт приложения. Держите их там где в этом больше всего смысла.


Это основная мысль моего другого поста Application State Management with React. Держите ваши состояния как можно ближе к месту где они используются, это улучшит как производительность, так и удобство работы с кодом. При таком подходе, единственное что, возможно, будет ухудшать производительность вашего приложения это особенно сложные взаимодействия с элементами интерфейса.


Так что использовать, контексты или Redux?


Если вы читали "One simple trick to optimize React re-renders", то знаете что можно сделать так, чтобы обновлялись только те компоненты которые используют изменившееся состояние. Таким образом вы можете просто обойти эту проблему. Но люди все еще сталкиваются с проблемами производительности при использовании Редакса. Проблема в том, что React-Redux ожидает, что вы следуете его рекомендациям, чтобы избежать лишнего рендера подключенных компонентов. Всегда есть шанс ошибки, можно случайно настроить компоненты так, что они начнут слишком часто рендериться при изменении других глобальных состояний. И чем больше ваше приложение, тем сильнее будет негативный эффект от этого, особенно если вы добавляете в Редакс слишком много состояний.


Существуют методы которые помогут вам избежать этих проблем, к примеру используйте memoized Reselect селекторы для оптимизации фукций mapState, или почитайте документацию Редакса с дополнительной информацией о повышении производительности приложений.


Стоит отметить что совместное размещение можно применять вместе с Редаксом. Просто используйте Редакс только для глобальных состояний, а для всего остального используйте совместное размещение. В FAQ Редакса есть несколько полезных правил, которые помогут вам решить, должно ли состояние работать в Редаксе или оно должно оставаться в компоненте.


К слову, если вы разделяете свое состояние по доменам (используя несколько контекстов, зависящих от домена), то проблема будет менее выраженной.


Но факт остается фактом: совместное размещение состояний снижает проблемы производительности и упрощает обслуживание кода.


Принимаем решение о том куда поместить состояние


Дерево решений:


where-to-put-state


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


  • 1 Начинаем разработку приложения. Идем к 2
  • 2 Состояние в компоненте. Идем к 3
  • 3 Состояние используется только этим компонентом?
    • Да? Идем к 4
    • Нет? Это состояние нужно только одному дочернему компоненту?
    • Да? Перемещаем его в этот дочерний компонент (применяем совместное размещение). Идем к 3.
    • Нет? Это состояние нужно родительским или соседним ("братским" компонентам, то есть детям того же родительского компонента) компонентам?
      • Да? Перемещаем состояние выше, в родительский компонент. Идем к 3
      • Нет? Идем к 4
  • 4 Оставляем как есть. Идем к 5
  • 5 Есть ли проблема с "проп дриллингом"?
    • Да? Перемещаем это состояние в провайдер контекста и рендерим этот провайдер в компоненте в котором идет управление состоянием. Идем к 6
    • Нет? Идем к 6
  • 6 Отправляем приложение. При появлении новых требований, идем к 1

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


Заключение


В целом разработчики довольно хорошо понимают когда нужно поднимать состояние ("lifting state") когда это требуется, но мы не так хорошо понимаем когда состояние нужно опускать. Предлагаю вам взглянуть на код вашего приложения и подумать где состояние можно было бы опустить, применив принцип "совместного размещения". Спросите себя, "а нужно ли мне состояние isOpen модального окна в Редаксе?" (ответ, скорее всего, "нет").


Применяйте принцип совместного размещения, и ваш код станет проще и быстрее.


Удачи!




К сожалению, не доступен сервер mySQL