Проблема React Context API

balaevarif

Arif Balaev

Posted on May 16, 2020

Проблема React Context API

Вольный перевод статьи The Problem with React's Context API

React Context API потрясающий. Как человек, который смотрел на Redux будучи младшим разработчиков и сразу почувствовавший себя побежденным, изучение контекста стало облегчением. Я использовал его в своих приложениях, быстро забыл о Redux и никогда не оглядывался назад.

То есть, пока я не услышал о предполагаемых проблемах производительности с Context API. Теперь громкие имена в сообществе React скажут вам не беспокоиться о производительности, если только вы не начнете видеть проблемы. И все же я продолжаю слышать о проблемах контекста от других разработчиков. Один парень даже упомянул, что его босс запретил использование контекста в их проекте.

Давайте рассмотрим Context API на тот случай, если вы незнакомы, прежде чем мы поговорим о его проблемах.

Зачем использовать Context API?

Context API полезен для обмена stat'ом между компонентами, которые вы не можете легко поделиться пропсами. Вот пример компонента кнопки, который должен установить состояние удаленного предка:

const { useState } = React

function CountDisplay({ count }) {
  return <h2>The Count is: {count}</h2>
}

function CountButton({ setCount }) {
  return (
    <button onClick={() => setCount(count => count + 1)}>
      Increment
    </button>
  )
}

const OuterWrapper = ({setCount}) => <InnerWrapper setCount={setCount}/>
const InnerWrapper = ({setCount}) => <CountButton setCount={setCount}/>

function App() {
  const [count, setCount] = useState(0)
  return (
    <div>
      <CountDisplay count={count} />
      <OuterWrapper setCount={setCount}/>
    </div>
  )
}

render(App)
Enter fullscreen mode Exit fullscreen mode

Компонент кнопки находится в нескольких других компонентах ниже по дереву и должен получать доступ к состоянию из более высокого уровня в приложении. Поэтому мы должны передать setCount каждому компоненту, чтобы, наконец, получить его в наш компонент CountButton. Такое поведение известно как «prop-drilling», и когда-то это была огромная проблема в React.

К счастью, Context API сокращает работу в таких ситуациях.

Как использовать Context API

У Kent C. Dodds есть фантастическая запись в блоге, на которую я ссылаюсь всякий раз, когда внедряю Context API. Если у вас нет времени, чтобы прочитать это, вот короткая версия: Контекст - это способ поделиться состоянием между несвязанными или удаленными компонентами. Все, что вам нужно сделать, это обернуть ваши компоненты в Context.Provider, а затем вызвать useContext (Context) внутри этого компонента, чтобы получить доступ к вашему состоянию и вспомогательным функциям.

Вот аналог примера выше, написанный с помощью context API:

const {useContext, useState, createContext} = React

const AppContext = createContext()

function AppProvider(props) {
  const [count, setCount] = useState(0)
  const value = { count, setCount }
  return (
    <AppContext.Provider value={value}>
      {props.children}
    </AppContext.Provider>
  )
}

function CountDisplay() {
  const { count } = useContext(AppContext)
  return <h2>The Count is: {count}</h2>
}

function CountButton() {
  const { setCount } = useContext(AppContext)
  return (
    <button onClick={() => setCount(count => count + 1)}>
      Increment
    </button>
  )
}

const OuterWrapper = () => <InnerWrapper />

const InnerWrapper = () => <CountButton />

function App() {
  return (
    <div>
      <AppProvider>
        <CountDisplay/>
        <OuterWrapper/>
      </AppProvider>
    </div>
  )
}

render(App)
Enter fullscreen mode Exit fullscreen mode

Здесь у нас есть компоненты CountDisplay и CountButton, которые должны взаимодействовать с состоянием счета более высокого уровня в нашем контексте. Мы начинаем с создания контекста с createContext, затем компонента провайдера в AppProvider, чтобы обернуть наши зависимые компоненты, и, наконец, вызовем useContext в каждом компоненте, чтобы извлечь нужные нам значения. Неважно, насколько далеко друг от друга находятся компоненты, если они заключены в провайдере.

Прикольно, правда?

Оптимизация от Kent C. Dodds

Мы можем немного улучшить примеп, внедрив некоторые вещи, которые Kent упомянул в своей статье об управлении state'ом. Давайте взглянем:

const {useContext, useState, createContext, useMemo} = React
const AppContext = createContext()

// вместо вызова useContext напрямую в наших компонентах,
// мы создаем собственный хук, который вызывает ошибку
// когда мы пытаем достучаться до контекста вне провадера
function useAppContext() {
  const context = useContext(AppContext)
  if (!context)
    throw new Error('AppContext must be used with AppProvider!')
  return context
}

function AppProvider(props) {
  const [count, setCount] = useState(0)
  // here we use useMemo for... reasons.
  // this says don't give back a new count/setCount unless count changes
  const value = useMemo(() => ({ count, setCount }), [count])
  return <AppContext.Provider value={value} {...props} />
}

function CountDisplay() {
  const { count } = useAppContext()
  return <h2>The Count is: {count}</h2>
}

function CountButton() {
  const { setCount } = useAppContext()
  return (
    <button onClick={() => setCount(count => count + 1)}>
      Increment
    </button>
  )
}

const OuterWrapper = () => <InnerWrapper />

const InnerWrapper = () => <CountButton />

function App() {
  return (
    <div>
      <AppProvider>
        <CountDisplay />
        <OuterWrapper />
      </AppProvider>
    </div>
  )
}

render(App)
Enter fullscreen mode Exit fullscreen mode

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

Во-вторых, запомните значение нашего контекста, чтобы оно перерендеривалось повторно только при изменении количества. Использование useMemo не легкая вещь, но суть в том, что когда вы что-то запоминаете, вы говорите, что больше ничего не вернете, пока не изменится указанное вами значение. У Kent'а тоже есть отличная статья, если вы хотите узнать больше.

Маленький грязный секрет Context API

Эй, Context API, конечно, пушка. Он очень прост в использовании по сравнению с Redux и требует гораздо меньше кода, так почему бы вам не использовать его?

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

Это означает, что если вы используете свой контекст повсеместно в своем приложении или, что еще хуже, используете один контекст для состояния всего приложения, то вы вызываете тонну повторных ререндерингов повсюду!

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

Звучит как математическая проблема средней школы, но если вы посмотрите на этот код и получающееся приложение, проблема станет очевидной:

const {useContext, useState, createContext} = React
const AppContext = createContext()

function useAppContext() {
  const context = useContext(AppContext)
  if (!context)
    throw new Error('useAppContext must be used within AppProvider!')
  return context
}

function AppProvider(props) {
  // счетчик
  const [count, setCount] = useState(0)
  // это сообщение никогда не поменяется!
  const [message, setMessage] = useState('Hello from Context!')
  const value = {
    count,
    setCount,
    message,
    setMessage
  }
  return <AppContext.Provider value={value} {...props}/>
}

function Message() {
  const { message } = useAppContext()
  // сообщение рендерится в рандомном цвете
  // для кождого соданного Message компонента
  const getColor = () => (Math.floor(Math.random() * 255))
  const style = {
    color: `rgb(${getColor()},${getColor()},${getColor()})`
  }
  return (
    <div>
      <h4 style={style}>{message}</h4>
    </div>
  )
}

function Count() {
  const {count, setCount} = useAppContext()
  return (
    <div>
      <h3>Current count from context: {count}</h3>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  )
}

function App() {
  return (
    <div>
      <AppProvider>
        <h2>Re-renders! 😩</h2>
        <Message />
        <Message />
        <Message />
        <Count />
      </AppProvider>
    </div>
  )
}
render(App)
Enter fullscreen mode Exit fullscreen mode

Все перерисовывается, когда мы нажимаем Increment кнопку 😱.

Message компоненты даже не используют счет из нашего контекста, но они все равно перерисовываются. Па-бам!

Что насчет мемоизации (запоминания) ?

Может мы просто забыли использовать useMemo как объяснил Kent. Давайте попробуем замемоизировать наш контекст и посмотрим, что произошло

const {useContext, useState, createContext, useMemo} = React
const AppContext = createContext()

function useAppContext() {
  const context = useContext(AppContext)
  if (!context) throw new Error('useAppContext must be used within AppProvider!')
  return context
}

function AppProvider(props) {
  const [count, setCount] = useState(0)
  const [message, setMessage] = useState('Hello from Context!')
  // здесь мы оборачиваем наше value в useMemo,
  // и говорим useMemo давать только новые значения
  // когда count или message поменяются
  const value = useMemo(() => ({
    count,
    setCount,
    message,
    setMessage
  }), [count, message])
  return <AppContext.Provider value={value} {...props}/>
}

function Message() {
  const { message } = useAppContext()
  const getColor = () => (Math.floor(Math.random() * 255))
  const style = {
    color: `rgb(${getColor()},${getColor()},${getColor()})`
  }
  return (
    <div>
      <h4 style={style}>{message}</h4>
    </div>
  )
}

function Count() {
  const {count, setCount} = useAppContext()
  return (
    <div>
      <h3>Current count from context: {count}</h3>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  )
}

function App() {
  return (
    <div>
      <AppProvider>
        <h2>Re-renders! 😩</h2>
        <Message />
        <Message />
        <Message />
        <Count />
      </AppProvider>
    </div>
  )
}
render(App)
Enter fullscreen mode Exit fullscreen mode

Неа, мемоизация с useMemo нам не помогла вообще...

Что настет компонентов, которые не запрашивают context? Они перерендериваются?

const {useContext, useState, createContext, useMemo} = React
const AppContext = createContext()

function useAppContext() {
  const context = useContext(AppContext)
  if (!context) throw new Error('useAppContext must be used within AppProvider!')
  return context
}

function AppProvider(props) {
  const [count, setCount] = useState(0)
  const [message, setMessage] = useState('Hello from Context!')
  const value = useMemo(() => ({
    count,
    setCount,
    message,
    setMessage
  }), [count, message])
  return <AppContext.Provider value={value} {...props}/>
}

// этот компонент НЕ запрашивает context
// но находится внутри Provider компонента
function IndependentMessage() {
  const getColor = () => (Math.floor(Math.random() * 255))
  const style = {
    color: `rgb(${getColor()},${getColor()},${getColor()})`
  }
  return (
    <div>
      <h4 style={style}>I'm my own Independent Message!</h4>
    </div>
  )
}

function Message() {
  const { message } = useAppContext()
  const getColor = () => (Math.floor(Math.random() * 255))
  const style = {
    color: `rgb(${getColor()},${getColor()},${getColor()})`
  }
  return (
    <div>
      <h4 style={style}>{message}</h4>
    </div>
  )
}

function Count() {
  const {count, setCount} = useAppContext()
  return (
    <div>
      <h3>Current count from context: {count}</h3>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  )
}

function App() {
  return (
    <div>
      <AppProvider>
        <h2>Re-renders! 😩</h2>
        <Message />
        <Message />
        <Message />
        <IndependentMessage />
        <Count />
      </AppProvider>
    </div>
  )
}
render(App)
Enter fullscreen mode Exit fullscreen mode

Ну, это пока единственная хорошая новость. Только компоненты, которые вызывают useContext, повторно ререндерятся при изменении стейта контекста.

Тем не менее, это плохие новости для нашего приложения. Мы не хотим запускать кучу ненужных повторных рендерингов везде, где мы используем контекст.

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

Стоит прекратить использование контекста?

Сразу скажу: нет, это не причина прекращать использование контекста. Есть тонна приложений, использующих контекст и просто отлично выполняющих работу, включая кучу моих собственных приложений.

Тем не менее, производительность является своего рода большой задачей. Я не хочу, чтобы вы сидели ночью и беспокоились о грязном маленьком секрете Context API. Итак, давайте поговорим о некоторых способах решения этой проблемы.

Вариант 1: Вообще не парьтесь. Продолжайте в том же духе, что и вы!

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

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

Вариант 2: используйте Redux или MobX

Redux и Mobx оба используют контекстный API, так как они помогают? Хранилище, которое разделяют эти библиотеки управления состоянием с контекстом, немного отличается от совместного использования состояния непосредственно с контекстом. Когда вы используете Redux и Mobx, работает алгоритм сравнения, который обеспечивает повторный рендеринг только тех компонентов, которые действительно необходимы для визуализации.

Тем не менее, контекст должен был избавить нас от необходимости изучать Redux и Mobx! Существует много абстракций и шаблонов, связанных с использованием библиотеки управления состоянием, что делает ее непривлекательным решением для некоторых людей.

Кроме того, не является ли хранение всего в глобальном состоянии плохой практикой?

Вариант 3: Используйте несколько контекстов и держите state близко к его зависимым компонентам

Это решение требует самых изощренных усилий, но дает вам лучшую производительность, не достигая Redux и Mobx. Он полагается на умный выбор вариантов управления состоянием и передачу состояния только если вам нужно поделиться им между удаленными компонентами.

Есть несколько ключевых этапов этой стратегии:

  1. Если это возможно, позвольте компоненту управлять своим собственным состоянием. Это хорошая практика, которой нужно следовать независимо от вашего выбора управления состоянием. Например, если у вас есть модальное окно, которое должно отслеживать открытое / закрытое состояние, но никакие другие компоненты не должны знать, открыт ли этот модальный режим, сохраните это открытое / закрытое состояние в модальном окне. Не вставляйте state в контекст (или Redux), если это не нужно!
  2. Если ваше состояние делится между родителем и несколькими детьми, просто прокиньте его через children. Это старый метод передачи state. Просто передайте его в качестве children дочерним компонентам, которые в этом нуждаются. Передача props или «prop-drilling» может быть ужасна из-за глубоко вложенных компонентов, но если вы прокидываете props только на несколько уровней, вам, вероятно, стоит просто сделать это.
  3. Если предыдущие две вещи не подошли, используйте контекст, но держите его близко к компонентам, которые зависят от него. Это означает, что если вам нужно поделиться каким-либо состоянием, например формой, например, с несколькими компонентами, сделайте отдельный контекст только для формы и оберните компоненты формы в вашем провайдере.

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

const { useContext, useState, createContext } = React
const CountContext = createContext()

// count контекст только работает со счетом!
function useCountContext() {
  const context = useContext(CountContext)
  if (!context)
    throw new Error('useCountContext must be used within CountProvider!')
  return context
}

function CountProvider(props) {
  const [count, setCount] = useState(0)
  const value = { count, setCount }
  return <CountContext.Provider value={value} {...props}/>
}

// message контекст только работает с сообщением!
const MessageContext = createContext()

function useMessageContext() {
  const context = useContext(MessageContext)
  if (!context)
    throw new Error('useMessageContext must be used within MessageProvider!')
  return context
}

function MessageProvider(props) {
  const [message, setMessage] = useState('Hello from Context!')
  const value = { message, setMessage }
  return <MessageContext.Provider value={value} {...props}/>
}

function Message() {
  const { message } = useMessageContext()
  const getColor = () => (Math.floor(Math.random() * 255))
  const style = {
    color: `rgb(${getColor()},${getColor()},${getColor()})`
  }
  return (
    <div>
      <h4 style={style}>{message}</h4>
    </div>
  )
}

function Count() {
  const {count, setCount} = useCountContext()
  return (
    <div>
      <h3>Current count from context: {count}</h3>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  )
}

function App() {
  return (
    <div>
      <h2>No Unnecessary Re-renders! 😎</h2>
      <MessageProvider>
        <Message />
        <Message />
        <Message />
      </MessageProvider>
      <CountProvider>
        <Count />
      </CountProvider>
    </div>
  )
}
render(App)
Enter fullscreen mode Exit fullscreen mode

Теперь наше состояние доступно только тем компонентам, которым нужно это состояние. Когда мы увеличиваем счет, цвета наших компонентов сообщений остаются теми же, потому что count живет за пределами messageContext.

Резюмирую

Хотя заголовок этой статьи немного яркий, и «проблема» с контекстом, возможно, не такая острая, я все же думаю, что об этом стоило рассказать. Гибкость React делает его отличной платформой для начинающих, также как и разрушителем для тех, кто не знает его внутренностей. Мне не кажется, что многие люди сталкиваются с этой конкретной проблемой, но если вы используете контекст и видите проблемы с производительностью, эту информацию вам полезно знать!

💖 💪 🙅 🚩
balaevarif
Arif Balaev

Posted on May 16, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related